@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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/dist/lib/index.js +1023 -160
  3. package/dist/lib/index.js.map +1 -1
  4. package/dist/lib/routes/AcceptInviteRoute.js +1 -1
  5. package/dist/lib/routes/AcceptInviteRoute.js.map +1 -1
  6. package/dist/lib/routes/AdminDashboardRoute.js +1 -1
  7. package/dist/lib/routes/AdminDashboardRoute.js.map +1 -1
  8. package/dist/lib/routes/AdminPromoCodesRoute.js +19 -0
  9. package/dist/lib/routes/AdminPromoCodesRoute.js.map +1 -0
  10. package/dist/lib/routes/AdminTeamRoute.js +1 -1
  11. package/dist/lib/routes/AdminTeamRoute.js.map +1 -1
  12. package/dist/lib/routes/AdminTeamsRoute.js +1 -1
  13. package/dist/lib/routes/AdminTeamsRoute.js.map +1 -1
  14. package/dist/lib/routes/AdminUsersRoute.js +1 -1
  15. package/dist/lib/routes/AdminUsersRoute.js.map +1 -1
  16. package/dist/lib/routes/AdminWaitlistRoute.js +19 -0
  17. package/dist/lib/routes/AdminWaitlistRoute.js.map +1 -0
  18. package/dist/lib/routes/ApiKeysRoute.js +1 -1
  19. package/dist/lib/routes/ApiKeysRoute.js.map +1 -1
  20. package/dist/lib/routes/AutomationsRoute.js +1 -1
  21. package/dist/lib/routes/AutomationsRoute.js.map +1 -1
  22. package/dist/lib/routes/ChatRoute.js +1 -1
  23. package/dist/lib/routes/ChatRoute.js.map +1 -1
  24. package/dist/lib/routes/DocumentsRoute.js +1 -1
  25. package/dist/lib/routes/DocumentsRoute.js.map +1 -1
  26. package/dist/lib/routes/OAuthConsentRoute.js +1 -1
  27. package/dist/lib/routes/OAuthConsentRoute.js.map +1 -1
  28. package/dist/lib/routes/PricingRoute.js +1 -1
  29. package/dist/lib/routes/PricingRoute.js.map +1 -1
  30. package/dist/lib/routes/PrivacyRoute.js +1 -1
  31. package/dist/lib/routes/PrivacyRoute.js.map +1 -1
  32. package/dist/lib/routes/TeamSettingsRoute.js +1 -1
  33. package/dist/lib/routes/TeamSettingsRoute.js.map +1 -1
  34. package/dist/lib/routes/TermsRoute.js +1 -1
  35. package/dist/lib/routes/TermsRoute.js.map +1 -1
  36. package/dist/lib/routes/VerifyEmailRoute.js +1 -1
  37. package/dist/lib/routes/VerifyEmailRoute.js.map +1 -1
  38. package/dist/lib/routes.js +47 -37
  39. package/dist/lib/routes.js.map +1 -1
  40. package/dist/lib/ssr-utils.js +64 -1
  41. package/dist/lib/ssr-utils.js.map +1 -1
  42. package/dist/lib/ssr.js +23 -0
  43. package/dist/lib/ssr.js.map +1 -1
  44. package/dist/lib/styles.css +58 -62
  45. package/dist/lib/useExtensions-B5nX_8XD.js.map +1 -1
  46. package/package.json +25 -12
  47. package/src/components/MessageItem.tsx +35 -4
  48. package/src/components/MessageList.tsx +51 -5
  49. package/src/components/OAuthAppsSection.tsx +1 -1
  50. package/src/components/Sidebar.tsx +1 -3
  51. package/src/components/ToolCallDisplay.tsx +102 -11
  52. package/src/components/tool-renderers/DocumentListRenderer.tsx +44 -0
  53. package/src/components/tool-renderers/DocumentReadRenderer.tsx +33 -0
  54. package/src/components/tool-renderers/DocumentSaveRenderer.tsx +32 -0
  55. package/src/components/tool-renderers/DocumentSearchRenderer.tsx +33 -0
  56. package/src/components/tool-renderers/index.ts +36 -0
  57. package/src/components/tool-renderers/utils.ts +7 -0
  58. package/src/contexts/AuthContext.tsx +16 -6
  59. package/src/contexts/ConfigContext.tsx +60 -28
  60. package/src/contexts/ThemeContext.tsx +39 -68
  61. package/src/extensions/registry.ts +2 -1
  62. package/src/hooks/__tests__/basePath.test.ts +42 -0
  63. package/src/index.tsx +11 -2
  64. package/src/pages/AdminDashboardPage.tsx +15 -1
  65. package/src/pages/AdminPromoCodesPage.tsx +378 -0
  66. package/src/pages/AdminTeamPage.tsx +29 -1
  67. package/src/pages/AdminTeamsPage.tsx +15 -1
  68. package/src/pages/AdminUsersPage.tsx +15 -1
  69. package/src/pages/AdminWaitlistPage.tsx +156 -0
  70. package/src/pages/RegisterPage.tsx +91 -9
  71. package/src/routes/AcceptInviteRoute.tsx +1 -1
  72. package/src/routes/AdminDashboardRoute.tsx +1 -1
  73. package/src/routes/AdminPromoCodesRoute.tsx +24 -0
  74. package/src/routes/AdminTeamRoute.tsx +1 -1
  75. package/src/routes/AdminTeamsRoute.tsx +1 -1
  76. package/src/routes/AdminUsersRoute.tsx +1 -1
  77. package/src/routes/AdminWaitlistRoute.tsx +24 -0
  78. package/src/routes/ApiKeysRoute.tsx +1 -1
  79. package/src/routes/AutomationsRoute.tsx +1 -1
  80. package/src/routes/ChatRoute.tsx +2 -1
  81. package/src/routes/DocumentsRoute.tsx +1 -1
  82. package/src/routes/OAuthConsentRoute.tsx +1 -1
  83. package/src/routes/PricingRoute.tsx +1 -1
  84. package/src/routes/PrivacyRoute.tsx +1 -1
  85. package/src/routes/TeamSettingsRoute.tsx +1 -1
  86. package/src/routes/TermsRoute.tsx +1 -1
  87. package/src/routes/VerifyEmailRoute.tsx +1 -1
  88. package/src/routes/index.ts +2 -0
  89. package/src/ssr-utils.tsx +100 -1
  90. package/src/ssr.ts +59 -0
  91. package/src/stores/chatStore.ts +5 -0
  92. package/src/styles/index.css +16 -63
  93. package/src/tailwind-preset.js +360 -0
  94. package/dist/favicon.svg +0 -11
  95. package/dist/index.html +0 -17
  96. 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
- export function ThemeProvider({ children }: { children: ReactNode }) {
19
- const config = useConfig();
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 config default
22
- const stored = localStorage.getItem('theme');
23
- if (stored && config.theming.themes[stored]) {
24
- return stored;
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 config.theming.defaultTheme;
54
+ return defaultTheme;
27
55
  });
28
56
 
29
- const availableThemes = Object.keys(config.theming.themes);
30
-
31
57
  useEffect(() => {
32
- // Apply theme to document
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 (config.theming.themes[newTheme]) {
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<{ result: unknown }>;
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
+ }