@chaaskit/client 0.1.0 → 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 +1023 -160
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/routes/AcceptInviteRoute.js +1 -1
- package/dist/lib/routes/AcceptInviteRoute.js.map +1 -1
- package/dist/lib/routes/AdminDashboardRoute.js +1 -1
- package/dist/lib/routes/AdminDashboardRoute.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/AdminTeamRoute.js +1 -1
- package/dist/lib/routes/AdminTeamRoute.js.map +1 -1
- package/dist/lib/routes/AdminTeamsRoute.js +1 -1
- package/dist/lib/routes/AdminTeamsRoute.js.map +1 -1
- package/dist/lib/routes/AdminUsersRoute.js +1 -1
- package/dist/lib/routes/AdminUsersRoute.js.map +1 -1
- package/dist/lib/routes/AdminWaitlistRoute.js +19 -0
- package/dist/lib/routes/AdminWaitlistRoute.js.map +1 -0
- package/dist/lib/routes/ApiKeysRoute.js +1 -1
- package/dist/lib/routes/ApiKeysRoute.js.map +1 -1
- package/dist/lib/routes/AutomationsRoute.js +1 -1
- package/dist/lib/routes/AutomationsRoute.js.map +1 -1
- package/dist/lib/routes/ChatRoute.js +1 -1
- package/dist/lib/routes/ChatRoute.js.map +1 -1
- package/dist/lib/routes/DocumentsRoute.js +1 -1
- package/dist/lib/routes/DocumentsRoute.js.map +1 -1
- package/dist/lib/routes/OAuthConsentRoute.js +1 -1
- package/dist/lib/routes/OAuthConsentRoute.js.map +1 -1
- package/dist/lib/routes/PricingRoute.js +1 -1
- package/dist/lib/routes/PricingRoute.js.map +1 -1
- package/dist/lib/routes/PrivacyRoute.js +1 -1
- package/dist/lib/routes/PrivacyRoute.js.map +1 -1
- package/dist/lib/routes/TeamSettingsRoute.js +1 -1
- package/dist/lib/routes/TeamSettingsRoute.js.map +1 -1
- package/dist/lib/routes/TermsRoute.js +1 -1
- package/dist/lib/routes/TermsRoute.js.map +1 -1
- package/dist/lib/routes/VerifyEmailRoute.js +1 -1
- package/dist/lib/routes/VerifyEmailRoute.js.map +1 -1
- package/dist/lib/routes.js +47 -37
- package/dist/lib/routes.js.map +1 -1
- package/dist/lib/ssr-utils.js +64 -1
- package/dist/lib/ssr-utils.js.map +1 -1
- package/dist/lib/ssr.js +23 -0
- package/dist/lib/ssr.js.map +1 -1
- package/dist/lib/styles.css +58 -62
- package/dist/lib/useExtensions-B5nX_8XD.js.map +1 -1
- package/package.json +25 -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 +60 -28
- package/src/contexts/ThemeContext.tsx +39 -68
- package/src/extensions/registry.ts +2 -1
- package/src/hooks/__tests__/basePath.test.ts +42 -0
- package/src/index.tsx +11 -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/AcceptInviteRoute.tsx +1 -1
- package/src/routes/AdminDashboardRoute.tsx +1 -1
- package/src/routes/AdminPromoCodesRoute.tsx +24 -0
- package/src/routes/AdminTeamRoute.tsx +1 -1
- package/src/routes/AdminTeamsRoute.tsx +1 -1
- package/src/routes/AdminUsersRoute.tsx +1 -1
- package/src/routes/AdminWaitlistRoute.tsx +24 -0
- package/src/routes/ApiKeysRoute.tsx +1 -1
- package/src/routes/AutomationsRoute.tsx +1 -1
- package/src/routes/ChatRoute.tsx +2 -1
- package/src/routes/DocumentsRoute.tsx +1 -1
- package/src/routes/OAuthConsentRoute.tsx +1 -1
- package/src/routes/PricingRoute.tsx +1 -1
- package/src/routes/PrivacyRoute.tsx +1 -1
- package/src/routes/TeamSettingsRoute.tsx +1 -1
- package/src/routes/TermsRoute.tsx +1 -1
- package/src/routes/VerifyEmailRoute.tsx +1 -1
- package/src/routes/index.ts +2 -0
- package/src/ssr-utils.tsx +100 -1
- package/src/ssr.ts +59 -0
- package/src/stores/chatStore.ts +5 -0
- package/src/styles/index.css +16 -63
- package/src/tailwind-preset.js +360 -0
- package/dist/favicon.svg +0 -11
- package/dist/index.html +0 -17
- package/dist/logo.svg +0 -12
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
useEffect,
|
|
6
6
|
type ReactNode,
|
|
7
7
|
} from 'react';
|
|
8
|
-
import { useConfig } from './ConfigContext';
|
|
9
8
|
|
|
10
9
|
interface ThemeContextType {
|
|
11
10
|
theme: string;
|
|
@@ -15,73 +14,56 @@ interface ThemeContextType {
|
|
|
15
14
|
|
|
16
15
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Default available themes.
|
|
19
|
+
* Apps can override this via the ThemeProvider's availableThemes prop.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_THEMES = ['light', 'dark'];
|
|
22
|
+
const DEFAULT_THEME = 'dark';
|
|
23
|
+
|
|
24
|
+
interface ThemeProviderProps {
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
/** Available theme names. Defaults to ['light', 'dark'] */
|
|
27
|
+
availableThemes?: string[];
|
|
28
|
+
/** Default theme if none stored. Defaults to 'dark' */
|
|
29
|
+
defaultTheme?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ThemeProvider manages the current theme via data-theme attribute.
|
|
34
|
+
*
|
|
35
|
+
* Theme CSS variables should be defined in your Tailwind config using
|
|
36
|
+
* html[data-theme="themeName"] selectors. This provider only manages:
|
|
37
|
+
* - The data-theme attribute on <html>
|
|
38
|
+
* - localStorage persistence
|
|
39
|
+
* - Cookie sync for SSR
|
|
40
|
+
*/
|
|
41
|
+
export function ThemeProvider({
|
|
42
|
+
children,
|
|
43
|
+
availableThemes = DEFAULT_THEMES,
|
|
44
|
+
defaultTheme = DEFAULT_THEME,
|
|
45
|
+
}: ThemeProviderProps) {
|
|
20
46
|
const [theme, setThemeState] = useState(() => {
|
|
21
|
-
// Check localStorage first, then
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
47
|
+
// Check localStorage first, then use default
|
|
48
|
+
if (typeof window !== 'undefined') {
|
|
49
|
+
const stored = localStorage.getItem('theme');
|
|
50
|
+
if (stored && availableThemes.includes(stored)) {
|
|
51
|
+
return stored;
|
|
52
|
+
}
|
|
25
53
|
}
|
|
26
|
-
return
|
|
54
|
+
return defaultTheme;
|
|
27
55
|
});
|
|
28
56
|
|
|
29
|
-
const availableThemes = Object.keys(config.theming.themes);
|
|
30
|
-
|
|
31
57
|
useEffect(() => {
|
|
32
|
-
//
|
|
58
|
+
// Set data-theme attribute - CSS selectors handle the actual styling
|
|
33
59
|
document.documentElement.setAttribute('data-theme', theme);
|
|
34
60
|
localStorage.setItem('theme', theme);
|
|
35
61
|
// Set cookie for SSR to read
|
|
36
62
|
document.cookie = `theme=${theme};path=/;max-age=31536000;SameSite=Lax`;
|
|
37
|
-
|
|
38
|
-
// Apply theme colors as CSS variables
|
|
39
|
-
const themeConfig = config.theming.themes[theme];
|
|
40
|
-
if (themeConfig) {
|
|
41
|
-
const root = document.documentElement;
|
|
42
|
-
const colors = themeConfig.colors;
|
|
43
|
-
|
|
44
|
-
// Convert hex colors to RGB values
|
|
45
|
-
Object.entries(colors).forEach(([key, value]) => {
|
|
46
|
-
const cssKey = `--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
|
47
|
-
const rgb = hexToRgb(value);
|
|
48
|
-
if (rgb) {
|
|
49
|
-
root.style.setProperty(cssKey, `${rgb.r} ${rgb.g} ${rgb.b}`);
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Apply fonts
|
|
55
|
-
document.documentElement.style.setProperty(
|
|
56
|
-
'--font-sans',
|
|
57
|
-
config.theming.fonts.sans
|
|
58
|
-
);
|
|
59
|
-
document.documentElement.style.setProperty(
|
|
60
|
-
'--font-mono',
|
|
61
|
-
config.theming.fonts.mono
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
// Apply border radius
|
|
65
|
-
document.documentElement.style.setProperty(
|
|
66
|
-
'--radius-sm',
|
|
67
|
-
config.theming.borderRadius.sm
|
|
68
|
-
);
|
|
69
|
-
document.documentElement.style.setProperty(
|
|
70
|
-
'--radius-md',
|
|
71
|
-
config.theming.borderRadius.md
|
|
72
|
-
);
|
|
73
|
-
document.documentElement.style.setProperty(
|
|
74
|
-
'--radius-lg',
|
|
75
|
-
config.theming.borderRadius.lg
|
|
76
|
-
);
|
|
77
|
-
document.documentElement.style.setProperty(
|
|
78
|
-
'--radius-full',
|
|
79
|
-
config.theming.borderRadius.full
|
|
80
|
-
);
|
|
81
|
-
}, [theme, config.theming]);
|
|
63
|
+
}, [theme]);
|
|
82
64
|
|
|
83
65
|
function setTheme(newTheme: string) {
|
|
84
|
-
if (
|
|
66
|
+
if (availableThemes.includes(newTheme)) {
|
|
85
67
|
setThemeState(newTheme);
|
|
86
68
|
}
|
|
87
69
|
}
|
|
@@ -100,14 +82,3 @@ export function useTheme() {
|
|
|
100
82
|
}
|
|
101
83
|
return context;
|
|
102
84
|
}
|
|
103
|
-
|
|
104
|
-
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
105
|
-
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
106
|
-
return result
|
|
107
|
-
? {
|
|
108
|
-
r: parseInt(result[1]!, 16),
|
|
109
|
-
g: parseInt(result[2]!, 16),
|
|
110
|
-
b: parseInt(result[3]!, 16),
|
|
111
|
-
}
|
|
112
|
-
: null;
|
|
113
|
-
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
|
+
import type { ToolCall, ToolResult } from '@chaaskit/shared';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Page extension configuration
|
|
@@ -31,7 +32,7 @@ export interface ToolExtension {
|
|
|
31
32
|
/** Description of the tool */
|
|
32
33
|
description: string;
|
|
33
34
|
/** Custom renderer for tool results */
|
|
34
|
-
resultRenderer?: ComponentType<{
|
|
35
|
+
resultRenderer?: ComponentType<{ toolCall: ToolCall; toolResult: ToolResult }>;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -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,12 +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';
|
|
77
|
+
import type { PublicAppConfig } from '@chaaskit/shared';
|
|
74
78
|
import { AuthProvider as Auth } from './contexts/AuthContext';
|
|
75
79
|
import { ThemeProvider as Theme } from './contexts/ThemeContext';
|
|
76
80
|
import { ConfigProvider as Config } from './contexts/ConfigContext';
|
|
@@ -79,6 +83,11 @@ import { ProjectProvider as Project } from './contexts/ProjectContext';
|
|
|
79
83
|
|
|
80
84
|
export interface ChatProvidersProps {
|
|
81
85
|
children: React.ReactNode;
|
|
86
|
+
/**
|
|
87
|
+
* Initial config to use immediately, avoiding a flash of default values.
|
|
88
|
+
* Pass the config from SSR loaders to prevent "Welcome to AI Chat" flash.
|
|
89
|
+
*/
|
|
90
|
+
initialConfig?: PublicAppConfig;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
/**
|
|
@@ -99,9 +108,9 @@ export interface ChatProvidersProps {
|
|
|
99
108
|
* }
|
|
100
109
|
* ```
|
|
101
110
|
*/
|
|
102
|
-
export function ChatProviders({ children }: ChatProvidersProps) {
|
|
111
|
+
export function ChatProviders({ children, initialConfig }: ChatProvidersProps) {
|
|
103
112
|
return (
|
|
104
|
-
<Config>
|
|
113
|
+
<Config initialConfig={initialConfig}>
|
|
105
114
|
<Theme>
|
|
106
115
|
<Auth>
|
|
107
116
|
<Team>
|
|
@@ -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
|
+
}
|