Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: org details panel layout #17

Merged
merged 10 commits into from
May 20, 2024
37 changes: 37 additions & 0 deletions src/orgs/components/init-org-details-syncer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEffect } from 'react';

import { useAtom } from 'jotai';

import { useIsDesktop } from '@/shared/hooks/use-media-query';

import { activeOrgIdAtom, initOrgAtom } from '@/orgs/core/atoms';
import { useOrgDetails } from '@/orgs/hooks/use-org-details';

interface Props {
id: string;
}

export const InitOrgDetailsSyncer = ({ id }: Props) => {
const [activeOrgId, setActiveOrgId] = useAtom(activeOrgIdAtom);
const [initOrg, setInitOrg] = useAtom(initOrgAtom);

const isDesktop = useIsDesktop();

const { data } = useOrgDetails(id);

// Initialize org details
useEffect(() => {
if (!initOrg && data) {
setInitOrg(data);
}
}, [data, initOrg, setInitOrg]);

// Set active org ID on desktop
useEffect(() => {
if (isDesktop && !activeOrgId && data) {
setActiveOrgId(data.orgId);
}
}, [activeOrgId, data, isDesktop, setActiveOrgId]);

return null;
};
37 changes: 37 additions & 0 deletions src/orgs/components/org-details-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';

import { HREFS } from '@/shared/core/constants';
import { getQueryClient } from '@/shared/utils/get-query-client';
import { DetailsPanelLayout } from '@/shared/components/details-panel/layout';

import { orgQueryKeys } from '@/orgs/core/query-keys';
import { getOrgDetails } from '@/orgs/data/get-org-details';

import { InitOrgDetailsSyncer } from './init-org-details-syncer';
import { OrgDetailsPanelHeader } from './org-details-panel-header';
import { OrgTabs } from './org-tabs';

interface Props {
children: React.ReactNode;
id: string;
}

export const OrgDetailsLayout = async ({ children, id }: Props) => {
const queryClient = getQueryClient();

await queryClient.prefetchQuery({
queryKey: orgQueryKeys.details(id),
queryFn: () => getOrgDetails(id),
});

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DetailsPanelLayout backHref={HREFS.ORGS_PAGE}>
<InitOrgDetailsSyncer id={id} />
<OrgDetailsPanelHeader id={id} />
<OrgTabs id={id} />
{children}
</DetailsPanelLayout>
</HydrationBoundary>
);
};
19 changes: 19 additions & 0 deletions src/orgs/components/org-details-panel-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client';

import { Spinner } from '@nextui-org/spinner';

import { DetailsPanelHeader } from '@/shared/components/details-panel/header';

import { useOrgDetails } from '@/orgs/hooks/use-org-details';

interface Props {
id: string;
}

export const OrgDetailsPanelHeader = ({ id }: Props) => {
const { data } = useOrgDetails(id);

if (!data) return <Spinner size="sm" color="white" />;

return <DetailsPanelHeader org={data} />;
};
60 changes: 60 additions & 0 deletions src/orgs/components/org-tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Spinner } from '@nextui-org/spinner';

import { HREFS, ROUTE_TABS } from '@/shared/core/constants';
import { getPluralText } from '@/shared/utils/get-plural-text';
import { DetailsPanelTabs } from '@/shared/components/details-panel/tabs';

import { OrgDetails } from '@/orgs/core/schemas';
import { useOrgDetails } from '@/orgs/hooks/use-org-details';

interface Props {
id: string;
}

export const OrgTabs = ({ id }: Props) => {
const { data } = useOrgDetails(id);

if (!data) return <Spinner size="sm" color="white" />;

const tabs = createTabs(data);

return <DetailsPanelTabs tabs={tabs} />;
};

const createTabs = (org: OrgDetails) => {
const { projects, jobs } = org;

const tabs = [
{ text: 'Organization Details', href: `/${ROUTE_TABS.SHARED.DETAILS}` },
];

const projectCount = projects.length;
if (projectCount > 0) {
const projectText = getPluralText('Project', projectCount);
const countText = ` (${projectCount})`;
tabs.push({
text: `${projectText}${countText}`,
href: `/${ROUTE_TABS.ORGS.PROJECTS}`,
});
}

const jobCount = jobs.length;
if (jobCount > 0) {
const jobText = getPluralText('Job', jobCount);
const countText = ` (${jobCount})`;
tabs.push({
text: `${jobText}${countText}`,
href: `/${ROUTE_TABS.ORGS.JOBS}`,
});
}

tabs.push({
text: 'Reviews',
href: `/${ROUTE_TABS.ORGS.REVIEWS}`,
});

return tabs.map((tab) => ({
...tab,
href: `${HREFS.ORGS_PAGE}/${org.orgId}${tab.href}`,
}));
};
14 changes: 14 additions & 0 deletions src/orgs/hooks/use-org-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';

import { QUERY_STALETIME } from '@/shared/core/constants';

import { orgQueryKeys } from '@/orgs/core/query-keys';
import { getOrgDetails } from '@/orgs/data/get-org-details';

export const useOrgDetails = (orgId: string) => {
return useQuery({
queryKey: orgQueryKeys.details(orgId),
queryFn: () => getOrgDetails(orgId),
staleTime: QUERY_STALETIME.DEFAULT,
});
};
23 changes: 23 additions & 0 deletions src/shared/components/details-panel/back-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';

import { useRouter } from 'next/navigation';

import { A11Y } from '@/shared/core/constants';

interface Props {
href: string;
text?: string;
}

export const DetailsPanelBackButton = ({
href,
text = A11Y.LINK.BACK,
}: Props) => {
const router = useRouter();

const onClick = () => {
router.push(href, { scroll: false });
};

return <span onClick={onClick}>{text}</span>;
};
62 changes: 62 additions & 0 deletions src/shared/components/details-panel/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { OrgInfo, OrgInfoTagProps } from '@/shared/core/schemas';
import { InfoTagProps } from '@/shared/core/types';
import { createSocialsInfoTagProps } from '@/shared/utils/create-socials-info-tag-props';
import { getLogoUrl } from '@/shared/utils/get-logo-url';
import { getWebsiteText } from '@/shared/utils/get-website-text';
import { LocationIcon } from '@/shared/components/icons/location-icon';
import { UsersThreeIcon } from '@/shared/components/icons/users-three-icon';
import { InfoTags } from '@/shared/components/info-tags';
import { LogoTitle } from '@/shared/components/logo-title';
import { Text } from '@/shared/components/text';

interface Props {
org: OrgInfo;
}

export const DetailsPanelHeader = ({ org }: Props) => {
const { name, logoUrl, website, summary } = org;
const src = getLogoUrl(website!, logoUrl);
const tags = createInfoTagProps(org);
const socials = createSocialsInfoTagProps(org, { website: false });

return (
<div className="flex flex-col gap-4">
<LogoTitle src={src} name={name} />
<InfoTags isDraggable tags={tags} />
<Text text={summary} />
<InfoTags isDraggable tags={socials} />
</div>
);
};

export const createInfoTagProps = (props: OrgInfoTagProps) => {
const { website, location, headcountEstimate } = props;

const tags: InfoTagProps[] = [];

if (website) {
const { hostname, link } = getWebsiteText(website);
tags.push({
text: hostname,
icon: null,
link,
showExternalIcon: true,
});
}

if (location) {
tags.push({
text: location,
icon: <LocationIcon />,
});
}

if (headcountEstimate) {
tags.push({
text: `Employees: ${headcountEstimate}`,
icon: <UsersThreeIcon />,
});
}

return tags;
};
20 changes: 20 additions & 0 deletions src/shared/components/details-panel/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { MobileHeader } from '@/shared/components/mobile-header';
import { PageScrollDisableSyncer } from '@/shared/components/page-scroll-syncer';

import { DetailsPanelBackButton } from './back-button';

interface Props {
backHref: string;
children: React.ReactNode;
}

export const DetailsPanelLayout = ({ backHref, children }: Props) => {
return (
<div className="hide-scrollbar fixed right-0 top-0 z-20 size-full min-h-screen overflow-y-auto bg-darkest-gray pt-[68px] md:w-[calc((100%-212px))] md:pt-20 lg:w-[calc((100%-212px)/2)] lg:pt-0">
<MobileHeader left={<DetailsPanelBackButton href={backHref} />} />
<div className="flex flex-col gap-4 p-5 lg:p-6">{children}</div>

<PageScrollDisableSyncer shouldDisable />
</div>
);
};
45 changes: 45 additions & 0 deletions src/shared/components/details-panel/tab-mapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client';

import { usePathname } from 'next/navigation';

import { HREFS, ROUTE_TABS } from '@/shared/core/constants';

import { DetailsPanelTab } from './tab';

interface Props {
tabs: { text: string; href: string }[];
}

export const DetailsPanelTabMapper = ({ tabs }: Props) => {
const pathname = usePathname();

return (
<>
{tabs.map(({ text, href }) => (
<DetailsPanelTab
key={text}
text={text}
href={href}
isActive={checkIsActive(pathname, href)}
/>
))}
</>
);
};

const DEFAULT_TAB = ROUTE_TABS.SHARED.DETAILS;
const LIST_PAGES_PATHS = Object.values(HREFS);

const checkIsActive = (pathname: string, href: string) => {
const isMatch = pathname === href;

// Pages w/ list e.g. /jobs does not have [tab] param.
// We default to 'details' tab
const isListHrefPath = LIST_PAGES_PATHS.includes(
pathname as (typeof LIST_PAGES_PATHS)[number],
);
const isDefaultTab =
href.substring(href.length - DEFAULT_TAB.length) === DEFAULT_TAB;

return isMatch || (isListHrefPath && isDefaultTab);
};
37 changes: 37 additions & 0 deletions src/shared/components/details-panel/tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Button } from '@nextui-org/button';
import { Link } from '@nextui-org/link';

import { cn } from '@/shared/utils/cn';
import { getGradientBorderStyle } from '@/shared/utils/get-gradient-border-style';

interface TabProps {
text: string;
href: string;
isActive?: boolean;
}

export const DetailsPanelTab = ({ href, isActive, text }: TabProps) => {
const linkStyle = isActive ? getGradientBorderStyle() : undefined;

const wrapperClassName =
'flex h-10 shrink-0 items-center justify-center rounded-lg border border-white/20 px-4 py-2 sm:h-12 md:h-8';

const contentClassName = cn(
`rounded-lg border border-transparent font-lato text-sm`,
{
'border-0': isActive, // Prevent active border layout shift
},
);

return (
<Button
as={Link}
href={href}
data-active={isActive}
className={wrapperClassName}
style={linkStyle}
>
<span className={contentClassName}>{text}</span>
</Button>
);
};
19 changes: 19 additions & 0 deletions src/shared/components/details-panel/tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { DraggableWrapper } from '@/shared/components/draggable-wrapper';

import { DetailsPanelTabMapper } from './tab-mapper';

interface Props {
tabs: { text: string; href: string }[];
asyncTabs?: React.ReactNode;
}

export const DetailsPanelTabs = ({ tabs, asyncTabs }: Props) => {
return (
<DraggableWrapper className={ROW_CLASSNAME}>
<DetailsPanelTabMapper tabs={tabs} />
{asyncTabs}
</DraggableWrapper>
);
};

const ROW_CLASSNAME = 'flex items-center gap-4';
Loading
Loading