Skip to content
Open
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
121 changes: 67 additions & 54 deletions openframe-frontend-core/src/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,14 @@ function UniversalFooter({ config, renderLink }: { config: FooterConfig; renderL

return (
<footer className={`w-full flex flex-col justify-center items-center ${config.backgroundColor || 'bg-ods-card'} px-6 py-10 relative gap-6 md:gap-6 min-h-[auto] md:min-h-[248px] z-[44] border-t border-ods-border`}>
<div className="w-full grid grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8 items-start">

{/* Column 1: Logo and optionally description */}
<div className="flex flex-col gap-4 md:gap-6 items-start text-left col-span-2 md:col-span-1 lg:col-span-1">
<div className="w-full flex flex-col-reverse md:flex-row gap-6 md:gap-8 items-start">

{/* Brand column — extracted OUT of the sections grid. Kept inside the
grid, a platform with 4 link sections needs 5 columns (logo + 4) and
overflowed the 4-col grid. As its own flex item (flex-1) beside the
sections grid (flex-[4] + lg:grid-cols-4) it becomes an equal 5th
column on desktop; the grid then holds only the link sections. */}
<div className="flex flex-col gap-4 md:gap-6 items-start text-left w-full md:flex-1">
{/* Logo and name */}
<div className="flex items-center gap-2">
{config.logo && (
Expand Down Expand Up @@ -129,59 +133,68 @@ function UniversalFooter({ config, renderLink }: { config: FooterConfig; renderL
</Suspense>
)}
</div>

{/* Dynamic sections - 1 column each on all screens */}
{config.sections.map((section, index) => (
<div key={index} className="flex flex-col gap-3 items-start text-left col-span-1">
<h3 className="text-h5 tracking-[-0.02em] text-ods-text-muted">
{section.title}
</h3>
<div className="flex flex-col gap-3">
{section.links.map((link, linkIndex) => (
<Suspense key={linkIndex} fallback={<NavLinkSkeleton />}>
{linkRenderer(link) as any}
</Suspense>
))}
</div>
</div>
))}

{/* Custom component column - full width on mobile and medium, 1 column on large */}
{config.customComponent && (
<div className="flex flex-col col-span-2 md:col-span-1 lg:col-span-1 justify-center">
<Suspense fallback={<Skeleton className="h-32 w-full" />}>
{config.customComponent as any}
</Suspense>
</div>
)}

{/* Right column content - shows if rightColumnContent is provided OR if moving description to right */}
{(config.rightColumnContent || config.moveDescriptionToRight) && (
<div className="flex flex-col col-span-2 md:col-span-1 lg:col-span-1 justify-start gap-4 md:gap-6">
{/* Show description in right column if moveDescriptionToRight is true */}
{config.moveDescriptionToRight && (
<>
<p className="font-body font-medium text-sm md:text-sm leading-[1.43] text-ods-text-primary">
{config.description}
</p>

{/* Custom content below description - only if NOT keeping it on left */}
{config.belowDescriptionContent && !config.keepBelowDescriptionLeft && (
<Suspense fallback={<Skeleton className="h-8 w-full" />}>
{config.belowDescriptionContent as any}

{/* Sections (+ optional custom / right columns) grid.
Tablet: same width as the brand column (both flex-1 → 50/50), split
into 2 columns (2x2 for 4 sections).
Desktop: widens to the remaining 4/5 (flex-[4]) as 4 equal columns,
so brand + 4 sections read as 5 equal columns.
Mobile: full width, 2 columns. */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8 items-start w-full md:flex-1 lg:flex-[4] min-w-0">

{/* Dynamic sections - 1 column each on all screens */}
{config.sections.map((section, index) => (
<div key={index} className="flex flex-col gap-3 items-start text-left col-span-1">
<h3 className="text-h5 tracking-[-0.02em] text-ods-text-muted">
{section.title}
</h3>
<div className="flex flex-col gap-3">
{section.links.map((link, linkIndex) => (
<Suspense key={linkIndex} fallback={<NavLinkSkeleton />}>
{linkRenderer(link) as any}
</Suspense>
)}
</>
)}

{/* Regular right column content */}
{config.rightColumnContent && (
))}
</div>
</div>
))}

{/* Custom component column - full width on mobile and medium, 1 column on large */}
{config.customComponent && (
<div className="flex flex-col col-span-2 md:col-span-1 lg:col-span-1 justify-center">
<Suspense fallback={<Skeleton className="h-32 w-full" />}>
{config.rightColumnContent as any}
{config.customComponent as any}
</Suspense>
)}
</div>
)}
</div>
)}

{/* Right column content - shows if rightColumnContent is provided OR if moving description to right */}
{(config.rightColumnContent || config.moveDescriptionToRight) && (
<div className="flex flex-col col-span-2 md:col-span-1 lg:col-span-1 justify-start gap-4 md:gap-6">
{/* Show description in right column if moveDescriptionToRight is true */}
{config.moveDescriptionToRight && (
<>
<p className="font-body font-medium text-sm md:text-sm leading-[1.43] text-ods-text-primary">
{config.description}
</p>

{/* Custom content below description - only if NOT keeping it on left */}
{config.belowDescriptionContent && !config.keepBelowDescriptionLeft && (
<Suspense fallback={<Skeleton className="h-8 w-full" />}>
{config.belowDescriptionContent as any}
</Suspense>
)}
</>
)}

{/* Regular right column content */}
{config.rightColumnContent && (
<Suspense fallback={<Skeleton className="h-32 w-full" />}>
{config.rightColumnContent as any}
</Suspense>
)}
</div>
)}
</div>
</div>

{/* Copyright */}
Expand Down
5 changes: 3 additions & 2 deletions openframe-frontend-core/src/components/social-icon-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { Mail } from 'lucide-react';
import { Button } from './ui/button';
import { GitHubIcon, RedditIcon, XLogo, LinkedInIcon, LumaIcon, WhatsAppIcon, GlobeIcon, MessageCircleIcon, TelegramIcon, YouTubeIcon, InstagramIcon, FacebookIcon, SlackIcon, CopyIcon } from './icons';
import { GitHubIcon, XLogo, LinkedInIcon, LumaIcon, WhatsAppIcon, GlobeIcon, MessageCircleIcon, TelegramIcon, YouTubeIcon, InstagramIcon, FacebookIcon, SlackIcon, CopyIcon } from './icons';
import { RedditLogoIcon } from '@/components/icons-v2-generated';

/** Exactly ONE of `href` (anchor, target _blank) or `onClick` (action
* button — share popups via window.open inside the click gesture,
Expand Down Expand Up @@ -52,7 +53,7 @@ function renderSocialIcon(platform: string) {
case 'x':
return <XLogo className="w-5 h-5" />;
case 'reddit':
return <RedditIcon className="w-5 h-5" variant="white" />;
return <RedditLogoIcon className="w-5 h-5" />;
case 'linkedin':
return <LinkedInIcon className="w-5 h-5" />;
case 'luma':
Expand Down
181 changes: 181 additions & 0 deletions openframe-frontend-core/src/stories/Footer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Footer } from '../components/footer';
import { FlamingoLogo } from '../components/icons';

const meta = {
title: 'Navigation/Footer',
component: Footer,
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<div style={{ minHeight: '400px', background: 'var(--ods-system-greys-background, #161616)' }}>
<Story />
</div>
),
],
} satisfies Meta<typeof Footer>;

export default meta;
type Story = StoryObj<typeof meta>;

const logo = <FlamingoLogo fill="var(--ods-flamingo-pink-base)" className="flex-shrink-0 w-8 h-8" />;

const social = {
github: '#',
twitter: '#',
reddit: '#',
};

const baseConfig = {
name: 'Flamingo',
legalName: 'Flamingo AI, Inc.',
description:
'AI-driven open-source OS for MSPs. Swap bloated vendor tools for open ones. Automate the boring crap. Take your margin back.',
logo,
social,
};

/** Dummy links — Storybook only exercises layout, so nothing needs to navigate. */
const link = (label: string) => ({ href: '#', label });

const productsSection = {
title: 'PRODUCTS',
links: ['OpenFrame', 'Customers', 'Partners', 'Releases', 'Knowledge Hub'].map(link),
};

const companySection = {
title: 'COMPANY',
links: ['Flamingo', 'About', 'Blog', 'Webinars', 'Media'].map(link),
};

const communitySection = {
title: 'COMMUNITY',
links: ['OpenMSP', 'Community', 'OpenMSP Podcast'].map(link),
};

const supportSection = {
title: 'SUPPORT',
links: ['Contact Us', 'Privacy Policy', 'Terms of Service'].map(link),
};

const PromoCard = () => (
<div className="bg-ods-bg border border-ods-border rounded-lg p-6 w-full">
<p className="font-body font-bold text-ods-text-primary">Ready to Break Free?</p>
<p className="font-body text-sm text-ods-text-secondary pt-2">
Stop paying vendor taxes. Start using open-source tools.
</p>
</div>
);

/**
* Four link sections + the brand column — the widest supported layout.
*
* The brand column sits OUTSIDE the sections grid, so this renders as 5 equal
* columns on desktop (brand `flex-1` + grid `flex-[4]`/`lg:grid-cols-4`).
* Resize the viewport: tablet splits 50/50 (brand beside a 2x2 of the sections),
* mobile stacks the brand on top with a 2x2 below.
*/
export const FourSections: Story = {
args: {
config: {
...baseConfig,
sections: [productsSection, companySection, communitySection, supportSection],
},
},
};

/**
* Three link sections — brand + 3 columns.
*/
export const ThreeSections: Story = {
args: {
config: {
...baseConfig,
sections: [productsSection, companySection, supportSection],
},
},
};

/**
* Two link sections — the leanest layout.
*/
export const TwoSections: Story = {
args: {
config: {
...baseConfig,
sections: [productsSection, companySection],
},
},
};

/**
* Social row hidden via `hideSocialRow`.
*/
export const WithoutSocialRow: Story = {
args: {
config: {
...baseConfig,
hideSocialRow: true,
sections: [productsSection, companySection, communitySection, supportSection],
},
},
};

/**
* A `customComponent` (e.g. a CTA card) rendered as a cell in the sections grid.
*/
export const WithCustomComponent: Story = {
args: {
config: {
...baseConfig,
sections: [productsSection, companySection],
customComponent: <PromoCard />,
},
},
};

/**
* Arbitrary `rightColumnContent` rendered after the sections.
*/
export const WithRightColumn: Story = {
args: {
config: {
...baseConfig,
sections: [productsSection, companySection],
rightColumnContent: <PromoCard />,
},
},
};

/**
* `moveDescriptionToRight` — the brand column keeps only the logo/name while the
* description moves into the right column.
*/
export const DescriptionMovedToRight: Story = {
args: {
config: {
...baseConfig,
sections: [productsSection, companySection],
moveDescriptionToRight: true,
rightColumnContent: <PromoCard />,
},
},
};

/**
* Custom `nameElement` and a `backgroundColor` override.
*/
export const CustomNameAndBackground: Story = {
args: {
config: {
...baseConfig,
backgroundColor: 'bg-ods-card',
nameElement: (
<span className="font-mono text-heading-4 font-bold text-ods-text-primary">Flamingo</span>
),
sections: [productsSection, companySection, communitySection, supportSection],
},
},
};
Loading