Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e7ed5db
chore(prd): add prd to implement Family brandingConfig for logotype f…
babblebey Jul 9, 2026
fd89308
feat(branding): update logotype formatting and related tests for Fami…
babblebey Jul 9, 2026
a6e8631
feat(branding): add brandingConfig to Family model and implement logo…
babblebey Jul 9, 2026
d48ad70
feat(branding): implement brandingConfig handling in invite router an…
babblebey Jul 9, 2026
0172f98
feat(branding): implement logotype font selection and preview in Fami…
babblebey Jul 9, 2026
79775d2
feat(branding): enhance logotype preview styling and add text stroke …
babblebey Jul 9, 2026
1f82b69
feat(branding): integrate Logo component for enhanced logotype previe…
babblebey Jul 9, 2026
9406601
feat(branding): implement FamilyLogotypeLockup component and integrat…
babblebey Jul 9, 2026
6c69a36
feat(branding): update FamilySettingsPage to use hydrated branding co…
babblebey Jul 9, 2026
e638c2b
feat(branding): add font loader for dynamic logotype font stylesheets
babblebey Jul 9, 2026
5e1883d
feat(branding): remove unused logotype font names from the list
babblebey Jul 9, 2026
2584a8f
feat(branding): mark tasks as completed and update brandingConfig han…
babblebey Jul 9, 2026
916931e
feat(branding): adjust padding in SheetHeader for improved layout in …
babblebey Jul 9, 2026
6743c92
feat(branding): adjust margin and spacing in DesktopSidebar for impro…
babblebey Jul 9, 2026
c85136e
feat(branding): update logotype font section text for clarity
babblebey Jul 9, 2026
d8c8b5a
feat(branding): enhance logotype preview layout and styling for bette…
babblebey Jul 9, 2026
f756042
chore(prd): add reference to implementation pull request for family l…
babblebey Jul 9, 2026
93b3205
feat(branding): adjust padding in SheetHeader based on logotype font …
babblebey Jul 10, 2026
ffdaa1d
feat(branding): implement dropdown menu for mobile settings navigatio…
babblebey Jul 10, 2026
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
231 changes: 231 additions & 0 deletions .project/prds/030-family-branded-instance-phase2/prd.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Family" ADD COLUMN "brandingConfig" JSONB;
29 changes: 15 additions & 14 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -83,22 +83,23 @@ enum NotificationDeliveryStatus {
}

model Family {
id String @id @default(cuid())
name String
slug String @unique
image String?
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
members FamilyMember[]
invites Invite[]
domains Domain[]
features Ffeature[]
id String @id @default(cuid())
name String
slug String @unique
image String?
description String?
brandingConfig Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
members FamilyMember[]
invites Invite[]
domains Domain[]
features Ffeature[]
integrationCredentials IntegrationCredential[]
notifications Notification[]
notifications Notification[]
notificationPreferences NotificationPreference[]
pushSubscriptions PushSubscription[]
pushSubscriptions PushSubscription[]
}

model Domain {
Expand Down
108 changes: 108 additions & 0 deletions src/app/(app)/settings/family/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@ import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { AlertCircle, Camera, Loader } from "~/components/ui/icons";

import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { FamilyLogotypeLockup } from "~/components/branding/family-logotype-lockup";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Logo } from "~/components/ui/logo";
import { Skeleton } from "~/components/ui/skeleton";
import { tryParseBrandingConfig } from "~/lib/branding/branding-config";
import {
LOGOTYPE_FONT_NAMES,
LOGOTYPE_FONT_PROVIDER,
resolveLogotypeFontName,
} from "~/lib/branding/logotype-fonts";
import { compressImage, createInstantPreviewUrl, resolveMediaMimeType } from "~/lib/media-compression";
import { api } from "~/trpc/react";

Expand All @@ -36,6 +44,7 @@ const managementContextSchema = z.object({
name: z.string(),
description: z.string().nullable(),
image: z.string().nullable(),
brandingConfig: z.unknown().nullable().optional(),
})
.nullable(),
role: z.enum(["OWNER", "ADMIN", "MEMBER"]).nullable(),
Expand Down Expand Up @@ -99,6 +108,7 @@ export default function FamilySettingsPage() {
const [selectedImagePreviewUrl, setSelectedImagePreviewUrl] = useState<string | null>(null);
const [isPreviewConverting, setIsPreviewConverting] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [selectedLogotypeFontName, setSelectedLogotypeFontName] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState<string | null>(null);
Expand Down Expand Up @@ -126,6 +136,18 @@ export default function FamilySettingsPage() {
const familyId = managementContextData?.family?.id;
const canManageFamilyIdentity =
managementContextData?.role === "ADMIN" || managementContextData?.role === "OWNER";
const brandingConfig = managementContextData?.family?.brandingConfig ?? null;
const resolvedSelectedLogotypeFont =
resolveLogotypeFontName(selectedLogotypeFontName);
const resolvedExistingBrandingConfig = tryParseBrandingConfig(brandingConfig);
const hasInvalidBrandingConfig = brandingConfig !== null && resolvedExistingBrandingConfig === null;
const logotypeFontMetadata = useMemo(
() => ({
provider: LOGOTYPE_FONT_PROVIDER,
names: [...LOGOTYPE_FONT_NAMES],
}),
[],
);
const previewImage = selectedImagePreviewUrl ?? familyImageUrl;
const previewName = familyName.trim().length > 0 ? familyName : "Family";

Expand All @@ -135,9 +157,16 @@ export default function FamilySettingsPage() {
return;
}

const hydratedBrandingConfig = tryParseBrandingConfig(family.brandingConfig ?? null);

setFamilyName(family.name);
setFamilyDescription(family.description ?? "");
setFamilyImageUrl(family.image ?? "");
setSelectedLogotypeFontName(
hydratedBrandingConfig?.logotype.enabled
? (hydratedBrandingConfig.logotype.fontName ?? "")
: "",
);
setSaveError(null);
setIsPreviewConverting(false);
}, [managementContextData?.family]);
Expand Down Expand Up @@ -296,6 +325,16 @@ export default function FamilySettingsPage() {
description:
normalizedFamilyDescription.length > 0 ? normalizedFamilyDescription : null,
image: nextFamilyImageUrl.length > 0 ? nextFamilyImageUrl : null,
brandingConfig: resolvedSelectedLogotypeFont
? {
version: 1,
logotype: {
enabled: true,
fontName: resolvedSelectedLogotypeFont,
fontProvider: LOGOTYPE_FONT_PROVIDER,
},
}
: null,
});

await trpcUtils.invite.getManagementContext.invalidate();
Expand Down Expand Up @@ -454,6 +493,75 @@ export default function FamilySettingsPage() {
<p className="text-muted-foreground text-xs">{familyDescription.length}/500</p>
</div>

<div className="space-y-3 rounded-2xl border bg-muted/10 p-4">
<div className="space-y-1">
<p className="font-medium text-sm">Family logotype</p>
<p className="text-muted-foreground text-xs">
Choose a style for how your family name appears across your space.
</p>
</div>

<div className="space-y-1.5">
<label htmlFor="family-logotype-font" className="text-sm font-medium">
Style
</label>
<select
id="family-logotype-font"
value={selectedLogotypeFontName}
onChange={(event) => {
setSelectedLogotypeFontName(event.target.value);
setSaveError(null);
setSaveSuccess(null);
}}
className="block h-10 w-full rounded-2xl border border-input bg-background px-3 text-sm text-foreground shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSaving || !canManageFamilyIdentity || !familyId}
>
<option value="">Use default Fircle lockup (disable custom logotype)</option>
{logotypeFontMetadata.names.map((fontName) => (
<option key={fontName} value={fontName}>
{fontName}
</option>
))}
</select>
</div>

<div className="space-y-2 rounded-xl border bg-card/80 p-4">
<p className="text-muted-foreground text-xs">Live preview</p>
<div className="inline-flex max-w-full items-center gap-2 rounded-lg border bg-background px-3 py-3 text-base sm:gap-3 sm:px-5 sm:py-4 sm:text-lg">
{resolvedSelectedLogotypeFont ? (
<FamilyLogotypeLockup
familyName={previewName}
fontName={resolvedSelectedLogotypeFont}
className="px-3 sm:px-6"
familyNameClassName="text-5xl sm:text-7xl"
leadingClassName="text-sm sm:text-base"
trailingClassName="text-sm sm:text-base translate-y-[70%] sm:translate-y-[95%]"
/>
) : (
<>
<span className="flex items-center gap-3 sm:gap-4">
<Logo className="h-6 w-auto text-foreground sm:h-8" aria-hidden="true" />
<span className="font-semibold text-xl leading-none tracking-tight sm:text-2xl">Fircle</span>
</span>
<span className="text-muted-foreground text-xs sm:text-sm">Default fallback preview</span>
</>
)}
</div>
</div>

{hasInvalidBrandingConfig ? (
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-amber-700 text-xs dark:text-amber-200">
Your saved logotype configuration is invalid or uses a font outside the allowlist. Choose a new allowlisted font and save to fix it.
</p>
) : null}

{!resolvedSelectedLogotypeFont ? (
<p className="text-muted-foreground text-xs">
Custom logotype is disabled. Navigation surfaces will use the fallback Fircle lockup.
</p>
) : null}
</div>

{saveError ? (
<p role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{saveError}
Expand Down
65 changes: 55 additions & 10 deletions src/app/(app)/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import { usePathname, useRouter } from "next/navigation";

import { beginNavigationProgress } from "~/components/nav/navigation-progress";
import { LogoutButton } from "~/components/auth/logout-button";
import { Logout } from "~/components/ui/icons";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Logout, More } from "~/components/ui/icons";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";

Expand Down Expand Up @@ -93,6 +100,19 @@ export default function SettingsLayout({
return true;
});

const activeSettingsHref =
visibleSettingsNav.find((item) => isActivePath(pathname, item.href))?.href ??
visibleSettingsNav[0]?.href ??
"/settings";
const activeSettingsLabel =
visibleSettingsNav.find((item) => item.href === activeSettingsHref)?.label ?? "Settings";

function navigateToSettings(href: string) {
if (href === pathname) return;
beginNavigationProgress();
router.push(href);
}

return (
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
Expand All @@ -104,17 +124,42 @@ export default function SettingsLayout({
</div>

<div className="flex flex-col gap-6 md:flex-row">
{/* Settings side nav — desktop */}
<nav
aria-label="Settings navigation"
className="shrink-0 md:w-52"
>
{/* Mobile: horizontal tab strip */}
<ul className="flex gap-1 overflow-x-auto rounded-xl border bg-card/60 p-1 md:flex-col md:overflow-x-visible">
<div className="md:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="text-sm w-full justify-between rounded-xl border bg-card/60 text-foreground"
aria-label="Settings navigation"
>
<span className="truncate">{activeSettingsLabel}</span>
<More aria-hidden className="size-4 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-(--radix-dropdown-menu-trigger-width) rounded-xl" align="start">
{visibleSettingsNav.map((item) => {
const active = item.href === activeSettingsHref;
return (
<DropdownMenuItem
key={item.href}
className={cn("cursor-pointer", active && "bg-muted text-foreground")}
onSelect={() => navigateToSettings(item.href)}
>
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>

<nav aria-label="Settings navigation" className="hidden shrink-0 md:block md:w-52">
<ul className="flex gap-1 rounded-xl border bg-card/60 p-1 md:flex-col">
{visibleSettingsNav.map((item) => {
const active = isActivePath(pathname, item.href);
return (
<li key={item.href} className="min-w-0 flex-1 md:flex-none">
<li key={item.href} className="min-w-0 md:flex-none">
<Link
href={item.href}
aria-current={active ? "page" : undefined}
Expand All @@ -125,7 +170,7 @@ export default function SettingsLayout({
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
)}
>
<span className="block truncate whitespace-nowrap">{item.label}</span>
<span className="block truncate">{item.label}</span>
</Link>
</li>
);
Expand Down
21 changes: 18 additions & 3 deletions src/components/auth/membership-guard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import { useEffect, useRef } from "react";
import { signOut } from "next-auth/react";
import { FamilyLogotypeLockup } from "~/components/branding/family-logotype-lockup";
import { Loader } from "~/components/ui/icons";
import { Logo } from "~/components/ui/logo";
import { tryParseBrandingConfig } from "~/lib/branding/branding-config";
import { normalizeFamilyNameInput } from "~/lib/family-name";

import { api } from "~/trpc/react";

Expand All @@ -21,6 +24,11 @@ export function MembershipGuard({
refetchOnWindowFocus: false,
});

const parsedBrandingConfig = tryParseBrandingConfig(managementContext.data?.family?.brandingConfig ?? null);
const selectedLogotypeFontName =
parsedBrandingConfig?.logotype.enabled ? (parsedBrandingConfig.logotype.fontName ?? null) : null;
const selectedFamilyName = normalizeFamilyNameInput(managementContext.data?.family?.name ?? "") || "Family";

const hasFamilyMembership = Boolean(managementContext.data?.family?.id);

useEffect(() => {
Expand All @@ -36,10 +44,17 @@ export function MembershipGuard({
return (
<div className="flex flex-col min-h-dvh items-center px-4 text-center text-foreground text-sm">
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<div className="flex items-center justify-center gap-2 text-foreground">
<Logo className="h-6 w-auto shrink-0" aria-hidden="true" />
{selectedLogotypeFontName ? (
<FamilyLogotypeLockup
familyName={selectedFamilyName}
fontName={selectedLogotypeFontName}
familyNameClassName="text-4xl"
leadingClassName="text-xs"
trailingClassName="text-xs"
/>
) : (
<p className="font-semibold text-base leading-none tracking-tight">{primaryLockup}</p>
</div>
)}
<Loader className="size-6 animate-spin" />
</div>
<div className="items-end pb-6">
Expand Down
Loading
Loading