Skip to content
Closed
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
14 changes: 12 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,16 @@
"viewPoll": "View poll",
"responseOptions": "Response Options",
"viewResults": "View results",
"pollClosedDescription": "No more responses are being accepted."
"pollClosedDescription": "No more responses are being accepted.",
"customBrandingAlertTitle": "Show your brand to participants",
"pollAdminCustomBrandingAlertDescription": "Upgrade to Pro to show your logo and brand colors to participants.",
"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",
"brandingEnabled": "Custom branding enabled",
"brandingDisabled": "Custom branding disabled",
"customBranding": "Custom Branding",
"customBrandingDescription": "Show your logo and brand colors to your participants"
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PollViewTracker } from "@/components/poll/poll-view-tracker";
import { ResponsiveResults } from "@/components/poll/responsive-results";
import { VotingForm } from "@/components/poll/voting-form";
import { usePoll } from "@/contexts/poll";
import { CustomBrandingAlert } from "./custom-branding-alert";
import { GuestPollAlert } from "./guest-poll-alert";

export function AdminPage() {
Expand All @@ -15,13 +16,21 @@ export function AdminPage() {

return (
<div className="space-y-3 lg:space-y-4">
{poll.space?.showBranding && poll.space.primaryColor ? (
{poll.space?.showBranding && poll.space?.primaryColor ? (
<PollBranding primaryColor={poll.space.primaryColor} />
) : null}
{/* Track poll views */}
<PollViewTracker pollId={poll.id} />
<GuestPollAlert />
<EventCard />
<CustomBrandingAlert />
<EventCard
name={poll.space?.name ?? ""}
logoUrl={
poll.space?.showBranding
? (poll.space?.image ?? undefined)
: undefined
}
/>
<VotingForm>
<ResponsiveResults />
</VotingForm>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";

import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@rallly/ui/alert";
import { Button } from "@rallly/ui/button";
import { PaletteIcon } from "lucide-react";
import { usePoll } from "@/contexts/poll";
import { useBilling } from "@/features/billing/client";
import { Trans } from "@/i18n/client";

export function CustomBrandingAlert() {
const poll = usePoll();
const { isFree, showPayWall } = useBilling();

if (!isFree || !poll.space || poll.space.showBranding) {
return null;
}

return (
<Alert variant="primary">
<PaletteIcon />
<AlertTitle>
<Trans
i18nKey="customBrandingAlertTitle"
defaults="Show your brand to participants"
/>
</AlertTitle>
<AlertDescription>
<p className="text-sm">
<Trans
i18nKey="pollAdminCustomBrandingAlertDescription"
defaults="Upgrade to Pro to show your logo and brand colors to participants."
/>
</p>
</AlertDescription>
<AlertAction>
<Button size="sm" variant="default" onClick={showPayWall}>
<Trans i18nKey="upgrade" defaults="Upgrade" />
</Button>
</AlertAction>
</Alert>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";

import { Button } from "@rallly/ui/button";
import { ColorPicker, parseColor } from "@rallly/ui/color-picker";
import { Field, FieldGroup, FieldLabel } from "@rallly/ui/field";
import { toast } from "@rallly/ui/sonner";
import React from "react";
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionHeader,
PageSectionTitle,
} from "@/app/components/page-layout";
import { ProBadge } from "@/components/pro-badge";
import { DEFAULT_PRIMARY_COLOR } from "@/features/branding/constants";
import { useSpace } from "@/features/space/client";
import { Trans, useTranslation } from "@/i18n/client";
import { trpc } from "@/trpc/client";
import { CustomBrandingSwitch } from "./custom-branding-switch";

export function CustomBrandingSection() {
const { data: space } = useSpace();
const { t } = useTranslation();
const currentColor = space.primaryColor ?? DEFAULT_PRIMARY_COLOR;
const [color, setColor] = React.useState(() => parseColor(currentColor));
const hexColor = color.toString("hex");
const isDirty = hexColor !== currentColor;

const updateSpace = trpc.spaces.update.useMutation();
const utils = trpc.useUtils();

const handleSave = async () => {
const value = hexColor === DEFAULT_PRIMARY_COLOR ? null : hexColor;
toast.promise(
updateSpace
.mutateAsync({ name: space.name, primaryColor: value })
.then(() => utils.spaces.getCurrent.invalidate()),
{
loading: t("savingBranding", { defaultValue: "Saving..." }),
success: t("brandingSaved", { defaultValue: "Branding saved" }),
error: t("brandingSaveError", {
defaultValue: "Failed to save branding",
}),
},
);
};

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">
<CustomBrandingSwitch checked={space.showBranding} />
<FieldLabel>
<Trans
i18nKey="useCustomBranding"
defaults="Enable Custom Branding"
/>
{space.tier !== "pro" && <ProBadge />}
</FieldLabel>
</Field>
<div
className={
!space.showBranding ? "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={!isDirty}
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
@@ -0,0 +1,50 @@
"use client";

import { toast } from "@rallly/ui/sonner";
import { Switch } from "@rallly/ui/switch";
import { useBilling } from "@/features/billing/client";
import { useTranslation } from "@/i18n/client";
import { trpc } from "@/trpc/client";

export function CustomBrandingSwitch({
checked,
onChange,
}: {
checked: boolean;
onChange?: (checked: boolean) => void;
}) {
const { t } = useTranslation();
const { isFree, showPayWall } = useBilling();
const updateShowBranding = trpc.spaces.updateShowBranding.useMutation();
const utils = trpc.useUtils();

const handleToggle = (checked: boolean) => {
if (isFree) {
showPayWall();
return;
}
toast.promise(
updateShowBranding
.mutateAsync({ showBranding: checked })
.then(() => utils.spaces.getCurrent.invalidate()),
{
loading: t("savingBranding", { defaultValue: "Saving..." }),
success: checked
? t("brandingEnabled", { defaultValue: "Custom branding enabled" })
: t("brandingDisabled", { defaultValue: "Custom branding disabled" }),
error: t("brandingSaveError", {
defaultValue: "Failed to save branding",
}),
},
);
onChange?.(checked);
};
Comment on lines +21 to +41
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.

⚠️ Potential issue | 🟡 Minor

onChange callback fires before mutation completes.

onChange?.(checked) on line 40 executes immediately after starting the mutation, not after it succeeds. If the mutation fails, the parent component's state may be inconsistent with the server state.

Consider moving onChange into the .then() chain or removing it entirely since utils.spaces.getCurrent.invalidate() already refreshes the data.

🐛 Proposed fix
   const handleToggle = (checked: boolean) => {
     if (isFree) {
       showPayWall();
       return;
     }
     toast.promise(
       updateShowBranding
         .mutateAsync({ showBranding: checked })
-        .then(() => utils.spaces.getCurrent.invalidate()),
+        .then(() => {
+          utils.spaces.getCurrent.invalidate();
+          onChange?.(checked);
+        }),
       {
         loading: t("savingBranding", { defaultValue: "Saving..." }),
         success: checked
           ? t("brandingEnabled", { defaultValue: "Custom branding enabled" })
           : t("brandingDisabled", { defaultValue: "Custom branding disabled" }),
         error: t("brandingSaveError", {
           defaultValue: "Failed to save branding",
         }),
       },
     );
-    onChange?.(checked);
   };
🤖 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-switch.tsx
around lines 21 - 41, The onChange callback in handleToggle fires immediately
instead of after the mutation settles, causing possible UI-server state
mismatch; move the onChange?.(checked) call into the success path of the
mutation (e.g., chain it inside the promise .then() after
updateShowBranding.mutateAsync and utils.spaces.getCurrent.invalidate()), or
remove the call entirely and rely on utils.spaces.getCurrent.invalidate() to
refresh state—update handleToggle to only invoke onChange after the mutation and
invalidation complete.


return (
<Switch
checked={checked}
onCheckedChange={handleToggle}
disabled={updateShowBranding.isPending}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

import { Button } from "@rallly/ui/button";
import { ColorPicker, parseColor } from "@rallly/ui/color-picker";
import { toast } from "@rallly/ui/sonner";
import React from "react";
import { DEFAULT_PRIMARY_COLOR } from "@/features/branding/constants";
import type { SpaceDTO } from "@/features/space/types";
import { Trans, useTranslation } from "@/i18n/client";
import { trpc } from "@/trpc/client";

export function SpaceBrandingForm({ space }: { space: SpaceDTO }) {
const { t } = useTranslation();
const currentColor = space.primaryColor ?? DEFAULT_PRIMARY_COLOR;
const [color, setColor] = React.useState(() => parseColor(currentColor));
const hexColor = color.toString("hex");
const isDirty = hexColor !== currentColor;

const updateSpace = trpc.spaces.update.useMutation();
const utils = trpc.useUtils();

const handleSave = async () => {
const value = hexColor === DEFAULT_PRIMARY_COLOR ? null : hexColor;
toast.promise(
updateSpace
.mutateAsync({ name: space.name, primaryColor: value })
.then(() => utils.spaces.getCurrent.invalidate()),
{
loading: t("savingBranding", { defaultValue: "Saving..." }),
success: t("brandingSaved", { defaultValue: "Branding saved" }),
error: t("brandingSaveError", {
defaultValue: "Failed to save branding",
}),
},
);
};

return (
<div
className={
!space.showBranding ? "pointer-events-none opacity-50" : undefined
}
>
<div className="w-56 space-y-4">
<ColorPicker value={color} onChange={setColor} />
<Button
onClick={handleSave}
disabled={!space.showBranding || !isDirty}
loading={updateSpace.isPending}
>
<Trans i18nKey="save" defaults="Save" />
</Button>
</div>
</div>
);
}
Loading
Loading