first commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Avatar, Button, Form, Icon, ImageUploader, Label, TextAreaField } from "@calcom/ui";
|
||||
|
||||
const querySchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const AboutOrganizationForm = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const routerQuery = useRouterQuery();
|
||||
const { id: orgId } = querySchema.parse(routerQuery);
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
|
||||
const [image, setImage] = useState("");
|
||||
|
||||
const aboutOrganizationFormMethods = useForm<{
|
||||
logo: string;
|
||||
bio: string;
|
||||
}>();
|
||||
|
||||
const updateOrganizationMutation = trpc.viewer.organizations.update.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.update) {
|
||||
router.push(`/settings/organizations/${orgId}/onboard-members`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setServerErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
form={aboutOrganizationFormMethods}
|
||||
className="space-y-5"
|
||||
handleSubmit={(v) => {
|
||||
if (!updateOrganizationMutation.isPending) {
|
||||
setServerErrorMessage(null);
|
||||
updateOrganizationMutation.mutate({ ...v, orgId });
|
||||
}
|
||||
}}>
|
||||
{serverErrorMessage && (
|
||||
<div>
|
||||
<Alert severity="error" message={serverErrorMessage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Controller
|
||||
control={aboutOrganizationFormMethods.control}
|
||||
name="logo"
|
||||
render={() => (
|
||||
<>
|
||||
<Label>{t("organization_logo")}</Label>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
fallback={<Icon name="plus" className="text-subtle h-6 w-6" />}
|
||||
className="items-center"
|
||||
imageSrc={image}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("upload")}
|
||||
handleAvatarChange={(newAvatar: string) => {
|
||||
setImage(newAvatar);
|
||||
aboutOrganizationFormMethods.setValue("logo", newAvatar);
|
||||
}}
|
||||
imageSrc={image}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Controller
|
||||
control={aboutOrganizationFormMethods.control}
|
||||
name="bio"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<TextAreaField
|
||||
name="about"
|
||||
defaultValue={value}
|
||||
onChange={(e) => {
|
||||
aboutOrganizationFormMethods.setValue("bio", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-subtle text-sm">{t("organization_about_description")}</p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<Button
|
||||
disabled={
|
||||
aboutOrganizationFormMethods.formState.isSubmitting || updateOrganizationMutation.isPending
|
||||
}
|
||||
color="primary"
|
||||
EndIcon="arrow-right"
|
||||
type="submit"
|
||||
className="w-full justify-center">
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { IS_TEAM_BILLING_ENABLED_CLIENT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, CheckboxField, Form, Icon, showToast, TextField } from "@calcom/ui";
|
||||
|
||||
const querySchema = z.object({
|
||||
id: z.string().transform((val) => parseInt(val)),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
teams: z.array(
|
||||
z.object({
|
||||
name: z.string().trim(),
|
||||
})
|
||||
),
|
||||
moveTeams: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
shouldMove: z.boolean(),
|
||||
newSlug: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const AddNewTeamsForm = () => {
|
||||
const { data: teams } = trpc.viewer.teams.list.useQuery();
|
||||
const routerQuery = useRouterQuery();
|
||||
|
||||
const { id: orgId } = querySchema.parse(routerQuery);
|
||||
|
||||
const { data: org } = trpc.viewer.teams.get.useQuery({ teamId: orgId, isOrg: true });
|
||||
|
||||
if (!teams || !org) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orgWithRequestedSlug = {
|
||||
...org,
|
||||
requestedSlug: org.metadata.requestedSlug ?? null,
|
||||
};
|
||||
|
||||
const regularTeams = teams.filter((team) => !team.parentId);
|
||||
return <AddNewTeamsFormChild org={orgWithRequestedSlug} teams={regularTeams} />;
|
||||
};
|
||||
|
||||
const AddNewTeamsFormChild = ({
|
||||
teams,
|
||||
org,
|
||||
}: {
|
||||
org: { id: number; slug: string | null; requestedSlug: string | null };
|
||||
teams: { id: number; name: string; slug: string | null }[];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [counter, setCounter] = useState(1);
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
teams: [{ name: "" }],
|
||||
moveTeams: teams.map((team) => ({
|
||||
id: team.id,
|
||||
shouldMove: false,
|
||||
newSlug: getSuggestedSlug({ teamSlug: team.slug, orgSlug: org.slug || org.requestedSlug }),
|
||||
})),
|
||||
}, // Set initial values
|
||||
resolver: async (data) => {
|
||||
try {
|
||||
const validatedData = await schema.parseAsync(data);
|
||||
return { values: validatedData, errors: {} };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
values: {
|
||||
teams: [],
|
||||
},
|
||||
errors: error.formErrors.fieldErrors,
|
||||
};
|
||||
}
|
||||
return { values: {}, errors: { teams: { message: "Error validating input" } } };
|
||||
}
|
||||
},
|
||||
});
|
||||
const session = useSession();
|
||||
const isAdmin =
|
||||
session.data?.user?.role === UserPermissionRole.ADMIN ||
|
||||
session.data?.user?.impersonatedBy?.role === UserPermissionRole.ADMIN;
|
||||
|
||||
const allowWizardCompletionWithoutUpgrading = !IS_TEAM_BILLING_ENABLED_CLIENT || isAdmin;
|
||||
const { register, control, watch, getValues } = form;
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "teams",
|
||||
});
|
||||
|
||||
const publishOrgMutation = trpc.viewer.organizations.publish.useMutation({
|
||||
onSuccess(data) {
|
||||
router.push(data.url);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleCounterIncrease = () => {
|
||||
if (counter >= 0 && counter < 5) {
|
||||
setCounter((prevCounter) => prevCounter + 1);
|
||||
append({ name: "" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveInput = (index: number) => {
|
||||
remove(index);
|
||||
setCounter((prevCounter) => prevCounter - 1);
|
||||
};
|
||||
|
||||
const createTeamsMutation = trpc.viewer.organizations.createTeams.useMutation({
|
||||
async onSuccess(data) {
|
||||
if (data.duplicatedSlugs.length) {
|
||||
showToast(t("duplicated_slugs_warning", { slugs: data.duplicatedSlugs.join(", ") }), "warning");
|
||||
// Server will return array of duplicated slugs, so we need to wait for user to read the warning
|
||||
// before pushing to next page
|
||||
setTimeout(() => handleSuccessRedirect, 3000);
|
||||
} else {
|
||||
handleSuccessRedirect();
|
||||
}
|
||||
|
||||
function handleSuccessRedirect() {
|
||||
if (allowWizardCompletionWithoutUpgrading) {
|
||||
router.push(`/event-types`);
|
||||
return;
|
||||
}
|
||||
// mutate onSuccess will take care of routing to the correct place.
|
||||
publishOrgMutation.mutate();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(t(error.message), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
const fields = getValues("teams");
|
||||
const moveTeams = getValues("moveTeams");
|
||||
createTeamsMutation.mutate({ orgId: org.id, moveTeams, teamNames: fields.map((field) => field.name) });
|
||||
};
|
||||
|
||||
const moveTeams = watch("moveTeams");
|
||||
return (
|
||||
<>
|
||||
<Form form={form} handleSubmit={handleFormSubmit}>
|
||||
{moveTeams.length ? (
|
||||
<>
|
||||
<label className="text-emphasis mb-2 block text-sm font-medium leading-none">
|
||||
Move existing teams
|
||||
</label>
|
||||
<ul className="mb-8 space-y-4">
|
||||
{moveTeams.map((team, index) => {
|
||||
return (
|
||||
<li key={team.id}>
|
||||
<Controller
|
||||
name={`moveTeams.${index}.shouldMove`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CheckboxField
|
||||
defaultValue={value}
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
description={teams.find((t) => t.id === team.id)?.name ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{moveTeams[index].shouldMove ? (
|
||||
<TextField
|
||||
placeholder="New Slug"
|
||||
defaultValue={teams.find((t) => t.id === team.id)?.slug ?? ""}
|
||||
{...register(`moveTeams.${index}.newSlug`)}
|
||||
className="mt-2"
|
||||
label=""
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
<label className="text-emphasis mb-2 block text-sm font-medium leading-none">Add New Teams</label>
|
||||
{fields.map((field, index) => (
|
||||
<div className={classNames("relative", index > 0 ? "mb-2" : "")} key={field.id}>
|
||||
<TextField
|
||||
key={field.id}
|
||||
{...register(`teams.${index}.name`)}
|
||||
label=""
|
||||
addOnClassname="bg-transparent p-0 border-l-0"
|
||||
className={index > 0 ? "mb-2" : ""}
|
||||
placeholder={t(`org_team_names_example_${index + 1}`)}
|
||||
addOnSuffix={
|
||||
index > 0 && (
|
||||
<Button
|
||||
color="minimal"
|
||||
className="group/remove mx-2 px-0 hover:bg-transparent"
|
||||
onClick={() => handleRemoveInput(index)}
|
||||
aria-label="Remove Team">
|
||||
<Icon
|
||||
name="x"
|
||||
className="bg-subtle text group-hover/remove:text-inverted group-hover/remove:bg-inverted h-5 w-5 rounded-full p-1"
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
minLength={2}
|
||||
maxLength={63}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{counter === 5 && <p className="text-subtle my-2 text-sm">{t("org_max_team_warnings")}</p>}
|
||||
{counter < 5 && (
|
||||
<Button
|
||||
StartIcon="plus"
|
||||
color="secondary"
|
||||
disabled={createTeamsMutation.isPending}
|
||||
onClick={handleCounterIncrease}
|
||||
aria-label={t("add_a_team")}
|
||||
className="my-1">
|
||||
{t("add_a_team")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
EndIcon="arrow-right"
|
||||
color="primary"
|
||||
className="mt-6 w-full justify-center"
|
||||
data-testId="continue_or_checkout"
|
||||
disabled={createTeamsMutation.isPending || createTeamsMutation.isSuccess}
|
||||
onClick={handleFormSubmit}>
|
||||
{allowWizardCompletionWithoutUpgrading ? t("continue") : t("checkout")}
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getSuggestedSlug = ({ teamSlug, orgSlug }: { teamSlug: string | null; orgSlug: string | null }) => {
|
||||
// If there is no orgSlug, we can't suggest a slug
|
||||
if (!teamSlug || !orgSlug) {
|
||||
return teamSlug;
|
||||
}
|
||||
|
||||
return teamSlug.replace(`${orgSlug}-`, "").replace(`-${orgSlug}`, "");
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
import type { SessionContextValue } from "next-auth/react";
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { MINIMUM_NUMBER_OF_ORG_SEATS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import { Alert, Button, Form, Label, RadioGroup as RadioArea, TextField, ToggleGroup } from "@calcom/ui";
|
||||
|
||||
function extractDomainFromEmail(email: string) {
|
||||
let out = "";
|
||||
try {
|
||||
const match = email.match(/^(?:.*?:\/\/)?.*?(?<root>[\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/);
|
||||
out = (match && match.groups?.root) ?? "";
|
||||
} catch (ignore) {}
|
||||
return out.split(".")[0];
|
||||
}
|
||||
|
||||
export const CreateANewOrganizationForm = () => {
|
||||
const session = useSession();
|
||||
if (!session.data) {
|
||||
return null;
|
||||
}
|
||||
return <CreateANewOrganizationFormChild session={session} />;
|
||||
};
|
||||
|
||||
enum BillingPeriod {
|
||||
MONTHLY = "MONTHLY",
|
||||
ANNUALLY = "ANNUALLY",
|
||||
}
|
||||
|
||||
const CreateANewOrganizationFormChild = ({
|
||||
session,
|
||||
}: {
|
||||
session: Ensure<SessionContextValue, "data">;
|
||||
isPlatformOrg?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
|
||||
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
|
||||
const defaultOrgOwnerEmail = session.data.user.email ?? "";
|
||||
const newOrganizationFormMethods = useForm<{
|
||||
name: string;
|
||||
seats: number;
|
||||
billingPeriod: BillingPeriod;
|
||||
pricePerSeat: number;
|
||||
slug: string;
|
||||
orgOwnerEmail: string;
|
||||
}>({
|
||||
defaultValues: {
|
||||
billingPeriod: BillingPeriod.MONTHLY,
|
||||
slug: !isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined,
|
||||
orgOwnerEmail: !isAdmin ? defaultOrgOwnerEmail : undefined,
|
||||
name: !isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
telemetry.event(telemetryEventTypes.org_created);
|
||||
// This is necessary so that server token has the updated upId
|
||||
await session.update({
|
||||
upId: data.upId,
|
||||
});
|
||||
if (isAdmin && data.userId !== session.data?.user.id) {
|
||||
// Impersonate the user chosen as the organization owner(if the admin user isn't the owner himself), so that admin can now configure the organisation on his behalf.
|
||||
// He won't need to have access to the org directly in this way.
|
||||
signIn("impersonation-auth", {
|
||||
username: data.email,
|
||||
callbackUrl: `/settings/organizations/${data.organizationId}/about`,
|
||||
});
|
||||
}
|
||||
router.push(`/settings/organizations/${data.organizationId}/about`);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.message === "organization_url_taken") {
|
||||
newOrganizationFormMethods.setError("slug", { type: "custom", message: t("url_taken") });
|
||||
} else if (err.message === "domain_taken_team" || err.message === "domain_taken_project") {
|
||||
newOrganizationFormMethods.setError("slug", {
|
||||
type: "custom",
|
||||
message: t("problem_registering_domain"),
|
||||
});
|
||||
} else {
|
||||
setServerErrorMessage(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
form={newOrganizationFormMethods}
|
||||
className="space-y-5"
|
||||
id="createOrg"
|
||||
handleSubmit={(v) => {
|
||||
if (!createOrganizationMutation.isPending) {
|
||||
setServerErrorMessage(null);
|
||||
createOrganizationMutation.mutate(v);
|
||||
}
|
||||
}}>
|
||||
<div>
|
||||
{serverErrorMessage && (
|
||||
<div className="mb-4">
|
||||
<Alert severity="error" message={serverErrorMessage} />
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="mb-5">
|
||||
<Controller
|
||||
name="billingPeriod"
|
||||
control={newOrganizationFormMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label htmlFor="billingPeriod">Billing Period</Label>
|
||||
<ToggleGroup
|
||||
isFullWidth
|
||||
id="billingPeriod"
|
||||
value={value}
|
||||
onValueChange={(e: BillingPeriod) => {
|
||||
if ([BillingPeriod.ANNUALLY, BillingPeriod.MONTHLY].includes(e)) {
|
||||
onChange(e);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "MONTHLY",
|
||||
label: "Monthly",
|
||||
},
|
||||
{
|
||||
value: "ANNUALLY",
|
||||
label: "Annually",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Controller
|
||||
name="orgOwnerEmail"
|
||||
control={newOrganizationFormMethods.control}
|
||||
rules={{
|
||||
required: t("must_enter_organization_admin_email"),
|
||||
}}
|
||||
render={({ field: { value } }) => (
|
||||
<div className="flex">
|
||||
<TextField
|
||||
containerClassName="w-full"
|
||||
placeholder="john@acme.com"
|
||||
name="orgOwnerEmail"
|
||||
disabled={!isAdmin}
|
||||
label={t("admin_email")}
|
||||
defaultValue={value}
|
||||
onChange={(e) => {
|
||||
const email = e?.target.value;
|
||||
const slug = deriveSlugFromEmail(email);
|
||||
newOrganizationFormMethods.setValue("orgOwnerEmail", email.trim());
|
||||
if (newOrganizationFormMethods.getValues("slug") === "") {
|
||||
newOrganizationFormMethods.setValue("slug", slug);
|
||||
}
|
||||
newOrganizationFormMethods.setValue("name", deriveOrgNameFromEmail(email));
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="name"
|
||||
control={newOrganizationFormMethods.control}
|
||||
defaultValue=""
|
||||
rules={{
|
||||
required: t("must_enter_organization_name"),
|
||||
}}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<TextField
|
||||
className="mt-2"
|
||||
placeholder="Acme"
|
||||
name="name"
|
||||
label={t("organization_name")}
|
||||
defaultValue={value}
|
||||
onChange={(e) => {
|
||||
newOrganizationFormMethods.setValue("name", e?.target.value.trim());
|
||||
if (newOrganizationFormMethods.formState.touchedFields["slug"] === undefined) {
|
||||
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value));
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Controller
|
||||
name="slug"
|
||||
control={newOrganizationFormMethods.control}
|
||||
rules={{
|
||||
required: "Must enter organization slug",
|
||||
}}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
className="mt-2"
|
||||
name="slug"
|
||||
label={t("organization_url")}
|
||||
placeholder="acme"
|
||||
addOnSuffix={`.${subdomainSuffix()}`}
|
||||
defaultValue={value}
|
||||
onChange={(e) => {
|
||||
newOrganizationFormMethods.setValue("slug", slugify(e?.target.value), {
|
||||
shouldTouch: true,
|
||||
});
|
||||
newOrganizationFormMethods.clearErrors("slug");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<section className="grid grid-cols-2 gap-2">
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="seats"
|
||||
control={newOrganizationFormMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="flex">
|
||||
<TextField
|
||||
containerClassName="w-full"
|
||||
placeholder="30"
|
||||
name="seats"
|
||||
type="number"
|
||||
label="Seats (optional)"
|
||||
min={isAdmin ? 1 : MINIMUM_NUMBER_OF_ORG_SEATS}
|
||||
defaultValue={value || MINIMUM_NUMBER_OF_ORG_SEATS}
|
||||
onChange={(e) => {
|
||||
onChange(+e.target.value);
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="pricePerSeat"
|
||||
control={newOrganizationFormMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="flex">
|
||||
<TextField
|
||||
containerClassName="w-full"
|
||||
placeholder="30"
|
||||
name="pricePerSeat"
|
||||
type="number"
|
||||
addOnSuffix="$"
|
||||
label="Price per seat (optional)"
|
||||
defaultValue={value}
|
||||
onChange={(e) => {
|
||||
onChange(+e.target.value);
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* This radio group does nothing - its just for visuall purposes */}
|
||||
{!isAdmin && (
|
||||
<>
|
||||
<div className="bg-subtle space-y-5 rounded-lg p-5">
|
||||
<h3 className="font-cal text-default text-lg font-semibold leading-4">
|
||||
Upgrade to Organizations
|
||||
</h3>
|
||||
<RadioArea.Group className={classNames("mt-1 flex flex-col gap-4")} value="ORGANIZATION">
|
||||
<RadioArea.Item
|
||||
className={classNames("bg-default w-full text-sm opacity-70")}
|
||||
value="TEAMS"
|
||||
disabled>
|
||||
<strong className="mb-1 block">{t("teams")}</strong>
|
||||
<p>{t("your_current_plan")}</p>
|
||||
</RadioArea.Item>
|
||||
<RadioArea.Item className={classNames("bg-default w-full text-sm")} value="ORGANIZATION">
|
||||
<strong className="mb-1 block">{t("organization")}</strong>
|
||||
<p>{t("organization_price_per_user_month")}</p>
|
||||
</RadioArea.Item>
|
||||
</RadioArea.Group>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
disabled={
|
||||
newOrganizationFormMethods.formState.isSubmitting || createOrganizationMutation.isPending
|
||||
}
|
||||
color="primary"
|
||||
EndIcon="arrow-right"
|
||||
type="submit"
|
||||
form="createOrg"
|
||||
className="w-full justify-center">
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function deriveSlugFromEmail(email: string) {
|
||||
const domain = extractDomainFromEmail(email);
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
export function deriveOrgNameFromEmail(email: string) {
|
||||
const domain = extractDomainFromEmail(email);
|
||||
|
||||
return domain.charAt(0).toUpperCase() + domain.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useIsPlatform } from "@calcom/atoms/monorepo";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast, TopBanner } from "@calcom/ui";
|
||||
|
||||
export type OrgUpgradeBannerProps = {
|
||||
data: RouterOutputs["viewer"]["getUserTopBanners"]["orgUpgradeBanner"];
|
||||
};
|
||||
|
||||
export function OrgUpgradeBanner({ data }: OrgUpgradeBannerProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const publishOrgMutation = trpc.viewer.organizations.publish.useMutation({
|
||||
onSuccess(data) {
|
||||
router.push(data.url);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
const [membership] = data;
|
||||
if (!membership) return null;
|
||||
|
||||
// TODO: later figure out how to not show this banner on platform since platform is different to orgs (it just uses the same code)
|
||||
if (isPlatform) return null;
|
||||
|
||||
return (
|
||||
<TopBanner
|
||||
text={t("org_upgrade_banner_description", { teamName: membership.team.name })}
|
||||
variant="warning"
|
||||
actions={
|
||||
<button
|
||||
data-testid="upgrade_org_banner_button"
|
||||
className="border-b border-b-black"
|
||||
onClick={() => {
|
||||
publishOrgMutation.mutate();
|
||||
}}>
|
||||
{t("upgrade_banner_action")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { Avatar, TextField } from "@calcom/ui";
|
||||
|
||||
type TeamInviteFromOrgProps = PropsWithChildren<{
|
||||
selectedEmails?: string | string[];
|
||||
handleOnChecked: (usersEmail: string) => void;
|
||||
orgMembers?: RouterOutputs["viewer"]["organizations"]["getMembers"];
|
||||
}>;
|
||||
|
||||
const keysToCheck = ["name", "email", "username"] as const; // array of keys to check
|
||||
|
||||
export default function TeamInviteFromOrg({
|
||||
handleOnChecked,
|
||||
selectedEmails,
|
||||
orgMembers,
|
||||
}: TeamInviteFromOrgProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const filteredMembers = orgMembers?.filter((member) => {
|
||||
if (!searchQuery) {
|
||||
return true; // return all members if searchQuery is empty
|
||||
}
|
||||
const { user } = member ?? {}; // destructuring with default value in case member is undefined
|
||||
return keysToCheck.some((key) => user?.[key]?.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-muted border-subtle flex flex-col rounded-md border p-4">
|
||||
<div className="-my-1">
|
||||
<TextField placeholder="Search..." onChange={(e) => setSearchQuery(e.target.value)} />
|
||||
</div>
|
||||
<hr className="border-subtle -mx-4 mt-2" />
|
||||
<div className="scrollbar min-h-48 flex max-h-48 flex-col space-y-0.5 overflow-y-scroll pt-2">
|
||||
<>
|
||||
{filteredMembers &&
|
||||
filteredMembers.map((member) => {
|
||||
const isSelected = Array.isArray(selectedEmails)
|
||||
? selectedEmails.includes(member.user.email)
|
||||
: selectedEmails === member.user.email;
|
||||
return (
|
||||
<UserToInviteItem
|
||||
key={member.user.id}
|
||||
member={member}
|
||||
isSelected={isSelected}
|
||||
onChange={() => handleOnChecked(member.user.email)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserToInviteItem({
|
||||
member,
|
||||
isSelected,
|
||||
onChange,
|
||||
}: {
|
||||
member: RouterOutputs["viewer"]["organizations"]["getMembers"][number];
|
||||
isSelected: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
key={member.userId}
|
||||
onClick={() => onChange()} // We handle this on click on the div also - for a11y we handle it with label and checkbox below
|
||||
className={classNames(
|
||||
"flex cursor-pointer items-center rounded-md px-2 py-1",
|
||||
isSelected ? "bg-emphasis" : "hover:bg-subtle "
|
||||
)}>
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<Avatar size="sm" alt="Users avatar" asChild imageSrc={member.user.avatarUrl} />
|
||||
<label
|
||||
htmlFor={`${member.user.id}`}
|
||||
className="text-emphasis cursor-pointer text-sm font-medium leading-none">
|
||||
{member.user.name || member.user.email || "Nameless User"}
|
||||
</label>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<input
|
||||
id={`${member.user.id}`}
|
||||
checked={isSelected}
|
||||
type="checkbox"
|
||||
className="text-emphasis focus:ring-emphasis dark:text-muted border-default hover:bg-subtle inline-flex h-4 w-4 place-self-center justify-self-end rounded checked:bg-gray-800"
|
||||
onChange={() => {
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CreateANewOrganizationForm } from "./CreateANewOrganizationForm";
|
||||
export { AboutOrganizationForm } from "./AboutOrganizationForm";
|
||||
export { AddNewTeamsForm } from "./AddNewTeamsForm";
|
||||
Reference in New Issue
Block a user