Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"use client";
import { StepWizard } from "@unkey/ui";
import { useState } from "react";
import { OnboardingHeader } from "./onboarding-header";
import { ConnectGithubStep } from "./steps/connect-github";
import { CreateProjectStep } from "./steps/create-project";
import { SelectRepo } from "./steps/select-repo";

export const Onboarding = () => {
const [projectId, setProjectId] = useState<string | null>(null);

return (
<div className="flex flex-col items-center justify-center h-screen relative">
<StepWizard.Root>
<OnboardingHeader projectId={projectId} />
<StepWizard.Step id="create-project" label="Create project">
<CreateProjectStep onProjectCreated={setProjectId} />
</StepWizard.Step>
<StepWizard.Step id="connect-github" label="Connect GitHub">
<ConnectGithubStep projectId={projectId} />
</StepWizard.Step>
<StepWizard.Step id="select-repo" label="Connect GitHub">
{/* // Clean this up later. ProjectId cannot be null after the first step */}
<SelectRepo projectId={projectId ?? undefined} />
</StepWizard.Step>
</StepWizard.Root>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"use client";
import { trpc } from "@/lib/trpc/client";
import { Check, CloudUp, Harddrive, HeartPulse, Location2, Nodes2, XMark } from "@unkey/icons";
import { useStepWizard } from "@unkey/ui";
import { cn } from "@unkey/ui/src/lib/utils";
import { useState } from "react";

type IconBoxProps = {
children?: React.ReactNode;
large?: boolean;
className?: string;
};

const IconBox = ({ children, large, className }: IconBoxProps) => (
<div
className={cn(
"shrink-0 flex items-center justify-center rounded-[10px] bg-transparent ring-1 ring-grayA-4 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.12),0_0_0_0.75px_rgba(0,0,0,0.08)]",
large ? "size-16" : "size-9",
className,
)}
>
{children}
</div>
);

const iconItems: { icon: React.ReactNode; large?: boolean; opacity: string }[] = [
{ icon: null, opacity: "opacity-60" },
{ icon: <Harddrive className="size-[18px]" iconSize="md-medium" />, opacity: "opacity-75" },
{ icon: <Location2 className="size-[18px]" iconSize="md-medium" />, opacity: "opacity-80" },
{ icon: <CloudUp className="size-9" iconSize="md-thin" />, large: true, opacity: "opacity-90" },
{ icon: <HeartPulse className="size-[18px]" iconSize="md-medium" />, opacity: "opacity-80" },
{ icon: <Nodes2 className="size-[18px]" iconSize="md-medium" />, opacity: "opacity-75" },
{ icon: null, opacity: "opacity-60" },
];

const IconRow = () => (
<div
className="p-2"
style={{
maskImage: "linear-gradient(to right, transparent, black 15%, black 85%, transparent)",
WebkitMaskImage: "linear-gradient(to right, transparent, black 15%, black 85%, transparent)",
}}
>
<div className="flex gap-6 items-center justify-center text-gray-12">
{iconItems.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: its okay
<IconBox key={i} large={item.large} className={item.opacity}>
{item.icon}
</IconBox>
))}
</div>
</div>
);

type StepConfig = {
title: string;
subtitle: React.ReactNode;
showIconRow: boolean;
};

const stepConfigs: Record<string, StepConfig> = {
"create-project": {
title: "Deploy your first project",
subtitle: (
<>
Connect a GitHub repo and get a live URL in minutes.
<br />
Unkey handles builds, infra, scaling, and routing.
</>
),
showIconRow: true,
},
"connect-github": {
title: "Deploy your first project",
subtitle: (
<>
Connect a GitHub repo and get a live URL in minutes.
<br />
Unkey handles builds, infra, scaling, and routing.
</>
),
showIconRow: true,
},
"select-repo": {
title: "Select a repository",
subtitle: (
<>
Choose a repository and a branch containing your project.
<br />
We’ll automatically detect Dockerfiles.
</>
),
showIconRow: false,
},
};

type OnboardingHeaderProps = {
projectId: string | null;
};

export const OnboardingHeader = ({ projectId }: OnboardingHeaderProps) => {
const { activeStepId } = useStepWizard();
const [isDismissed, setIsDismissed] = useState(false);
const config = stepConfigs[activeStepId];

const isGithubStep = activeStepId === "connect-github";
const { data } = trpc.github.getInstallations.useQuery(
{ projectId: projectId ?? "" },
{ enabled: isGithubStep && Boolean(projectId), staleTime: 0 },
);
const hasInstallations = (data?.installations?.length ?? 0) > 0;
const showBanner = isGithubStep && hasInstallations && !isDismissed;

if (!config) {
return null;
}

return (
<>
{showBanner && (
<div className="absolute top-2 left-2 right-2 rounded-[10px] p-3 gap-2.5 flex items-center shadow-[inset_0_0_0_0.75px_rgba(0,0,0,0.10)] bg-gradient-to-r from-successA-4 via-successA-1 to-success-1">
<Check iconSize="sm-regular" />
<div className="flex items-center gap-1">
<span className="font-medium text-[13px] text-success-12">
GitHub connected successfully.
</span>
<span className="text-[13px] text-success-12">
You can now select a repository to deploy
</span>
</div>
<button type="button" onClick={() => setIsDismissed(true)} className="ml-auto">
<XMark iconSize="sm-regular" />
</button>
</div>
)}
<div className="flex flex-col items-center">
{config.showIconRow && <IconRow />}
<div className="mb-5" />
<div className="flex flex-col items-center justify-center gap-2">
<div className="font-semibold text-lg text-gray-12">{config.title}</div>
<div className="text-[13px] text-gray-11 text-center">{config.subtitle}</div>
</div>
<div className="mb-6" />
</div>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";
import { Github, Layers3 } from "@unkey/icons";
import { Button } from "@unkey/ui";
import { OnboardingLinks } from "./onboarding-links";

type ConnectGithubStepProps = {
projectId: string | null;
};

export const ConnectGithubStep = ({ projectId }: ConnectGithubStepProps) => {
const installUrl = `https://github.com/apps/${process.env.NEXT_PUBLIC_GITHUB_APP_NAME}/installations/new?state=${encodeURIComponent(JSON.stringify({ projectId }))}`;

return (
<div className="flex flex-col items-center">
<div className="border border-grayA-5 rounded-[14px] flex justify-center items-center gap-4 py-[18px] px-4 min-w-[600px]">
<div className="size-8 rounded-[10px] bg-gray-12 grid place-items-center">
<Layers3 className="size-[18px] text-gray-1" iconSize="md-medium" />
</div>
<div className="flex flex-col gap-3">
<span className="font-medium text-gray-12 text-[13px] leading-[9px]">Import project</span>
<span className="text-gray-10 text-[13px] leading-[9px]">
Add a repo from your GitHub account
</span>
</div>
<a
href={installUrl}
target="_blank"
rel="noopener noreferrer"
className={projectId ? "" : "pointer-events-none opacity-50"}
aria-disabled={!projectId}
>
<Button
variant="outline"
className="ml-20 rounded-lg border-grayA-4 hover:bg-grayA-2 shadow-sm hover:shadow-md transition-all"
disabled={!projectId}
>
<Github className="!size-[18px] text-gray-12 shrink-0" />
<span className="text-[13px] text-gray-12 font-medium">Import from GitHub</span>
</Button>
</a>
</div>
<div className="mb-7" />
<OnboardingLinks />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"use client";
import { collection } from "@/lib/collections";
import {
type CreateProjectRequestSchema,
createProjectRequestSchema,
} from "@/lib/collections/deploy/projects";
import { zodResolver } from "@hookform/resolvers/zod";
import { DuplicateKeyError } from "@tanstack/react-db";
import { Button, FormInput, useStepWizard } from "@unkey/ui";
import { useForm } from "react-hook-form";
import { OnboardingLinks } from "./onboarding-links";

type CreateProjectStepProps = {
onProjectCreated: (id: string) => void;
};

export const CreateProjectStep = ({ onProjectCreated }: CreateProjectStepProps) => {
const { next } = useStepWizard();

const {
register,
handleSubmit,
setValue,
setError,
formState: { errors, isSubmitting, isValid },
} = useForm<CreateProjectRequestSchema>({
resolver: zodResolver(createProjectRequestSchema),
defaultValues: {
name: "",
slug: "",
},
mode: "onChange",
});

const onSubmitForm = async (values: CreateProjectRequestSchema) => {
try {
const tx = collection.projects.insert({
name: values.name,
slug: values.slug,
repositoryFullName: null,
liveDeploymentId: null,
isRolledBack: false,
id: "will-be-replace-by-server",
latestDeploymentId: null,
author: "will-be-replace-by-server",
authorAvatar: "will-be-replace-by-server",
branch: "will-be-replace-by-server",
commitTimestamp: Date.now(),
commitTitle: "will-be-replace-by-server",
domain: "will-be-replace-by-server",
regions: [],
});
await tx.isPersisted.promise;
// await collection.projects.utils.refetch();
const created = collection.projects.toArray.find((p) => p.slug === values.slug);
if (created) {
onProjectCreated(created.id);
}
next();
} catch (error) {
if (error instanceof DuplicateKeyError) {
setError("slug", {
type: "custom",
message: "Project with this slug already exists",
});
} else {
console.error("Form submission error:", error);
}
}
};

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value;
const slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
setValue("slug", slug);
};

return (
<div className="w-full justify-center items-center flex flex-col">
<div className="flex flex-col items-center border border-grayA-5 rounded-[14px] justify-center gap-4 py-[18px] px-4 min-w-[600px]">
<form onSubmit={handleSubmit(onSubmitForm)} className="flex flex-col gap-4 w-full">
<FormInput
required
label="Project Name"
className="[&_input:first-of-type]:h-[36px]"
description="A descriptive name for your project."
error={errors.name?.message}
{...register("name", {
onChange: handleNameChange,
})}
placeholder="My Awesome Project"
/>

<FormInput
required
label="Slug"
className="[&_input:first-of-type]:h-[36px]"
description="URL-friendly identifier for your project (auto-generated from name)."
error={errors.slug?.message}
{...register("slug")}
placeholder="my-awesome-project"
/>

<Button
type="submit"
variant="primary"
size="xlg"
disabled={isSubmitting || !isValid}
loading={isSubmitting}
className="w-full rounded-lg mt-2"
>
Create Project
</Button>
</form>
</div>
<div className="mb-7" />
<OnboardingLinks />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { BookBookmark, Discord } from "@unkey/icons";
import { Button } from "@unkey/ui";

export const OnboardingLinks = () => (
<div className="flex gap-3 items-center">
<Button
Comment thread
ogzhanolguncu marked this conversation as resolved.
variant="outline"
className="text-gray-12 text-[13px] font-medium border border-grayA-4 rounded-full px-3 py-1.5 transition-all "
>
<a
href="https://www.unkey.com/docs/introduction"
target="_blank"
rel="noopener noreferrer"
className="flex items-center w-full gap-2"
>
<BookBookmark className="text-gray-12 shrink-0 size-[18px]" iconSize="sm-regular" />
View documentation
</a>
</Button>
<Button
variant="outline"
className="text-gray-12 text-[13px] font-medium border border-grayA-4 rounded-full px-3 py-1.5 transition-all "
>
<a
href="https://unkey.com/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center w-full gap-2"
>
<div className="size-[18px] overflow-hidden flex items-center justify-center">
<Discord
className="text-feature-11 shrink-0"
style={{ width: 18, height: 18 }}
iconSize="sm-regular"
/>
</div>
Join community
</a>
</Button>
</div>
);
Loading