diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index d360261ddca..893665dec47 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -365,7 +365,6 @@ "uploadImage": "Upload", "removeImage": "Remove", "imageUploadDescription": "Up to 2MB, JPG or PNG", - "image": "Image", "billingPlanTitle": "Plan", "billingSettings": "Billing Settings", "billingSettingsDescription": "View and manage your space's subscription and billing information.", @@ -620,5 +619,12 @@ "viewPoll": "View poll", "responseOptions": "Response Options", "viewResults": "View results", - "pollClosedDescription": "No more responses are being accepted." + "pollClosedDescription": "No more responses are being accepted.", + "showBrandingDescription": "Show your brand identity on your public pages and emails", + "useCustomBranding": "Enable Custom Branding", + "savingBranding": "Saving...", + "brandingSaved": "Branding saved", + "brandingSaveError": "Failed to save branding", + "customBranding": "Custom Branding", + "customBrandingDescription": "Show your logo and brand colors to your participants" } diff --git a/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx b/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx new file mode 100644 index 00000000000..ceedab6712c --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { usePostHog } from "@rallly/posthog/client"; +import { Button } from "@rallly/ui/button"; +import { ColorPicker, parseColor } from "@rallly/ui/color-picker"; +import { Field, FieldGroup, FieldLabel } from "@rallly/ui/field"; +import { Switch } from "@rallly/ui/switch"; +import React from "react"; +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionHeader, + PageSectionTitle, +} from "@/app/components/page-layout"; +import { ProBadge } from "@/components/pro-badge"; +import { useBilling } from "@/features/billing/client"; +import { DEFAULT_PRIMARY_COLOR } from "@/features/branding/constants"; +import { useSpace } from "@/features/space/client"; +import { Trans } from "@/i18n/client"; +import { useToastProgress } from "@/lib/use-toast-progress"; +import { trpc } from "@/trpc/client"; + +export function CustomBrandingSection({ + disabled = false, +}: { + disabled?: boolean; +}) { + const { data: space } = useSpace(); + const { isFree, showPayWall } = useBilling(); + const toastProgress = useToastProgress(); + const utils = trpc.useUtils(); + const posthog = usePostHog(); + + const currentColor = space.primaryColor ?? DEFAULT_PRIMARY_COLOR; + const [color, setColor] = React.useState(() => parseColor(currentColor)); + const hexColor = color.toString("hex"); + const isDirty = hexColor !== currentColor; + + const updateShowBranding = trpc.spaces.updateShowBranding.useMutation({ + onMutate: async ({ showBranding }) => { + await utils.spaces.getCurrent.cancel(); + const previousData = utils.spaces.getCurrent.getData(); + utils.spaces.getCurrent.setData(undefined, (old) => + old ? { ...old, showBranding } : old, + ); + return { previousData }; + }, + onError: (_err, _vars, context) => { + utils.spaces.getCurrent.setData(undefined, context?.previousData); + }, + }); + + const updateSpace = trpc.spaces.update.useMutation(); + + const handleToggle = (newChecked: boolean) => { + if (isFree) { + posthog?.capture("branding_settings:paywall_trigger"); + showPayWall(); + return; + } + toastProgress(updateShowBranding.mutateAsync({ showBranding: newChecked })); + }; + + const handleSave = async () => { + const value = hexColor === DEFAULT_PRIMARY_COLOR ? null : hexColor; + toastProgress(updateSpace.mutateAsync({ primaryColor: value })); + }; + + return ( + + + + + + + + + + + + + + + + {space.tier !== "pro" && } + + +
+ + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx b/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx index a1ae69ac9d1..0522860df5b 100644 --- a/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx +++ b/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx @@ -3,16 +3,15 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@rallly/ui/button"; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@rallly/ui/form"; + Field, + FieldError, + FieldGroup, + FieldLabel, + FieldSet, +} from "@rallly/ui/field"; import { Input } from "@rallly/ui/input"; import { toast } from "@rallly/ui/sonner"; -import { useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { ImageUpload, @@ -63,69 +62,76 @@ export function SpaceSettingsForm({ }; return ( -
- { - toast.promise( - updateSpace.mutateAsync({ + { + toast.promise( + updateSpace + .mutateAsync({ name: data.name, + }) + .then(() => { + form.reset(data); }), - { - loading: t("updatingSpace", { - defaultValue: "Updating space...", - }), - success: t("spaceUpdatedSuccess", { - defaultValue: "Space updated successfully", - }), - error: t("spaceUpdatedError", { - defaultValue: "Failed to update space", - }), - }, - ); - - form.reset(data); - })} - className="space-y-6" - > -
- - - - - - - - getImageUploadUrl.mutateAsync(input)} - onUploadSuccess={handleImageUploadSuccess} - onRemoveSuccess={handleImageRemoveSuccess} - hasCurrentImage={!!space.image} - /> - -
- ( - - - - - - + +
+ + + + + + + + + + getImageUploadUrl.mutateAsync(input)} + onUploadSuccess={handleImageUploadSuccess} + onRemoveSuccess={handleImageRemoveSuccess} + hasCurrentImage={!!space.image} /> - - - - )} - /> -
+ + + ( + + + + + + {fieldState.invalid && ( + + )} + + )} + /> + +
+ - - - + +
+ ); } diff --git a/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx b/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx index f0e4aeb6280..2e621c607a2 100644 --- a/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx +++ b/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx @@ -21,6 +21,7 @@ import { import { useSpace } from "@/features/space/client"; import { Trans } from "@/i18n/client"; import { trpc } from "@/trpc/client"; +import { CustomBrandingSection } from "./components/custom-branding-section"; import { DeleteSpaceButton } from "./components/delete-space-button"; import { LeaveSpaceButton } from "./components/leave-space-button"; import { SpaceSettingsForm } from "./components/space-settings-form"; @@ -64,6 +65,7 @@ export function GeneralSettingsPageClient() { + {!isOwner ? ( diff --git a/apps/web/src/features/billing/components/pay-wall-dialog.tsx b/apps/web/src/features/billing/components/pay-wall-dialog.tsx index 889b493d738..537d8b87006 100644 --- a/apps/web/src/features/billing/components/pay-wall-dialog.tsx +++ b/apps/web/src/features/billing/components/pay-wall-dialog.tsx @@ -21,6 +21,7 @@ import { ClockIcon, CopyIcon, LifeBuoyIcon, + PaletteIcon, SettingsIcon, SparklesIcon, TimerResetIcon, @@ -304,6 +305,21 @@ export function PayWallDialog({ + } + title={ + + } + description={ + + } + /> } title={ diff --git a/apps/web/src/features/space/client.tsx b/apps/web/src/features/space/client.tsx index a3ea3560f25..674a2779a86 100644 --- a/apps/web/src/features/space/client.tsx +++ b/apps/web/src/features/space/client.tsx @@ -51,17 +51,19 @@ export function SpaceProvider({ children }: { children: React.ReactNode }) { posthog?.group("space", space.id, { name: space.name, tier: space.tier, + custom_branding_enabled: space.showBranding, }); } - }, [posthog, space?.id, space?.name, space?.tier]); + }, [posthog, space?.id, space?.name, space?.tier, space?.showBranding]); if (!space) { return ; } - const primaryColorVars = space.primaryColor - ? getPrimaryColorVars(space.primaryColor) - : null; + const primaryColorVars = + space.showBranding && space.primaryColor + ? getPrimaryColorVars(space.primaryColor) + : null; return ( <> diff --git a/apps/web/src/lib/use-toast-progress.ts b/apps/web/src/lib/use-toast-progress.ts new file mode 100644 index 00000000000..4e7420ccae3 --- /dev/null +++ b/apps/web/src/lib/use-toast-progress.ts @@ -0,0 +1,14 @@ +"use client"; + +import { toast } from "@rallly/ui/sonner"; +import { useTranslation } from "@/i18n/client"; + +export function useToastProgress() { + const { t } = useTranslation(); + return (promise: Promise) => + toast.promise(promise, { + loading: t("saving", { defaultValue: "Saving..." }), + success: t("saved", { defaultValue: "Saved" }), + error: t("unexpectedError", { defaultValue: "Unexpected Error" }), + }); +} diff --git a/apps/web/src/trpc/routers/spaces.ts b/apps/web/src/trpc/routers/spaces.ts index 08f2789ca3f..ee844d3c5ea 100644 --- a/apps/web/src/trpc/routers/spaces.ts +++ b/apps/web/src/trpc/routers/spaces.ts @@ -266,7 +266,16 @@ export const spaces = router({ }), update: spaceProcedure - .input(z.object({ name: z.string().min(1).max(100) })) + .input( + z.object({ + name: z.string().min(1).max(100).optional(), + primaryColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color") + .nullable() + .optional(), + }), + ) .mutation(async ({ ctx, input }) => { const memberAbility = defineAbilityForMember({ user: ctx.user, @@ -282,7 +291,12 @@ export const spaces = router({ await prisma.space.update({ where: { id: ctx.space.id }, - data: { name: input.name }, + data: { + ...(input.name !== undefined && { name: input.name }), + ...(input.primaryColor !== undefined && { + primaryColor: input.primaryColor, + }), + }, }); posthog()?.capture({ @@ -297,34 +311,6 @@ export const spaces = router({ }); }), - updatePrimaryColor: spaceProcedure - .input( - z.object({ - primaryColor: z - .string() - .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color") - .nullable(), - }), - ) - .mutation(async ({ ctx, input }) => { - const memberAbility = defineAbilityForMember({ - user: ctx.user, - space: ctx.space, - }); - - if (memberAbility.cannot("update", subject("Space", ctx.space))) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You do not have permission to update this space", - }); - } - - await prisma.space.update({ - where: { id: ctx.space.id }, - data: { primaryColor: input.primaryColor }, - }); - }), - updateShowBranding: spaceProcedure .input(z.object({ showBranding: z.boolean() })) .mutation(async ({ ctx, input }) => { @@ -351,6 +337,14 @@ export const spaces = router({ where: { id: ctx.space.id }, data: { showBranding: input.showBranding }, }); + + posthog()?.groupIdentify({ + groupType: "space", + groupKey: ctx.space.id, + properties: { + custom_branding_enabled: input.showBranding, + }, + }); }), inviteMember: spaceProcedure