Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/web/public/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
@@ -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 }));
};
Comment on lines +56 to +63
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Free-tier guard currently blocks turning branding off.

Line 55 applies the paywall guard regardless of target state, so free users with showBranding=true cannot disable it.

💡 Suggested fix
   const handleToggle = (newChecked: boolean) => {
-    if (isFree) {
+    if (newChecked && isFree) {
       showPayWall();
       return;
     }
     toastProgress(updateShowBranding.mutateAsync({ showBranding: newChecked }));
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleToggle = (newChecked: boolean) => {
if (isFree) {
showPayWall();
return;
}
toastProgress(updateShowBranding.mutateAsync({ showBranding: newChecked }));
};
const handleToggle = (newChecked: boolean) => {
if (newChecked && isFree) {
showPayWall();
return;
}
toastProgress(updateShowBranding.mutateAsync({ showBranding: newChecked }));
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/app/`[locale]/(space)/settings/general/components/custom-branding-section.tsx
around lines 54 - 60, The free-tier guard in handleToggle currently blocks all
toggles; change it so it only blocks attempts to turn branding off. Update the
condition in handleToggle (referencing isFree, newChecked, and showPayWall) to
call showPayWall() and return only when isFree && newChecked === false,
otherwise proceed to call toastProgress with updateShowBranding.mutateAsync({
showBranding: newChecked }).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a space gets downgraded we disable show branding so this shouldn't happen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukevella, that makes sense — since downgrading automatically disables showBranding, a free-tier space can never have it enabled in the first place, so the guard is correctly placed. Thanks for the clarification!


✏️ Learnings added
Learnt from: lukevella
Repo: lukevella/rallly PR: 2321
File: apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx:54-60
Timestamp: 2026-04-09T10:38:54.240Z
Learning: In the Rallly codebase (`apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx`), when a space is downgraded from a paid tier to free, `showBranding` is automatically set to `false`. Therefore, a free-tier space can never have `showBranding=true`, and the paywall guard in `handleToggle` (blocking all toggles for `isFree` users) is intentional and correct — free users will never need to turn branding off.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


const handleSave = async () => {
const value = hexColor === DEFAULT_PRIMARY_COLOR ? null : hexColor;
toastProgress(updateSpace.mutateAsync({ primaryColor: value }));
};

return (
<PageSection variant="card">
<PageSectionHeader>
<PageSectionTitle>
<Trans i18nKey="branding" defaults="Branding" />
</PageSectionTitle>
<PageSectionDescription>
<Trans
i18nKey="showBrandingDescription"
defaults="Show your brand identity on your public pages and emails"
/>
</PageSectionDescription>
</PageSectionHeader>
<PageSectionContent>
<FieldGroup>
<Field orientation="horizontal">
<Switch
checked={space.showBranding}
onCheckedChange={handleToggle}
disabled={disabled || updateShowBranding.isPending}
/>
<FieldLabel>
<Trans
i18nKey="useCustomBranding"
defaults="Enable Custom Branding"
/>
{space.tier !== "pro" && <ProBadge />}
</FieldLabel>
</Field>
<div
className={
!space.showBranding || disabled
? "pointer-events-none opacity-50"
: undefined
}
>
<FieldGroup>
<Field>
<FieldLabel>
<Trans i18nKey="primaryColor" defaults="Primary Color" />
</FieldLabel>
<ColorPicker value={color} onChange={setColor} />
</Field>
<Field orientation="horizontal">
<Button
onClick={handleSave}
disabled={!space.showBranding || !isDirty || disabled}
loading={updateSpace.isPending}
>
<Trans i18nKey="save" defaults="Save" />
</Button>
</Field>
</FieldGroup>
</div>
</FieldGroup>
</PageSectionContent>
</PageSection>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,69 +62,76 @@ export function SpaceSettingsForm({
};

return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (data) => {
toast.promise(
updateSpace.mutateAsync({
<form
onSubmit={form.handleSubmit(async (data) => {
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"
>
<div>
<FormLabel>
<Trans i18nKey="image" defaults="Image" />
</FormLabel>
<ImageUpload className="mt-2">
<ImageUploadPreview>
<SpaceIcon name={space.name} src={space.image} size="xl" />
</ImageUploadPreview>
<ImageUploadControl
getUploadUrl={(input) => getImageUploadUrl.mutateAsync(input)}
onUploadSuccess={handleImageUploadSuccess}
onRemoveSuccess={handleImageRemoveSuccess}
hasCurrentImage={!!space.image}
/>
</ImageUpload>
</div>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="name" defaults="Name" />
</FormLabel>
<FormControl>
<Input
className="w-72"
placeholder={t("spaceNamePlaceholder", {
defaultValue: "My Team",
})}
disabled={disabled}
{...field}
{
loading: t("updatingSpace", {
defaultValue: "Updating space...",
}),
success: t("spaceUpdatedSuccess", {
defaultValue: "Space updated successfully",
}),
error: t("spaceUpdatedError", {
defaultValue: "Failed to update space",
}),
},
);
})}
>
<FieldGroup>
<FieldSet>
<FieldGroup>
<Field>
<FieldLabel>
<Trans i18nKey="logo" defaults="Logo" />
</FieldLabel>
<ImageUpload>
<ImageUploadPreview>
<SpaceIcon name={space.name} src={space.image} size="xl" />
</ImageUploadPreview>
<ImageUploadControl
getUploadUrl={(input) => getImageUploadUrl.mutateAsync(input)}
onUploadSuccess={handleImageUploadSuccess}
onRemoveSuccess={handleImageRemoveSuccess}
hasCurrentImage={!!space.image}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
</ImageUpload>
</Field>
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>
<Trans i18nKey="name" defaults="Name" />
</FieldLabel>
<Input
{...field}
disabled={disabled}
className="w-48"
placeholder={t("spaceNamePlaceholder", {
defaultValue: "My Team",
})}
id={field.name}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</FieldSet>
<Field orientation="horizontal">
<Button
type="submit"
disabled={
Expand All @@ -134,8 +140,8 @@ export function SpaceSettingsForm({
>
<Trans i18nKey="save" defaults="Save" />
</Button>
</div>
</form>
</Form>
</Field>
</FieldGroup>
</form>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -64,6 +65,7 @@ export function GeneralSettingsPageClient() {
<SpaceSettingsForm space={space} disabled={!isAdmin} />
</PageSectionContent>
</PageSection>
<CustomBrandingSection disabled={!isAdmin} />
{!isOwner ? (
<PageSection variant="card">
<PageSectionHeader>
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/features/billing/components/pay-wall-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
ClockIcon,
CopyIcon,
LifeBuoyIcon,
PaletteIcon,
SettingsIcon,
SparklesIcon,
TimerResetIcon,
Expand Down Expand Up @@ -304,6 +305,21 @@ export function PayWallDialog({
<Trans i18nKey="keyBenefits" defaults="Key Benefits" />
</SubHeading>
<KeyBenefits>
<KeyBenefitsItem
icon={<PaletteIcon />}
title={
<Trans
i18nKey="customBranding"
defaults="Custom Branding"
/>
}
description={
<Trans
i18nKey="customBrandingDescription"
defaults="Show your logo and brand colors to your participants"
/>
}
/>
<KeyBenefitsItem
icon={<CalendarCheckIcon />}
title={
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/features/space/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RouterLoadingIndicator />;
}

const primaryColorVars = space.primaryColor
? getPrimaryColorVars(space.primaryColor)
: null;
const primaryColorVars =
space.showBranding && space.primaryColor
? getPrimaryColorVars(space.primaryColor)
: null;

return (
<>
Expand Down
Loading
Loading