feat: refactor user configuration table

This commit is contained in:
Abhishek Kumar 2026-06-12 22:00:51 +05:30
parent 03daaba7a1
commit e5cc1308ed
31 changed files with 932 additions and 419 deletions

View file

@ -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>
);

View file

@ -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",
};

View file

@ -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');
}

View file

@ -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

View file

@ -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?: {

View file

@ -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">

View file

@ -148,12 +148,7 @@ const NAV_SECTIONS: SidebarNavSection[] = [
title: "Reports",
url: "/reports",
icon: FileText,
},
{
title: "Credits & Billing",
url: "/billing",
icon: CircleDollarSign,
},
}
],
},
];

View file

@ -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 {

View file

@ -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";

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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}