@chaaskit/client 0.1.1 → 0.1.2
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/LICENSE +21 -0
- package/dist/lib/index.js +970 -80
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/routes/AdminPromoCodesRoute.js +19 -0
- package/dist/lib/routes/AdminPromoCodesRoute.js.map +1 -0
- package/dist/lib/routes/AdminWaitlistRoute.js +19 -0
- package/dist/lib/routes/AdminWaitlistRoute.js.map +1 -0
- package/dist/lib/routes.js +47 -37
- package/dist/lib/routes.js.map +1 -1
- package/dist/lib/ssr-utils.js +36 -16
- package/dist/lib/ssr-utils.js.map +1 -1
- package/dist/lib/styles.css +37 -0
- package/dist/lib/useExtensions-B5nX_8XD.js.map +1 -1
- package/package.json +20 -12
- package/src/components/MessageItem.tsx +35 -4
- package/src/components/MessageList.tsx +51 -5
- package/src/components/OAuthAppsSection.tsx +1 -1
- package/src/components/Sidebar.tsx +1 -3
- package/src/components/ToolCallDisplay.tsx +102 -11
- package/src/components/tool-renderers/DocumentListRenderer.tsx +44 -0
- package/src/components/tool-renderers/DocumentReadRenderer.tsx +33 -0
- package/src/components/tool-renderers/DocumentSaveRenderer.tsx +32 -0
- package/src/components/tool-renderers/DocumentSearchRenderer.tsx +33 -0
- package/src/components/tool-renderers/index.ts +36 -0
- package/src/components/tool-renderers/utils.ts +7 -0
- package/src/contexts/AuthContext.tsx +16 -6
- package/src/contexts/ConfigContext.tsx +22 -28
- package/src/extensions/registry.ts +2 -1
- package/src/hooks/__tests__/basePath.test.ts +42 -0
- package/src/index.tsx +5 -2
- package/src/pages/AdminDashboardPage.tsx +15 -1
- package/src/pages/AdminPromoCodesPage.tsx +378 -0
- package/src/pages/AdminTeamPage.tsx +29 -1
- package/src/pages/AdminTeamsPage.tsx +15 -1
- package/src/pages/AdminUsersPage.tsx +15 -1
- package/src/pages/AdminWaitlistPage.tsx +156 -0
- package/src/pages/RegisterPage.tsx +91 -9
- package/src/routes/AdminPromoCodesRoute.tsx +24 -0
- package/src/routes/AdminWaitlistRoute.tsx +24 -0
- package/src/routes/index.ts +2 -0
- package/src/ssr-utils.tsx +32 -12
- package/src/stores/chatStore.ts +5 -0
- package/dist/favicon.svg +0 -11
- package/dist/index.html +0 -17
- package/dist/logo.svg +0 -12
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
let mockConfig: { app?: { basePath?: string | null } } | null = null;
|
|
2
|
+
|
|
3
|
+
async function importUseBasePath() {
|
|
4
|
+
vi.resetModules();
|
|
5
|
+
vi.doMock('../../contexts/ConfigContext.js', () => ({
|
|
6
|
+
useConfig: () => mockConfig,
|
|
7
|
+
}));
|
|
8
|
+
return import('../useBasePath.js');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function importUseAppPath() {
|
|
12
|
+
vi.resetModules();
|
|
13
|
+
vi.doMock('react', () => ({
|
|
14
|
+
useCallback: (fn: unknown) => fn,
|
|
15
|
+
}));
|
|
16
|
+
vi.doMock('../../contexts/ConfigContext.js', () => ({
|
|
17
|
+
useConfig: () => mockConfig,
|
|
18
|
+
}));
|
|
19
|
+
return import('../useAppPath.js');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('useBasePath defaults to /chat when empty', async () => {
|
|
23
|
+
mockConfig = { app: { basePath: '' } };
|
|
24
|
+
const { useBasePath } = await importUseBasePath();
|
|
25
|
+
|
|
26
|
+
expect(useBasePath()).toBe('/chat');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('useBasePath returns configured basePath', async () => {
|
|
30
|
+
mockConfig = { app: { basePath: '/custom' } };
|
|
31
|
+
const { useBasePath } = await importUseBasePath();
|
|
32
|
+
|
|
33
|
+
expect(useBasePath()).toBe('/custom');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('useAppPath builds paths with /chat default', async () => {
|
|
37
|
+
mockConfig = { app: { basePath: '' } };
|
|
38
|
+
const { useAppPath } = await importUseAppPath();
|
|
39
|
+
|
|
40
|
+
const appPath = useAppPath();
|
|
41
|
+
expect(appPath('/settings')).toBe('/chat/settings');
|
|
42
|
+
});
|
package/src/index.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Import styles for CSS extraction during build
|
|
2
2
|
import './styles/index.css';
|
|
3
|
+
import './components/tool-renderers';
|
|
3
4
|
|
|
4
5
|
// ClientOnly and Loading Skeletons for SSR-safe rendering
|
|
5
6
|
export { ClientOnly } from './components/ClientOnly';
|
|
@@ -65,13 +66,15 @@ export { default as AdminDashboardPage } from './pages/AdminDashboardPage';
|
|
|
65
66
|
export { default as AdminUsersPage } from './pages/AdminUsersPage';
|
|
66
67
|
export { default as AdminTeamsPage } from './pages/AdminTeamsPage';
|
|
67
68
|
export { default as AdminTeamPage } from './pages/AdminTeamPage';
|
|
69
|
+
export { default as AdminWaitlistPage } from './pages/AdminWaitlistPage';
|
|
70
|
+
export { default as AdminPromoCodesPage } from './pages/AdminPromoCodesPage';
|
|
68
71
|
|
|
69
72
|
// ============================================
|
|
70
73
|
// ChatProviders - Wraps the chat app with all required providers
|
|
71
74
|
// Use in React Router routes that render the chat interface
|
|
72
75
|
// ============================================
|
|
73
76
|
import React from 'react';
|
|
74
|
-
import type {
|
|
77
|
+
import type { PublicAppConfig } from '@chaaskit/shared';
|
|
75
78
|
import { AuthProvider as Auth } from './contexts/AuthContext';
|
|
76
79
|
import { ThemeProvider as Theme } from './contexts/ThemeContext';
|
|
77
80
|
import { ConfigProvider as Config } from './contexts/ConfigContext';
|
|
@@ -84,7 +87,7 @@ export interface ChatProvidersProps {
|
|
|
84
87
|
* Initial config to use immediately, avoiding a flash of default values.
|
|
85
88
|
* Pass the config from SSR loaders to prevent "Welcome to AI Chat" flash.
|
|
86
89
|
*/
|
|
87
|
-
initialConfig?:
|
|
90
|
+
initialConfig?: PublicAppConfig;
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
import { Link, useLocation } from 'react-router';
|
|
3
|
-
import { Users, Building2, X, LayoutDashboard } from 'lucide-react';
|
|
3
|
+
import { Users, Building2, X, LayoutDashboard, Mail, Tag } from 'lucide-react';
|
|
4
4
|
import { api } from '../utils/api';
|
|
5
5
|
import { useConfig } from '../contexts/ConfigContext';
|
|
6
6
|
import { useAppPath } from '../hooks/useAppPath';
|
|
@@ -145,6 +145,20 @@ export default function AdminDashboardPage() {
|
|
|
145
145
|
Teams
|
|
146
146
|
</Link>
|
|
147
147
|
)}
|
|
148
|
+
<Link
|
|
149
|
+
to={appPath('/admin/waitlist')}
|
|
150
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
151
|
+
>
|
|
152
|
+
<Mail size={16} />
|
|
153
|
+
Waitlist
|
|
154
|
+
</Link>
|
|
155
|
+
<Link
|
|
156
|
+
to={appPath('/admin/promo-codes')}
|
|
157
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
158
|
+
>
|
|
159
|
+
<Tag size={16} />
|
|
160
|
+
Promo Codes
|
|
161
|
+
</Link>
|
|
148
162
|
</div>
|
|
149
163
|
|
|
150
164
|
{/* Stats Cards */}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Link } from 'react-router';
|
|
3
|
+
import { LayoutDashboard, Users, Building2, X, Mail, Tag } from 'lucide-react';
|
|
4
|
+
import { useConfig } from '../contexts/ConfigContext';
|
|
5
|
+
import { useAppPath } from '../hooks/useAppPath';
|
|
6
|
+
import { api } from '../utils/api';
|
|
7
|
+
import type {
|
|
8
|
+
AdminPromoCode,
|
|
9
|
+
AdminPromoCodesResponse,
|
|
10
|
+
AdminCreatePromoCodeRequest,
|
|
11
|
+
AdminCreatePromoCodeResponse,
|
|
12
|
+
AdminUpdatePromoCodeRequest,
|
|
13
|
+
AdminUpdatePromoCodeResponse,
|
|
14
|
+
} from '@chaaskit/shared';
|
|
15
|
+
|
|
16
|
+
export default function AdminPromoCodesPage() {
|
|
17
|
+
const config = useConfig();
|
|
18
|
+
const appPath = useAppPath();
|
|
19
|
+
const [promoCodes, setPromoCodes] = useState<AdminPromoCode[]>([]);
|
|
20
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
const [success, setSuccess] = useState('');
|
|
23
|
+
const [search, setSearch] = useState('');
|
|
24
|
+
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'expired' | 'scheduled'>('all');
|
|
25
|
+
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
const [form, setForm] = useState({
|
|
28
|
+
code: '',
|
|
29
|
+
credits: 10,
|
|
30
|
+
maxUses: 100,
|
|
31
|
+
startsAt: '',
|
|
32
|
+
endsAt: '',
|
|
33
|
+
creditsExpiresAt: '',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function loadPromoCodes() {
|
|
37
|
+
setIsLoading(true);
|
|
38
|
+
setError('');
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const params = new URLSearchParams();
|
|
42
|
+
if (search.trim()) {
|
|
43
|
+
params.set('search', search.trim());
|
|
44
|
+
}
|
|
45
|
+
if (statusFilter !== 'all') {
|
|
46
|
+
params.set('status', statusFilter);
|
|
47
|
+
}
|
|
48
|
+
const query = params.toString();
|
|
49
|
+
const response = await api.get<AdminPromoCodesResponse>(`/api/admin/promo-codes${query ? `?${query}` : ''}`);
|
|
50
|
+
setPromoCodes(response.promoCodes);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
setError(err instanceof Error ? err.message : 'Failed to load promo codes');
|
|
53
|
+
} finally {
|
|
54
|
+
setIsLoading(false);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
loadPromoCodes();
|
|
60
|
+
}, [search, statusFilter]);
|
|
61
|
+
|
|
62
|
+
async function handleCreatePromoCode(e: React.FormEvent) {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
setError('');
|
|
65
|
+
setSuccess('');
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const payload: AdminCreatePromoCodeRequest = {
|
|
69
|
+
code: form.code.trim(),
|
|
70
|
+
credits: Number(form.credits),
|
|
71
|
+
maxUses: Number(form.maxUses),
|
|
72
|
+
startsAt: form.startsAt || undefined,
|
|
73
|
+
endsAt: form.endsAt || undefined,
|
|
74
|
+
creditsExpiresAt: form.creditsExpiresAt || undefined,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const response = await api.post<AdminCreatePromoCodeResponse>('/api/admin/promo-codes', payload);
|
|
78
|
+
setPromoCodes((prev) => [response.promoCode, ...prev]);
|
|
79
|
+
setSuccess(`Promo code ${response.promoCode.code} created`);
|
|
80
|
+
setForm({
|
|
81
|
+
code: '',
|
|
82
|
+
credits: 10,
|
|
83
|
+
maxUses: 100,
|
|
84
|
+
startsAt: '',
|
|
85
|
+
endsAt: '',
|
|
86
|
+
creditsExpiresAt: '',
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
setError(err instanceof Error ? err.message : 'Failed to create promo code');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const promoEnabled = config.credits?.enabled && config.credits?.promoEnabled !== false;
|
|
94
|
+
|
|
95
|
+
async function handleDeactivate(promo: AdminPromoCode) {
|
|
96
|
+
setError('');
|
|
97
|
+
setSuccess('');
|
|
98
|
+
try {
|
|
99
|
+
const response = await api.patch<AdminUpdatePromoCodeResponse>(`/api/admin/promo-codes/${promo.id}`, {
|
|
100
|
+
endsAt: new Date().toISOString(),
|
|
101
|
+
} satisfies AdminUpdatePromoCodeRequest);
|
|
102
|
+
setPromoCodes((prev) => prev.map((p) => (p.id === promo.id ? response.promoCode : p)));
|
|
103
|
+
setSuccess(`Promo code ${promo.code} deactivated`);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
setError(err instanceof Error ? err.message : 'Failed to deactivate promo code');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function handleReactivate(promo: AdminPromoCode) {
|
|
110
|
+
setError('');
|
|
111
|
+
setSuccess('');
|
|
112
|
+
try {
|
|
113
|
+
const response = await api.patch<AdminUpdatePromoCodeResponse>(`/api/admin/promo-codes/${promo.id}`, {
|
|
114
|
+
endsAt: null,
|
|
115
|
+
} satisfies AdminUpdatePromoCodeRequest);
|
|
116
|
+
setPromoCodes((prev) => prev.map((p) => (p.id === promo.id ? response.promoCode : p)));
|
|
117
|
+
setSuccess(`Promo code ${promo.code} reactivated`);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
setError(err instanceof Error ? err.message : 'Failed to reactivate promo code');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleCopy(code: string) {
|
|
124
|
+
try {
|
|
125
|
+
await navigator.clipboard.writeText(code);
|
|
126
|
+
setCopiedCode(code);
|
|
127
|
+
setTimeout(() => setCopiedCode((current) => (current === code ? null : current)), 1500);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
setError(err instanceof Error ? err.message : 'Failed to copy code');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="min-h-screen bg-background p-4 sm:p-8">
|
|
135
|
+
<div className="mx-auto max-w-6xl">
|
|
136
|
+
<div className="flex items-center justify-between mb-4">
|
|
137
|
+
<h1 className="text-xl sm:text-2xl font-bold text-text-primary">Admin</h1>
|
|
138
|
+
<Link
|
|
139
|
+
to={appPath('/')}
|
|
140
|
+
className="flex items-center justify-center rounded-lg p-2 text-text-muted hover:text-text-primary hover:bg-background-secondary"
|
|
141
|
+
aria-label="Close"
|
|
142
|
+
>
|
|
143
|
+
<X size={20} />
|
|
144
|
+
</Link>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div className="flex items-center gap-2 mb-6 sm:mb-8">
|
|
148
|
+
<Link
|
|
149
|
+
to={appPath('/admin')}
|
|
150
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
151
|
+
>
|
|
152
|
+
<LayoutDashboard size={16} />
|
|
153
|
+
Overview
|
|
154
|
+
</Link>
|
|
155
|
+
<Link
|
|
156
|
+
to={appPath('/admin/users')}
|
|
157
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
158
|
+
>
|
|
159
|
+
<Users size={16} />
|
|
160
|
+
Users
|
|
161
|
+
</Link>
|
|
162
|
+
{config.teams?.enabled && (
|
|
163
|
+
<Link
|
|
164
|
+
to={appPath('/admin/teams')}
|
|
165
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
166
|
+
>
|
|
167
|
+
<Building2 size={16} />
|
|
168
|
+
Teams
|
|
169
|
+
</Link>
|
|
170
|
+
)}
|
|
171
|
+
<Link
|
|
172
|
+
to={appPath('/admin/waitlist')}
|
|
173
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
174
|
+
>
|
|
175
|
+
<Mail size={16} />
|
|
176
|
+
Waitlist
|
|
177
|
+
</Link>
|
|
178
|
+
<span className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-2 text-sm font-medium text-white">
|
|
179
|
+
<Tag size={16} />
|
|
180
|
+
Promo Codes
|
|
181
|
+
</span>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{!promoEnabled && (
|
|
185
|
+
<div className="mb-6 rounded-lg bg-warning/10 p-4 text-sm text-warning">
|
|
186
|
+
Promo codes are disabled in config. Enable `credits.promoEnabled` to use this page.
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{error && (
|
|
191
|
+
<div className="mb-6 rounded-lg bg-error/10 p-4 text-sm text-error">
|
|
192
|
+
{error}
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{success && (
|
|
197
|
+
<div className="mb-6 rounded-lg bg-success/10 p-4 text-sm text-success">
|
|
198
|
+
{success}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
203
|
+
<div className="lg:col-span-1 rounded-lg bg-background-secondary p-4">
|
|
204
|
+
<h2 className="text-base font-semibold text-text-primary mb-4">Create Promo Code</h2>
|
|
205
|
+
<form onSubmit={handleCreatePromoCode} className="space-y-3">
|
|
206
|
+
<div>
|
|
207
|
+
<label className="block text-sm font-medium text-text-primary" htmlFor="promo-code">
|
|
208
|
+
Code
|
|
209
|
+
</label>
|
|
210
|
+
<input
|
|
211
|
+
id="promo-code"
|
|
212
|
+
value={form.code}
|
|
213
|
+
onChange={(e) => setForm((prev) => ({ ...prev, code: e.target.value }))}
|
|
214
|
+
className="mt-1 w-full rounded-lg border border-input-border bg-input-background px-3 py-2 text-text-primary focus:border-primary focus:outline-none"
|
|
215
|
+
placeholder="WELCOME10"
|
|
216
|
+
required
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
<div className="grid grid-cols-2 gap-3">
|
|
220
|
+
<div>
|
|
221
|
+
<label className="block text-sm font-medium text-text-primary" htmlFor="promo-credits">
|
|
222
|
+
Credits
|
|
223
|
+
</label>
|
|
224
|
+
<input
|
|
225
|
+
id="promo-credits"
|
|
226
|
+
type="number"
|
|
227
|
+
min={1}
|
|
228
|
+
value={form.credits}
|
|
229
|
+
onChange={(e) => setForm((prev) => ({ ...prev, credits: Number(e.target.value) }))}
|
|
230
|
+
className="mt-1 w-full rounded-lg border border-input-border bg-input-background px-3 py-2 text-text-primary focus:border-primary focus:outline-none"
|
|
231
|
+
required
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
<div>
|
|
235
|
+
<label className="block text-sm font-medium text-text-primary" htmlFor="promo-uses">
|
|
236
|
+
Max Uses
|
|
237
|
+
</label>
|
|
238
|
+
<input
|
|
239
|
+
id="promo-uses"
|
|
240
|
+
type="number"
|
|
241
|
+
min={1}
|
|
242
|
+
value={form.maxUses}
|
|
243
|
+
onChange={(e) => setForm((prev) => ({ ...prev, maxUses: Number(e.target.value) }))}
|
|
244
|
+
className="mt-1 w-full rounded-lg border border-input-border bg-input-background px-3 py-2 text-text-primary focus:border-primary focus:outline-none"
|
|
245
|
+
required
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
<div>
|
|
250
|
+
<label className="block text-sm font-medium text-text-primary" htmlFor="promo-start">
|
|
251
|
+
Starts At (optional)
|
|
252
|
+
</label>
|
|
253
|
+
<input
|
|
254
|
+
id="promo-start"
|
|
255
|
+
type="datetime-local"
|
|
256
|
+
value={form.startsAt}
|
|
257
|
+
onChange={(e) => setForm((prev) => ({ ...prev, startsAt: e.target.value }))}
|
|
258
|
+
className="mt-1 w-full rounded-lg border border-input-border bg-input-background px-3 py-2 text-text-primary focus:border-primary focus:outline-none"
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
<div>
|
|
262
|
+
<label className="block text-sm font-medium text-text-primary" htmlFor="promo-end">
|
|
263
|
+
Ends At (optional)
|
|
264
|
+
</label>
|
|
265
|
+
<input
|
|
266
|
+
id="promo-end"
|
|
267
|
+
type="datetime-local"
|
|
268
|
+
value={form.endsAt}
|
|
269
|
+
onChange={(e) => setForm((prev) => ({ ...prev, endsAt: e.target.value }))}
|
|
270
|
+
className="mt-1 w-full rounded-lg border border-input-border bg-input-background px-3 py-2 text-text-primary focus:border-primary focus:outline-none"
|
|
271
|
+
/>
|
|
272
|
+
</div>
|
|
273
|
+
<div>
|
|
274
|
+
<label className="block text-sm font-medium text-text-primary" htmlFor="promo-expires">
|
|
275
|
+
Credits Expire At (optional)
|
|
276
|
+
</label>
|
|
277
|
+
<input
|
|
278
|
+
id="promo-expires"
|
|
279
|
+
type="datetime-local"
|
|
280
|
+
value={form.creditsExpiresAt}
|
|
281
|
+
onChange={(e) => setForm((prev) => ({ ...prev, creditsExpiresAt: e.target.value }))}
|
|
282
|
+
className="mt-1 w-full rounded-lg border border-input-border bg-input-background px-3 py-2 text-text-primary focus:border-primary focus:outline-none"
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
<button
|
|
286
|
+
type="submit"
|
|
287
|
+
disabled={!promoEnabled}
|
|
288
|
+
className="w-full rounded-lg bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover disabled:opacity-50"
|
|
289
|
+
>
|
|
290
|
+
Create promo code
|
|
291
|
+
</button>
|
|
292
|
+
</form>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<div className="lg:col-span-2 rounded-lg bg-background-secondary overflow-hidden">
|
|
296
|
+
<div className="px-4 py-3 bg-background flex flex-wrap items-center gap-3">
|
|
297
|
+
<input
|
|
298
|
+
type="text"
|
|
299
|
+
placeholder="Search code..."
|
|
300
|
+
value={search}
|
|
301
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
302
|
+
className="flex-1 min-w-[160px] rounded-lg border border-input-border bg-input-background px-3 py-2 text-sm text-text-primary focus:border-primary focus:outline-none"
|
|
303
|
+
/>
|
|
304
|
+
<select
|
|
305
|
+
value={statusFilter}
|
|
306
|
+
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
|
307
|
+
className="rounded-lg border border-input-border bg-input-background px-3 py-2 text-sm text-text-primary focus:border-primary focus:outline-none"
|
|
308
|
+
>
|
|
309
|
+
<option value="all">All</option>
|
|
310
|
+
<option value="active">Active</option>
|
|
311
|
+
<option value="scheduled">Scheduled</option>
|
|
312
|
+
<option value="expired">Expired</option>
|
|
313
|
+
</select>
|
|
314
|
+
</div>
|
|
315
|
+
<div className="hidden md:block px-4 py-3 bg-background">
|
|
316
|
+
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-text-muted">
|
|
317
|
+
<div className="col-span-3">Code</div>
|
|
318
|
+
<div className="col-span-2">Credits</div>
|
|
319
|
+
<div className="col-span-2">Uses</div>
|
|
320
|
+
<div className="col-span-2">Active</div>
|
|
321
|
+
<div className="col-span-3">Actions</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<div className="divide-y divide-background">
|
|
326
|
+
{isLoading ? (
|
|
327
|
+
<div className="px-4 py-8 text-center text-text-muted">Loading...</div>
|
|
328
|
+
) : promoCodes.length === 0 ? (
|
|
329
|
+
<div className="px-4 py-8 text-center text-text-muted">No promo codes yet</div>
|
|
330
|
+
) : (
|
|
331
|
+
promoCodes.map((promo) => {
|
|
332
|
+
const now = new Date();
|
|
333
|
+
const startsAt = promo.startsAt ? new Date(promo.startsAt) : null;
|
|
334
|
+
const endsAt = promo.endsAt ? new Date(promo.endsAt) : null;
|
|
335
|
+
const active = (!startsAt || startsAt <= now) && (!endsAt || endsAt >= now);
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div key={promo.id} className="px-4 py-3 hover:bg-background/50">
|
|
339
|
+
<div className="grid grid-cols-1 md:grid-cols-12 gap-2 md:gap-4 text-sm">
|
|
340
|
+
<div className="md:col-span-3 font-medium text-text-primary flex items-center gap-2">
|
|
341
|
+
<span>{promo.code}</span>
|
|
342
|
+
<button
|
|
343
|
+
type="button"
|
|
344
|
+
onClick={() => handleCopy(promo.code)}
|
|
345
|
+
className="text-xs text-primary hover:underline"
|
|
346
|
+
>
|
|
347
|
+
{copiedCode === promo.code ? 'Copied' : 'Copy'}
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
<div className="md:col-span-2 text-text-secondary">{promo.credits}</div>
|
|
351
|
+
<div className="md:col-span-2 text-text-secondary">
|
|
352
|
+
{promo.redeemedCount} / {promo.maxUses}
|
|
353
|
+
</div>
|
|
354
|
+
<div className={`md:col-span-2 ${active ? 'text-success' : 'text-text-muted'}`}>
|
|
355
|
+
{active ? 'Active' : 'Inactive'}
|
|
356
|
+
</div>
|
|
357
|
+
<div className="md:col-span-3 text-text-muted flex items-center gap-2">
|
|
358
|
+
<span>{new Date(promo.createdAt).toLocaleDateString()}</span>
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
onClick={() => (active ? handleDeactivate(promo) : handleReactivate(promo))}
|
|
362
|
+
className="rounded-lg border border-input-border px-2 py-1 text-xs text-text-primary hover:bg-background"
|
|
363
|
+
>
|
|
364
|
+
{active ? 'Deactivate' : 'Reactivate'}
|
|
365
|
+
</button>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
})
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
import { Link, useParams } from 'react-router';
|
|
3
|
-
import { LayoutDashboard, X, Users, Building2 } from 'lucide-react';
|
|
3
|
+
import { LayoutDashboard, X, Users, Building2, Mail, Tag } from 'lucide-react';
|
|
4
4
|
import { useAppPath } from '../hooks/useAppPath';
|
|
5
5
|
import { api } from '../utils/api';
|
|
6
6
|
import type { AdminTeamDetails } from '@chaaskit/shared';
|
|
@@ -86,6 +86,20 @@ export default function AdminTeamPage() {
|
|
|
86
86
|
<Building2 size={16} />
|
|
87
87
|
Teams
|
|
88
88
|
</Link>
|
|
89
|
+
<Link
|
|
90
|
+
to={appPath('/admin/waitlist')}
|
|
91
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
92
|
+
>
|
|
93
|
+
<Mail size={16} />
|
|
94
|
+
Waitlist
|
|
95
|
+
</Link>
|
|
96
|
+
<Link
|
|
97
|
+
to={appPath('/admin/promo-codes')}
|
|
98
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
99
|
+
>
|
|
100
|
+
<Tag size={16} />
|
|
101
|
+
Promo Codes
|
|
102
|
+
</Link>
|
|
89
103
|
</div>
|
|
90
104
|
|
|
91
105
|
<div className="rounded-lg bg-error/10 p-4 text-sm text-error">
|
|
@@ -142,6 +156,20 @@ export default function AdminTeamPage() {
|
|
|
142
156
|
<Building2 size={16} />
|
|
143
157
|
Teams
|
|
144
158
|
</Link>
|
|
159
|
+
<Link
|
|
160
|
+
to={appPath('/admin/waitlist')}
|
|
161
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
162
|
+
>
|
|
163
|
+
<Mail size={16} />
|
|
164
|
+
Waitlist
|
|
165
|
+
</Link>
|
|
166
|
+
<Link
|
|
167
|
+
to={appPath('/admin/promo-codes')}
|
|
168
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
169
|
+
>
|
|
170
|
+
<Tag size={16} />
|
|
171
|
+
Promo Codes
|
|
172
|
+
</Link>
|
|
145
173
|
</div>
|
|
146
174
|
|
|
147
175
|
{/* Team Header */}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react';
|
|
2
2
|
import { Link } from 'react-router';
|
|
3
|
-
import { LayoutDashboard, X, Users, Building2 } from 'lucide-react';
|
|
3
|
+
import { LayoutDashboard, X, Users, Building2, Mail, Tag } from 'lucide-react';
|
|
4
4
|
import { useAppPath } from '../hooks/useAppPath';
|
|
5
5
|
import { api } from '../utils/api';
|
|
6
6
|
import type { AdminTeam, AdminTeamsResponse } from '@chaaskit/shared';
|
|
@@ -91,6 +91,20 @@ export default function AdminTeamsPage() {
|
|
|
91
91
|
<Building2 size={16} />
|
|
92
92
|
Teams
|
|
93
93
|
</Link>
|
|
94
|
+
<Link
|
|
95
|
+
to={appPath('/admin/waitlist')}
|
|
96
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
97
|
+
>
|
|
98
|
+
<Mail size={16} />
|
|
99
|
+
Waitlist
|
|
100
|
+
</Link>
|
|
101
|
+
<Link
|
|
102
|
+
to={appPath('/admin/promo-codes')}
|
|
103
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
104
|
+
>
|
|
105
|
+
<Tag size={16} />
|
|
106
|
+
Promo Codes
|
|
107
|
+
</Link>
|
|
94
108
|
</div>
|
|
95
109
|
|
|
96
110
|
{error && (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react';
|
|
2
2
|
import { Link } from 'react-router';
|
|
3
|
-
import { LayoutDashboard, X, Users, Building2 } from 'lucide-react';
|
|
3
|
+
import { LayoutDashboard, X, Users, Building2, Mail, Tag } from 'lucide-react';
|
|
4
4
|
import { useConfig } from '../contexts/ConfigContext';
|
|
5
5
|
import { useAppPath } from '../hooks/useAppPath';
|
|
6
6
|
import { api } from '../utils/api';
|
|
@@ -134,6 +134,20 @@ export default function AdminUsersPage() {
|
|
|
134
134
|
Teams
|
|
135
135
|
</Link>
|
|
136
136
|
)}
|
|
137
|
+
<Link
|
|
138
|
+
to={appPath('/admin/waitlist')}
|
|
139
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
140
|
+
>
|
|
141
|
+
<Mail size={16} />
|
|
142
|
+
Waitlist
|
|
143
|
+
</Link>
|
|
144
|
+
<Link
|
|
145
|
+
to={appPath('/admin/promo-codes')}
|
|
146
|
+
className="flex items-center gap-1.5 rounded-full bg-background-secondary px-4 py-2 text-sm font-medium text-text-secondary hover:bg-background-secondary/80"
|
|
147
|
+
>
|
|
148
|
+
<Tag size={16} />
|
|
149
|
+
Promo Codes
|
|
150
|
+
</Link>
|
|
137
151
|
</div>
|
|
138
152
|
|
|
139
153
|
{error && (
|