diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/index.tsx new file mode 100644 index 0000000000..52d8912fdb --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/index.tsx @@ -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(null); + + return ( +
+ + + + + + + + + + {/* // Clean this up later. ProjectId cannot be null after the first step */} + + + +
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/onboarding-header.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/onboarding-header.tsx new file mode 100644 index 0000000000..b40f00f3f2 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/onboarding-header.tsx @@ -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) => ( +
+ {children} +
+); + +const iconItems: { icon: React.ReactNode; large?: boolean; opacity: string }[] = [ + { icon: null, opacity: "opacity-60" }, + { icon: , opacity: "opacity-75" }, + { icon: , opacity: "opacity-80" }, + { icon: , large: true, opacity: "opacity-90" }, + { icon: , opacity: "opacity-80" }, + { icon: , opacity: "opacity-75" }, + { icon: null, opacity: "opacity-60" }, +]; + +const IconRow = () => ( +
+
+ {iconItems.map((item, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: its okay + + {item.icon} + + ))} +
+
+); + +type StepConfig = { + title: string; + subtitle: React.ReactNode; + showIconRow: boolean; +}; + +const stepConfigs: Record = { + "create-project": { + title: "Deploy your first project", + subtitle: ( + <> + Connect a GitHub repo and get a live URL in minutes. +
+ 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. +
+ 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. +
+ 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 && ( +
+ +
+ + GitHub connected successfully. + + + You can now select a repository to deploy + +
+ +
+ )} +
+ {config.showIconRow && } +
+
+
{config.title}
+
{config.subtitle}
+
+
+
+ + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/connect-github.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/connect-github.tsx new file mode 100644 index 0000000000..5d2a9afda5 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/connect-github.tsx @@ -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 ( +
+
+
+ +
+
+ Import project + + Add a repo from your GitHub account + +
+ + + +
+
+ +
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/create-project.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/create-project.tsx new file mode 100644 index 0000000000..5b5b4eaa38 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/create-project.tsx @@ -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({ + 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) => { + 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 ( +
+
+
+ + + + + + +
+
+ +
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/onboarding-links.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/onboarding-links.tsx new file mode 100644 index 0000000000..d40c02c770 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/onboarding-links.tsx @@ -0,0 +1,41 @@ +import { BookBookmark, Discord } from "@unkey/icons"; +import { Button } from "@unkey/ui"; + +export const OnboardingLinks = () => ( + +); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/select-repo/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/select-repo/index.tsx new file mode 100644 index 0000000000..e4154332af --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/select-repo/index.tsx @@ -0,0 +1,130 @@ +import { Combobox } from "@/components/ui/combobox"; +import { trpc } from "@/lib/trpc/client"; +import { Github, Magnifier } from "@unkey/icons"; +import { Input, toast } from "@unkey/ui"; +import { useMemo, useState } from "react"; +import { RepoListItem } from "./repo-list-item"; +import { SelectRepoSkeleton } from "./skeleton"; + +export const SelectRepo = ({ + projectId = "", +}: { + projectId?: string; +}) => { + const utils = trpc.useUtils(); + const [selectedOwner, setSelectedOwner] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + + const { data: reposData, isLoading: isLoadingRepos } = trpc.github.listRepositories.useQuery( + { + projectId, + }, + { + enabled: Boolean(projectId), + refetchOnWindowFocus: false, + }, + ); + + const selectRepoMutation = trpc.github.selectRepository.useMutation({ + onSuccess: async () => { + toast.success("Repository connected"); + await utils.github.getInstallations.invalidate(); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const ownerOptions = useMemo(() => { + const owners = new Set( + (reposData?.repositories ?? []).map((repo) => repo.fullName.split("/")[0]), + ); + return [...owners].map((owner) => ({ + value: owner, + label: {owner}, + searchValue: owner, + selectedLabel: {owner}, + })); + }, [reposData?.repositories]); + + const filteredRepos = useMemo( + () => + (reposData?.repositories ?? []).filter((repo) => { + const matchesOwner = !selectedOwner || repo.fullName.startsWith(`${selectedOwner}/`); + const matchesSearch = + !searchQuery || repo.fullName.toLowerCase().includes(searchQuery.toLowerCase()); + return matchesOwner && matchesSearch; + }), + [reposData?.repositories, selectedOwner, searchQuery], + ); + + const handleSelectOwner = (value: string) => { + setSelectedOwner(value); + setSearchQuery(""); + }; + + const handleSelectRepository = (repo: { + id: number; + fullName: string; + installationId: number; + }) => { + selectRepoMutation.mutate({ + projectId, + repositoryId: repo.id, + repositoryFullName: repo.fullName, + installationId: repo.installationId, + }); + }; + + return ( +
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> +
+ {isLoadingRepos ? ( + + ) : ownerOptions.length ? ( +
+ Select an account...} + searchPlaceholder="Filter accounts..." + leftIcon={} + /> + setSearchQuery(e.target.value)} + placeholder="Search repositories..." + leftIcon={ + + } + /> +
+ ) : null} +
+ + {(reposData?.repositories ?? []).length > 0 && + (filteredRepos.length > 0 ? ( +
    + {filteredRepos.map((repo) => ( +
  • + +
  • + ))} +
+ ) : ( +
+

No repositories found

+
+ ))} +
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/select-repo/repo-list-item.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/select-repo/repo-list-item.tsx new file mode 100644 index 0000000000..415f306a5c --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/select-repo/repo-list-item.tsx @@ -0,0 +1,111 @@ +import { trpc } from "@/lib/trpc/client"; +import { Check, ChevronDown, CircleDotted, CodeBranch } from "@unkey/icons"; +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + TimestampInfo, +} from "@unkey/ui"; +import { useState } from "react"; + +export type RepoItem = { + id: number; + fullName: string; + installationId: number; + defaultBranch: string; + pushedAt: string | null; +}; + +export const RepoListItem = ({ + repo, + projectId, + onSelect, + disabled, +}: { + repo: RepoItem; + projectId: string; + onSelect: (repo: RepoItem) => void; + disabled: boolean; +}) => { + const [owner, repoName] = repo.fullName.split("/"); + const [selectedBranch, setSelectedBranch] = useState(repo.defaultBranch); + + const { data: details } = trpc.github.getRepositoryDetails.useQuery( + { + projectId, + installationId: repo.installationId, + owner, + repo: repoName, + defaultBranch: repo.defaultBranch, + }, + { + refetchOnWindowFocus: false, + }, + ); + + const isLoading = details === undefined; + + return ( +
+
+ +
+
+
+ {repoName} +
+
+ {" "} +
+
+
+ {isLoading ? ( +
+ ) : details.hasDockerfile ? ( + <> + + Dockerfile detected + + ) : ( + No Dockerfile + )} +
+
+
+ {isLoading ? ( +
+ ) : ( + + )} +
+ +
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/select-repo/skeleton.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/select-repo/skeleton.tsx new file mode 100644 index 0000000000..8855bd5765 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/(onboarding)/steps/select-repo/skeleton.tsx @@ -0,0 +1,39 @@ +import { CircleDotted } from "@unkey/icons"; + +export const RepoListItemSkeleton = () => ( +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +export const SelectRepoSkeleton = () => ( +
+
+
+
+
+
    + {Array.from({ length: 3 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton list +
  • + +
  • + ))} +
+
+); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/projects-client.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/projects-client.tsx index f95d53a56a..fb5f52e748 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/projects-client.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/projects-client.tsx @@ -1,11 +1,18 @@ "use client"; +import { useSearchParams } from "next/navigation"; +import { Onboarding } from "./(onboarding)"; import { ProjectsListControls } from "./_components/controls"; import { ProjectsList } from "./_components/list"; import { ProjectsListNavigation } from "./navigation"; export function ProjectsClient() { - return ( + const searchParams = useSearchParams(); + const hasOnboarding = searchParams.get("onboarding"); + + return hasOnboarding ? ( + + ) : (
diff --git a/web/apps/dashboard/app/(app)/integrations/github/callback/page.tsx b/web/apps/dashboard/app/(app)/integrations/github/callback/page.tsx index 24d9429c90..4d360dce86 100644 --- a/web/apps/dashboard/app/(app)/integrations/github/callback/page.tsx +++ b/web/apps/dashboard/app/(app)/integrations/github/callback/page.tsx @@ -3,11 +3,10 @@ import { PageLoading } from "@/components/dashboard/page-loading"; import { trpc } from "@/lib/trpc/client"; import { Empty, toast } from "@unkey/ui"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { useEffect, useMemo } from "react"; export default function Page() { - const router = useRouter(); const searchParams = useSearchParams(); const installationId = searchParams?.get("installation_id") ?? null; const state = searchParams?.get("state") ?? null; @@ -21,9 +20,8 @@ export default function Page() { }, [installationId]); const mutation = trpc.github.registerInstallation.useMutation({ - onSuccess: (data) => { + onSuccess: () => { toast.success("GitHub App installed"); - router.push(`/${data.workspaceSlug}/projects/${data.projectId}/settings`); }, onError: (error) => { toast.error(error.message); diff --git a/web/apps/dashboard/components/virtual-table/components/empty-state.tsx b/web/apps/dashboard/components/virtual-table/components/empty-state.tsx index 73e709d286..29fe0a0631 100644 --- a/web/apps/dashboard/components/virtual-table/components/empty-state.tsx +++ b/web/apps/dashboard/components/virtual-table/components/empty-state.tsx @@ -12,11 +12,7 @@ export const EmptyState = ({ content }: { content?: React.ReactNode }) => ( Ready to get started? Check our documentation for a step-by-step guide. - +