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:
5
.changeset/heavy-poets-swim.md
Normal file
5
.changeset/heavy-poets-swim.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@nhost/dashboard': minor
|
||||
---
|
||||
|
||||
feat: dashboard: new onboarding
|
||||
59
dashboard/src/components/auth/SignInRightColumn.tsx
Normal file
59
dashboard/src/components/auth/SignInRightColumn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function DeleteOrg() {
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleting || org?.plan?.isFree || maintenanceActive}
|
||||
disabled={deleting || maintenanceActive}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
@@ -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...`,
|
||||
|
||||
@@ -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/_') ||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
466
dashboard/src/pages/onboarding/index.tsx
Normal file
466
dashboard/src/pages/onboarding/index.tsx
Normal 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'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'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'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>
|
||||
);
|
||||
};
|
||||
330
dashboard/src/pages/onboarding/project.tsx
Normal file
330
dashboard/src/pages/onboarding/project.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
368
dashboard/src/utils/__generated__/graphql.ts
generated
368
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
12
dashboard/src/utils/constants/organizationTypes.ts
Normal file
12
dashboard/src/utils/constants/organizationTypes.ts
Normal 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'];
|
||||
Reference in New Issue
Block a user