refactor: team settings redesign (#12230)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
parent
0b96ef5476
commit
a4c1df3658
|
@ -250,10 +250,7 @@ const AppearanceView = ({
|
|||
/>
|
||||
{lightModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
<Alert severity="warning" message={t("light_theme_contrast_error")} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
@ -282,10 +279,7 @@ const AppearanceView = ({
|
|||
/>
|
||||
{darkModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
<Alert severity="warning" message={t("dark_theme_contrast_error")} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
@ -56,6 +56,8 @@
|
|||
"a_refund_failed": "A refund failed",
|
||||
"awaiting_payment_subject": "Awaiting Payment: {{title}} on {{date}}",
|
||||
"meeting_awaiting_payment": "Your meeting is awaiting payment",
|
||||
"dark_theme_contrast_error":"Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.",
|
||||
"light_theme_contrast_error":"Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.",
|
||||
"payment_not_created_error": "Payment could not be created",
|
||||
"couldnt_charge_card_error": "Could not charge card for Payment",
|
||||
"no_available_users_found_error": "No available users found. Could you try another time slot?",
|
||||
|
@ -615,6 +617,7 @@
|
|||
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
|
||||
"danger_zone": "Danger zone",
|
||||
"account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.",
|
||||
"team_deletion_cannot_be_undone":"Be Careful. Team deletion cannot be undone",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"cancel_all_remaining": "Cancel all remaining",
|
||||
|
@ -1413,6 +1416,7 @@
|
|||
"slot_length": "Slot length",
|
||||
"booking_appearance": "Booking Appearance",
|
||||
"appearance_team_description": "Manage settings for your team's booking appearance",
|
||||
"appearance_org_description": "Manage settings for your organization's booking appearance",
|
||||
"only_owner_change": "Only the owner of this team can make changes to the team's booking ",
|
||||
"team_disable_cal_branding_description": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}'",
|
||||
"invited_by_team": "{{teamName}} has invited you to join their team as a {{role}}",
|
||||
|
|
|
@ -0,0 +1,130 @@
|
|||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
|
||||
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, ColorPicker, SettingsToggle, Alert } from "@calcom/ui";
|
||||
|
||||
type BrandColorsFormValues = {
|
||||
brandColor: string;
|
||||
darkBrandColor: string;
|
||||
};
|
||||
|
||||
const BrandColorsForm = ({
|
||||
onSubmit,
|
||||
brandColor,
|
||||
darkBrandColor,
|
||||
}: {
|
||||
onSubmit: (values: BrandColorsFormValues) => void;
|
||||
brandColor: string | undefined;
|
||||
darkBrandColor: string | undefined;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const brandColorsFormMethods = useFormContext();
|
||||
const {
|
||||
formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty },
|
||||
handleSubmit,
|
||||
} = brandColorsFormMethods;
|
||||
|
||||
const [isCustomBrandColorChecked, setIsCustomBrandColorChecked] = useState(
|
||||
brandColor !== DEFAULT_LIGHT_BRAND_COLOR || darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
|
||||
);
|
||||
const [darkModeError, setDarkModeError] = useState(false);
|
||||
const [lightModeError, setLightModeError] = useState(false);
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("custom_brand_colors")}
|
||||
description={t("customize_your_brand_colors")}
|
||||
checked={isCustomBrandColorChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsCustomBrandColorChecked(checked);
|
||||
if (!checked) {
|
||||
onSubmit({
|
||||
brandColor: DEFAULT_LIGHT_BRAND_COLOR,
|
||||
darkBrandColor: DEFAULT_DARK_BRAND_COLOR,
|
||||
});
|
||||
}
|
||||
}}
|
||||
childrenClassName="lg:ml-0"
|
||||
switchContainerClassName={classNames(
|
||||
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
||||
isCustomBrandColorChecked && "rounded-b-none"
|
||||
)}>
|
||||
<div className="border-subtle flex flex-col gap-6 border-x p-6">
|
||||
<Controller
|
||||
name="brandColor"
|
||||
control={brandColorsFormMethods.control}
|
||||
defaultValue={brandColor}
|
||||
render={() => (
|
||||
<div>
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={brandColor || DEFAULT_LIGHT_BRAND_COLOR}
|
||||
resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR}
|
||||
onChange={(value) => {
|
||||
try {
|
||||
checkWCAGContrastColor("#ffffff", value);
|
||||
setLightModeError(false);
|
||||
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
|
||||
} catch (err) {
|
||||
setLightModeError(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{lightModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert severity="warning" message={t("light_theme_contrast_error")} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="darkBrandColor"
|
||||
control={brandColorsFormMethods.control}
|
||||
defaultValue={darkBrandColor}
|
||||
render={() => (
|
||||
<div className="mt-6 sm:mt-0">
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={darkBrandColor || DEFAULT_DARK_BRAND_COLOR}
|
||||
resetDefaultValue={DEFAULT_DARK_BRAND_COLOR}
|
||||
onChange={(value) => {
|
||||
try {
|
||||
checkWCAGContrastColor("#101010", value);
|
||||
setDarkModeError(false);
|
||||
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
|
||||
} catch (err) {
|
||||
setDarkModeError(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{darkModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert severity="warning" message={t("dark_theme_contrast_error")} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
|
||||
color="primary"
|
||||
type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandColorsForm;
|
|
@ -0,0 +1,31 @@
|
|||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { Meta, SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
|
||||
export const AppearanceSkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={false} />
|
||||
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
</div>
|
||||
<div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6">
|
||||
<div className="flex w-full items-center justify-center gap-6">
|
||||
<div className="bg-emphasis h-32 flex-1 animate-pulse rounded-md p-5" />
|
||||
<div className="bg-emphasis h-32 flex-1 animate-pulse rounded-md p-5" />
|
||||
<div className="bg-emphasis h-32 flex-1 animate-pulse rounded-md p-5" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
</div>
|
||||
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
<div className="rounded-b-xl">
|
||||
<SectionBottomActions align="end">
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</SectionBottomActions>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
|
@ -1,60 +1,20 @@
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import BrandColorsForm from "@calcom/features/ee/components/BrandColorsForm";
|
||||
import { AppearanceSkeletonLoader } from "@calcom/features/ee/components/CommonSkeletonLoaders";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import ThemeLabel from "@calcom/features/settings/ThemeLabel";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
ColorPicker,
|
||||
Form,
|
||||
Meta,
|
||||
showToast,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
SettingsToggle,
|
||||
Alert,
|
||||
} from "@calcom/ui";
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={false} />
|
||||
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
</div>
|
||||
<div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
</div>
|
||||
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
<div className="rounded-b-xl">
|
||||
<SectionBottomActions align="end">
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</SectionBottomActions>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
import { Button, Form, Meta, showToast, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
type BrandColorsFormValues = {
|
||||
brandColor: string;
|
||||
|
@ -100,11 +60,13 @@ const OrgAppearanceView = ({
|
|||
await utils.viewer.organizations.listCurrent.invalidate();
|
||||
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
brandColorsFormMethods.reset({
|
||||
brandColor: res.data.brandColor as string,
|
||||
darkBrandColor: res.data.darkBrandColor as string,
|
||||
});
|
||||
resetOrgThemeReset({ theme: res.data.theme as string | undefined });
|
||||
if (res) {
|
||||
brandColorsFormMethods.reset({
|
||||
brandColor: res.data.brandColor as string,
|
||||
darkBrandColor: res.data.darkBrandColor as string,
|
||||
});
|
||||
resetOrgThemeReset({ theme: res.data.theme as string | undefined });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -116,7 +78,7 @@ const OrgAppearanceView = ({
|
|||
<LicenseRequired>
|
||||
<Meta
|
||||
title={t("appearance")}
|
||||
description={t("appearance_team_description")}
|
||||
description={t("appearance_org_description")}
|
||||
borderInShellHeader={false}
|
||||
/>
|
||||
{isAdminOrOwner ? (
|
||||
|
@ -175,8 +137,8 @@ const OrgAppearanceView = ({
|
|||
}}>
|
||||
<BrandColorsForm
|
||||
onSubmit={onBrandColorsFormSubmit}
|
||||
orgBrandColor={currentOrg?.brandColor}
|
||||
orgDarkBrandColor={currentOrg?.darkBrandColor}
|
||||
brandColor={currentOrg?.brandColor}
|
||||
darkBrandColor={currentOrg?.darkBrandColor}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
|
@ -190,7 +152,7 @@ const OrgAppearanceView = ({
|
|||
setHideBrandingValue(checked);
|
||||
mutation.mutate({ hideBranding: checked });
|
||||
}}
|
||||
switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6"
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
@ -202,126 +164,6 @@ const OrgAppearanceView = ({
|
|||
);
|
||||
};
|
||||
|
||||
const BrandColorsForm = ({
|
||||
onSubmit,
|
||||
orgBrandColor,
|
||||
orgDarkBrandColor,
|
||||
}: {
|
||||
onSubmit: (values: BrandColorsFormValues) => void;
|
||||
orgBrandColor: string | undefined;
|
||||
orgDarkBrandColor: string | undefined;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const brandColorsFormMethods = useFormContext();
|
||||
const {
|
||||
formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty },
|
||||
handleSubmit,
|
||||
} = brandColorsFormMethods;
|
||||
|
||||
const [isCustomBrandColorChecked, setIsCustomBrandColorChecked] = useState(
|
||||
orgBrandColor !== DEFAULT_LIGHT_BRAND_COLOR || orgDarkBrandColor !== DEFAULT_DARK_BRAND_COLOR
|
||||
);
|
||||
const [darkModeError, setDarkModeError] = useState(false);
|
||||
const [lightModeError, setLightModeError] = useState(false);
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("custom_brand_colors")}
|
||||
description={t("customize_your_brand_colors")}
|
||||
checked={isCustomBrandColorChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsCustomBrandColorChecked(checked);
|
||||
if (!checked) {
|
||||
onSubmit({
|
||||
brandColor: DEFAULT_LIGHT_BRAND_COLOR,
|
||||
darkBrandColor: DEFAULT_DARK_BRAND_COLOR,
|
||||
});
|
||||
}
|
||||
}}
|
||||
childrenClassName="lg:ml-0"
|
||||
switchContainerClassName={classNames(
|
||||
"py-6 px-4 sm:px-6 border-subtle rounded-xl border",
|
||||
isCustomBrandColorChecked && "rounded-b-none"
|
||||
)}>
|
||||
<div className="border-subtle flex flex-col gap-6 border-x p-6">
|
||||
<Controller
|
||||
name="brandColor"
|
||||
control={brandColorsFormMethods.control}
|
||||
defaultValue={orgBrandColor}
|
||||
render={() => (
|
||||
<div>
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={orgBrandColor || DEFAULT_LIGHT_BRAND_COLOR}
|
||||
resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR}
|
||||
onChange={(value) => {
|
||||
try {
|
||||
checkWCAGContrastColor("#ffffff", value);
|
||||
setLightModeError(false);
|
||||
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
|
||||
} catch (err) {
|
||||
setLightModeError(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{lightModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="darkBrandColor"
|
||||
control={brandColorsFormMethods.control}
|
||||
defaultValue={orgDarkBrandColor}
|
||||
render={() => (
|
||||
<div className="mt-6 sm:mt-0">
|
||||
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={orgDarkBrandColor || DEFAULT_DARK_BRAND_COLOR}
|
||||
resetDefaultValue={DEFAULT_DARK_BRAND_COLOR}
|
||||
onChange={(value) => {
|
||||
try {
|
||||
checkWCAGContrastColor("#101010", value);
|
||||
setDarkModeError(false);
|
||||
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
|
||||
} catch (err) {
|
||||
setDarkModeError(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{darkModeError ? (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
severity="warning"
|
||||
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button
|
||||
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
|
||||
color="primary"
|
||||
type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OrgAppearanceViewWrapper = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
@ -332,7 +174,7 @@ const OrgAppearanceViewWrapper = () => {
|
|||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonLoader title={t("appearance")} description={t("appearance_team_description")} />;
|
||||
return <AppearanceSkeletonLoader title={t("appearance")} description={t("appearance_org_description")} />;
|
||||
}
|
||||
|
||||
if (!currentOrg) return null;
|
||||
|
|
|
@ -141,25 +141,6 @@ const OrgProfileView = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Disable Org disbanding */}
|
||||
{/* <hr className="border-subtle my-8 border" />
|
||||
<div className="text-default mb-3 text-base font-semibold">{t("danger_zone")}</div>
|
||||
{currentOrganisation?.user.role === "OWNER" ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={Trash2}>
|
||||
{t("disband_org")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_org")}
|
||||
confirmBtnText={t("confirm")}
|
||||
onConfirm={deleteTeam}>
|
||||
{t("disband_org_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
) : null} */}
|
||||
{/* LEAVE ORG should go above here ^ */}
|
||||
</>
|
||||
</LicenseRequired>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { classNames } from "@calcom/lib";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast, Switch } from "@calcom/ui";
|
||||
import { showToast, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
const DisableTeamImpersonation = ({
|
||||
teamId,
|
||||
|
@ -24,35 +25,23 @@ const DisableTeamImpersonation = ({
|
|||
await utils.viewer.teams.getMembershipbyUser.invalidate();
|
||||
},
|
||||
});
|
||||
const [allowImpersonation, setAllowImpersonation] = useState(!query.data?.disableImpersonation ?? true);
|
||||
if (query.isLoading) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2
|
||||
className={classNames(
|
||||
"font-cal mb-0.5 text-sm font-semibold leading-6",
|
||||
disabled ? "text-muted " : "text-emphasis "
|
||||
)}>
|
||||
{t("user_impersonation_heading")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className={classNames("text-sm leading-5 ", disabled ? "text-gray-300" : "text-default")}>
|
||||
{t("team_impersonation_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
defaultChecked={!query.data?.disableImpersonation}
|
||||
onCheckedChange={(isChecked) => {
|
||||
mutation.mutate({ teamId, memberId, disableImpersonation: !isChecked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("user_impersonation_heading")}
|
||||
disabled={disabled || mutation?.isLoading}
|
||||
description={t("team_impersonation_description")}
|
||||
checked={allowImpersonation}
|
||||
onCheckedChange={(_allowImpersonation) => {
|
||||
setAllowImpersonation(_allowImpersonation);
|
||||
mutation.mutate({ teamId, memberId, disableImpersonation: !_allowImpersonation });
|
||||
}}
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { classNames } from "@calcom/lib";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast, Switch } from "@calcom/ui";
|
||||
import { showToast, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
const MakeTeamPrivateSwitch = ({
|
||||
teamId,
|
||||
|
@ -26,34 +27,23 @@ const MakeTeamPrivateSwitch = ({
|
|||
},
|
||||
});
|
||||
|
||||
const [isTeamPrivate, setTeamPrivate] = useState(isPrivate);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2
|
||||
className={classNames(
|
||||
"font-cal mb-0.5 text-sm font-semibold leading-6",
|
||||
disabled ? "text-muted " : "text-emphasis "
|
||||
)}>
|
||||
{t("make_team_private")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className={classNames("text-sm leading-5 ", disabled ? "text-gray-300" : "text-default")}>
|
||||
{t("make_team_private_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
data-testid="make-team-private-check"
|
||||
defaultChecked={isPrivate}
|
||||
onCheckedChange={(isChecked) => {
|
||||
mutation.mutate({ id: teamId, isPrivate: isChecked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("make_team_private")}
|
||||
disabled={disabled || mutation?.isLoading}
|
||||
description={t("make_team_private_description")}
|
||||
checked={isTeamPrivate}
|
||||
onCheckedChange={(checked) => {
|
||||
setTeamPrivate(checked);
|
||||
mutation.mutate({ id: teamId, isPrivate: checked });
|
||||
}}
|
||||
switchContainerClassName="mt-6"
|
||||
data-testid="make-team-private-check"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,73 +1,188 @@
|
|||
import { useRouter } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import BrandColorsForm from "@calcom/features/ee/components/BrandColorsForm";
|
||||
import { AppearanceSkeletonLoader } from "@calcom/features/ee/components/CommonSkeletonLoaders";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
ColorPicker,
|
||||
Form,
|
||||
Meta,
|
||||
showToast,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
Switch,
|
||||
} from "@calcom/ui";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Button, Form, Meta, showToast, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
import ThemeLabel from "../../../settings/ThemeLabel";
|
||||
import { getLayout } from "../../../settings/layouts/SettingsLayout";
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} />
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<div className="flex items-center">
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
</div>
|
||||
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
interface TeamAppearanceValues {
|
||||
hideBranding: boolean;
|
||||
hideBookATeamMember: boolean;
|
||||
type BrandColorsFormValues = {
|
||||
brandColor: string;
|
||||
darkBrandColor: string;
|
||||
theme: string | null | undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const ProfileView = () => {
|
||||
const params = useParamsWithFallback();
|
||||
type ProfileViewProps = { team: RouterOutputs["viewer"]["teams"]["get"] };
|
||||
|
||||
const ProfileView = ({ team }: ProfileViewProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const [hideBrandingValue, setHideBrandingValue] = useState(team?.hideBranding ?? false);
|
||||
const [hideBookATeamMember, setHideBookATeamMember] = useState(team?.hideBookATeamMember ?? false);
|
||||
|
||||
const themeForm = useForm<{ theme: string | null | undefined }>({
|
||||
defaultValues: {
|
||||
theme: team?.theme,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isSubmitting: isThemeSubmitting, isDirty: isThemeDirty },
|
||||
reset: resetTheme,
|
||||
} = themeForm;
|
||||
|
||||
const brandColorsFormMethods = useForm<BrandColorsFormValues>({
|
||||
defaultValues: {
|
||||
brandColor: team?.brandColor || DEFAULT_LIGHT_BRAND_COLOR,
|
||||
darkBrandColor: team?.darkBrandColor || DEFAULT_DARK_BRAND_COLOR,
|
||||
},
|
||||
});
|
||||
|
||||
const { reset: resetBrandColors } = brandColorsFormMethods;
|
||||
|
||||
const mutation = trpc.viewer.teams.update.useMutation({
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
async onSuccess() {
|
||||
async onSuccess(res) {
|
||||
await utils.viewer.teams.get.invalidate();
|
||||
if (res) {
|
||||
resetTheme({ theme: res.theme });
|
||||
resetBrandColors({ brandColor: res.brandColor, darkBrandColor: res.darkBrandColor });
|
||||
}
|
||||
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
const onBrandColorsFormSubmit = (values: BrandColorsFormValues) => {
|
||||
mutation.mutate({ ...values, id: team.id });
|
||||
};
|
||||
|
||||
const isAdmin =
|
||||
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={t("booking_appearance")}
|
||||
description={t("appearance_team_description")}
|
||||
borderInShellHeader={false}
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<Form
|
||||
form={themeForm}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
id: team.id,
|
||||
theme: values.theme || null,
|
||||
});
|
||||
}}>
|
||||
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
|
||||
<div>
|
||||
<p className="font-semibold">{t("theme")}</p>
|
||||
<p className="text-default">{t("theme_applies_note")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={null}
|
||||
label={t("theme_system")}
|
||||
defaultChecked={team.theme === null}
|
||||
register={themeForm.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="light"
|
||||
value="light"
|
||||
label={t("light")}
|
||||
defaultChecked={team.theme === "light"}
|
||||
register={themeForm.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="dark"
|
||||
value="dark"
|
||||
label={t("dark")}
|
||||
defaultChecked={team.theme === "dark"}
|
||||
register={themeForm.register}
|
||||
/>
|
||||
</div>
|
||||
<SectionBottomActions className="mb-6" align="end">
|
||||
<Button
|
||||
disabled={isThemeSubmitting || !isThemeDirty}
|
||||
type="submit"
|
||||
data-testid="update-org-theme-btn"
|
||||
color="primary">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
|
||||
<Form
|
||||
form={brandColorsFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
onBrandColorsFormSubmit(values);
|
||||
}}>
|
||||
<BrandColorsForm
|
||||
onSubmit={onBrandColorsFormSubmit}
|
||||
brandColor={team?.brandColor}
|
||||
darkBrandColor={team?.darkBrandColor}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6">
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("disable_cal_branding", { appName: APP_NAME })}
|
||||
disabled={mutation?.isLoading}
|
||||
description={t("removes_cal_branding", { appName: APP_NAME })}
|
||||
checked={hideBrandingValue}
|
||||
onCheckedChange={(checked) => {
|
||||
setHideBrandingValue(checked);
|
||||
mutation.mutate({ id: team.id, hideBranding: checked });
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("hide_book_a_team_member")}
|
||||
disabled={mutation?.isLoading}
|
||||
description={t("hide_book_a_team_member_description", { appName: APP_NAME })}
|
||||
checked={hideBookATeamMember ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
setHideBookATeamMember(checked);
|
||||
mutation.mutate({ id: team.id, hideBookATeamMember: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="border-subtle rounded-md border p-5">
|
||||
<span className="text-default text-sm">{t("only_owner_change")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileViewWrapper = () => {
|
||||
const router = useRouter();
|
||||
const params = useParamsWithFallback();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
|
||||
{ teamId: Number(params.id) },
|
||||
{
|
||||
|
@ -77,170 +192,16 @@ const ProfileView = () => {
|
|||
}
|
||||
);
|
||||
|
||||
const form = useForm<TeamAppearanceValues>({
|
||||
defaultValues: {
|
||||
theme: team?.theme,
|
||||
brandColor: team?.brandColor,
|
||||
darkBrandColor: team?.darkBrandColor,
|
||||
hideBranding: team?.hideBranding,
|
||||
},
|
||||
});
|
||||
if (isLoading)
|
||||
return (
|
||||
<AppearanceSkeletonLoader title={t("appearance")} description={t("appearance_team_description")} />
|
||||
);
|
||||
|
||||
const isAdmin =
|
||||
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
|
||||
if (!team) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonLoader title={t("booking_appearance")} description={t("appearance_team_description")} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("booking_appearance")} description={t("appearance_team_description")} />
|
||||
{isAdmin ? (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
id: team.id,
|
||||
...values,
|
||||
theme: values.theme || null,
|
||||
});
|
||||
}}>
|
||||
<div className="mb-6 flex items-center text-sm">
|
||||
<div>
|
||||
<p className="font-semibold">{t("theme")}</p>
|
||||
<p className="text-default">{t("theme_applies_note")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={null}
|
||||
label={t("theme_system")}
|
||||
defaultChecked={team.theme === null}
|
||||
register={form.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="light"
|
||||
value="light"
|
||||
label={t("light")}
|
||||
defaultChecked={team.theme === "light"}
|
||||
register={form.register}
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="dark"
|
||||
value="dark"
|
||||
label={t("dark")}
|
||||
defaultChecked={team.theme === "dark"}
|
||||
register={form.register}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-subtle my-8" />
|
||||
<div className="text-default mb-6 flex items-center text-sm">
|
||||
<div>
|
||||
<p className="font-semibold">{t("custom_brand_colors")}</p>
|
||||
<p className="mt-0.5 leading-5">{t("customize_your_brand_colors")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="block justify-between sm:flex">
|
||||
<Controller
|
||||
name="brandColor"
|
||||
control={form.control}
|
||||
defaultValue={team.brandColor}
|
||||
render={() => (
|
||||
<div>
|
||||
<p className="text-emphasis mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={team.brandColor}
|
||||
resetDefaultValue="#292929"
|
||||
onChange={(value) => form.setValue("brandColor", value, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="darkBrandColor"
|
||||
control={form.control}
|
||||
defaultValue={team.darkBrandColor}
|
||||
render={() => (
|
||||
<div className="mt-6 sm:mt-0">
|
||||
<p className="text-emphasis mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
|
||||
<ColorPicker
|
||||
defaultValue={team.darkBrandColor}
|
||||
resetDefaultValue="#fafafa"
|
||||
onChange={(value) => form.setValue("darkBrandColor", value, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-subtle my-8" />
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex-grow text-sm">
|
||||
<label htmlFor="hide-branding" className="text-default font-medium">
|
||||
{t("disable_cal_branding", { appName: APP_NAME })}
|
||||
</label>
|
||||
<p className="text-subtle">
|
||||
{t("team_disable_cal_branding_description", { appName: APP_NAME })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-none">
|
||||
<Controller
|
||||
control={form.control}
|
||||
defaultValue={team?.hideBranding ?? false}
|
||||
name="hideBranding"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
form.setValue("hideBranding", isChecked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex-grow text-sm">
|
||||
<label htmlFor="hide-branding" className="text-default font-medium">
|
||||
{t("hide_book_a_team_member")}
|
||||
</label>
|
||||
<p className="text-subtle">{t("hide_book_a_team_member_description")}</p>
|
||||
</div>
|
||||
<div className="flex-none">
|
||||
<Controller
|
||||
control={form.control}
|
||||
defaultValue={team?.hideBookATeamMember ?? false}
|
||||
name="hideBookATeamMember"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
form.setValue("hideBookATeamMember", isChecked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="border-subtle rounded-md border p-5">
|
||||
<span className="text-default text-sm">{t("only_owner_change")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <ProfileView team={team} />;
|
||||
};
|
||||
|
||||
ProfileView.getLayout = getLayout;
|
||||
ProfileViewWrapper.getLayout = getLayout;
|
||||
|
||||
export default ProfileView;
|
||||
export default ProfileViewWrapper;
|
||||
|
|
|
@ -170,7 +170,6 @@ const MembersView = () => {
|
|||
{((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && (
|
||||
<>
|
||||
<MembersList team={team} isOrgAdminOrOwner={isOrgAdminOrOwner} />
|
||||
<hr className="border-subtle my-8" />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
@ -183,10 +182,7 @@ const MembersView = () => {
|
|||
)}
|
||||
|
||||
{team && (isAdmin || isOrgAdminOrOwner) && (
|
||||
<>
|
||||
<hr className="border-subtle my-8" />
|
||||
<MakeTeamPrivateSwitch teamId={team.id} isPrivate={team.isPrivate} disabled={isInviteOpen} />
|
||||
</>
|
||||
<MakeTeamPrivateSwitch teamId={team.id} isPrivate={team.isPrivate} disabled={isInviteOpen} />
|
||||
)}
|
||||
</div>
|
||||
{showMemberInvitationModal && team && (
|
||||
|
|
|
@ -9,6 +9,7 @@ import { z } from "zod";
|
|||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -19,6 +20,7 @@ import objectKeys from "@calcom/lib/objectKeys";
|
|||
import slugify from "@calcom/lib/slugify";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Avatar,
|
||||
|
@ -36,6 +38,8 @@ import {
|
|||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
TextField,
|
||||
SkeletonAvatar,
|
||||
SkeletonButton,
|
||||
} from "@calcom/ui";
|
||||
import { ExternalLink, Link as LinkIcon, LogOut, Trash2 } from "@calcom/ui/components/icon";
|
||||
|
||||
|
@ -51,10 +55,31 @@ const teamProfileFormSchema = z.object({
|
|||
message: "Url can only have alphanumeric characters(a-z, 0-9) and hyphen(-) symbol.",
|
||||
})
|
||||
.min(1, { message: "Url cannot be left empty" }),
|
||||
logo: z.string(),
|
||||
logo: z.string().nullable(),
|
||||
bio: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof teamProfileFormSchema>;
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Meta title={title} description={description} borderInShellHeader={true} />
|
||||
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
|
||||
<div className="flex items-center">
|
||||
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
|
||||
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
|
||||
</div>
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileView = () => {
|
||||
const params = useParamsWithFallback();
|
||||
const teamId = Number(params.id);
|
||||
|
@ -62,27 +87,11 @@ const ProfileView = () => {
|
|||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const session = useSession();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.body.focus();
|
||||
}, []);
|
||||
|
||||
const mutation = trpc.viewer.teams.update.useMutation({
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
async onSuccess() {
|
||||
await utils.viewer.teams.get.invalidate();
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(teamProfileFormSchema),
|
||||
});
|
||||
|
||||
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
|
||||
{ teamId, includeTeamLogo: true },
|
||||
{
|
||||
|
@ -90,17 +99,6 @@ const ProfileView = () => {
|
|||
onError: () => {
|
||||
router.push("/settings");
|
||||
},
|
||||
onSuccess: (team) => {
|
||||
if (team) {
|
||||
form.setValue("name", team.name || "");
|
||||
form.setValue("slug", team.slug || "");
|
||||
form.setValue("bio", team.bio || "");
|
||||
form.setValue("logo", team.logo || "");
|
||||
if (team.slug === null && (team?.metadata as Prisma.JsonObject)?.requestedSlug) {
|
||||
form.setValue("slug", ((team?.metadata as Prisma.JsonObject)?.requestedSlug as string) || "");
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -131,17 +129,6 @@ const ProfileView = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const publishMutation = trpc.viewer.teams.publish.useMutation({
|
||||
async onSuccess(data: { url?: string }) {
|
||||
if (data.url) {
|
||||
router.push(data.url);
|
||||
}
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam() {
|
||||
if (team?.id) deleteTeamMutation.mutate({ teamId: team.id });
|
||||
}
|
||||
|
@ -154,233 +141,284 @@ const ProfileView = () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonLoader title={t("profile")} description={t("profile_team_description")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("profile")} description={t("profile_team_description")} />
|
||||
{!isLoading ? (
|
||||
<>
|
||||
{isAdmin ? (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
if (team) {
|
||||
const variables = {
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
bio: values.bio,
|
||||
logo: values.logo,
|
||||
};
|
||||
objectKeys(variables).forEach((key) => {
|
||||
if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key];
|
||||
});
|
||||
mutation.mutate({ id: team.id, ...variables });
|
||||
}
|
||||
}}>
|
||||
{!team.parent && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={getPlaceholderAvatar(value, team?.name as string)}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("update")}
|
||||
handleAvatarChange={(newLogo) => {
|
||||
form.setValue("logo", newLogo);
|
||||
}}
|
||||
imageSrc={value}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<hr className="border-subtle my-8" />
|
||||
</>
|
||||
)}
|
||||
<Meta title={t("profile")} description={t("profile_team_description")} borderInShellHeader={true} />
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="name"
|
||||
label={t("team_name")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
form.setValue("name", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="slug"
|
||||
label={t("team_url")}
|
||||
value={value}
|
||||
addOnLeading={
|
||||
team.parent && orgBranding
|
||||
? `${getOrgFullOrigin(orgBranding?.slug, { protocol: false })}/`
|
||||
: `${WEBAPP_URL}/team/`
|
||||
}
|
||||
onChange={(e) => {
|
||||
form.clearErrors("slug");
|
||||
form.setValue("slug", slugify(e?.target.value, true));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Label>{t("about")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(form.getValues("bio") || "")}
|
||||
setText={(value: string) => form.setValue("bio", turndown(value))}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
disableLists
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-default mt-2 text-sm">{t("team_description")}</p>
|
||||
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
{IS_TEAM_BILLING_ENABLED &&
|
||||
team.slug === null &&
|
||||
(team.metadata as Prisma.JsonObject)?.requestedSlug && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="ml-2 mt-8"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
publishMutation.mutate({ teamId: team.id });
|
||||
}}>
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
) : (
|
||||
<div className="flex">
|
||||
<div className="flex-grow">
|
||||
<div>
|
||||
<Label className="text-emphasis">{t("team_name")}</Label>
|
||||
<p className="text-default text-sm">{team?.name}</p>
|
||||
</div>
|
||||
{team && !isBioEmpty && (
|
||||
<>
|
||||
<Label className="text-emphasis mt-5">{t("about")}</Label>
|
||||
<div
|
||||
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
dangerouslySetInnerHTML={{ __html: md.render(markdownToSafeHTML(team.bio)) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="">
|
||||
<Link href={permalink} passHref={true} target="_blank">
|
||||
<LinkIconButton Icon={ExternalLink}>{t("preview")}</LinkIconButton>
|
||||
</Link>
|
||||
<LinkIconButton
|
||||
Icon={LinkIcon}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Copied to clipboard", "success");
|
||||
}}>
|
||||
{t("copy_link_team")}
|
||||
</LinkIconButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<hr className="border-subtle my-8 border" />
|
||||
|
||||
<div className="text-default mb-3 text-base font-semibold">{t("danger_zone")}</div>
|
||||
{team?.membership.role === "OWNER" ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={Trash2}>
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={deleteTeam}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={LogOut}>
|
||||
{t("leave_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={leaveTeam}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
{isAdmin ? (
|
||||
<TeamProfileForm team={team} />
|
||||
) : (
|
||||
<>
|
||||
<SkeletonContainer as="form">
|
||||
<div className="flex items-center">
|
||||
<div className="ms-4">
|
||||
<SkeletonContainer>
|
||||
<div className="bg-emphasis h-16 w-16 rounded-full" />
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="flex-grow">
|
||||
<div>
|
||||
<Label className="text-emphasis">{t("team_name")}</Label>
|
||||
<p className="text-default text-sm">{team?.name}</p>
|
||||
</div>
|
||||
<hr className="border-subtle my-8" />
|
||||
<SkeletonContainer>
|
||||
<div className="mt-8">
|
||||
<SkeletonText className="h-6 w-48" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer>
|
||||
<div className="mt-8">
|
||||
<SkeletonText className="h-6 w-48" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
<div className="mt-8">
|
||||
<SkeletonContainer>
|
||||
<div className="bg-emphasis h-24 rounded-md" />
|
||||
</SkeletonContainer>
|
||||
<SkeletonText className="mt-4 h-12 w-32" />
|
||||
</div>
|
||||
<SkeletonContainer>
|
||||
<div className="mt-8">
|
||||
<SkeletonText className="h-9 w-24" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</SkeletonContainer>
|
||||
</>
|
||||
{team && !isBioEmpty && (
|
||||
<>
|
||||
<Label className="text-emphasis mt-5">{t("about")}</Label>
|
||||
<div
|
||||
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
dangerouslySetInnerHTML={{ __html: md.render(markdownToSafeHTML(team.bio)) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="">
|
||||
<Link href={permalink} passHref={true} target="_blank">
|
||||
<LinkIconButton Icon={ExternalLink}>{t("preview")}</LinkIconButton>
|
||||
</Link>
|
||||
<LinkIconButton
|
||||
Icon={LinkIcon}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Copied to clipboard", "success");
|
||||
}}>
|
||||
{t("copy_link_team")}
|
||||
</LinkIconButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
|
||||
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||
{team?.membership.role === "OWNER" && (
|
||||
<p className="text-subtle text-sm">{t("team_deletion_cannot_be_undone")}</p>
|
||||
)}
|
||||
</div>
|
||||
{team?.membership.role === "OWNER" ? (
|
||||
<Dialog>
|
||||
<SectionBottomActions align="end">
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={Trash2}>
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</SectionBottomActions>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={deleteTeam}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Dialog>
|
||||
<SectionBottomActions align="end">
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={LogOut}>
|
||||
{t("leave_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</SectionBottomActions>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={leaveTeam}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export type TeamProfileFormProps = { team: RouterOutputs["viewer"]["teams"]["get"] };
|
||||
|
||||
const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = trpc.viewer.teams.update.useMutation({
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
async onSuccess(res) {
|
||||
reset({
|
||||
logo: (res?.logo || "") as string,
|
||||
name: (res?.name || "") as string,
|
||||
bio: (res?.bio || "") as string,
|
||||
slug: res?.slug as string,
|
||||
});
|
||||
await utils.viewer.teams.get.invalidate();
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
name: team?.name || "",
|
||||
logo: team?.logo || "",
|
||||
bio: team?.bio || "",
|
||||
slug: team?.slug || ((team?.metadata as Prisma.JsonObject)?.requestedSlug as string) || "",
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
resolver: zodResolver(teamProfileFormSchema),
|
||||
});
|
||||
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
const {
|
||||
formState: { isSubmitting, isDirty },
|
||||
reset,
|
||||
} = form;
|
||||
|
||||
const isDisabled = isSubmitting || !isDirty;
|
||||
|
||||
const publishMutation = trpc.viewer.teams.publish.useMutation({
|
||||
async onSuccess(data: { url?: string }) {
|
||||
if (data.url) {
|
||||
router.push(data.url);
|
||||
}
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
if (team) {
|
||||
const variables = {
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
bio: values.bio,
|
||||
logo: values.logo,
|
||||
};
|
||||
objectKeys(variables).forEach((key) => {
|
||||
if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key];
|
||||
});
|
||||
mutation.mutate({ id: team.id, ...variables });
|
||||
}
|
||||
}}>
|
||||
<div className="border-subtle border-x px-4 py-8 sm:px-6">
|
||||
{!team.parent && (
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => {
|
||||
const showRemoveLogoButton = !!value;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Avatar
|
||||
alt={defaultValues.name || ""}
|
||||
imageSrc={getPlaceholderAvatar(value, team?.name as string)}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="ms-4 flex gap-2">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("upload_logo")}
|
||||
handleAvatarChange={(newLogo) => {
|
||||
form.setValue("logo", newLogo, { shouldDirty: true });
|
||||
}}
|
||||
triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"}
|
||||
imageSrc={value ?? undefined}
|
||||
/>
|
||||
{showRemoveLogoButton && (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
form.setValue("logo", null, { shouldDirty: true });
|
||||
}}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="name"
|
||||
label={t("team_name")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
form.setValue("name", e?.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="slug"
|
||||
label={t("team_url")}
|
||||
value={value}
|
||||
addOnLeading={
|
||||
team.parent && orgBranding
|
||||
? `${getOrgFullOrigin(orgBranding?.slug, { protocol: false })}/`
|
||||
: `${WEBAPP_URL}/team/`
|
||||
}
|
||||
onChange={(e) => {
|
||||
form.clearErrors("slug");
|
||||
form.setValue("slug", slugify(e?.target.value, true), { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Label>{t("about")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(form.getValues("bio") || "")}
|
||||
setText={(value: string) => form.setValue("bio", turndown(value), { shouldDirty: true })}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
disableLists
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-default mt-2 text-sm">{t("team_description")}</p>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button color="primary" type="submit" loading={mutation.isLoading} disabled={isDisabled}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
{IS_TEAM_BILLING_ENABLED &&
|
||||
team.slug === null &&
|
||||
(team.metadata as Prisma.JsonObject)?.requestedSlug && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="ml-2"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
publishMutation.mutate({ teamId: team.id });
|
||||
}}>
|
||||
{t("team_publish")}
|
||||
</Button>
|
||||
)}
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileView.getLayout = getLayout;
|
||||
|
||||
export default ProfileView;
|
||||
|
|
|
@ -86,4 +86,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
|
||||
// Sync Services: Close.com
|
||||
if (prevTeam) closeComUpdateTeam(prevTeam, updatedTeam);
|
||||
|
||||
return {
|
||||
logo: updatedTeam.logo,
|
||||
name: updatedTeam.name,
|
||||
bio: updatedTeam.bio,
|
||||
slug: updatedTeam.slug,
|
||||
theme: updatedTeam.theme,
|
||||
brandColor: updatedTeam.brandColor,
|
||||
darkBrandColor: updatedTeam.darkBrandColor,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ export const ZUpdateInputSchema = z.object({
|
|||
id: z.number(),
|
||||
bio: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
logo: z.string().optional(),
|
||||
logo: z.string().nullable().optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.transform((val) => slugify(val.trim()))
|
||||
|
|
|
@ -163,9 +163,12 @@ export default function ImageUploader({
|
|||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={
|
||||
(opened) => !opened && setFile(null) // unset file on close
|
||||
}>
|
||||
onOpenChange={(opened) => {
|
||||
// unset file on close
|
||||
if (!opened) {
|
||||
setFile(null);
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button color={triggerButtonColor ?? "secondary"} type="button" className="py-1 text-sm">
|
||||
{buttonMsg}
|
||||
|
@ -201,10 +204,11 @@ export default function ImageUploader({
|
|||
</div>
|
||||
</div>
|
||||
<DialogFooter className="relative">
|
||||
<DialogClose color="minimal">{t("cancel")}</DialogClose>
|
||||
|
||||
<DialogClose color="primary" onClick={() => showCroppedImage(croppedAreaPixels)}>
|
||||
{t("save")}
|
||||
</DialogClose>
|
||||
<DialogClose color="minimal">{t("cancel")}</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
Loading…
Reference in New Issue
Block a user