Add card button component and enhance routing
This commit introduces the Card Button component to the UI package and improves URL matching by allowing end to be a boolean or function. Navigation menu components now support both cases. Additionally, changes have been made to improve the visual and functional aspects of various components throughout the application. This includes changes in pricing display, route active checking, and the introduction of new files such as HomeAddAccountButton and HomeAccountsList.
This commit is contained in:
@@ -24,7 +24,7 @@ function DocsNavLink({
|
||||
level: number;
|
||||
activePath: string;
|
||||
}) {
|
||||
const isCurrent = isRouteActive(url, activePath, 0);
|
||||
const isCurrent = isRouteActive(url, activePath, true);
|
||||
const isFirstLevel = level === 0;
|
||||
|
||||
return (
|
||||
|
||||
50
apps/web/app/home/(user)/_components/home-accounts-list.tsx
Normal file
50
apps/web/app/home/(user)/_components/home-accounts-list.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CardButton, CardButtonHeader } from '@kit/ui/card-button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
|
||||
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
|
||||
import { HomeAddAccountButton } from './home-add-account-button';
|
||||
|
||||
export function HomeAccountsList() {
|
||||
const { accounts } = use(loadUserWorkspace());
|
||||
|
||||
if (!accounts.length) {
|
||||
return <HomeAccountsListEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{accounts.map((account) => (
|
||||
<CardButton key={account.value} asChild>
|
||||
<Link href={`/home/${account.value}`}>
|
||||
<CardButtonHeader>{account.label}</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeAccountsListEmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center space-y-8 py-24">
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<Heading level={2}>You don't have any teams yet.</Heading>
|
||||
|
||||
<Heading
|
||||
className="font-sans font-medium text-muted-foreground"
|
||||
level={4}
|
||||
>
|
||||
Create a team to get started.
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<HomeAddAccountButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { CreateTeamAccountDialog } from '@kit/team-accounts/components';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
export function HomeAddAccountButton() {
|
||||
const [isAddingAccount, setIsAddingAccount] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setIsAddingAccount(true)}>
|
||||
Create a new team
|
||||
</Button>
|
||||
|
||||
<CreateTeamAccountDialog
|
||||
isOpen={isAddingAccount}
|
||||
setIsOpen={setIsAddingAccount}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
|
||||
path: string;
|
||||
label: string;
|
||||
Icon?: React.ReactNode;
|
||||
end?: boolean | undefined;
|
||||
end?: boolean | ((path: string) => boolean);
|
||||
}>
|
||||
>((acc, item) => {
|
||||
if ('children' in item) {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function TeamAccountNavigationMenu(props: {
|
||||
path: string;
|
||||
label: string;
|
||||
Icon?: React.ReactNode;
|
||||
end?: boolean | undefined;
|
||||
end?: boolean | ((path: string) => boolean);
|
||||
}>
|
||||
>((acc, item) => {
|
||||
if ('children' in item) {
|
||||
|
||||
@@ -89,6 +89,7 @@ export const PlanSchema = z
|
||||
interval: BillingIntervalSchema.optional(),
|
||||
custom: z.boolean().default(false).optional(),
|
||||
label: z.string().min(1).optional(),
|
||||
buttonLabel: z.string().min(1).optional(),
|
||||
href: z.string().min(1).optional(),
|
||||
lineItems: z.array(LineItemSchema).refine(
|
||||
(schema) => {
|
||||
|
||||
@@ -161,7 +161,7 @@ function PricingItem(
|
||||
data-cy={'subscription-plan'}
|
||||
className={cn(
|
||||
props.className,
|
||||
`s-full relative flex flex-1 grow flex-col items-stretch justify-between
|
||||
`s-full relative flex flex-1 grow flex-col items-stretch justify-between
|
||||
self-stretch rounded-lg border p-8 lg:w-4/12 xl:max-w-[20rem]`,
|
||||
{
|
||||
['border-primary']: highlighted,
|
||||
@@ -210,11 +210,9 @@ function PricingItem(
|
||||
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<Price>
|
||||
{lineItem ? (
|
||||
formatCurrency(props.product.currency, lineItem.cost)
|
||||
) : (
|
||||
<Trans i18nKey={'billing:custom'} />
|
||||
)}
|
||||
{lineItem
|
||||
? formatCurrency(props.product.currency, lineItem.cost)
|
||||
: props.plan.label ?? <Trans i18nKey={'billing:custom'} />}
|
||||
</Price>
|
||||
|
||||
<If condition={props.plan.name}>
|
||||
@@ -415,7 +413,7 @@ function DefaultCheckoutButton(
|
||||
id: string;
|
||||
name?: string | undefined;
|
||||
href?: string;
|
||||
label?: string;
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
product: {
|
||||
@@ -443,7 +441,7 @@ function DefaultCheckoutButton(
|
||||
`${signUpPath}?plan=${planId}&next=${subscriptionPath}?plan=${planId}${redirectToCheckoutParam}` ??
|
||||
'';
|
||||
|
||||
const label = props.plan.label ?? 'common:getStartedWithPlan';
|
||||
const label = props.plan.buttonLabel ?? 'common:getStartedWithPlan';
|
||||
|
||||
return (
|
||||
<Link className={'w-full'} href={linkHref}>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './settings/team-account-danger-zone';
|
||||
export * from './invitations/account-invitations-table';
|
||||
export * from './settings/team-account-settings-container';
|
||||
export * from './invitations/accept-invitation-container';
|
||||
export * from './create-team-account-dialog';
|
||||
|
||||
@@ -117,7 +117,8 @@
|
||||
"./enhanced-data-table": "./src/makerkit/data-table.tsx",
|
||||
"./language-selector": "./src/makerkit/language-selector.tsx",
|
||||
"./stepper": "./src/makerkit/stepper.tsx",
|
||||
"./cookie-banner": "./src/makerkit/cookie-banner.tsx"
|
||||
"./cookie-banner": "./src/makerkit/cookie-banner.tsx",
|
||||
"./card-button": "./src/makerkit/card-button.tsx"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
NavigationMenuItem,
|
||||
NavigationMenuList,
|
||||
} from '../shadcn/navigation-menu';
|
||||
|
||||
import { cn, isRouteActive } from '../utils';
|
||||
import { Trans } from './trans';
|
||||
|
||||
@@ -25,10 +26,14 @@ export function BorderedNavigationMenu(props: React.PropsWithChildren) {
|
||||
export function BorderedNavigationMenuItem(props: {
|
||||
path: string;
|
||||
label: string;
|
||||
end?: boolean | ((path: string) => boolean);
|
||||
active?: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const active = props.active ?? isRouteActive(pathname, props.path);
|
||||
|
||||
const active =
|
||||
props.active ??
|
||||
isRouteActive(props.path, pathname, props.end);
|
||||
|
||||
return (
|
||||
<NavigationMenuItem>
|
||||
@@ -47,4 +52,4 @@ export function BorderedNavigationMenuItem(props: {
|
||||
</Button>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
46
packages/ui/src/makerkit/card-button.tsx
Normal file
46
packages/ui/src/makerkit/card-button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Slot, Slottable } from '@radix-ui/react-slot';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import { cn } from '../utils';
|
||||
|
||||
export const CardButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
asChild?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
>(({ className, asChild, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group relative flex h-36 rounded-lg bg-secondary/80 p-4 transition-all hover:bg-secondary/90 hover:shadow-sm active:bg-secondary active:shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Slottable>{props.children}</Slottable>
|
||||
</Comp>
|
||||
);
|
||||
});
|
||||
|
||||
export function CardButtonHeader(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-sm font-medium text-muted-foreground transition-colors group-hover:text-secondary-foreground">
|
||||
{props.children}
|
||||
</span>
|
||||
|
||||
<ChevronRight
|
||||
className={
|
||||
'absolute right-2 top-4 h-4 text-muted-foreground transition-colors group-hover:text-secondary-foreground'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const RouteMatchingEnd = z
|
||||
.union([z.boolean(), z.function().args(z.string()).returns(z.boolean())])
|
||||
.default(false)
|
||||
.optional();
|
||||
|
||||
export const NavigationConfigSchema = z.object({
|
||||
style: z.enum(['custom', 'sidebar', 'header']).default('sidebar'),
|
||||
routes: z.array(
|
||||
@@ -8,7 +13,7 @@ export const NavigationConfigSchema = z.object({
|
||||
label: z.string(),
|
||||
path: z.string(),
|
||||
Icon: z.custom<React.ReactNode>(),
|
||||
end: z.boolean().optional(),
|
||||
end: RouteMatchingEnd,
|
||||
}),
|
||||
z.object({
|
||||
label: z.string(),
|
||||
@@ -19,7 +24,7 @@ export const NavigationConfigSchema = z.object({
|
||||
label: z.string(),
|
||||
path: z.string(),
|
||||
Icon: z.custom<React.ReactNode>(),
|
||||
end: z.boolean().optional(),
|
||||
end: RouteMatchingEnd,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -154,12 +154,12 @@ export function SidebarItem({
|
||||
}: React.PropsWithChildren<{
|
||||
path: string;
|
||||
Icon: React.ReactNode;
|
||||
end?: boolean;
|
||||
end?: boolean | ((path: string) => boolean);
|
||||
}>) {
|
||||
const { collapsed } = useContext(SidebarContext);
|
||||
|
||||
const currentPath = usePathname() ?? '';
|
||||
const active = isRouteActive(path, currentPath, end ? 0 : 3);
|
||||
|
||||
const active = isRouteActive(path, currentPath, end);
|
||||
const variant = active ? 'secondary' : 'ghost';
|
||||
const size = collapsed ? 'icon' : 'sm';
|
||||
|
||||
@@ -254,4 +254,4 @@ export function SidebarNavigation({
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,45 @@ const ROOT_PATH = '/';
|
||||
/**
|
||||
* @name isRouteActive
|
||||
* @description A function to check if a route is active. This is used to
|
||||
* @param end
|
||||
* @param path
|
||||
* @param currentPath
|
||||
*/
|
||||
export function isRouteActive(
|
||||
path: string,
|
||||
currentPath: string,
|
||||
end?: boolean | ((path: string) => boolean) | undefined,
|
||||
) {
|
||||
// if the path is the same as the current path, we return true
|
||||
if (path === currentPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if the end prop is a function, we call it with the current path
|
||||
if (typeof end === 'function') {
|
||||
return !end(currentPath);
|
||||
}
|
||||
|
||||
// otherwise - we use the evaluateIsRouteActive function
|
||||
const defaultEnd = end ?? true;
|
||||
const oneLevelDeep = 1;
|
||||
const threeLevelsDeep = 3;
|
||||
|
||||
// how far down should segments be matched?
|
||||
const depth = defaultEnd ? oneLevelDeep : threeLevelsDeep;
|
||||
|
||||
return checkIfRouteIsActive(path, currentPath, depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name checkIfRouteIsActive
|
||||
* @description A function to check if a route is active. This is used to
|
||||
* highlight the active link in the navigation.
|
||||
* @param targetLink - The link to check against
|
||||
* @param currentRoute - the current route
|
||||
* @param depth - how far down should segments be matched?
|
||||
*/
|
||||
export function isRouteActive(
|
||||
export function checkIfRouteIsActive(
|
||||
targetLink: string,
|
||||
currentRoute: string,
|
||||
depth = 1,
|
||||
|
||||
Reference in New Issue
Block a user