feat: UI refresh and user onboarding (#430)

* docs: design spec for lead-gen surfaces (Credits & Billing, Hire-an-Expert, Top-up, Enterprise)

Add brainstorming spec for: sidebar OBSERVE→MANAGE rename + Credits & Billing
link + Hire-an-Expert footer button; new /billing page with extracted Dograh
Model Credits card + CTAs; Top-up / Hire-an-Expert / Enterprise intake modals
with inline math captcha; and a workflow-builder Hire-an-Expert nudge. Frontend
only; submissions fire PostHog events via a submitLead() seam for a future
MongoDB endpoint. Also gitignore .superpowers/ brainstorm mockups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for user-onboarding lead-gen surfaces

14 bite-sized tasks: PostHog events, shared helpers (field options,
work-email blocklist, submitLead seam, math captcha), three intake modals
(enterprise/hire/top-up), LeadFormsProvider context, AppLayout mount, sidebar
MANAGE rename + Credits & Billing link + footer Hire button, extracted
DograhCreditsCard, /billing page, credits removal from Agent Runs, builder
nudge, and a full verification/dogfood pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): register PostHog events for lead-gen surfaces

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): shared field options, work-email validation, and submit seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): inline math captcha field

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): enterprise intake modal

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): hire-an-expert modal with enterprise link

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): top-up modal with >20k volume-pricing gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): shared lead-forms context provider

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): mount LeadFormsProvider in app layout

Wrap the sidebar branch of AppLayout with LeadFormsProvider so the shared
lead modals are available to the sidebar, billing card, and builder nudge.
Includes eslint import-order auto-fixes in TopUpModal and LeadFormsContext.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): rename OBSERVE to MANAGE, add Credits & Billing link and Hire-an-Expert footer button

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): extract DograhCreditsCard with top-up + hire CTAs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): add Credits & Billing page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(lead-gen): move Dograh Model Credits card out of Agent Runs to /billing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): delayed Hire-an-Expert nudge on the workflow builder

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(ui): add lint:lead-flow guard script

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ui): restructure lead forms, self-serve Buy Credits, dialog blur

Revised lead-capture surfaces and credits bar:

- Dialog overlay gains backdrop blur (bg-black/60 backdrop-blur-sm).
- Shared primitives: LeadModalShell (icon/eyebrow header, scrollable body,
  sticky footer, trust-line slot), PhoneField (react-international-phone,
  dark, E.164 out), FormTrustLine ("Average response: under 10 minutes...").
- HireExpertModal: Name, Company, Job title, agent goal, Phone (required),
  monthly volume. EnterpriseModal: + work email (required logged-out),
  conditional deployment (yes/no/maybe, source-gated), agent goal.
  OnboardingModal: drop useCase. Phone mandatory except onboarding.
- Volume buckets match the backend qualifier (0-5k/5k-100k/100k+/not-sure).
- Delete TopUpModal; DograhCreditsCard now self-serve Buy Credits (amount
  chips $5/$10/$25/$50/$100 + custom min $5 → startTopUp seam) + Hire an
  Expert + dashed custom-pricing link opening Enterprise (billing_custom_pricing).
- PostHog events: drop topup_*, add buy_credits_clicked,
  buy_credits_amount_selected, custom_pricing_clicked. LeadFormsContext
  drops topup; LeadKind/LeadSource updated.
- Introduce a single --cta warm accent token (CTAs + focus rings only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ui): split-screen auth + enterprise CTA + dark theme default

- AuthShell: dark two-column auth layout (brand/value panel with CSS-only
  waveform motif + proof points + Bland-style enterprise CTA block on the
  left, zinc-900 form card on the right; single-column on mobile).
- AuthEnterpriseCTA: "Talk to our team" → dograh.com/contact?intent=enterprise.
- stack-theme: dark StackTheme token overrides synced to globals.css.
- page.tsx: wrap StackHandler (non-fullPage) in AuthShell + StackTheme;
  local-auth fallback preserved inside the shell. BackButton slimmed for the card.
- Dark locked as default: <html className="dark">, next-themes ThemeProvider
  (defaultTheme="dark", enableSystem=false); inline no-FOUC script defaults dark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ui rezig, onboarding, billing, hire us & on prem cues

* ui changes

* chore: update comment

* chore: untrack docs/superpowers and gitignore it

* feat: refactor user configuration table

* feat(ui): 'check your email' confirmation on lead forms

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* added email and country in form submissions

* chore: update leads api

* fix: wrap dograh model config in card

---------

Co-authored-by: Pritesh <pritesh@dograh.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-06-17 19:49:33 +05:30 committed by GitHub
parent a2d9ed24ed
commit 00b35d6963
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 3819 additions and 604 deletions

View file

@ -0,0 +1,136 @@
"use client";
import posthog from "posthog-js";
import { createContext, type ReactNode,useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
import { EnterpriseModal } from "@/components/lead-forms/EnterpriseModal";
import { HireExpertModal } from "@/components/lead-forms/HireExpertModal";
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 { useAuth } from "@/lib/auth";
interface LeadFormsContextValue {
openHireExpert: (source: LeadSource) => void;
openEnterprise: (source: LeadSource, prefill?: { company?: string }) => void;
// True once the hire modal has been opened this session (used to suppress the builder nudge).
hasOpenedHireRef: React.MutableRefObject<boolean>;
}
const LeadFormsContext = createContext<LeadFormsContextValue | null>(null);
export function LeadFormsProvider({ children }: { children: ReactNode }) {
const [hireOpen, setHireOpen] = useState(false);
const [enterpriseOpen, setEnterpriseOpen] = useState(false);
// Track the originating source so the *_OPENED and submit events agree.
const [hireSource, setHireSource] = useState<LeadSource>("sidebar");
const [enterpriseSource, setEnterpriseSource] = useState<LeadSource>("sidebar");
const [enterprisePrefill, setEnterprisePrefill] = useState<{ company?: string } | undefined>(undefined);
const hasOpenedHireRef = useRef(false);
// ---- Post-signup onboarding gate ----
// Show the onboarding form ONCE per user, and ONLY to genuinely new users:
// (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 { 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 (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
// one lightweight count query per session for users whose flag is still
// unset — an accepted trade for a server-authoritative, cross-device gate.
(async () => {
try {
const res = await getWorkflowCountApiV1WorkflowCountGet();
// 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);
}
} catch {
// If the count can't be fetched, do NOT show the modal — fail closed so
// existing users are never disrupted.
}
})();
}, [authLoading, onboardingLoading, user]);
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);
markOnboardingCompleted({ skipped });
}, [markOnboardingCompleted]);
const openHireExpert = useCallback((source: LeadSource) => {
hasOpenedHireRef.current = true;
setHireSource(source);
setHireOpen(true);
posthog.capture(PostHogEvent.HIRE_EXPERT_OPENED, { source });
}, []);
const openEnterprise = useCallback((source: LeadSource, prefill?: { company?: string }) => {
setEnterpriseSource(source);
setEnterprisePrefill(prefill);
setEnterpriseOpen(true);
posthog.capture(PostHogEvent.ENTERPRISE_LEAD_OPENED, { source });
}, []);
const value = useMemo(
() => ({ openHireExpert, openEnterprise, hasOpenedHireRef }),
[openHireExpert, openEnterprise],
);
return (
<LeadFormsContext.Provider value={value}>
{children}
<HireExpertModal
open={hireOpen}
onOpenChange={setHireOpen}
source={hireSource}
onOpenEnterprise={() => openEnterprise("hire_expert")}
/>
<EnterpriseModal
open={enterpriseOpen}
onOpenChange={setEnterpriseOpen}
source={enterpriseSource}
prefill={enterprisePrefill}
/>
<OnboardingModal
open={onboardingOpen}
onComplete={completeOnboarding}
/>
</LeadFormsContext.Provider>
);
}
export function useLeadForms(): LeadFormsContextValue {
const ctx = useContext(LeadFormsContext);
if (!ctx) throw new Error("useLeadForms must be used within a LeadFormsProvider");
return ctx;
}

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';
// 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}