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:
Steven Enamakel
2026-02-10 22:28:54 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent 969503eec2
commit e23b3643bd
16 changed files with 632 additions and 74 deletions
+1 -1
Submodule skills updated: 4e0ed71460...91b4f1f7e7
+1 -1
View File
@@ -31,7 +31,7 @@ serde_json = "1"
# HTTP client: rustls for cross-platform TLS (Android), native-tls for desktop skill HTTP
# (some APIs/CDNs use TLS configs that rustls can't negotiate — native-tls uses OS TLS stack)
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2"] }
# Async runtime
tokio = { version = "1", features = ["full", "sync"] }
+1 -1
View File
@@ -51,7 +51,7 @@ fn do_fetch(url: &str, options_json: &str) -> Result<String, String> {
let timeout_secs = options.timeout.unwrap_or(30);
let client = reqwest::blocking::Client::builder()
.use_native_tls()
.use_rustls_tls()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
+171 -26
View File
@@ -257,6 +257,17 @@ impl QjsSkillInstance {
state.write().status = SkillStatus::Running;
log::info!("[skill:{}] Running (QuickJS)", config.skill_id);
// Immediate ping to verify the connection is healthy
match handle_js_call(&ctx, "onPing", "{}").await {
Ok(value) => {
log::info!("[skill:{}] Initial ping result: {}", config.skill_id, value);
}
Err(e) => {
log::warn!("[skill:{}] Initial ping failed: {}", config.skill_id, e);
}
}
drive_jobs(&rt).await;
// Run the event loop
run_event_loop(&rt, &ctx, &mut rx, &state, &config.skill_id, &timer_state, &published_state, _deps.app_handle.as_ref()).await;
})
@@ -267,13 +278,20 @@ impl QjsSkillInstance {
// Event Loop
// ============================================================================
/// Pending async tool call that is being driven by the event loop.
struct PendingToolCall {
reply: tokio::sync::oneshot::Sender<Result<ToolResult, String>>,
deadline: tokio::time::Instant,
}
/// The main event loop that drives the QuickJS runtime.
/// This continuously:
/// 1. Polls for ready timers and fires their callbacks
/// 2. Checks for incoming messages (non-blocking)
/// 3. Runs the QuickJS job queue for promises/async ops
/// 4. Syncs published state from ops → instance and emits Tauri events
/// 5. Sleeps efficiently when idle
/// 4. Checks if a pending async tool call has completed
/// 5. Syncs published state from ops → instance and emits Tauri events
/// 6. Sleeps efficiently when idle
async fn run_event_loop(
rt: &rquickjs::AsyncRuntime,
ctx: &rquickjs::AsyncContext,
@@ -288,6 +306,11 @@ async fn run_event_loop(
const MAX_IDLE_SLEEP: Duration = Duration::from_millis(100);
// Minimum sleep to prevent busy-spinning
const MIN_SLEEP: Duration = Duration::from_millis(1);
// Faster polling when waiting for an async tool call
const TOOL_POLL_SLEEP: Duration = Duration::from_millis(5);
// Tracks an in-flight async tool call whose Promise hasn't resolved yet.
let mut pending_tool: Option<PendingToolCall> = None;
loop {
// 1. Poll and fire ready timers
@@ -301,10 +324,12 @@ async fn run_event_loop(
fire_timer_callback(ctx, timer_id).await;
}
// 2. Check for incoming messages (non-blocking)
// 2. Check for incoming messages (non-blocking).
// While an async tool call is in flight, still process other
// messages (events, pings, etc.) but queue any new CallTool.
match rx.try_recv() {
Ok(msg) => {
let should_stop = handle_message(ctx, msg, state, skill_id).await;
let should_stop = handle_message(ctx, msg, state, skill_id, &mut pending_tool).await;
if should_stop {
break;
}
@@ -322,7 +347,34 @@ async fn run_event_loop(
// 3. Drive QuickJS job queue (process pending promises)
drive_jobs(rt).await;
// 4. Sync ops-level published state → instance published_state + emit event
// 4. Check if pending async tool call has completed
if pending_tool.is_some() {
let done = ctx.with(|js_ctx| {
js_ctx
.eval::<bool, _>(b"globalThis.__pendingToolDone === true")
.unwrap_or(false)
})
.await;
if done {
// Read the resolved value and send it back
let result = read_pending_tool_result(ctx).await;
if let Some(ptc) = pending_tool.take() {
let _ = ptc.reply.send(result);
}
} else if let Some(ref ptc) = pending_tool {
if tokio::time::Instant::now() >= ptc.deadline {
log::error!("[skill:{}] Async tool call timed out", skill_id);
if let Some(ptc) = pending_tool.take() {
let _ = ptc
.reply
.send(Err("Tool async execution timed out".to_string()));
}
}
}
}
// 5. Sync ops-level published state → instance published_state + emit event
{
let mut ops = ops_state.write();
if ops.dirty {
@@ -349,8 +401,11 @@ async fn run_event_loop(
}
}
// 5. Calculate sleep duration based on next timer
let sleep_duration = {
// 6. Calculate sleep duration based on next timer and pending tool call
let sleep_duration = if pending_tool.is_some() {
// Poll faster while waiting for an async tool result
TOOL_POLL_SLEEP
} else {
let (_, next_timer) = qjs_ops::poll_timers(timer_state);
match next_timer {
Some(d) if d < MIN_SLEEP => MIN_SLEEP,
@@ -388,13 +443,31 @@ async fn handle_message(
msg: SkillMessage,
state: &Arc<RwLock<SkillState>>,
skill_id: &str,
pending_tool: &mut Option<PendingToolCall>,
) -> bool {
match msg {
SkillMessage::CallTool { tool_name, arguments, reply } => {
// Lazy-load persisted OAuth credential before calling the tool
restore_oauth_credential(ctx, skill_id).await;
let result = handle_tool_call(ctx, &tool_name, arguments).await;
let _ = reply.send(result);
// Start the async tool execution. The JS code stores the result
// in globals when done. The main event loop checks for completion.
match start_async_tool_call(ctx, &tool_name, arguments).await {
Ok(Some(sync_result)) => {
// Tool returned synchronously (non-Promise)
let _ = reply.send(Ok(sync_result));
}
Ok(None) => {
// Tool returned a Promise — event loop will drive it
*pending_tool = Some(PendingToolCall {
reply,
deadline: tokio::time::Instant::now() + Duration::from_secs(120),
});
}
Err(e) => {
let _ = reply.send(Err(e));
}
}
}
SkillMessage::ServerEvent { event, data } => {
let _ = handle_server_event(ctx, &event, data).await;
@@ -626,19 +699,27 @@ fn extract_tools(js_ctx: &rquickjs::Ctx<'_>, state: &Arc<RwLock<SkillState>>) {
}
}
/// Handle a tool call.
async fn handle_tool_call(
/// Start an async tool call.
///
/// Calls the tool's `execute()` and checks if it returns a Promise.
/// - If sync: returns `Ok(Some(ToolResult))` with the immediate result.
/// - If async (Promise): attaches `.then`/`.catch` handlers that store the
/// resolved value in `globalThis.__pendingTool*` globals, and returns
/// `Ok(None)`. The caller should let the event loop drive the QuickJS
/// runtime and poll `__pendingToolDone` for completion.
async fn start_async_tool_call(
ctx: &rquickjs::AsyncContext,
tool_name: &str,
arguments: serde_json::Value,
) -> Result<ToolResult, String> {
) -> Result<Option<ToolResult>, String> {
let args_str = serde_json::to_string(&arguments)
.map_err(|e| format!("Failed to serialize args: {e}"))?;
let tool_name = tool_name.to_string();
let result_text = ctx.with(|js_ctx| {
let code = format!(
r#"(function() {{
let eval_result = ctx
.with(|js_ctx| {
let code = format!(
r#"(function() {{
var skill = globalThis.__skill && globalThis.__skill.default
? globalThis.__skill.default
: (globalThis.__skill || null);
@@ -647,6 +728,22 @@ async fn handle_tool_call(
if (tools[i].name === "{}") {{
var args = {};
var result = tools[i].execute(args);
if (result && typeof result.then === 'function') {{
globalThis.__pendingToolResult = undefined;
globalThis.__pendingToolError = undefined;
globalThis.__pendingToolDone = false;
result.then(
function(v) {{
globalThis.__pendingToolResult = v;
globalThis.__pendingToolDone = true;
}},
function(e) {{
globalThis.__pendingToolError = e;
globalThis.__pendingToolDone = true;
}}
);
return "__PROMISE__";
}}
if (result && typeof result === 'object') {{
return JSON.stringify(result);
}}
@@ -655,19 +752,67 @@ async fn handle_tool_call(
}}
throw new Error("Tool '{}' not found");
}})()"#,
tool_name.replace('"', r#"\""#),
args_str,
tool_name.replace('"', r#"\""#),
);
tool_name.replace('"', r#"\""#),
args_str,
tool_name.replace('"', r#"\""#),
);
match js_ctx.eval::<String, _>(code.as_bytes()) {
Ok(s) => Ok(s),
Err(e) => {
let detail = format_js_exception(&js_ctx, &e);
Err(format!("Tool execution failed: {detail}"))
match js_ctx.eval::<String, _>(code.as_bytes()) {
Ok(s) => Ok(s),
Err(e) => {
let detail = format_js_exception(&js_ctx, &e);
Err(format!("Tool execution failed: {detail}"))
}
}
}
}).await?;
})
.await?;
if eval_result == "__PROMISE__" {
// Async — caller should poll __pendingToolDone via the event loop
Ok(None)
} else {
// Sync — return immediately
Ok(Some(ToolResult {
content: vec![ToolContent::Text { text: eval_result }],
is_error: false,
}))
}
}
/// Read the result of a completed async tool call from JS globals.
/// Call this only after `globalThis.__pendingToolDone === true`.
async fn read_pending_tool_result(
ctx: &rquickjs::AsyncContext,
) -> Result<ToolResult, String> {
let result_text = ctx
.with(|js_ctx| {
let code = r#"(function() {
var err = globalThis.__pendingToolError;
globalThis.__pendingToolError = undefined;
globalThis.__pendingToolDone = false;
if (err !== undefined) {
var msg = (typeof err === 'object' && err !== null && err.message)
? err.message
: String(err);
globalThis.__pendingToolResult = undefined;
throw new Error(msg);
}
var r = globalThis.__pendingToolResult;
globalThis.__pendingToolResult = undefined;
if (r === undefined || r === null) return "null";
if (typeof r === 'object') return JSON.stringify(r);
return String(r);
})()"#;
match js_ctx.eval::<String, _>(code.as_bytes()) {
Ok(s) => Ok(s),
Err(e) => {
let detail = format_js_exception(&js_ctx, &e);
Err(format!("Tool async execution failed: {detail}"))
}
}
})
.await?;
Ok(ToolResult {
content: vec![ToolContent::Text { text: result_text }],
+16 -20
View File
@@ -689,11 +689,6 @@ globalThis.__db = {
},
};
globalThis.__net = {
fetch: function (url, optionsJson) {
return __ops.net_fetch(url, optionsJson);
},
};
globalThis.__platform = {
os: function () {
@@ -727,8 +722,8 @@ globalThis.db = {
};
globalThis.net = {
fetch: function (url, options) {
var result = __net.fetch(url, options ? JSON.stringify(options) : '{}');
fetch: async function (url, options) {
const result = await __ops.fetch(url, options ? JSON.stringify(options) : '{}');
return JSON.parse(result);
},
};
@@ -819,7 +814,7 @@ globalThis.data = {
* Make an authenticated API request proxied through the backend.
* Path is relative to manifest's apiBaseUrl.
*/
fetch: function (path, options) {
fetch: async function (path, options) {
if (!globalThis.__oauthCredential) {
return {
status: 401,
@@ -841,18 +836,19 @@ globalThis.data = {
headers[k] = options.headers[k];
}
}
var fetchOpts = JSON.stringify({
var fetchOpts = {
method: method,
headers: headers,
body: options ? options.body : undefined,
timeout: options ? options.timeout : undefined,
});
var result = __net.fetch(proxyUrl, fetchOpts);
return JSON.parse(result);
};
var result = await net.fetch(proxyUrl, fetchOpts);
return result;
},
/** Revoke the current OAuth credential server-side. */
revoke: function () {
revoke: async function () {
if (__oauthCredential) {
try {
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
@@ -864,7 +860,7 @@ globalThis.data = {
Authorization: 'Bearer ' + jwtToken,
},
});
__net.fetch(
await net.fetch(
backendUrl + '/auth/integrations/' + __oauthCredential.credentialId,
revokeOpts
);
@@ -991,13 +987,13 @@ globalThis.model = {
* @param {number} [options.temperature=0.7] - Sampling temperature
* @returns {string}
*/
generate: function (prompt, options) {
generate: async function (prompt, options) {
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
var jwtToken = __ops.get_session_token() || '';
var body = { prompt: prompt };
if (options && options.maxTokens) body.maxTokens = options.maxTokens;
if (options && options.temperature) body.temperature = options.temperature;
var result = __net.fetch(backendUrl + '/api/ai/generate', JSON.stringify({
var result = await net.fetch(backendUrl + '/api/ai/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1005,7 +1001,7 @@ globalThis.model = {
},
body: JSON.stringify(body),
timeout: 30000,
}));
});
var parsed = JSON.parse(result);
if (parsed.status >= 400) {
throw new Error('Backend returned ' + parsed.status + ': ' + parsed.body);
@@ -1021,12 +1017,12 @@ globalThis.model = {
* @param {number} [options.maxTokens=500] - Target summary length
* @returns {string}
*/
summarize: function (text, options) {
summarize: async function (text, options) {
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
var jwtToken = __ops.get_session_token() || '';
var body = { text: text };
if (options && options.maxTokens) body.maxTokens = options.maxTokens;
var result = __net.fetch(backendUrl + '/api/ai/summarize', JSON.stringify({
var result = await net.fetch(backendUrl + '/api/ai/summarize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1034,7 +1030,7 @@ globalThis.model = {
},
body: JSON.stringify(body),
timeout: 30000,
}));
});
var parsed = JSON.parse(result);
if (parsed.status >= 400) {
throw new Error('Backend returned ' + parsed.status + ': ' + parsed.body);
@@ -22,7 +22,7 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc<RwLock<Web
let body = opts["body"].as_str();
let client = reqwest::Client::builder()
.use_native_tls()
.use_rustls_tls()
.build()
.map_err(|e| js_err(e.to_string()))?;
let mut req = match method {
@@ -119,16 +119,5 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc<RwLock<Web
state.connections.remove(&id);
}))?;
}
// ========================================================================
// Net Bridge (1)
// ========================================================================
ops.set("net_fetch", Function::new(ctx.clone(),
|url: String, options_json: String| -> rquickjs::Result<String> {
crate::runtime::bridge::net::http_fetch(&url, &options_json).map_err(|e| js_err(e))
},
))?;
Ok(())
}
+11
View File
@@ -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"
+15
View File
@@ -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',
+163
View File
@@ -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;
+11 -7
View File
@@ -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;
+28
View File
@@ -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
View File
@@ -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({
+95
View File
@@ -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
View File
@@ -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 {
+24
View File
@@ -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;
}