mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
feat: refactor user configuration table
This commit is contained in:
parent
03daaba7a1
commit
e5cc1308ed
31 changed files with 932 additions and 419 deletions
|
|
@ -55,6 +55,7 @@ export default async function Handler(props: unknown) {
|
|||
}
|
||||
const normalizedSegment = segment.toLowerCase().replace(/-/g, "");
|
||||
const isAuthForm = segment !== "" && !FULL_PAGE_ROUTES.has(normalizedSegment);
|
||||
const showBackButton = !new Set(["signin", "login"]).has(normalizedSegment);
|
||||
|
||||
const handler = (
|
||||
<StackTheme theme={stackAuthDarkTheme}>
|
||||
|
|
@ -65,7 +66,7 @@ export default async function Handler(props: unknown) {
|
|||
if (isAuthForm) {
|
||||
return (
|
||||
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
|
||||
<BackButton />
|
||||
{showBackButton && <BackButton />}
|
||||
{handler}
|
||||
</AuthShell>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
// Dark token overrides for the embedded Stack Auth form so it blends into the
|
||||
// auth card surface (zinc-900 background, zinc-100 foreground, the warm CTA
|
||||
// accent on the primary button, zinc-800 borders/inputs). Kept in sync with the
|
||||
// .dark tokens in globals.css. Values are CSS color strings; Stack applies them
|
||||
// to its own CSS variables.
|
||||
// accent on the primary button, zinc-800 borders/inputs). Stack's theme parser
|
||||
// does not accept OKLCH strings, so keep these values in hex.
|
||||
|
||||
import type { StackTheme } from "@stackframe/stack";
|
||||
import type { ComponentProps } from "react";
|
||||
|
|
@ -11,25 +10,25 @@ type ThemeConfig = NonNullable<ComponentProps<typeof StackTheme>["theme"]>;
|
|||
|
||||
export const stackAuthDarkTheme: ThemeConfig = {
|
||||
dark: {
|
||||
background: "oklch(0.205 0 0)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.205 0 0)",
|
||||
cardForeground: "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.205 0 0)",
|
||||
popoverForeground: "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.78 0.16 67)",
|
||||
primaryForeground: "oklch(0.16 0.02 60)",
|
||||
secondary: "oklch(0.269 0 0)",
|
||||
secondaryForeground: "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.269 0 0)",
|
||||
mutedForeground: "oklch(0.708 0 0)",
|
||||
accent: "oklch(0.269 0 0)",
|
||||
accentForeground: "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
destructiveForeground: "oklch(0.985 0 0)",
|
||||
border: "oklch(0.269 0 0)",
|
||||
input: "oklch(0.269 0 0)",
|
||||
ring: "oklch(0.78 0.16 67)",
|
||||
background: "#27272a",
|
||||
foreground: "#fafafa",
|
||||
card: "#27272a",
|
||||
cardForeground: "#fafafa",
|
||||
popover: "#27272a",
|
||||
popoverForeground: "#fafafa",
|
||||
primary: "#fbbf24",
|
||||
primaryForeground: "#422006",
|
||||
secondary: "#3f3f46",
|
||||
secondaryForeground: "#fafafa",
|
||||
muted: "#3f3f46",
|
||||
mutedForeground: "#a1a1aa",
|
||||
accent: "#3f3f46",
|
||||
accentForeground: "#fafafa",
|
||||
destructive: "#ef4444",
|
||||
destructiveForeground: "#fafafa",
|
||||
border: "#3f3f46",
|
||||
input: "#3f3f46",
|
||||
ring: "#fbbf24",
|
||||
},
|
||||
radius: "0.625rem",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { isNextRouterError } from "next/dist/client/components/is-next-router-er
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
|
||||
import SignInClient from "@/components/SignInClient";
|
||||
import { getServerAccessToken,getServerAuthProvider,getServerUser } from "@/lib/auth/server";
|
||||
import logger from '@/lib/logger';
|
||||
import { getRedirectUrl } from "@/lib/utils";
|
||||
|
|
@ -92,16 +91,6 @@ export default async function Home() {
|
|||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<SignInClient />
|
||||
</div>
|
||||
);
|
||||
logger.debug('[HomePage] Redirecting unauthenticated Stack user to /handler/sign-in');
|
||||
redirect('/handler/sign-in');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Suspense } from 'react';
|
|||
|
||||
import { getWorkflowsApiV1WorkflowFetchGet, listFoldersApiV1FolderGet } from '@/client/sdk.gen';
|
||||
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
|
||||
import { AgentFolderView } from '@/components/workflow/folders/AgentFolderView';
|
||||
import { CreateFolderButton } from '@/components/workflow/folders/CreateFolderButton';
|
||||
|
|
@ -78,9 +79,11 @@ async function WorkflowList() {
|
|||
{activeWorkflows.length > 0 || folders.length > 0 ? (
|
||||
<AgentFolderView workflows={activeWorkflows} folders={folders} />
|
||||
) : (
|
||||
<div className="text-muted-foreground bg-muted rounded-lg p-8 text-center">
|
||||
No active workflows found. Create your first workflow to get started.
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
No active workflows found. Create your first workflow to get started.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -132,7 +135,11 @@ function WorkflowsLoading() {
|
|||
<div className="h-8 w-48 bg-muted rounded mb-6"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="bg-muted rounded-lg h-40"></div>
|
||||
<Card key={i}>
|
||||
<CardContent className="p-0">
|
||||
<div className="h-40 bg-muted/70" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -143,7 +150,11 @@ function WorkflowsLoading() {
|
|||
<div className="h-8 w-48 bg-muted rounded"></div>
|
||||
<div className="h-10 w-32 bg-muted rounded"></div>
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg h-96"></div>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="h-96 bg-muted/70" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -147,13 +147,13 @@ export type AwsBedrockLlmConfiguration = {
|
|||
/**
|
||||
* Api Key
|
||||
*
|
||||
* Not used for Bedrock — authentication is via the AWS credentials above. Leave blank.
|
||||
* Not used for Bedrock - authentication is via the AWS credentials above. Leave blank.
|
||||
*/
|
||||
api_key?: string | Array<string> | null;
|
||||
/**
|
||||
* Model
|
||||
*
|
||||
* Bedrock model ID — include the region inference-profile prefix (e.g. 'us.').
|
||||
* Bedrock model ID - include the region inference-profile prefix (e.g. 'us.').
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
|
|
@ -344,7 +344,7 @@ export type AzureOpenAiEmbeddingsConfiguration = {
|
|||
/**
|
||||
* Azure OpenAI Realtime
|
||||
*
|
||||
* Azure OpenAI Realtime API — low-latency speech-to-speech conversations.
|
||||
* Azure OpenAI Realtime API - low-latency speech-to-speech conversations.
|
||||
*/
|
||||
export type AzureRealtimeLlmConfiguration = {
|
||||
/**
|
||||
|
|
@ -384,7 +384,7 @@ export type AzureRealtimeLlmConfiguration = {
|
|||
/**
|
||||
* Azure Speech Services
|
||||
*
|
||||
* Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK.
|
||||
* Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.
|
||||
*/
|
||||
export type AzureSpeechSttConfiguration = {
|
||||
/**
|
||||
|
|
@ -418,7 +418,7 @@ export type AzureSpeechSttConfiguration = {
|
|||
/**
|
||||
* Azure Speech Services
|
||||
*
|
||||
* Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK.
|
||||
* Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.
|
||||
*/
|
||||
export type AzureSpeechTtsConfiguration = {
|
||||
/**
|
||||
|
|
@ -2627,7 +2627,7 @@ export type GoogleVertexLlmConfiguration = {
|
|||
/**
|
||||
* Api Key
|
||||
*
|
||||
* Not used for Vertex AI — authentication is via the service account in `credentials` (or ADC). Leave blank.
|
||||
* Not used for Vertex AI - authentication is via the service account in `credentials` (or ADC). Leave blank.
|
||||
*/
|
||||
api_key?: string | Array<string> | null;
|
||||
/**
|
||||
|
|
@ -2667,7 +2667,7 @@ export type GoogleVertexRealtimeLlmConfiguration = {
|
|||
/**
|
||||
* Api Key
|
||||
*
|
||||
* Not used for Vertex AI — authentication is via the service account in `credentials` (or ADC). Leave blank.
|
||||
* Not used for Vertex AI - authentication is via the service account in `credentials` (or ADC). Leave blank.
|
||||
*/
|
||||
api_key?: string | Array<string> | null;
|
||||
/**
|
||||
|
|
@ -3537,6 +3537,61 @@ export type NodeTypesResponse = {
|
|||
node_types: Array<NodeSpec>;
|
||||
};
|
||||
|
||||
/**
|
||||
* OnboardingState
|
||||
*
|
||||
* Per-user onboarding state, stored under UserConfigurationKey.ONBOARDING.
|
||||
*
|
||||
* Server-authoritative replacement for the browser-localStorage onboarding
|
||||
* store, so the post-signup gate and one-time tooltips hold across devices.
|
||||
*/
|
||||
export type OnboardingState = {
|
||||
/**
|
||||
* Completed At
|
||||
*/
|
||||
completed_at?: string | null;
|
||||
/**
|
||||
* Skipped
|
||||
*/
|
||||
skipped?: boolean;
|
||||
/**
|
||||
* Seen Tooltips
|
||||
*/
|
||||
seen_tooltips?: Array<string>;
|
||||
/**
|
||||
* Completed Actions
|
||||
*/
|
||||
completed_actions?: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* OnboardingStateUpdate
|
||||
*
|
||||
* Partial update merged into the stored state.
|
||||
*
|
||||
* Scalars overwrite when supplied; list entries are unioned into the stored
|
||||
* lists, so concurrent updates (e.g. two tabs marking different tooltips)
|
||||
* don't drop each other's items.
|
||||
*/
|
||||
export type OnboardingStateUpdate = {
|
||||
/**
|
||||
* Completed At
|
||||
*/
|
||||
completed_at?: string | null;
|
||||
/**
|
||||
* Skipped
|
||||
*/
|
||||
skipped?: boolean | null;
|
||||
/**
|
||||
* Seen Tooltips
|
||||
*/
|
||||
seen_tooltips?: Array<string> | null;
|
||||
/**
|
||||
* Completed Actions
|
||||
*/
|
||||
completed_actions?: Array<string> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenAI
|
||||
*/
|
||||
|
|
@ -8563,6 +8618,84 @@ export type UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses = {
|
|||
|
||||
export type UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse = UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses[keyof UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses];
|
||||
|
||||
export type GetUserOnboardingStateApiV1UserOnboardingStateGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/user/onboarding-state';
|
||||
};
|
||||
|
||||
export type GetUserOnboardingStateApiV1UserOnboardingStateGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetUserOnboardingStateApiV1UserOnboardingStateGetError = GetUserOnboardingStateApiV1UserOnboardingStateGetErrors[keyof GetUserOnboardingStateApiV1UserOnboardingStateGetErrors];
|
||||
|
||||
export type GetUserOnboardingStateApiV1UserOnboardingStateGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: OnboardingState;
|
||||
};
|
||||
|
||||
export type GetUserOnboardingStateApiV1UserOnboardingStateGetResponse = GetUserOnboardingStateApiV1UserOnboardingStateGetResponses[keyof GetUserOnboardingStateApiV1UserOnboardingStateGetResponses];
|
||||
|
||||
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutData = {
|
||||
body: OnboardingStateUpdate;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/user/onboarding-state';
|
||||
};
|
||||
|
||||
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutError = UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors[keyof UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors];
|
||||
|
||||
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: OnboardingState;
|
||||
};
|
||||
|
||||
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponse = UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses[keyof UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses];
|
||||
|
||||
export type ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type ServiceSegment,
|
||||
} from "@/components/ServiceConfigurationForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -359,79 +360,81 @@ export function AIModelConfigurationV2Editor({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="dograh" className="mt-0">
|
||||
<div className="rounded-lg border p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.voices.map((voice) => (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
{voice}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.voices.map((voice) => (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
{voice}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Speed</Label>
|
||||
<Select
|
||||
value={String(dograh.speed)}
|
||||
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select speed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.speeds.map((speed) => (
|
||||
<SelectItem key={speed} value={String(speed)}>
|
||||
{speed}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Speed</Label>
|
||||
<Select
|
||||
value={String(dograh.speed)}
|
||||
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select speed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.speeds.map((speed) => (
|
||||
<SelectItem key={speed} value={String(speed)}>
|
||||
{speed}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSavingDograh ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSavingDograh ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="byok" className="mt-0">
|
||||
|
|
|
|||
|
|
@ -148,12 +148,7 @@ const NAV_SECTIONS: SidebarNavSection[] = [
|
|||
title: "Reports",
|
||||
url: "/reports",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "Credits & Billing",
|
||||
url: "/billing",
|
||||
icon: CircleDollarSign,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -34,8 +34,9 @@ import { type OnboardingAnswers, skipOnboarding, submitOnboarding } from "./subm
|
|||
|
||||
interface OnboardingModalProps {
|
||||
open: boolean;
|
||||
// Called after a tracked outcome (submit or skip) to dismiss the gate.
|
||||
onComplete: () => void;
|
||||
// Called after a tracked outcome (submit or skip) to dismiss the gate and
|
||||
// stamp the matching server-side flag (completed_at vs skipped).
|
||||
onComplete: (skipped: boolean) => void;
|
||||
}
|
||||
|
||||
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||
|
|
@ -88,7 +89,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
setSubmitting(true);
|
||||
const data = answers();
|
||||
const efSnapshot = withEnterprise ? { ...ef } : null;
|
||||
onComplete();
|
||||
onComplete(skipped);
|
||||
void (async () => {
|
||||
const token = await getAccessToken().catch(() => undefined);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// Submission seam for the post-signup onboarding form.
|
||||
// Fires a PostHog capture (submit or skip) AND, when a token is supplied, POSTs
|
||||
// the answers to the separate user_onboarding service (best-effort). The "show
|
||||
// once per user" flag itself is stamped on the Dograh user-config by the caller
|
||||
// (LeadFormsContext.completeOnboarding), not here — that needs the saveUserConfig hook.
|
||||
// once per user" flag itself is stamped on the server-backed onboarding state
|
||||
// by the caller (LeadFormsContext.completeOnboarding → OnboardingContext), not here.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '@/client/sdk.gen';
|
||||
import type { FolderResponse } from '@/client/types.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -126,132 +127,134 @@ export function WorkflowTable({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Agent Name</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
<TableHead className="font-semibold text-center">Total Runs</TableHead>
|
||||
<TableHead className="font-semibold text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{workflows.map((workflow) => (
|
||||
<TableRow
|
||||
key={workflow.id}
|
||||
className={`hover:bg-accent transition-colors ${showArchived ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{workflow.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{workflow.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(workflow.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-muted rounded-full">
|
||||
{workflow.total_runs || 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(workflow.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Edit
|
||||
</Button>
|
||||
{folders && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={movingWorkflowId === workflow.id || isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{movingWorkflowId === workflow.id ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<FolderInput size={16} />
|
||||
)}
|
||||
Move
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={currentFolderId === null}
|
||||
onClick={() => handleMove(workflow.id, null)}
|
||||
>
|
||||
<Inbox size={14} className="mr-2" />
|
||||
Uncategorized
|
||||
{currentFolderId === null && (
|
||||
<Check size={14} className="ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{folders.map((folder) => (
|
||||
<DropdownMenuItem
|
||||
key={folder.id}
|
||||
disabled={folder.id === currentFolderId}
|
||||
onClick={() => handleMove(workflow.id, folder.id)}
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Agent Name</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
<TableHead className="font-semibold text-center">Total Runs</TableHead>
|
||||
<TableHead className="font-semibold text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{workflows.map((workflow) => (
|
||||
<TableRow
|
||||
key={workflow.id}
|
||||
className={`hover:bg-accent transition-colors ${showArchived ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{workflow.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{workflow.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(workflow.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-muted rounded-full">
|
||||
{workflow.total_runs || 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(workflow.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Edit
|
||||
</Button>
|
||||
{folders && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={movingWorkflowId === workflow.id || isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FolderIcon size={14} className="mr-2" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{folder.id === currentFolderId && (
|
||||
<Check size={14} className="ml-auto shrink-0" />
|
||||
{movingWorkflowId === workflow.id ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<FolderInput size={16} />
|
||||
)}
|
||||
Move
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={currentFolderId === null}
|
||||
onClick={() => handleMove(workflow.id, null)}
|
||||
>
|
||||
<Inbox size={14} className="mr-2" />
|
||||
Uncategorized
|
||||
{currentFolderId === null && (
|
||||
<Check size={14} className="ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Button
|
||||
variant={showArchived ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleArchiveToggle(workflow.id, workflow.status)}
|
||||
disabled={loadingWorkflowId === workflow.id || isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{loadingWorkflowId === workflow.id ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{showArchived ? 'Restoring...' : 'Archiving...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showArchived ? (
|
||||
<>
|
||||
<RotateCcw size={16} />
|
||||
Restore
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive size={16} />
|
||||
Archive
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{folders.map((folder) => (
|
||||
<DropdownMenuItem
|
||||
key={folder.id}
|
||||
disabled={folder.id === currentFolderId}
|
||||
onClick={() => handleMove(workflow.id, folder.id)}
|
||||
>
|
||||
<FolderIcon size={14} className="mr-2" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{folder.id === currentFolderId && (
|
||||
<Check size={14} className="ml-auto shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Button
|
||||
variant={showArchived ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleArchiveToggle(workflow.id, workflow.status)}
|
||||
disabled={loadingWorkflowId === workflow.id || isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{loadingWorkflowId === workflow.id ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{showArchived ? 'Restoring...' : 'Archiving...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showArchived ? (
|
||||
<>
|
||||
<RotateCcw size={16} />
|
||||
Restore
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive size={16} />
|
||||
Archive
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,7 @@ import type { LeadSource } from "@/components/lead-forms/leadFieldOptions";
|
|||
import { OnboardingModal } from "@/components/lead-forms/OnboardingModal";
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
import { useOnboarding } from "@/context/OnboardingContext";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
// The onboarding flag fields live on the Dograh user-config JSON blob. The
|
||||
// generated client type may not include them until `npm run generate-client`
|
||||
// is re-run against the updated backend, so read them through this shape.
|
||||
type OnboardingFlags = {
|
||||
onboarding_completed_at?: string | null;
|
||||
onboarding_skipped?: boolean | null;
|
||||
};
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
interface LeadFormsContextValue {
|
||||
openHireExpert: (source: LeadSource) => void;
|
||||
|
|
@ -40,32 +32,32 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
// ---- Post-signup onboarding gate ----
|
||||
// Show the onboarding form ONCE per user, and ONLY to genuinely new users:
|
||||
// (a) the completion flag is unset (server-side, cross-device), AND
|
||||
// (a) the completion/skip flag is unset (server-backed onboarding state,
|
||||
// cross-device), AND
|
||||
// (b) the user has zero workflows (grandfathers out all existing users —
|
||||
// they already have workflows, so they never see this modal).
|
||||
const { userConfig, loading: userConfigLoading, user, saveUserConfig } = useUserConfig();
|
||||
// Same-browser "show once" backstop, shared with the rest of onboarding
|
||||
// (tooltips/actions) via OnboardingProvider. Complements the server-side flag
|
||||
// so an instant reload before the async save round-trips can't re-show the gate.
|
||||
const { hasCompletedAction, markActionCompleted } = useOnboarding();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const {
|
||||
loading: onboardingLoading,
|
||||
onboardingCompletedAt,
|
||||
onboardingSkipped,
|
||||
markOnboardingCompleted,
|
||||
} = useOnboarding();
|
||||
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
||||
// Guard so the one-time workflow-count check runs at most once per mount.
|
||||
const onboardingCheckedRef = useRef(false);
|
||||
// Live view of the gate for the post-await re-check below.
|
||||
const onboardingDoneRef = useRef(false);
|
||||
onboardingDoneRef.current = Boolean(onboardingCompletedAt) || onboardingSkipped;
|
||||
|
||||
useEffect(() => {
|
||||
if (userConfigLoading || !user || onboardingCheckedRef.current) return;
|
||||
|
||||
const flags = userConfig as OnboardingFlags | null;
|
||||
const completed =
|
||||
hasCompletedAction("welcome_form_completed") ||
|
||||
Boolean(flags?.onboarding_completed_at) ||
|
||||
Boolean(flags?.onboarding_skipped);
|
||||
if (completed) {
|
||||
onboardingCheckedRef.current = true; // already done — never show
|
||||
if (authLoading || onboardingLoading || !user || onboardingCheckedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onboardingCheckedRef.current = true;
|
||||
if (onboardingDoneRef.current) return; // already done — never show
|
||||
|
||||
// Only brand-new users (no workflows yet) see the form. The count is
|
||||
// org-scoped (the user's selected organization), so a new user joining an
|
||||
// org that already has workflows is correctly grandfathered out. This costs
|
||||
|
|
@ -74,12 +66,9 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
|
|||
(async () => {
|
||||
try {
|
||||
const res = await getWorkflowCountApiV1WorkflowCountGet();
|
||||
// Re-read the flag after the await: a config save elsewhere may have
|
||||
// stamped completion while the count was in flight.
|
||||
const latest = userConfig as OnboardingFlags | null;
|
||||
const stillPending =
|
||||
!latest?.onboarding_completed_at && !latest?.onboarding_skipped;
|
||||
if (res.data?.total === 0 && stillPending) {
|
||||
// Re-check the flag after the await: a completion elsewhere (another
|
||||
// tab) may have stamped it while the count was in flight.
|
||||
if (res.data?.total === 0 && !onboardingDoneRef.current) {
|
||||
setOnboardingOpen(true);
|
||||
posthog.capture(PostHogEvent.ONBOARDING_SHOWN);
|
||||
}
|
||||
|
|
@ -88,24 +77,15 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
|
|||
// existing users are never disrupted.
|
||||
}
|
||||
})();
|
||||
}, [userConfigLoading, user, userConfig, hasCompletedAction]);
|
||||
}, [authLoading, onboardingLoading, user]);
|
||||
|
||||
const completeOnboarding = useCallback(() => {
|
||||
// Dismiss immediately. Mark the same-browser backstop synchronously via
|
||||
// OnboardingProvider (same store as the one-time tooltips/actions) so an
|
||||
// instant reload can't re-show the gate, then best-effort persist the server
|
||||
// flag (cross-device source of truth). saveUserConfig merges with the existing
|
||||
// config, so only the new field is needed.
|
||||
const completeOnboarding = useCallback((skipped: boolean) => {
|
||||
// Dismiss immediately, then persist the flag through OnboardingContext
|
||||
// (optimistic local state closes the gate even if the server write lags;
|
||||
// the write itself is best-effort and cross-device).
|
||||
setOnboardingOpen(false);
|
||||
markActionCompleted("welcome_form_completed");
|
||||
void saveUserConfig({
|
||||
onboarding_completed_at: new Date().toISOString(),
|
||||
} as Parameters<typeof saveUserConfig>[0]).catch(() => {
|
||||
// The local backstop already prevents a same-browser re-prompt; a failed
|
||||
// server stamp only risks a re-prompt on another device.
|
||||
console.error("[onboarding] failed to persist completion flag to user-config");
|
||||
});
|
||||
}, [saveUserConfig, markActionCompleted]);
|
||||
markOnboardingCompleted({ skipped });
|
||||
}, [markOnboardingCompleted]);
|
||||
|
||||
const openHireExpert = useCallback((source: LeadSource) => {
|
||||
hasOpenedHireRef.current = true;
|
||||
|
|
|
|||
|
|
@ -1,95 +1,168 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
getUserOnboardingStateApiV1UserOnboardingStateGet,
|
||||
updateUserOnboardingStateApiV1UserOnboardingStatePut,
|
||||
} from '@/client/sdk.gen';
|
||||
import type { OnboardingStateUpdate } from '@/client/types.gen';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
export type TooltipKey = 'web_call' | 'customize_workflow';
|
||||
export type OnboardingActionKey = 'web_call_started' | 'welcome_form_completed';
|
||||
export type OnboardingActionKey = 'web_call_started';
|
||||
|
||||
// Server-backed onboarding state (GET/PUT /user/onboarding-state), stored
|
||||
// per-user under the ONBOARDING user-configuration key — deliberately
|
||||
// independent of the AI model configuration. Replaces the old
|
||||
// localStorage-only store so one-time UI (post-signup gate, tooltips,
|
||||
// milestone actions) holds across devices and browsers.
|
||||
interface OnboardingState {
|
||||
seenTooltips: TooltipKey[];
|
||||
completedActions: OnboardingActionKey[];
|
||||
completed_at: string | null;
|
||||
skipped: boolean;
|
||||
seen_tooltips: string[];
|
||||
completed_actions: string[];
|
||||
}
|
||||
|
||||
interface OnboardingContextType {
|
||||
// True until the server state has been fetched. While loading, the
|
||||
// has* checks report "already seen/done" so one-time UI never flashes
|
||||
// for users who have in fact seen it.
|
||||
loading: boolean;
|
||||
// Post-signup onboarding form gate (set once on submit/skip).
|
||||
onboardingCompletedAt: string | null;
|
||||
onboardingSkipped: boolean;
|
||||
markOnboardingCompleted: (opts?: { skipped?: boolean }) => void;
|
||||
hasSeenTooltip: (key: TooltipKey) => boolean;
|
||||
markTooltipSeen: (key: TooltipKey) => void;
|
||||
hasCompletedAction: (key: OnboardingActionKey) => boolean;
|
||||
markActionCompleted: (key: OnboardingActionKey) => void;
|
||||
resetOnboarding: () => void;
|
||||
}
|
||||
|
||||
const ONBOARDING_STORAGE_KEY = 'dograh_onboarding_state';
|
||||
|
||||
const defaultState: OnboardingState = {
|
||||
seenTooltips: [],
|
||||
completedActions: [],
|
||||
completed_at: null,
|
||||
skipped: false,
|
||||
seen_tooltips: [],
|
||||
completed_actions: [],
|
||||
};
|
||||
|
||||
const union = (a: string[], b: string[] | null | undefined) =>
|
||||
[...a, ...(b ?? []).filter((item) => !a.includes(item))];
|
||||
|
||||
// Merge a server response into local state monotonically: flags only ever
|
||||
// advance, so a response that raced a newer optimistic mark can't revert it.
|
||||
const absorb = (prev: OnboardingState, server: Partial<OnboardingState>): OnboardingState => ({
|
||||
completed_at: prev.completed_at ?? server.completed_at ?? null,
|
||||
skipped: prev.skipped || Boolean(server.skipped),
|
||||
seen_tooltips: union(prev.seen_tooltips, server.seen_tooltips),
|
||||
completed_actions: union(prev.completed_actions, server.completed_actions),
|
||||
});
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
|
||||
|
||||
export const OnboardingProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [onboardingState, setOnboardingState] = useState<OnboardingState>(() => {
|
||||
// Initialize state from localStorage on first render
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedState = localStorage.getItem(ONBOARDING_STORAGE_KEY);
|
||||
if (savedState) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedState);
|
||||
return { ...defaultState, ...parsed };
|
||||
} catch (error) {
|
||||
console.error('Failed to parse onboarding state:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultState;
|
||||
});
|
||||
const [state, setState] = useState<OnboardingState>(defaultState);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const auth = useAuth();
|
||||
const authRef = useRef(auth);
|
||||
authRef.current = auth;
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
// Save state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(onboardingState));
|
||||
if (auth.loading || hasFetched.current) return;
|
||||
if (!auth.isAuthenticated) {
|
||||
// Unauthenticated pages (login/signup) have no onboarding state;
|
||||
// unblock consumers with defaults.
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
}, [onboardingState]);
|
||||
hasFetched.current = true;
|
||||
|
||||
const hasSeenTooltip = (key: TooltipKey): boolean => {
|
||||
return onboardingState.seenTooltips.includes(key);
|
||||
};
|
||||
(async () => {
|
||||
const res = await getUserOnboardingStateApiV1UserOnboardingStateGet().catch(() => null);
|
||||
if (res?.data) {
|
||||
const data = res.data as Partial<OnboardingState>;
|
||||
setState((prev) => absorb(prev, data));
|
||||
setLoaded(true);
|
||||
} else {
|
||||
// Fetch failed: stay in loading so one-time UI stays suppressed
|
||||
// (fail closed — never re-show onboarding to an onboarded user).
|
||||
console.error('[onboarding] failed to fetch onboarding state', res?.error);
|
||||
}
|
||||
})();
|
||||
}, [auth.loading, auth.isAuthenticated]);
|
||||
|
||||
const markTooltipSeen = (key: TooltipKey) => {
|
||||
setOnboardingState(prev => ({
|
||||
// Best-effort server write. Only the delta is sent; the server unions list
|
||||
// fields into the stored state, so concurrent tabs don't drop each other's
|
||||
// updates. The response is the merged state — use it to reconcile.
|
||||
const persist = useCallback((update: OnboardingStateUpdate) => {
|
||||
if (!authRef.current.isAuthenticated) return;
|
||||
void updateUserOnboardingStateApiV1UserOnboardingStatePut({ body: update })
|
||||
.then((res) => {
|
||||
if (res.error) {
|
||||
console.error('[onboarding] failed to persist onboarding state', res.error);
|
||||
} else if (res.data) {
|
||||
const data = res.data as Partial<OnboardingState>;
|
||||
setState((prev) => absorb(prev, data));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.error('[onboarding] failed to persist onboarding state');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markOnboardingCompleted = useCallback((opts?: { skipped?: boolean }) => {
|
||||
const skipped = opts?.skipped ?? false;
|
||||
const completedAt = new Date().toISOString();
|
||||
// Optimistic: the gate must close immediately and never re-open.
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
seenTooltips: prev.seenTooltips.includes(key)
|
||||
? prev.seenTooltips
|
||||
: [...prev.seenTooltips, key]
|
||||
skipped: prev.skipped || skipped,
|
||||
completed_at: prev.completed_at ?? (skipped ? null : completedAt),
|
||||
}));
|
||||
};
|
||||
persist(skipped ? { skipped: true } : { completed_at: completedAt });
|
||||
}, [persist]);
|
||||
|
||||
const hasCompletedAction = (key: OnboardingActionKey): boolean => {
|
||||
return onboardingState.completedActions.includes(key);
|
||||
};
|
||||
const hasSeenTooltip = useCallback(
|
||||
(key: TooltipKey) => !loaded || state.seen_tooltips.includes(key),
|
||||
[loaded, state.seen_tooltips],
|
||||
);
|
||||
|
||||
const markActionCompleted = (key: OnboardingActionKey) => {
|
||||
setOnboardingState(prev => ({
|
||||
...prev,
|
||||
completedActions: prev.completedActions.includes(key)
|
||||
? prev.completedActions
|
||||
: [...prev.completedActions, key]
|
||||
}));
|
||||
};
|
||||
const markTooltipSeen = useCallback((key: TooltipKey) => {
|
||||
setState((prev) =>
|
||||
prev.seen_tooltips.includes(key)
|
||||
? prev
|
||||
: { ...prev, seen_tooltips: [...prev.seen_tooltips, key] }
|
||||
);
|
||||
persist({ seen_tooltips: [key] });
|
||||
}, [persist]);
|
||||
|
||||
const resetOnboarding = () => {
|
||||
setOnboardingState(defaultState);
|
||||
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
|
||||
};
|
||||
const hasCompletedAction = useCallback(
|
||||
(key: OnboardingActionKey) => !loaded || state.completed_actions.includes(key),
|
||||
[loaded, state.completed_actions],
|
||||
);
|
||||
|
||||
const markActionCompleted = useCallback((key: OnboardingActionKey) => {
|
||||
setState((prev) =>
|
||||
prev.completed_actions.includes(key)
|
||||
? prev
|
||||
: { ...prev, completed_actions: [...prev.completed_actions, key] }
|
||||
);
|
||||
persist({ completed_actions: [key] });
|
||||
}, [persist]);
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider
|
||||
value={{
|
||||
loading: !loaded,
|
||||
onboardingCompletedAt: state.completed_at,
|
||||
onboardingSkipped: state.skipped,
|
||||
markOnboardingCompleted,
|
||||
hasSeenTooltip,
|
||||
markTooltipSeen,
|
||||
hasCompletedAction,
|
||||
markActionCompleted,
|
||||
resetOnboarding
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue