feat: dashboard: new onboarding (#3398)

### **PR Type**
Enhancement


___

### **Description**
- New onboarding flow onboarding flow implemented

- Sign-up and sign-in pages redesigned

- Organization creation process updated

- Project creation step added to onboarding


___

### Diagram Walkthrough


```mermaid
flowchart LR
  A["Sign Up"] --> B["Create Organization"]
  B --> C["Choose Plan"]
  C --> D["Create Project"]
  D --> E["Dashboard"]
  F["Sign In"] --> G["New Design"]
```



<details> <summary><h3> File Walkthrough</h3></summary>

<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody></tr></tbody></table>

</details>

___

---------

Co-authored-by: robertkasza <robert.kasza@bishop-co.com>
Co-authored-by: David BM <correodelnino@gmail.com>
This commit is contained in:
Nuno Pato
2025-07-30 10:07:09 +00:00
committed by GitHub
parent 40439b9987
commit 129ec1edfc
20 changed files with 1201 additions and 436 deletions

View File

@@ -0,0 +1,5 @@
---
'@nhost/dashboard': minor
---
feat: dashboard: new onboarding

View File

@@ -0,0 +1,59 @@
import Image from 'next/image';
export function SignInRightColumn() {
return (
<div className="grid gap-6 font-[Inter]">
<div className="text-center">
<h2 className="mb-2 text-2xl font-semibold text-white">
Ship 10x faster
</h2>
<p className="text-sm text-[#A2B3BE]">
Skip months of backend setup and focus on building what matters
</p>
</div>
<div className="rounded-lg border border-white/10 bg-gradient-to-r from-[#0052CD]/10 to-[#FF02F5]/10 p-5">
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<Image
src="/assets/signup/CircleWavyCheck.svg"
width={20}
height={20}
alt="Check"
/>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold text-white">
From idea to production
</h3>
<p className="text-xs text-[#A2B3BE]">
Everything you need to ship fast, without the setup complexity.
</p>
</div>
</div>
</div>
<div className="rounded-lg border border-white/10 bg-gradient-to-r from-[#0052CD]/10 to-[#FF02F5]/10 p-5">
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<Image
src="/assets/key.svg"
width={20}
height={20}
alt="Security"
/>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold text-white">
Sleep easy at night
</h3>
<p className="text-xs text-[#A2B3BE]">
Rock-solid security so you can focus on building, not
vulnerabilities.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -23,10 +23,13 @@ import Image from 'next/image';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
export interface AuthenticatedLayoutProps extends BaseLayoutProps {}
export interface AuthenticatedLayoutProps extends BaseLayoutProps {
withMainNav?: boolean;
}
export default function AuthenticatedLayout({
children,
withMainNav = true,
...props
}: AuthenticatedLayoutProps) {
const router = useRouter();
@@ -124,10 +127,9 @@ export default function AuthenticatedLayout({
{mainNavPinned && isMdOrLarger && <PinnedMainNav />}
<div
className={cn(
'relative flex h-full w-full flex-row bg-accent',
mainNavPinned && isMdOrLarger ? 'overflow-x-auto' : '',
)}
className={cn('relative flex h-full w-full flex-row bg-accent', {
'overflow-x-auto': mainNavPinned && isMdOrLarger && withMainNav,
})}
>
{(!mainNavPinned || !isMdOrLarger) && (
<div className="flex h-full w-6 justify-center">

View File

@@ -12,10 +12,13 @@ import Image from 'next/image';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
export interface UnauthenticatedLayoutProps extends BaseLayoutProps {}
export interface UnauthenticatedLayoutProps extends BaseLayoutProps {
rightColumnContent?: React.ReactNode;
}
export default function UnauthenticatedLayout({
children,
rightColumnContent,
...props
}: UnauthenticatedLayoutProps) {
const router = useRouter();
@@ -63,38 +66,46 @@ export default function UnauthenticatedLayout({
>
<Container
rootClassName="bg-transparent h-full"
className="grid h-full w-full items-center justify-items-center gap-12 bg-transparent pb-12 pt-8 lg:grid-cols-2 lg:gap-4 lg:pb-0 lg:pt-0"
className="grid h-full w-full items-center justify-items-center gap-12 bg-transparent pb-12 pt-8 lg:grid-cols-2 lg:gap-4 lg:pb-0 lg:pt-8"
>
<div className="relative z-10 order-2 grid w-full max-w-[544px] grid-flow-row gap-12 lg:order-1">
{children}
</div>
<div className="relative z-0 order-1 flex h-full w-full items-center justify-center md:min-h-[150px] lg:order-2 lg:min-h-[none]">
<div className="absolute bottom-0 left-0 right-0 top-0 mx-auto flex h-full w-full max-w-xl items-center justify-center overflow-hidden opacity-70">
<div className="relative z-0 order-1 flex h-full w-full flex-col items-center justify-center md:min-h-[150px] lg:order-2 lg:min-h-[none] lg:gap-8">
<div className="relative flex items-center justify-center">
<div className="absolute bottom-0 left-0 right-0 top-0 mx-auto flex h-full w-full max-w-xl items-center justify-center overflow-hidden opacity-70">
<Image
priority
src="/assets/line-grid.svg"
width={1003}
height={644}
alt="Transparent lines"
objectFit="fill"
className="h-full w-full scale-[200%]"
/>
</div>
<Box
className="backface-hidden absolute left-0 right-0 z-0 mx-auto h-20 w-20 transform-gpu rounded-full opacity-80 blur-[56px]"
sx={{
backgroundColor: (theme) => theme.palette.primary.main,
}}
/>
<Image
priority
src="/assets/line-grid.svg"
width={1003}
height={644}
alt="Transparent lines"
objectFit="fill"
className="h-full w-full scale-[200%]"
src="/assets/logo.svg"
width={119}
height={40}
alt="Nhost Logo"
/>
</div>
<Box
className="backface-hidden absolute left-0 right-0 z-0 mx-auto h-20 w-20 transform-gpu rounded-full opacity-80 blur-[56px]"
sx={{
backgroundColor: (theme) => theme.palette.primary.main,
}}
/>
<Image
src="/assets/logo.svg"
width={119}
height={40}
alt="Nhost Logo"
/>
{rightColumnContent && (
<div className="relative z-10 w-full max-w-md px-4 lg:px-0">
{rightColumnContent}
</div>
)}
</div>
</Container>
</Box>

View File

@@ -9,7 +9,7 @@ function useMfaEnabled() {
fetchPolicy: 'cache-first',
});
const isMfaEnabled = isNotEmptyValue(data?.user.activeMfaType);
const isMfaEnabled = isNotEmptyValue(data?.user?.activeMfaType);
return { loading, isMfaEnabled, refetch };
}

View File

@@ -13,11 +13,13 @@ function SignUpTabs() {
return (
<Tabs value={tab} onValueChange={setTab} className="w-full">
<TabsList className="w-full">
<TabsTrigger value="password" className="w-full">
Sign Up with a Password
<TabsTrigger value="password" className="w-full gap-2">
<span className="hidden sm:inline">Sign Up with a Password</span>
<span className="sm:hidden">Password</span>
</TabsTrigger>
<TabsTrigger value="security-key" className="w-full">
Sign Up with a Security key
<TabsTrigger value="security-key" className="w-full gap-2">
<span className="hidden sm:inline">Sign Up with a Security Key</span>
<span className="sm:hidden">Security Key</span>
</TabsTrigger>
</TabsList>
<div className="pt-7">

View File

@@ -8,6 +8,18 @@ import {
DialogTrigger,
} from '@/components/ui/v3/dialog';
import { Input } from '@/components/ui/v3/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/v3/select';
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
import { useUserData } from '@/hooks/useUserData';
import { analytics } from '@/lib/segment';
import { useRouter } from 'next/router';
import {
Form,
@@ -23,17 +35,22 @@ import { useUI } from '@/components/common/UIProvider';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
import { RadioGroup, RadioGroupItem } from '@/components/ui/v3/radio-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/v3/tooltip';
import { StripeEmbeddedForm } from '@/features/orgs/components/StripeEmbeddedForm';
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
import { planDescriptions } from '@/features/orgs/projects/common/utils/planDescriptions';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
import { useUserData } from '@/hooks/useUserData';
import { cn } from '@/lib/utils';
import {
useCreateOrganizationRequestMutation,
usePrefetchNewAppQuery,
type PrefetchNewAppPlansFragment,
} from '@/utils/__generated__/graphql';
import { ORGANIZATION_TYPES } from '@/utils/constants/organizationTypes';
import { zodResolver } from '@hookform/resolvers/zod';
import { DialogDescription } from '@radix-ui/react-dialog';
import { Plus } from 'lucide-react';
@@ -43,6 +60,7 @@ import { z } from 'zod';
const createOrgFormSchema = z.object({
name: z.string().min(2),
organizationType: z.string().min(1, 'Please select an organization type'),
plan: z.optional(z.string()),
});
@@ -56,12 +74,23 @@ interface CreateOrgFormProps {
}
function CreateOrgForm({ plans, onSubmit, onCancel }: CreateOrgFormProps) {
const { orgs } = useOrgs();
const starterPlan = plans.find(({ name }) => name === 'Starter');
const proPlan = plans.find(({ name }) => name === 'Pro')!;
const hasStarterOrg = orgs.some(
(org) => org.plan.name === 'Starter' || org.plan.isFree,
);
const defaultPlan =
!hasStarterOrg && starterPlan ? starterPlan.id : proPlan?.id || '';
const form = useForm<z.infer<typeof createOrgFormSchema>>({
resolver: zodResolver(createOrgFormSchema),
defaultValues: {
name: '',
plan: proPlan?.id || '',
plan: defaultPlan,
organizationType: '',
},
});
@@ -82,6 +111,31 @@ function CreateOrgForm({ plans, onSubmit, onCancel }: CreateOrgFormProps) {
)}
/>
<FormField
control={form.control}
name="organizationType"
render={({ field }) => (
<FormItem>
<FormLabel>What would best describe your organization?</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select organization type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ORGANIZATION_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="plan"
@@ -99,12 +153,24 @@ function CreateOrgForm({ plans, onSubmit, onCancel }: CreateOrgFormProps) {
defaultValue={field.value}
className="flex flex-col space-y-1"
>
{plans.map((plan) => (
<FormItem key={plan.id}>
<FormLabel className="flex w-full cursor-pointer flex-row items-center justify-between space-y-0 rounded-md border p-3">
{plans.map((plan) => {
const isStarterPlan =
plan.name === 'Starter' || plan.isFree;
const isDisabled = isStarterPlan && hasStarterOrg;
const labelContent = (
<FormLabel
className={cn(
'flex w-full cursor-pointer flex-row items-center justify-between space-y-0 rounded-md border p-3',
isDisabled && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex flex-row items-center space-x-3">
<FormControl>
<RadioGroupItem value={plan.id} />
<RadioGroupItem
value={plan.id}
disabled={isDisabled}
/>
</FormControl>
<div className="flex flex-col space-y-2">
<div className="text-md font-semibold">
@@ -120,8 +186,25 @@ function CreateOrgForm({ plans, onSubmit, onCancel }: CreateOrgFormProps) {
{plan.isFree ? 'Free' : `$${plan.price}/mo`}
</div>
</FormLabel>
</FormItem>
))}
);
return (
<FormItem key={plan.id}>
{isDisabled ? (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
{labelContent}
</TooltipTrigger>
<TooltipContent>
You can only have one Starter organization
</TooltipContent>
</Tooltip>
) : (
labelContent
)}
</FormItem>
);
})}
<div>
<div className="flex w-full cursor-pointer flex-row items-center justify-between space-y-0 rounded-md border p-3">
<div className="flex flex-row items-center space-x-3">
@@ -193,6 +276,8 @@ export default function CreateOrgDialog({
onOpenStateChange,
redirectUrl,
}: CreateOrgDialogProps) {
const router = useRouter();
const currentUser = useUserData();
const { maintenanceActive } = useUI();
const user = useUserData();
const isPlatform = useIsPlatform();
@@ -202,6 +287,7 @@ export default function CreateOrgDialog({
});
const [createOrganizationRequest] = useCreateOrganizationRequestMutation();
const [stripeClientSecret, setStripeClientSecret] = useState('');
const { refetch: refetchOrgs } = useOrgs();
const handleOpenChange = (newOpenState: boolean) => {
const controlledFromOutSide =
@@ -215,9 +301,11 @@ export default function CreateOrgDialog({
const createOrg = async ({
name,
organizationType,
plan,
}: {
name?: string;
organizationType?: string;
plan?: string;
}) => {
await execPromiseWithErrorToast(
@@ -233,7 +321,32 @@ export default function CreateOrgDialog({
redirectURL: redirectUrl ?? defaultRedirectUrl,
},
});
setStripeClientSecret(clientSecret);
if (clientSecret) {
setStripeClientSecret(clientSecret);
} else {
const {
data: { organizations },
} = await refetchOrgs();
const newOrg = organizations.find((org) => org.plan.isFree);
analytics.track('Organization Created', {
organizationId: newOrg.id,
organizationSlug: newOrg.slug,
organizationName: name,
organizationPlan: newOrg.plan.name,
organizationOwnerId: currentUser?.id,
organizationOwnerEmail: currentUser?.email,
organizationMetadata: {
organizationType,
},
isOnboarding: false,
});
router.push(`/orgs/${newOrg.slug}/projects`);
handleOpenChange(false);
}
},
{
loadingMessage: 'Redirecting to checkout',

View File

@@ -70,7 +70,7 @@ export default function DeleteOrg() {
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={deleting || org?.plan?.isFree || maintenanceActive}
disabled={deleting || maintenanceActive}
>
Delete
</Button>

View File

@@ -40,7 +40,7 @@ type Invite = OrganizationMemberInvitesQuery['organizationMemberInvites'][0];
export default function NotificationsTray() {
const userData = useUserData();
const { asPath, route, push, query, isReady: isRouterReady } = useRouter();
const { route, push, query, isReady: isRouterReady } = useRouter();
const { session_id } = query;
const { refetch: refetchOrgs } = useOrgs();
const [open, setOpen] = useState(false);
@@ -70,7 +70,7 @@ export default function NotificationsTray() {
},
});
}
}, [asPath, userData?.id, getInvites]);
}, [userData?.id, getInvites]);
useEffect(() => {
const checkForPendingOrgRequests = async () => {
@@ -99,11 +99,9 @@ export default function NotificationsTray() {
break;
case CheckoutStatus.Completed:
// Do nothing
break;
case CheckoutStatus.Expired:
// Do nothing
break;
default:
@@ -165,7 +163,7 @@ export default function NotificationsTray() {
},
});
refetchInvites();
await refetchInvites();
},
{
loadingMessage: `Processing...`,

View File

@@ -46,6 +46,8 @@ export default function useNotFoundRedirect() {
router.pathname === '/404' ||
router.pathname === '/' ||
router.pathname === '/account' ||
router.pathname === '/onboarding' ||
router.pathname === '/onboarding/project' ||
router.pathname === '/support/ticket' ||
router.pathname === '/run-one-click-install' ||
router.pathname.includes('/orgs/_') ||

View File

@@ -1,6 +1,6 @@
import { isPlatform } from '@/utils/env';
import { isDevOrStaging } from '@/utils/helpers';
import { AnalyticsBrowser } from '@segment/analytics-next';
import { isPlatform } from '@/utils/env';
export const analytics = AnalyticsBrowser.load(

View File

@@ -22,17 +22,20 @@ export default function IndexPage() {
}
if (orgs) {
const orgFromLastSlug = orgs.find((o) => o.slug === lastSlug);
if (orgs.length === 0) {
await push('/onboarding');
return;
}
const orgFromLastSlug = orgs.find((o) => o.slug === lastSlug);
if (orgFromLastSlug) {
await push(`/orgs/${orgFromLastSlug.slug}/projects`);
return;
}
const org = orgs.find((o) => o.plan.isFree) || orgs[0];
const personalOrg = orgs.find((org) => org.plan.isFree);
if (personalOrg) {
push(`/orgs/${personalOrg.slug}/projects`);
if (org) {
push(`/orgs/${org.slug}/projects`);
}
}
};

View File

@@ -0,0 +1,466 @@
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
import { Container } from '@/components/layout/Container';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
import { Alert } from '@/components/ui/v2/Alert';
import { Box } from '@/components/ui/v2/Box';
import { Text } from '@/components/ui/v2/Text';
import { Button } from '@/components/ui/v3/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/v3/form';
import { Input } from '@/components/ui/v3/input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/v3/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/v3/select';
import { StripeEmbeddedForm } from '@/features/orgs/components/StripeEmbeddedForm';
import { planDescriptions } from '@/features/orgs/projects/common/utils/planDescriptions';
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
import { useUserData } from '@/hooks/useUserData';
import {
useCreateOrganizationRequestMutation,
useOrganizationMemberInviteAcceptMutation,
useOrganizationMemberInvitesLazyQuery,
usePrefetchNewAppQuery,
} from '@/utils/__generated__/graphql';
import { ORGANIZATION_TYPES } from '@/utils/constants/organizationTypes';
import { zodResolver } from '@hookform/resolvers/zod';
import { formatDistance } from 'date-fns';
import { useRouter } from 'next/router';
import type { ReactElement } from 'react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const onboardingSchema = z.object({
organizationName: z
.string()
.min(2, 'Organization name must be at least 2 characters'),
organizationType: z.string().min(1, 'Please select an organization type'),
plan: z.string().min(1, 'Please select a plan'),
});
type OnboardingFormData = z.infer<typeof onboardingSchema>;
export default function OnboardingPage() {
const router = useRouter();
const user = useUserData();
const { orgs, loading: loadingOrgs } = useOrgs();
const { data: plansData, loading: loadingPlans } = usePrefetchNewAppQuery({
skip: !user,
});
const [createOrganizationRequest] = useCreateOrganizationRequestMutation();
const [stripeClientSecret, setStripeClientSecret] = useState('');
const [
getInvites,
{
loading: loadingInvites,
data: { organizationMemberInvites: invites = [] } = {},
},
] = useOrganizationMemberInvitesLazyQuery();
const [acceptInvite] = useOrganizationMemberInviteAcceptMutation();
const [showOnboardingForm, setShowOnboardingForm] = useState(false);
const form = useForm<OnboardingFormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
organizationName: '',
organizationType: '',
plan: '',
},
});
const selectedPlan = form.watch('plan');
const selectedPlanData = plansData?.plans?.find(
(plan) => plan.id === selectedPlan,
);
const isSelectedPlanPaid = selectedPlanData && !selectedPlanData.isFree;
useEffect(() => {
if (user?.id) {
getInvites({
variables: {
userId: user.id,
},
});
}
}, [user?.id, getInvites]);
useEffect(() => {
if (!loadingOrgs && orgs && orgs.length > 0) {
router.push('/');
}
}, [orgs, loadingOrgs, router]);
useEffect(() => {
if (plansData?.plans?.length > 0 && !form.getValues('plan')) {
form.setValue('plan', plansData.plans[0].id);
}
}, [plansData, form]);
const onSubmit = async (data: OnboardingFormData) => {
sessionStorage.setItem('onboarding', 'true');
await execPromiseWithErrorToast(
async () => {
const redirectUrl = `${window.location.origin}/orgs/verify`;
const {
data: { billingCreateOrganizationRequest: clientSecret },
} = await createOrganizationRequest({
variables: {
organizationName: data.organizationName,
planID: data.plan,
redirectURL: redirectUrl,
},
});
if (clientSecret) {
setStripeClientSecret(clientSecret);
} else {
router.push('/onboarding/project');
}
},
{
loadingMessage: 'Creating your organization...',
successMessage: 'Organization created successfully!',
errorMessage: 'Failed to create organization. Please try again.',
},
);
};
const handleAcceptInvite = async (invite: (typeof invites)[0]) => {
await execPromiseWithErrorToast(
async () => {
await acceptInvite({
variables: {
inviteId: invite.id,
},
});
await router.push(`/orgs/${invite?.organization?.slug}/projects`);
},
{
loadingMessage: `Joining ${invite.organization.name}...`,
successMessage: `Welcome to ${invite.organization.name}!`,
errorMessage: `Failed to join organization. Please try again.`,
},
);
};
if (loadingOrgs || loadingPlans || loadingInvites) {
return (
<div className="flex h-screen items-center justify-center">
<ActivityIndicator />
</div>
);
}
if (invites && invites.length > 0 && !showOnboardingForm) {
return (
<Container rootClassName="h-full">
<div className="mx-auto max-w-2xl py-12">
<div className="mb-8 text-center">
<Text variant="h2" className="mb-4 text-3xl font-bold">
You&apos;ve been invited!
</Text>
<Text className="text-lg text-muted-foreground">
You have{' '}
{invites.length === 1
? 'an invitation'
: `${invites.length} invitations`}{' '}
to join
{invites.length === 1 ? ' an organization' : ' organizations'}
</Text>
</div>
<div className="space-y-4">
{invites.map((invite) => (
<Box
key={invite.id}
className="rounded-lg border border-border bg-card p-6 shadow-sm"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<Text variant="h3" className="mb-2 text-xl font-semibold">
{invite.organization.name}
</Text>
<Text className="text-muted-foreground">
Join as {invite.role.toLowerCase()}
</Text>
<Text className="mt-1 text-sm text-muted-foreground">
Invited{' '}
{formatDistance(new Date(invite.createdAt), new Date(), {
addSuffix: true,
})}
</Text>
</div>
<div className="ml-6">
<Button
onClick={() => handleAcceptInvite(invite)}
className="min-w-[120px]"
>
Join Organization
</Button>
</div>
</div>
</Box>
))}
</div>
<div className="mt-8 text-center">
<Text className="mb-4 text-sm text-muted-foreground">
Don&apos;t want to join? You can create your own organization
instead.
</Text>
<Button
variant="outline"
onClick={() => setShowOnboardingForm(true)}
>
Create New Organization
</Button>
</div>
</div>
</Container>
);
}
if (stripeClientSecret) {
return (
<Container className="mx-auto max-w-2xl py-12">
<div className="mb-8 flex items-center justify-center">
<div className="flex items-center space-x-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-500 text-sm font-medium text-white">
</div>
<div className="h-1 w-16 bg-green-500" />
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
2
</div>
<div className="h-1 w-16 bg-muted" />
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
3
</div>
</div>
</div>
<Box className="rounded-lg border border-border bg-white p-6 shadow-sm">
<div className="mb-6 text-center">
<Text variant="h2" className="mb-2 text-2xl font-bold text-black">
Complete Payment
</Text>
<Text className="text-gray-600">
Complete your payment to create your organization
</Text>
</div>
<StripeEmbeddedForm clientSecret={stripeClientSecret} />
</Box>
</Container>
);
}
return (
<Container className="mx-auto max-w-2xl py-12">
<div className="mb-8 flex items-center justify-center">
<div className="flex items-center space-x-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
1
</div>
<div className="h-1 w-16 bg-muted" />
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
2
</div>
{isSelectedPlanPaid && (
<>
<div className="h-1 w-16 bg-muted" />
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
3
</div>
</>
)}
</div>
</div>
<Box className="rounded-lg border border-border bg-card p-6 shadow-sm">
<div className="mb-6 text-center">
<Text variant="h2" className="mb-2 text-2xl font-bold">
Welcome to Nhost!
</Text>
<Text className="text-muted-foreground">
Let&apos;s create your organization to get started
</Text>
</div>
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="organizationName"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Name</FormLabel>
<FormControl>
<Input placeholder="Acme Inc" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="organizationType"
render={({ field }) => (
<FormItem>
<FormLabel>
What would best describe your organization?
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
localStorage.setItem(
'metadata',
JSON.stringify({ organizationType: value }),
);
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select organization type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ORGANIZATION_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="plan"
render={({ field }) => (
<FormItem>
<FormLabel>Choose your plan</FormLabel>
<FormDescription>You can change this later</FormDescription>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="space-y-3"
>
{plansData?.plans?.map((plan) => (
<FormItem key={plan.id}>
<FormLabel className="flex w-full cursor-pointer items-center justify-between rounded-lg border p-4 hover:bg-accent">
<div className="flex items-center space-x-3">
<FormControl>
<RadioGroupItem value={plan.id} />
</FormControl>
<div>
<div className="font-medium">{plan.name}</div>
<div className="text-sm text-muted-foreground">
{planDescriptions[plan.name]}
</div>
</div>
</div>
<div className="text-lg font-semibold">
{plan.isFree ? 'Free' : `$${plan.price}/mo`}
</div>
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col justify-end gap-3 sm:flex-row">
{invites && invites.length > 0 && (
<Button
type="button"
variant="outline"
onClick={() => setShowOnboardingForm(false)}
className="w-full sm:w-auto"
>
Back to Invites
</Button>
)}
<Button
type="submit"
disabled={form.formState.isSubmitting}
className="w-full sm:w-auto"
>
{form.formState.isSubmitting ? (
<>
<ActivityIndicator className="mr-2 h-4 w-4" />
Creating Organization...
</>
) : (
'Create Organization'
)}
</Button>
</div>
{invites && invites.length > 0 && (
<Alert
severity="info"
className="bg-primary/8 mt-4 rounded-lg border border-primary/20"
>
<Text className="text-sm">
<span className="font-medium text-primary">
💡 Pending Invitation{invites.length > 1 ? 's' : ''}
</span>
<br />
<span className="mt-1.5 block text-sm text-gray-600 dark:text-gray-400">
You have {invites.length} pending invitation
{invites.length > 1 ? 's' : ''} to join existing
organization{invites.length > 1 ? 's' : ''}. You can
accept {invites.length > 1 ? 'them' : 'it'} instead of
creating a new organization.
</span>
</Text>
</Alert>
)}
</form>
</Form>
</div>
</Box>
</Container>
);
}
OnboardingPage.getLayout = function getLayout(page: ReactElement) {
return (
<AuthenticatedLayout
title="Onboarding - Welcome to Nhost"
withMainNav={false}
>
{page}
</AuthenticatedLayout>
);
};

View File

@@ -0,0 +1,330 @@
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
import { Container } from '@/components/layout/Container';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
import { Box } from '@/components/ui/v2/Box';
import { Text } from '@/components/ui/v2/Text';
import { Button } from '@/components/ui/v3/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/v3/form';
import { Input } from '@/components/ui/v3/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/v3/select';
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
import { useUserData } from '@/hooks/useUserData';
import { analytics } from '@/lib/segment';
import {
useInsertOrgApplicationMutation,
usePrefetchNewAppQuery,
} from '@/utils/__generated__/graphql';
import { zodResolver } from '@hookform/resolvers/zod';
import Image from 'next/image';
import { useRouter } from 'next/router';
import type { ReactElement } from 'react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import slugify from 'slugify';
import { z } from 'zod';
const projectSchema = z.object({
organizationId: z.string().min(1, 'Please select an organization'),
projectName: z
.string()
.min(1, 'Project name is required')
.max(32, 'Project name must be 32 characters or less'),
regionId: z.string().min(1, 'Please select a region'),
});
type ProjectFormData = z.infer<typeof projectSchema>;
export default function OnboardingProjectPage() {
const router = useRouter();
const user = useUserData();
const { orgs, loading: loadingOrgs } = useOrgs();
const { data: regionsData, loading: loadingRegions } = usePrefetchNewAppQuery(
{
skip: !user,
},
);
const [insertApp] = useInsertOrgApplicationMutation();
const form = useForm<ProjectFormData>({
resolver: zodResolver(projectSchema),
defaultValues: {
organizationId: '',
projectName: '',
regionId: '',
},
});
const selectedOrg =
orgs?.find((org) => org.id === form.watch('organizationId')) || null;
useEffect(() => {
if (orgs?.length > 0 && !form.getValues('organizationId')) {
form.setValue('organizationId', orgs[0].id);
}
}, [orgs, form]);
useEffect(() => {
if (regionsData?.regions?.length > 0 && !form.getValues('regionId')) {
const activeRegion = regionsData.regions.find((region) => region.active);
if (activeRegion) {
form.setValue('regionId', activeRegion.id);
}
}
}, [regionsData, form]);
const onSubmit = async (data: ProjectFormData) => {
if (!selectedOrg) {
return;
}
const slug = slugify(data.projectName, { lower: true, strict: true });
await execPromiseWithErrorToast(
async () => {
const { data: { insertApp: { subdomain } = {} } = {} } =
await insertApp({
variables: {
app: {
name: data.projectName,
slug,
organizationID: data.organizationId,
regionId: data.regionId,
},
},
});
if (subdomain) {
const metadata = localStorage.getItem('metadata');
const parsedMetadata = metadata ? JSON.parse(metadata) : {};
// we only track here if it is a starter org
// this is because in case of a paid org, we track the org creation in the verify page
if (selectedOrg?.plan?.name === 'Starter') {
analytics.track('Organization Created', {
organizationId: selectedOrg.id,
organizationSlug: selectedOrg.slug,
organizationName: selectedOrg.name,
organizationPlan: selectedOrg.plan.name,
organizationOwnerId: user?.id,
organizationOwnerEmail: user?.email,
organizationMetadata: parsedMetadata,
isOnboarding: true,
});
}
analytics.track('Project Created', {
projectName: data.projectName,
projectSlug: slug,
organizationId: selectedOrg?.id,
organizationName: selectedOrg?.name,
regionId: data.regionId,
isOnboarding: true,
});
// clear onboarding flow and redirect to project dashboard
sessionStorage.removeItem('onboarding');
router.push(`/orgs/${selectedOrg?.slug}/projects/${subdomain}`);
}
},
{
loadingMessage: 'Creating your project...',
successMessage: 'Project created successfully!',
errorMessage: 'Failed to create project. Please try again.',
},
);
};
if (loadingOrgs || loadingRegions) {
return (
<Container>
<div className="flex h-screen items-center justify-center">
<ActivityIndicator />
</div>
</Container>
);
}
return (
<Container rootClassName="h-full">
<div className="mx-auto max-w-2xl py-12">
<div className="mb-8 flex items-center justify-center">
<div className="flex items-center space-x-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-600 text-sm font-medium text-white">
</div>
<div className="h-1 w-16 bg-green-600" />
{selectedOrg?.plan?.name !== 'Starter' ? (
<>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-600 text-sm font-medium text-white">
</div>
<div className="h-1 w-16 bg-green-600" />
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
3
</div>
</>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
2
</div>
)}
</div>
</div>
<Box className="rounded-lg border border-border bg-card p-6 shadow-sm">
<div className="mb-6 text-center">
<Text variant="h2" className="mb-2 text-2xl font-bold">
Create Your First Project
</Text>
<Text className="text-muted-foreground">
Projects contain your backend services, database, and APIs
</Text>
</div>
<div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="organizationId"
render={({ field }) => (
<FormItem>
<FormLabel>Organization</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an organization" />
</SelectTrigger>
</FormControl>
<SelectContent>
{orgs?.map((org) => (
<SelectItem key={org.id} value={org.id}>
<div className="flex items-center space-x-3">
<Image
src="/logos/new.svg"
alt="Organization"
width={16}
height={16}
/>
<span className="font-medium">{org.name}</span>
<span className="text-xs text-muted-foreground">
({org.plan?.name} plan)
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input placeholder="My awesome project" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="regionId"
render={({ field }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a region" />
</SelectTrigger>
</FormControl>
<SelectContent>
{regionsData?.regions?.map((region) => (
<SelectItem
key={region.id}
value={region.id}
disabled={!region.active}
>
<div className="flex items-center space-x-3">
<Image
src={`/assets/flags/${region.country.code}.svg`}
alt={`${region.country.name} flag`}
width={16}
height={12}
/>
<span>
{region.city}, {region.country.name}
{!region.active && ' (Disabled)'}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={form.formState.isSubmitting}
className="w-full sm:w-auto"
>
{form.formState.isSubmitting ? (
<>
<ActivityIndicator className="mr-2 h-4 w-4" />
Creating Project...
</>
) : (
'Create Project'
)}
</Button>
</div>
</form>
</Form>
</div>
</Box>
</div>
</Container>
);
}
OnboardingProjectPage.getLayout = function getLayout(page: ReactElement) {
return (
<AuthenticatedLayout title="Onboarding - Create Project">
{page}
</AuthenticatedLayout>
);
};

View File

@@ -39,7 +39,14 @@ export default function PostCheckout() {
},
});
const metadata = localStorage.getItem('metadata') || null;
const parsedMetadata = metadata ? JSON.parse(metadata) : {};
const { id, name, slug, plan } = orgData.organizations[0];
const isFromOnboarding =
document.referrer.includes('/onboarding') ||
sessionStorage.getItem('onboarding') === 'true';
analytics.track('Organization Created', {
organizationId: id,
organizationSlug: slug,
@@ -47,9 +54,15 @@ export default function PostCheckout() {
organizationPlan: plan?.name,
organizationOwnerId: currentUser?.id,
organizationOwnerEmail: currentUser?.email,
organizationMetadata: parsedMetadata,
isOnboarding: isFromOnboarding,
});
router.push(`/orgs/${Slug}/projects`);
if (isFromOnboarding) {
router.push('/onboarding/project');
} else {
router.push(`/orgs/${Slug}/projects`);
}
},
[router, currentUser?.email, currentUser?.id, getOrganizations],
);

View File

@@ -1,3 +1,4 @@
import { SignInRightColumn } from '@/components/auth/SignInRightColumn';
import { UnauthenticatedLayout } from '@/components/layout/UnauthenticatedLayout';
import { Divider } from '@/components/ui/v2/Divider';
import { Button } from '@/components/ui/v3/button';
@@ -9,9 +10,14 @@ import type { ReactElement } from 'react';
export default function SigninPage() {
return (
<div className="grid gap-12 font-[Inter]">
<h2 className="text-center text-3.5xl font-semibold lg:text-4.5xl">
It&apos;s time to build
</h2>
<div className="text-center">
<h2 className="mb-3 text-3.5xl font-semibold lg:text-4.5xl">
Welcome back
</h2>
<p className="mx-auto max-w-md text-lg text-[#A2B3BE]">
Continue building amazing things with Nhost
</p>
</div>
<div className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
<SignInWithGithub />
@@ -62,5 +68,12 @@ export default function SigninPage() {
}
SigninPage.getLayout = function getLayout(page: ReactElement) {
return <UnauthenticatedLayout title="Sign In">{page}</UnauthenticatedLayout>;
return (
<UnauthenticatedLayout
title="Sign In"
rightColumnContent={<SignInRightColumn />}
>
{page}
</UnauthenticatedLayout>
);
};

View File

@@ -1,3 +1,4 @@
import { SignInRightColumn } from '@/components/auth/SignInRightColumn';
import { NavLink } from '@/components/common/NavLink';
import { UnauthenticatedLayout } from '@/components/layout/UnauthenticatedLayout';
import { SignInWithEmailAndPassword } from '@/features/auth/SignIn/SignInWithEmailAndPassword';
@@ -6,12 +7,19 @@ import type { ReactElement } from 'react';
function SigninPage() {
return (
<div className="grid gap-12 font-[Inter]">
<h1 className="text-center text-3.5xl font-semibold lg:text-4.5xl">
Sign In
</h1>
<div className="text-center">
<h1 className="mb-3 text-3.5xl font-semibold lg:text-4.5xl">
Welcome back
</h1>
<p className="mx-auto max-w-md text-lg text-[#A2B3BE]">
Continue building amazing things with Nhost
</p>
</div>
<div className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
<SignInWithEmailAndPassword />
</div>
<p className="text-center text-base lg:text-lg">
Don&apos;t have an account?{' '}
<NavLink href="/signup" color="white">
@@ -23,7 +31,14 @@ function SigninPage() {
}
SigninPage.getLayout = function getLayout(page: ReactElement) {
return <UnauthenticatedLayout title="Sign In">{page}</UnauthenticatedLayout>;
return (
<UnauthenticatedLayout
title="Sign In"
rightColumnContent={<SignInRightColumn />}
>
{page}
</UnauthenticatedLayout>
);
};
export default SigninPage;

View File

@@ -3,6 +3,7 @@ import { UnauthenticatedLayout } from '@/components/layout/UnauthenticatedLayout
import { Divider } from '@/components/ui/v2/Divider';
import { SignUpTabs } from '@/features/auth/SignUp/SignUpTabs';
import { SignUpWithGithub } from '@/features/auth/SignUp/SignUpWithGithub';
import Image from 'next/image';
import NextLink from 'next/link';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
@@ -14,6 +15,80 @@ declare global {
}
}
const rightColumnContent = (
<div className="grid gap-6 font-[Inter]">
<div className="text-center">
<h2 className="mb-2 text-2xl font-semibold text-white">
Everything you need to ship faster
</h2>
<p className="text-sm text-[#A2B3BE]">
A complete backend stack, ready to use and easy to extend.
</p>
</div>
<div className="grid gap-3">
<div className="rounded-lg border border-white/10 bg-gradient-to-r from-[#0052CD]/10 to-[#FF02F5]/10 p-4">
<div className="flex items-center gap-3">
<Image
src="/assets/signup/CircleWavyCheck.svg"
width={20}
height={20}
alt="Check"
/>
<p className="text-sm font-medium text-white">
Full backend in 1 minute
</p>
</div>
</div>
<div className="rounded-lg border border-white/10 bg-gradient-to-r from-[#0052CD]/10 to-[#FF02F5]/10 p-4">
<div className="flex items-center gap-3">
<Image
src="/assets/database.svg"
width={20}
height={20}
alt="Database"
/>
<p className="text-sm font-medium text-white">
No infrastructure headaches
</p>
</div>
</div>
<div className="rounded-lg border border-white/10 bg-gradient-to-r from-[#0052CD]/10 to-[#FF02F5]/10 p-4">
<div className="flex items-center gap-3">
<Image
src="/assets/functions/ts.svg"
width={20}
height={20}
alt="Functions"
/>
<p className="text-sm font-medium text-white">Easy to extend</p>
</div>
</div>
</div>
<div className="rounded-lg border border-white/5 bg-gradient-to-br from-[#0052CD]/5 to-[#FF02F5]/5 p-5">
<div className="text-center">
<blockquote className="mb-3 text-sm italic text-white">
Nhost has freed us from the tedious tasks of building and maintaining
our backend infrastructure, allowing us to focus on creating a
platform that delivers real value to our users.
</blockquote>
<div className="flex items-center justify-center gap-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-r from-[#0052CD] to-[#FF02F5]">
<span className="text-xs font-semibold text-white">A</span>
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">Alex</p>
<p className="text-xs text-[#68717A]">CTPO, Yalink</p>
</div>
</div>
</div>
</div>
</div>
);
export default function SignUpPage() {
const initializeGoogleAds = useCallback(() => {
if (window.gtag) {
@@ -49,10 +124,15 @@ export default function SignUpPage() {
return (
<>
<div className="flex flex-col gap-12 font-[Inter]">
<h1 className="text-center text-3.5xl font-semibold lg:text-4.5xl">
Sign Up
</h1>
<div className="flex flex-col gap-12 pt-4 font-[Inter]">
<div className="text-center">
<h1 className="mb-3 text-3.5xl font-semibold lg:text-4.5xl">
Build. Deploy. Scale.
</h1>
<p className="mx-auto max-w-md text-lg text-[#A2B3BE]">
Join thousands of developers building with Nhost
</p>
</div>
<div className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
<SignUpWithGithub />
@@ -100,5 +180,12 @@ export default function SignUpPage() {
}
SignUpPage.getLayout = function getLayout(page: ReactElement) {
return <UnauthenticatedLayout title="Sign Up">{page}</UnauthenticatedLayout>;
return (
<UnauthenticatedLayout
title="Sign Up"
rightColumnContent={rightColumnContent}
>
{page}
</UnauthenticatedLayout>
);
};

View File

@@ -33,7 +33,6 @@ export type Scalars = {
jsonb: any;
map: any;
smallint: any;
timestamp: any;
timestamptz: any;
uuid: any;
};
@@ -7054,229 +7053,6 @@ export type AuthUserSecurityKeys_Variance_Order_By = {
counter?: InputMaybe<Order_By>;
};
/** Internal table for tracking migrations. Don't modify its structure as Hasura Auth relies on it to function properly. */
export type Auth_Migrations = {
__typename?: 'auth_migrations';
executed_at?: Maybe<Scalars['timestamp']>;
hash: Scalars['String'];
id: Scalars['Int'];
name: Scalars['String'];
};
/** aggregated selection of "auth.migrations" */
export type Auth_Migrations_Aggregate = {
__typename?: 'auth_migrations_aggregate';
aggregate?: Maybe<Auth_Migrations_Aggregate_Fields>;
nodes: Array<Auth_Migrations>;
};
/** aggregate fields of "auth.migrations" */
export type Auth_Migrations_Aggregate_Fields = {
__typename?: 'auth_migrations_aggregate_fields';
avg?: Maybe<Auth_Migrations_Avg_Fields>;
count: Scalars['Int'];
max?: Maybe<Auth_Migrations_Max_Fields>;
min?: Maybe<Auth_Migrations_Min_Fields>;
stddev?: Maybe<Auth_Migrations_Stddev_Fields>;
stddev_pop?: Maybe<Auth_Migrations_Stddev_Pop_Fields>;
stddev_samp?: Maybe<Auth_Migrations_Stddev_Samp_Fields>;
sum?: Maybe<Auth_Migrations_Sum_Fields>;
var_pop?: Maybe<Auth_Migrations_Var_Pop_Fields>;
var_samp?: Maybe<Auth_Migrations_Var_Samp_Fields>;
variance?: Maybe<Auth_Migrations_Variance_Fields>;
};
/** aggregate fields of "auth.migrations" */
export type Auth_Migrations_Aggregate_FieldsCountArgs = {
columns?: InputMaybe<Array<Auth_Migrations_Select_Column>>;
distinct?: InputMaybe<Scalars['Boolean']>;
};
/** aggregate avg on columns */
export type Auth_Migrations_Avg_Fields = {
__typename?: 'auth_migrations_avg_fields';
id?: Maybe<Scalars['Float']>;
};
/** Boolean expression to filter rows from the table "auth.migrations". All fields are combined with a logical 'AND'. */
export type Auth_Migrations_Bool_Exp = {
_and?: InputMaybe<Array<Auth_Migrations_Bool_Exp>>;
_not?: InputMaybe<Auth_Migrations_Bool_Exp>;
_or?: InputMaybe<Array<Auth_Migrations_Bool_Exp>>;
executed_at?: InputMaybe<Timestamp_Comparison_Exp>;
hash?: InputMaybe<String_Comparison_Exp>;
id?: InputMaybe<Int_Comparison_Exp>;
name?: InputMaybe<String_Comparison_Exp>;
};
/** unique or primary key constraints on table "auth.migrations" */
export enum Auth_Migrations_Constraint {
/** unique or primary key constraint on columns "name" */
MigrationsNameKey = 'migrations_name_key',
/** unique or primary key constraint on columns "id" */
MigrationsPkey = 'migrations_pkey'
}
/** input type for incrementing numeric columns in table "auth.migrations" */
export type Auth_Migrations_Inc_Input = {
id?: InputMaybe<Scalars['Int']>;
};
/** input type for inserting data into table "auth.migrations" */
export type Auth_Migrations_Insert_Input = {
executed_at?: InputMaybe<Scalars['timestamp']>;
hash?: InputMaybe<Scalars['String']>;
id?: InputMaybe<Scalars['Int']>;
name?: InputMaybe<Scalars['String']>;
};
/** aggregate max on columns */
export type Auth_Migrations_Max_Fields = {
__typename?: 'auth_migrations_max_fields';
executed_at?: Maybe<Scalars['timestamp']>;
hash?: Maybe<Scalars['String']>;
id?: Maybe<Scalars['Int']>;
name?: Maybe<Scalars['String']>;
};
/** aggregate min on columns */
export type Auth_Migrations_Min_Fields = {
__typename?: 'auth_migrations_min_fields';
executed_at?: Maybe<Scalars['timestamp']>;
hash?: Maybe<Scalars['String']>;
id?: Maybe<Scalars['Int']>;
name?: Maybe<Scalars['String']>;
};
/** response of any mutation on the table "auth.migrations" */
export type Auth_Migrations_Mutation_Response = {
__typename?: 'auth_migrations_mutation_response';
/** number of rows affected by the mutation */
affected_rows: Scalars['Int'];
/** data from the rows affected by the mutation */
returning: Array<Auth_Migrations>;
};
/** on_conflict condition type for table "auth.migrations" */
export type Auth_Migrations_On_Conflict = {
constraint: Auth_Migrations_Constraint;
update_columns?: Array<Auth_Migrations_Update_Column>;
where?: InputMaybe<Auth_Migrations_Bool_Exp>;
};
/** Ordering options when selecting data from "auth.migrations". */
export type Auth_Migrations_Order_By = {
executed_at?: InputMaybe<Order_By>;
hash?: InputMaybe<Order_By>;
id?: InputMaybe<Order_By>;
name?: InputMaybe<Order_By>;
};
/** primary key columns input for table: auth.migrations */
export type Auth_Migrations_Pk_Columns_Input = {
id: Scalars['Int'];
};
/** select columns of table "auth.migrations" */
export enum Auth_Migrations_Select_Column {
/** column name */
ExecutedAt = 'executed_at',
/** column name */
Hash = 'hash',
/** column name */
Id = 'id',
/** column name */
Name = 'name'
}
/** input type for updating data in table "auth.migrations" */
export type Auth_Migrations_Set_Input = {
executed_at?: InputMaybe<Scalars['timestamp']>;
hash?: InputMaybe<Scalars['String']>;
id?: InputMaybe<Scalars['Int']>;
name?: InputMaybe<Scalars['String']>;
};
/** aggregate stddev on columns */
export type Auth_Migrations_Stddev_Fields = {
__typename?: 'auth_migrations_stddev_fields';
id?: Maybe<Scalars['Float']>;
};
/** aggregate stddev_pop on columns */
export type Auth_Migrations_Stddev_Pop_Fields = {
__typename?: 'auth_migrations_stddev_pop_fields';
id?: Maybe<Scalars['Float']>;
};
/** aggregate stddev_samp on columns */
export type Auth_Migrations_Stddev_Samp_Fields = {
__typename?: 'auth_migrations_stddev_samp_fields';
id?: Maybe<Scalars['Float']>;
};
/** Streaming cursor of the table "auth_migrations" */
export type Auth_Migrations_Stream_Cursor_Input = {
/** Stream column input with initial value */
initial_value: Auth_Migrations_Stream_Cursor_Value_Input;
/** cursor ordering */
ordering?: InputMaybe<Cursor_Ordering>;
};
/** Initial value of the column from where the streaming should start */
export type Auth_Migrations_Stream_Cursor_Value_Input = {
executed_at?: InputMaybe<Scalars['timestamp']>;
hash?: InputMaybe<Scalars['String']>;
id?: InputMaybe<Scalars['Int']>;
name?: InputMaybe<Scalars['String']>;
};
/** aggregate sum on columns */
export type Auth_Migrations_Sum_Fields = {
__typename?: 'auth_migrations_sum_fields';
id?: Maybe<Scalars['Int']>;
};
/** update columns of table "auth.migrations" */
export enum Auth_Migrations_Update_Column {
/** column name */
ExecutedAt = 'executed_at',
/** column name */
Hash = 'hash',
/** column name */
Id = 'id',
/** column name */
Name = 'name'
}
export type Auth_Migrations_Updates = {
/** increments the numeric columns with given value of the filtered values */
_inc?: InputMaybe<Auth_Migrations_Inc_Input>;
/** sets the columns of the filtered rows to the given values */
_set?: InputMaybe<Auth_Migrations_Set_Input>;
/** filter the rows which have to be updated */
where: Auth_Migrations_Bool_Exp;
};
/** aggregate var_pop on columns */
export type Auth_Migrations_Var_Pop_Fields = {
__typename?: 'auth_migrations_var_pop_fields';
id?: Maybe<Scalars['Float']>;
};
/** aggregate var_samp on columns */
export type Auth_Migrations_Var_Samp_Fields = {
__typename?: 'auth_migrations_var_samp_fields';
id?: Maybe<Scalars['Float']>;
};
/** aggregate variance on columns */
export type Auth_Migrations_Variance_Fields = {
__typename?: 'auth_migrations_variance_fields';
id?: Maybe<Scalars['Float']>;
};
/** columns and relationships of "backups" */
export type Backups = {
__typename?: 'backups';
@@ -13417,10 +13193,6 @@ export type Mutation_Root = {
delete_announcements?: Maybe<Announcements_Mutation_Response>;
/** delete single row from the table: "announcements" */
delete_announcements_by_pk?: Maybe<Announcements>;
/** delete data from the table: "auth.migrations" */
delete_auth_migrations?: Maybe<Auth_Migrations_Mutation_Response>;
/** delete single row from the table: "auth.migrations" */
delete_auth_migrations_by_pk?: Maybe<Auth_Migrations>;
/** delete data from the table: "billing.report_resource_type" */
delete_billing_report_resource_type?: Maybe<Billing_Report_Resource_Type_Mutation_Response>;
/** delete single row from the table: "billing.report_resource_type" */
@@ -13636,10 +13408,6 @@ export type Mutation_Root = {
insert_announcements?: Maybe<Announcements_Mutation_Response>;
/** insert a single row into the table: "announcements" */
insert_announcements_one?: Maybe<Announcements>;
/** insert data into the table: "auth.migrations" */
insert_auth_migrations?: Maybe<Auth_Migrations_Mutation_Response>;
/** insert a single row into the table: "auth.migrations" */
insert_auth_migrations_one?: Maybe<Auth_Migrations>;
/** insert data into the table: "billing.report_resource_type" */
insert_billing_report_resource_type?: Maybe<Billing_Report_Resource_Type_Mutation_Response>;
/** insert a single row into the table: "billing.report_resource_type" */
@@ -13919,12 +13687,6 @@ export type Mutation_Root = {
update_authUserRoles_many?: Maybe<Array<Maybe<AuthUserRoles_Mutation_Response>>>;
/** update multiples rows of table: "auth.user_security_keys" */
update_authUserSecurityKeys_many?: Maybe<Array<Maybe<AuthUserSecurityKeys_Mutation_Response>>>;
/** update data of the table: "auth.migrations" */
update_auth_migrations?: Maybe<Auth_Migrations_Mutation_Response>;
/** update single row of the table: "auth.migrations" */
update_auth_migrations_by_pk?: Maybe<Auth_Migrations>;
/** update multiples rows of table: "auth.migrations" */
update_auth_migrations_many?: Maybe<Array<Maybe<Auth_Migrations_Mutation_Response>>>;
/** update multiples rows of table: "backups" */
update_backups_many?: Maybe<Array<Maybe<Backups_Mutation_Response>>>;
/** update multiples rows of table: "billing.dedicated_compute" */
@@ -14677,18 +14439,6 @@ export type Mutation_RootDelete_Announcements_By_PkArgs = {
};
/** mutation root */
export type Mutation_RootDelete_Auth_MigrationsArgs = {
where: Auth_Migrations_Bool_Exp;
};
/** mutation root */
export type Mutation_RootDelete_Auth_Migrations_By_PkArgs = {
id: Scalars['Int'];
};
/** mutation root */
export type Mutation_RootDelete_Billing_Report_Resource_TypeArgs = {
where: Billing_Report_Resource_Type_Bool_Exp;
@@ -15432,20 +15182,6 @@ export type Mutation_RootInsert_Announcements_OneArgs = {
};
/** mutation root */
export type Mutation_RootInsert_Auth_MigrationsArgs = {
objects: Array<Auth_Migrations_Insert_Input>;
on_conflict?: InputMaybe<Auth_Migrations_On_Conflict>;
};
/** mutation root */
export type Mutation_RootInsert_Auth_Migrations_OneArgs = {
object: Auth_Migrations_Insert_Input;
on_conflict?: InputMaybe<Auth_Migrations_On_Conflict>;
};
/** mutation root */
export type Mutation_RootInsert_Billing_Report_Resource_TypeArgs = {
objects: Array<Billing_Report_Resource_Type_Insert_Input>;
@@ -16533,28 +16269,6 @@ export type Mutation_RootUpdate_AuthUserSecurityKeys_ManyArgs = {
};
/** mutation root */
export type Mutation_RootUpdate_Auth_MigrationsArgs = {
_inc?: InputMaybe<Auth_Migrations_Inc_Input>;
_set?: InputMaybe<Auth_Migrations_Set_Input>;
where: Auth_Migrations_Bool_Exp;
};
/** mutation root */
export type Mutation_RootUpdate_Auth_Migrations_By_PkArgs = {
_inc?: InputMaybe<Auth_Migrations_Inc_Input>;
_set?: InputMaybe<Auth_Migrations_Set_Input>;
pk_columns: Auth_Migrations_Pk_Columns_Input;
};
/** mutation root */
export type Mutation_RootUpdate_Auth_Migrations_ManyArgs = {
updates: Array<Auth_Migrations_Updates>;
};
/** mutation root */
export type Mutation_RootUpdate_Backups_ManyArgs = {
updates: Array<Backups_Updates>;
@@ -19790,12 +19504,6 @@ export type Query_Root = {
authUserSecurityKeys: Array<AuthUserSecurityKeys>;
/** fetch aggregated fields from the table: "auth.user_security_keys" */
authUserSecurityKeysAggregate: AuthUserSecurityKeys_Aggregate;
/** fetch data from the table: "auth.migrations" */
auth_migrations: Array<Auth_Migrations>;
/** fetch aggregated fields from the table: "auth.migrations" */
auth_migrations_aggregate: Auth_Migrations_Aggregate;
/** fetch data from the table: "auth.migrations" using primary key columns */
auth_migrations_by_pk?: Maybe<Auth_Migrations>;
/** fetch data from the table: "backups" using primary key columns */
backup?: Maybe<Backups>;
/** An array relationship */
@@ -20393,29 +20101,6 @@ export type Query_RootAuthUserSecurityKeysAggregateArgs = {
};
export type Query_RootAuth_MigrationsArgs = {
distinct_on?: InputMaybe<Array<Auth_Migrations_Select_Column>>;
limit?: InputMaybe<Scalars['Int']>;
offset?: InputMaybe<Scalars['Int']>;
order_by?: InputMaybe<Array<Auth_Migrations_Order_By>>;
where?: InputMaybe<Auth_Migrations_Bool_Exp>;
};
export type Query_RootAuth_Migrations_AggregateArgs = {
distinct_on?: InputMaybe<Array<Auth_Migrations_Select_Column>>;
limit?: InputMaybe<Scalars['Int']>;
offset?: InputMaybe<Scalars['Int']>;
order_by?: InputMaybe<Array<Auth_Migrations_Order_By>>;
where?: InputMaybe<Auth_Migrations_Bool_Exp>;
};
export type Query_RootAuth_Migrations_By_PkArgs = {
id: Scalars['Int'];
};
export type Query_RootBackupArgs = {
id: Scalars['uuid'];
};
@@ -23362,14 +23047,6 @@ export type Subscription_Root = {
authUserSecurityKeysAggregate: AuthUserSecurityKeys_Aggregate;
/** fetch data from the table in a streaming manner: "auth.user_security_keys" */
authUserSecurityKeys_stream: Array<AuthUserSecurityKeys>;
/** fetch data from the table: "auth.migrations" */
auth_migrations: Array<Auth_Migrations>;
/** fetch aggregated fields from the table: "auth.migrations" */
auth_migrations_aggregate: Auth_Migrations_Aggregate;
/** fetch data from the table: "auth.migrations" using primary key columns */
auth_migrations_by_pk?: Maybe<Auth_Migrations>;
/** fetch data from the table in a streaming manner: "auth.migrations" */
auth_migrations_stream: Array<Auth_Migrations>;
/** fetch data from the table: "backups" using primary key columns */
backup?: Maybe<Backups>;
/** An array relationship */
@@ -24088,36 +23765,6 @@ export type Subscription_RootAuthUserSecurityKeys_StreamArgs = {
};
export type Subscription_RootAuth_MigrationsArgs = {
distinct_on?: InputMaybe<Array<Auth_Migrations_Select_Column>>;
limit?: InputMaybe<Scalars['Int']>;
offset?: InputMaybe<Scalars['Int']>;
order_by?: InputMaybe<Array<Auth_Migrations_Order_By>>;
where?: InputMaybe<Auth_Migrations_Bool_Exp>;
};
export type Subscription_RootAuth_Migrations_AggregateArgs = {
distinct_on?: InputMaybe<Array<Auth_Migrations_Select_Column>>;
limit?: InputMaybe<Scalars['Int']>;
offset?: InputMaybe<Scalars['Int']>;
order_by?: InputMaybe<Array<Auth_Migrations_Order_By>>;
where?: InputMaybe<Auth_Migrations_Bool_Exp>;
};
export type Subscription_RootAuth_Migrations_By_PkArgs = {
id: Scalars['Int'];
};
export type Subscription_RootAuth_Migrations_StreamArgs = {
batch_size: Scalars['Int'];
cursor: Array<InputMaybe<Auth_Migrations_Stream_Cursor_Input>>;
where?: InputMaybe<Auth_Migrations_Bool_Exp>;
};
export type Subscription_RootBackupArgs = {
id: Scalars['uuid'];
};
@@ -25325,19 +24972,6 @@ export type Subscription_RootWorkspaces_StreamArgs = {
where?: InputMaybe<Workspaces_Bool_Exp>;
};
/** Boolean expression to compare columns of type "timestamp". All fields are combined with logical 'AND'. */
export type Timestamp_Comparison_Exp = {
_eq?: InputMaybe<Scalars['timestamp']>;
_gt?: InputMaybe<Scalars['timestamp']>;
_gte?: InputMaybe<Scalars['timestamp']>;
_in?: InputMaybe<Array<Scalars['timestamp']>>;
_is_null?: InputMaybe<Scalars['Boolean']>;
_lt?: InputMaybe<Scalars['timestamp']>;
_lte?: InputMaybe<Scalars['timestamp']>;
_neq?: InputMaybe<Scalars['timestamp']>;
_nin?: InputMaybe<Array<Scalars['timestamp']>>;
};
/** Boolean expression to compare columns of type "timestamptz". All fields are combined with logical 'AND'. */
export type Timestamptz_Comparison_Exp = {
_eq?: InputMaybe<Scalars['timestamptz']>;
@@ -34525,4 +34159,4 @@ export type GetFreeAndActiveProjectsLazyQueryHookResult = ReturnType<typeof useG
export type GetFreeAndActiveProjectsQueryResult = Apollo.QueryResult<GetFreeAndActiveProjectsQuery, GetFreeAndActiveProjectsQueryVariables>;
export function refetchGetFreeAndActiveProjectsQuery(variables: GetFreeAndActiveProjectsQueryVariables) {
return { query: GetFreeAndActiveProjectsDocument, variables: variables }
}
}

View File

@@ -0,0 +1,12 @@
export const ORGANIZATION_TYPES = [
{ value: 'personal', label: 'Personal Project' },
{ value: 'startup', label: 'Startup' },
{ value: 'agency', label: 'Agency' },
{ value: 'enterprise', label: 'Enterprise' },
{ value: 'nonprofit', label: 'Non-profit' },
{ value: 'opensource', label: 'Open Source' },
{ value: 'student', label: 'Student' },
{ value: 'other', label: 'Other' },
] as const;
export type OrganizationType = (typeof ORGANIZATION_TYPES)[number]['value'];