mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-29 16:14:37 +00:00
feat: drop the webhook step from onboarding (nobody wires a webhook on first sign-in) and make the self-hosted cloud step minimal: a two-line pitch that says it is about warmup, no button of its own, Get started requests the code and turns into Waiting for approval until the link lands, Skip for now stays
This commit is contained in:
@@ -14,7 +14,6 @@ import AuthButton from "@/components/auth/button";
|
||||
import useCompleteOnboarding from "@/lib/api/hooks/auth/useCompleteOnboarding";
|
||||
import useUpdateOrganization from "@/lib/api/hooks/app/organizations/useUpdateOrganization";
|
||||
import useCurrentOrganization from "@/lib/api/hooks/app/organizations/useCurrentOrganization";
|
||||
import createWebhook from "@/lib/api/client/app/webhooks/createWebhook";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
|
||||
@@ -33,15 +32,6 @@ const schema = z.object({
|
||||
referral_source: z.enum(["reddit", "x", "facebook", "google", "other"], {
|
||||
error: "Let us know how you found us",
|
||||
}),
|
||||
// Optional final step: connect a webhook. Skippable, so an empty value is OK;
|
||||
// when present it must look like a URL.
|
||||
webhook_url: z
|
||||
.string()
|
||||
.trim()
|
||||
.url("Enter a valid URL (https://…)")
|
||||
.refine((u) => u.startsWith("https://"), "Webhook URLs must use https")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
});
|
||||
|
||||
type OnboardingForm = z.infer<typeof schema>;
|
||||
@@ -64,19 +54,14 @@ const BASE_STEPS = [
|
||||
title: "A few quick questions",
|
||||
subtitle: "This helps us tailor Warmbly to how you send.",
|
||||
},
|
||||
{
|
||||
fields: ["webhook_url"] as const,
|
||||
title: "Connect a webhook",
|
||||
subtitle: "Optional. Get a realtime HTTP callback when things happen in your workspace. You can skip this and add it later.",
|
||||
},
|
||||
];
|
||||
|
||||
// Self-hosted instances get one more step: linking to Warmbly Cloud so the
|
||||
// pool warms their mailboxes. Skippable; Settings > Warmbly Cloud has it too.
|
||||
const CLOUD_STEP = {
|
||||
fields: [] as const,
|
||||
title: "Warm your mailboxes in the Warmbly pool",
|
||||
subtitle: "Optional. Link this instance to Warmbly Cloud and we run warmup for up to 10 mailboxes for free. Everything else stays on this server.",
|
||||
title: "Warm up your mailboxes",
|
||||
subtitle: "Get started links this instance to Warmbly Cloud so it warms your mailboxes. You can skip it.",
|
||||
};
|
||||
|
||||
const ROLES = [
|
||||
@@ -177,6 +162,7 @@ export default function OnboardingPage() {
|
||||
const cloudStep = selfHosted ? STEPS.length - 1 : -1;
|
||||
const [cloudLinked, setCloudLinked] = useState(false);
|
||||
const [cloudOrg, setCloudOrg] = useState("");
|
||||
const [cloudStart, setCloudStart] = useState(0);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const isLast = step === STEPS.length - 1;
|
||||
@@ -214,16 +200,6 @@ export default function OnboardingPage() {
|
||||
role: data.role,
|
||||
team_size: data.team_size,
|
||||
});
|
||||
// Optional webhook. Best effort: an empty URL skips it, and a hiccup
|
||||
// here must never block completing onboarding.
|
||||
const webhookUrl = (data.webhook_url ?? "").trim();
|
||||
if (webhookUrl) {
|
||||
try {
|
||||
await createWebhook({ url: webhookUrl, event_types: [], enabled: true });
|
||||
} catch {
|
||||
/* keep going — onboarding completion matters more */
|
||||
}
|
||||
}
|
||||
queryClient.removeQueries({ queryKey: ["auth", "me"] });
|
||||
navigate("/app/emails");
|
||||
} catch (e) {
|
||||
@@ -236,6 +212,11 @@ export default function OnboardingPage() {
|
||||
if (pending) return;
|
||||
const ok = await trigger(STEPS[step].fields as unknown as (keyof OnboardingForm)[]);
|
||||
if (!ok) return;
|
||||
if (step === cloudStep && !cloudLinked) {
|
||||
// First press asks the cloud for a code; the card then waits for approval.
|
||||
setCloudStart((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
if (isLast) await finish();
|
||||
else setStep((s) => s + 1);
|
||||
};
|
||||
@@ -353,6 +334,8 @@ export default function OnboardingPage() {
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<CloudLinkCard
|
||||
compact
|
||||
minimal
|
||||
startSignal={cloudStart}
|
||||
linked={cloudLinked}
|
||||
orgName={cloudOrg}
|
||||
onLinked={(name) => {
|
||||
@@ -362,34 +345,16 @@ export default function OnboardingPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<FieldLabel>Webhook URL</FieldLabel>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://acme.com/webhooks/warmbly"
|
||||
className={INPUT}
|
||||
autoFocus
|
||||
{...register("webhook_url")}
|
||||
/>
|
||||
<FieldError message={errors.webhook_url?.message} />
|
||||
<p className="text-sm text-slate-400 mt-2 leading-relaxed">
|
||||
We'll POST a signed callback here for every workspace event. Subscribe to specific events and
|
||||
manage signing secrets later in Settings. You can skip this for now.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<AuthButton loading={isLast && pending}>{isLast ? "Get started" : "Continue"}</AuthButton>
|
||||
<AuthButton loading={isLast && pending}>{step === cloudStep && cloudStart > 0 && !cloudLinked ? "Waiting for approval" : isLast ? "Get started" : "Continue"}</AuthButton>
|
||||
|
||||
{isLast && (
|
||||
{isLast && step === cloudStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (pending) return;
|
||||
setValue("webhook_url", "");
|
||||
void finish();
|
||||
}}
|
||||
disabled={pending}
|
||||
|
||||
@@ -18,6 +18,8 @@ export default function CloudLinkCard({
|
||||
onLinked,
|
||||
compact = false,
|
||||
autoStart = false,
|
||||
minimal = false,
|
||||
startSignal = 0,
|
||||
}: {
|
||||
linked: boolean;
|
||||
orgName?: string;
|
||||
@@ -27,6 +29,10 @@ export default function CloudLinkCard({
|
||||
compact?: boolean;
|
||||
/** Request a code immediately instead of showing the pitch first. */
|
||||
autoStart?: boolean;
|
||||
/** Short pitch, no heading or button: the host owns the primary action. */
|
||||
minimal?: boolean;
|
||||
/** Increment to request a code from outside (the host's primary button). */
|
||||
startSignal?: number;
|
||||
}) {
|
||||
const start = useStartCloudLinkConnect();
|
||||
const poll = usePollCloudLinkConnect();
|
||||
@@ -50,6 +56,11 @@ export default function CloudLinkCard({
|
||||
}
|
||||
}, [autoStart, linked, pending, begin]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (startSignal > 0 && !linked && !pending) void begin();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [startSignal]);
|
||||
|
||||
// Poll on the cloud's interval while a code is out.
|
||||
React.useEffect(() => {
|
||||
if (!pending || linked) return;
|
||||
@@ -110,6 +121,25 @@ export default function CloudLinkCard({
|
||||
);
|
||||
}
|
||||
|
||||
if (!pending && minimal) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="size-9 rounded-md bg-sky-600 text-white inline-flex items-center justify-center shrink-0">
|
||||
<FlameIcon className="w-4 h-4" />
|
||||
</span>
|
||||
<div className="text-[13px] text-slate-600 leading-relaxed">
|
||||
<p className="text-slate-900 font-medium">Warmbly warms your mailboxes for you.</p>
|
||||
<p className="mt-1">Free for 10 mailboxes. Your data stays on this server; only warmup runs in the cloud.</p>
|
||||
{start.isPending && (
|
||||
<p className="mt-2 inline-flex items-center gap-1.5 text-slate-400">
|
||||
<Loader2Icon className="w-3 h-3 animate-spin" /> Getting a code
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pending) {
|
||||
return (
|
||||
<div className={compact ? "space-y-4" : "grid md:grid-cols-[1fr_260px] gap-6 items-start"}>
|
||||
|
||||
Reference in New Issue
Block a user