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 (
-
-
+
+
+
);
}
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