From e23b3643bdffb439c650b2e866ecf719bba3d9ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:28:54 +0530 Subject: [PATCH] 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 * 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 --- skills | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/runtime/bridge/net.rs | 2 +- src-tauri/src/runtime/qjs_skill_instance.rs | 197 +++++++++++++++--- .../src/services/quickjs-libs/bootstrap.js | 36 ++-- .../services/quickjs-libs/qjs_ops/ops_net.rs | 13 +- src/AppRoutes.tsx | 11 + src/components/MiniSidebar.tsx | 15 ++ src/pages/Invites.tsx | 163 +++++++++++++++ src/pages/onboarding/Onboarding.tsx | 18 +- src/pages/onboarding/steps/InviteCodeStep.tsx | 90 ++++++++ src/services/api/inviteApi.ts | 28 +++ src/store/index.ts | 5 +- src/store/inviteSlice.ts | 95 +++++++++ src/types/api.ts | 5 +- src/types/invite.ts | 24 +++ 16 files changed, 632 insertions(+), 74 deletions(-) create mode 100644 src/pages/Invites.tsx create mode 100644 src/pages/onboarding/steps/InviteCodeStep.tsx create mode 100644 src/services/api/inviteApi.ts create mode 100644 src/store/inviteSlice.ts create mode 100644 src/types/invite.ts diff --git a/skills b/skills index 4e0ed7146..91b4f1f7e 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 4e0ed71460276d86a0b5f27e06009526372d606f +Subproject commit 91b4f1f7e7918c3eded06a53bcb87a354c972b9f diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 58aa58f29..523e72ecd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/runtime/bridge/net.rs b/src-tauri/src/runtime/bridge/net.rs index b938aa68c..fd6f2248e 100644 --- a/src-tauri/src/runtime/bridge/net.rs +++ b/src-tauri/src/runtime/bridge/net.rs @@ -51,7 +51,7 @@ fn do_fetch(url: &str, options_json: &str) -> Result { 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}"))?; diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index 795752dcd..2fa13af5a 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -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>, + 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 = 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::(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>, skill_id: &str, + pending_tool: &mut Option, ) -> 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>) { } } -/// 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 { +) -> Result, 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::(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::(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 { + 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::(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 }], diff --git a/src-tauri/src/services/quickjs-libs/bootstrap.js b/src-tauri/src/services/quickjs-libs/bootstrap.js index fffac251c..906abc765 100644 --- a/src-tauri/src/services/quickjs-libs/bootstrap.js +++ b/src-tauri/src/services/quickjs-libs/bootstrap.js @@ -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); diff --git a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs index 14863d5f2..4024ef8e2 100644 --- a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs +++ b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs @@ -22,7 +22,7 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc rquickjs::Result { - crate::runtime::bridge::net::http_fetch(&url, &options_json).map_err(|e| js_err(e)) - }, - ))?; - Ok(()) } diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index e1d91eb79..9c6697add 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -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 */} + + + + } + /> + {/* Agents */} // ), // }, + { + id: 'invites', + label: 'Invite Friends', + path: '/invites', + icon: ( + + + + ), + }, { id: 'settings', label: 'Settings', diff --git a/src/pages/Invites.tsx b/src/pages/Invites.tsx new file mode 100644 index 000000000..a979c9022 --- /dev/null +++ b/src/pages/Invites.tsx @@ -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 ( +
+
+ {invite.code} + {claimed && ( +

+ Claimed by {displayName} +

+ )} +
+
+ {claimed ? ( + + Used + + ) : ( + + Available + + )} + +
+
+ ); +}; + +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 ( +
+
+
+
+ {/* Redeem Section — shown only if user hasn't redeemed yet */} + {!hasBeenInvited && ( +
+

Redeem an Invite Code

+

+ Got a code from a friend? Enter it below to unlock free credits. +

+
+ 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'} + /> + +
+ {redeemStatus === 'success' && ( +

Invite code redeemed successfully!

+ )} + {redeemStatus === 'error' && redeemError && ( +

{redeemError}

+ )} +
+ )} + + {/* Your Invite Codes */} +
+
+

Your Invite Codes

+

+ Share these codes with friends. Each code can be used once. +

+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : codes.length > 0 ? ( +
+ {codes.map(invite => ( + + ))} +
+ ) : ( +

+ No invite codes available yet. +

+ )} +
+
+
+
+
+ ); +}; + +export default Invites; diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx index 173faa12f..26399b758 100644 --- a/src/pages/onboarding/Onboarding.tsx +++ b/src/pages/onboarding/Onboarding.tsx @@ -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 ; + return ; case 2: - return ; + return ; case 3: + return ; + case 4: return ; default: - return ; + return ; } }; diff --git a/src/pages/onboarding/steps/InviteCodeStep.tsx b/src/pages/onboarding/steps/InviteCodeStep.tsx new file mode 100644 index 000000000..1e9c0f6d2 --- /dev/null +++ b/src/pages/onboarding/steps/InviteCodeStep.tsx @@ -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(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 ( +
+
+

Have an Invite Code?

+

+ Enter an invite code from a friend to unlock free credits. You can also skip this step. +

+
+ + {success ? ( +
+
+ + + +
+

Invite code redeemed successfully!

+
+ ) : ( + <> +
+ 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 &&

{error}

} +
+ +
+ + +
+ + )} +
+ ); +}; + +export default InviteCodeStep; diff --git a/src/services/api/inviteApi.ts b/src/services/api/inviteApi.ts new file mode 100644 index 000000000..43a5c4217 --- /dev/null +++ b/src/services/api/inviteApi.ts @@ -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 => { + const response = await apiClient.get>('/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>('/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>( + `/invite/status?code=${encodeURIComponent(code)}`, + { requireAuth: false } + ); + return response.data; + }, +}; diff --git a/src/store/index.ts b/src/store/index.ts index 27356dd29..374c49e23 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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({ diff --git a/src/store/inviteSlice.ts b/src/store/inviteSlice.ts new file mode 100644 index 000000000..098bb3fca --- /dev/null +++ b/src/store/inviteSlice.ts @@ -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; diff --git a/src/types/api.ts b/src/types/api.ts index ecb28d249..514c77495 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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 { diff --git a/src/types/invite.ts b/src/types/invite.ts new file mode 100644 index 000000000..dbb7ea75e --- /dev/null +++ b/src/types/invite.ts @@ -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; +}