mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Add invite codes feature (#94)
* Refactor import statement in store configuration for clarity - Combined the import of `configureStore` and `Middleware` from '@reduxjs/toolkit' into a single line for improved readability. * Add invite codes feature with onboarding step and dedicated page Implement frontend for the invite codes system: users get 5 invite codes to share, can redeem codes for free credits, and new users are prompted during onboarding (step 1) to enter an invite code. - Add invite types, API service, and Redux slice - Add InviteCodeStep as first onboarding step (skip-able) - Add /invites page with redeem input and code list with copy buttons - Add "Invite Friends" nav item to sidebar - Update UserReferral interface to match backend PR #418 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update reqwest dependency to enable HTTP/2 support and switch to rustls TLS in network requests - Modified Cargo.toml to include the "http2" feature for reqwest. - Updated network request implementations in bridge and ops_net modules to use rustls TLS instead of native TLS for improved security and compatibility. * Update event loop to handle async tool calls and improve message processing - Introduced a `PendingToolCall` struct to manage in-flight async tool calls. - Enhanced the event loop to check for completion of async tool calls and handle timeouts. - Updated message handling to support async tool execution, allowing the event loop to process other messages concurrently. - Refactored `handle_tool_call` to differentiate between synchronous and asynchronous tool results. - Modified JavaScript fetch functions to use async/await for improved readability and performance. * Enhance skill instance with initial ping verification and job driving - Added an immediate ping to verify the connection health during skill execution, logging the result or any errors encountered. - Updated the event loop to drive jobs asynchronously after the initial ping check. - Removed unnecessary logging statements from the network operations for cleaner output. * Update subproject commit reference in skills directory --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
969503eec2
commit
e23b3643bd
@@ -7,6 +7,7 @@ import PublicRoute from './components/PublicRoute';
|
||||
import Agents from './pages/Agents';
|
||||
import Conversations from './pages/Conversations';
|
||||
import Home from './pages/Home';
|
||||
import Invites from './pages/Invites';
|
||||
import Login from './pages/Login';
|
||||
import Onboarding from './pages/onboarding/Onboarding';
|
||||
import Settings from './pages/Settings';
|
||||
@@ -97,6 +98,16 @@ const AppRoutes = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Invites */}
|
||||
<Route
|
||||
path="/invites"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<Invites />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Agents */}
|
||||
<Route
|
||||
path="/agents"
|
||||
|
||||
@@ -48,6 +48,21 @@ const navItems = [
|
||||
// </svg>
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
id: 'invites',
|
||||
label: 'Invite Friends',
|
||||
path: '/invites',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useUser } from '../hooks/useUser';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { clearRedeemStatus, fetchInviteCodes, redeemCode } from '../store/inviteSlice';
|
||||
import type { InviteCode } from '../types/invite';
|
||||
|
||||
const CodeRow = ({ invite }: { invite: InviteCode }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const claimed = invite.currentUses >= invite.maxUses;
|
||||
const claimedUser = invite.usageHistory[0]?.userId;
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(invite.code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const displayName = claimedUser?.username
|
||||
? `@${claimedUser.username}`
|
||||
: claimedUser?.firstName || 'Someone';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-white/5 hover:bg-white/[0.07] transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-mono text-sm tracking-wider">{invite.code}</span>
|
||||
{claimed && (
|
||||
<p className="text-xs text-stone-500 mt-0.5">
|
||||
Claimed by {displayName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
{claimed ? (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-stone-700/50 text-stone-400">
|
||||
Used
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-sage-500/20 text-sage-500">
|
||||
Available
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-stone-400 hover:text-stone-200"
|
||||
title="Copy code">
|
||||
{copied ? (
|
||||
<svg className="w-4 h-4 text-sage-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Invites = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { user, refetch: refetchUser } = useUser();
|
||||
const { codes, isLoading, redeemStatus, redeemError } = useAppSelector(state => state.invite);
|
||||
|
||||
const [redeemInput, setRedeemInput] = useState('');
|
||||
const hasBeenInvited = !!user?.referral?.invitedBy;
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchInviteCodes());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleRedeem = async () => {
|
||||
const trimmed = redeemInput.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const result = await dispatch(redeemCode(trimmed));
|
||||
if (redeemCode.fulfilled.match(result)) {
|
||||
setRedeemInput('');
|
||||
refetchUser();
|
||||
setTimeout(() => dispatch(clearRedeemStatus()), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full relative">
|
||||
<div className="relative z-10 min-h-full flex flex-col">
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full space-y-4">
|
||||
{/* Redeem Section — shown only if user hasn't redeemed yet */}
|
||||
{!hasBeenInvited && (
|
||||
<div className="glass rounded-3xl p-6 shadow-large animate-fade-up">
|
||||
<h2 className="text-lg font-bold mb-1">Redeem an Invite Code</h2>
|
||||
<p className="text-xs opacity-70 mb-4">
|
||||
Got a code from a friend? Enter it below to unlock free credits.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={redeemInput}
|
||||
onChange={e => setRedeemInput(e.target.value.toUpperCase())}
|
||||
onKeyDown={e => e.key === 'Enter' && handleRedeem()}
|
||||
placeholder="Enter code"
|
||||
className="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl font-mono text-sm tracking-wider placeholder:text-stone-500 placeholder:tracking-normal placeholder:font-sans focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
|
||||
disabled={redeemStatus === 'loading'}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRedeem}
|
||||
disabled={redeemStatus === 'loading' || !redeemInput.trim()}
|
||||
className="btn-primary px-5 py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap">
|
||||
{redeemStatus === 'loading' ? '...' : 'Redeem'}
|
||||
</button>
|
||||
</div>
|
||||
{redeemStatus === 'success' && (
|
||||
<p className="text-sage-500 text-xs mt-2">Invite code redeemed successfully!</p>
|
||||
)}
|
||||
{redeemStatus === 'error' && redeemError && (
|
||||
<p className="text-coral-500 text-xs mt-2">{redeemError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Your Invite Codes */}
|
||||
<div className="glass rounded-3xl p-6 shadow-large animate-fade-up">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-bold mb-1">Your Invite Codes</h2>
|
||||
<p className="text-xs opacity-70">
|
||||
Share these codes with friends. Each code can be used once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-12 bg-white/5 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : codes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{codes.map(invite => (
|
||||
<CodeRow key={invite._id} invite={invite} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-stone-500 text-center py-6">
|
||||
No invite codes available yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Invites;
|
||||
@@ -8,6 +8,7 @@ import { setOnboardedForUser } from '../../store/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import FeaturesStep from './steps/FeaturesStep';
|
||||
import GetStartedStep from './steps/GetStartedStep';
|
||||
import InviteCodeStep from './steps/InviteCodeStep';
|
||||
import PrivacyStep from './steps/PrivacyStep';
|
||||
|
||||
const Onboarding = () => {
|
||||
@@ -15,13 +16,14 @@ const Onboarding = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector(state => state.user.user);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const totalSteps = 3;
|
||||
const totalSteps = 4;
|
||||
|
||||
// Lottie animation files for each step
|
||||
const stepAnimations = [
|
||||
'/lottie/wave.json', // Step 1 - Features
|
||||
'/lottie/safe3.json', // Step 2 - Privacy
|
||||
'/lottie/trophy.json', // Step 3 - Get Started
|
||||
'/lottie/trophy.json', // Step 1 - Invite Code
|
||||
'/lottie/wave.json', // Step 2 - Features
|
||||
'/lottie/safe3.json', // Step 3 - Privacy
|
||||
'/lottie/trophy.json', // Step 4 - Get Started
|
||||
];
|
||||
|
||||
const handleNext = () => {
|
||||
@@ -52,13 +54,15 @@ const Onboarding = () => {
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return <FeaturesStep onNext={handleNext} />;
|
||||
return <InviteCodeStep onNext={handleNext} />;
|
||||
case 2:
|
||||
return <PrivacyStep onNext={handleNext} />;
|
||||
return <FeaturesStep onNext={handleNext} />;
|
||||
case 3:
|
||||
return <PrivacyStep onNext={handleNext} />;
|
||||
case 4:
|
||||
return <GetStartedStep onComplete={handleComplete} />;
|
||||
default:
|
||||
return <FeaturesStep onNext={handleNext} />;
|
||||
return <InviteCodeStep onNext={handleNext} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { inviteApi } from '../../../services/api/inviteApi';
|
||||
|
||||
interface InviteCodeStepProps {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const InviteCodeStep = ({ onNext }: InviteCodeStepProps) => {
|
||||
const [code, setCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleRedeem = async () => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await inviteApi.redeemInviteCode(trimmed);
|
||||
setSuccess(true);
|
||||
setTimeout(() => onNext(), 1500);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String((err as { error: string }).error)
|
||||
: 'Invalid or expired invite code';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-bold mb-2">Have an Invite Code?</h1>
|
||||
<p className="opacity-70 text-sm">
|
||||
Enter an invite code from a friend to unlock free credits. You can also skip this step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-12 h-12 bg-sage-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-6 h-6 text-sage-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sage-500 font-medium text-sm">Invite code redeemed successfully!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value.toUpperCase())}
|
||||
onKeyDown={e => e.key === 'Enter' && handleRedeem()}
|
||||
placeholder="Enter invite code"
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-center font-mono text-lg tracking-widest placeholder:text-stone-500 placeholder:tracking-normal placeholder:font-sans placeholder:text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{error && <p className="text-coral-500 text-xs mt-2 text-center">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={handleRedeem}
|
||||
disabled={isLoading || !code.trim()}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isLoading ? 'Redeeming...' : 'Redeem Code'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={isLoading}
|
||||
className="w-full py-2.5 text-sm font-medium rounded-xl text-stone-400 hover:text-stone-200 transition-colors">
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteCodeStep;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ApiResponse } from '../../types/api';
|
||||
import type { InviteCode } from '../../types/invite';
|
||||
import { apiClient } from '../apiClient';
|
||||
|
||||
export const inviteApi = {
|
||||
/** GET /invite/my-codes — list user's 5 invite codes with usage history */
|
||||
getMyInviteCodes: async (): Promise<InviteCode[]> => {
|
||||
const response = await apiClient.get<ApiResponse<InviteCode[]>>('/invite/my-codes');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /invite/redeem — redeem an invite code */
|
||||
redeemInviteCode: async (code: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post<ApiResponse<{ message: string }>>('/invite/redeem', {
|
||||
code,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /invite/status?code=X — check if an invite code is valid (no auth required) */
|
||||
checkInviteCode: async (code: string): Promise<{ valid: boolean }> => {
|
||||
const response = await apiClient.get<ApiResponse<{ valid: boolean }>>(
|
||||
`/invite/status?code=${encodeURIComponent(code)}`,
|
||||
{ requireAuth: false }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
+3
-2
@@ -1,5 +1,4 @@
|
||||
import type { Middleware } from '@reduxjs/toolkit';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { configureStore, type Middleware } from '@reduxjs/toolkit';
|
||||
import { createLogger } from 'redux-logger';
|
||||
import {
|
||||
FLUSH,
|
||||
@@ -17,6 +16,7 @@ import { IS_DEV } from '../utils/config';
|
||||
import { storeSession } from '../utils/tauriCommands';
|
||||
import aiReducer from './aiSlice';
|
||||
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
|
||||
import inviteReducer from './inviteSlice';
|
||||
import skillsReducer from './skillsSlice';
|
||||
import socketReducer from './socketSlice';
|
||||
import teamReducer from './teamSlice';
|
||||
@@ -78,6 +78,7 @@ export const store = configureStore({
|
||||
ai: persistedAiReducer,
|
||||
skills: persistedSkillsReducer,
|
||||
team: teamReducer,
|
||||
invite: inviteReducer,
|
||||
},
|
||||
middleware: getDefaultMiddleware => {
|
||||
const middleware = getDefaultMiddleware({
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { inviteApi } from '../services/api/inviteApi';
|
||||
import type { InviteCode } from '../types/invite';
|
||||
|
||||
interface InviteState {
|
||||
codes: InviteCode[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
redeemStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
redeemError: string | null;
|
||||
}
|
||||
|
||||
const initialState: InviteState = {
|
||||
codes: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
redeemStatus: 'idle',
|
||||
redeemError: null,
|
||||
};
|
||||
|
||||
export const fetchInviteCodes = createAsyncThunk(
|
||||
'invite/fetchInviteCodes',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
return await inviteApi.getMyInviteCodes();
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to fetch invite codes';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const redeemCode = createAsyncThunk(
|
||||
'invite/redeemCode',
|
||||
async (code: string, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const result = await inviteApi.redeemInviteCode(code);
|
||||
// Re-fetch codes after successful redeem
|
||||
dispatch(fetchInviteCodes());
|
||||
return result;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to redeem invite code';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const inviteSlice = createSlice({
|
||||
name: 'invite',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearRedeemStatus: state => {
|
||||
state.redeemStatus = 'idle';
|
||||
state.redeemError = null;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
// fetchInviteCodes
|
||||
.addCase(fetchInviteCodes.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchInviteCodes.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.codes = action.payload;
|
||||
})
|
||||
.addCase(fetchInviteCodes.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// redeemCode
|
||||
.addCase(redeemCode.pending, state => {
|
||||
state.redeemStatus = 'loading';
|
||||
state.redeemError = null;
|
||||
})
|
||||
.addCase(redeemCode.fulfilled, state => {
|
||||
state.redeemStatus = 'success';
|
||||
})
|
||||
.addCase(redeemCode.rejected, (state, action) => {
|
||||
state.redeemStatus = 'error';
|
||||
state.redeemError = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearRedeemStatus } = inviteSlice.actions;
|
||||
export default inviteSlice.reducer;
|
||||
+1
-4
@@ -29,12 +29,9 @@ export interface IUserUsage {
|
||||
}
|
||||
|
||||
export interface UserReferral {
|
||||
inviteCode?: string | null;
|
||||
inviteCodeUsages: number;
|
||||
maxInviteCodeUsages?: number | null;
|
||||
invitedByCode?: string | null;
|
||||
inviteCodeUsedAt?: string;
|
||||
invitedBy?: string | null;
|
||||
pendingInviteCode?: string | null;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface InviteCodeUser {
|
||||
_id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
telegramId?: string;
|
||||
}
|
||||
|
||||
export interface UsageHistoryEntry {
|
||||
userId: InviteCodeUser;
|
||||
usedAt: string;
|
||||
}
|
||||
|
||||
export interface InviteCode {
|
||||
_id: string;
|
||||
code: string;
|
||||
owner: string;
|
||||
type: 'USER' | 'CAMPAIGN';
|
||||
maxUses: number;
|
||||
currentUses: number;
|
||||
usageHistory: UsageHistoryEntry[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user