Design Updates: Breadcrumbs, Empty State, new Charts and new colors

Design Updates: Breadcrumbs, Empty State, new Charts and new colors

* Add Breadcrumb component to UI package

* Add AppBreadcrumbs for improved navigation: Replaced static text descriptions with the new AppBreadcrumbs component across multiple pages to enhance navigation. Addressed an issue with Supabase client warnings by temporarily suppressing getSession warnings. Also made minor UI adjustments, including adjustments to heading styles and layout features.

* Enhance UI styling and configuration settings: Updated various UI components and global styles to improve styling consistency and responsiveness.

* Update global styles and adjust padding: Updated several CSS variables for improved color accuracy and appearance. Added padding to admin account page body for better layout consistency.

* Refactor UI components and adjust styling: Replaced Heading tags in Plan Picker with span for consistency. Added active and hover states to buttons in the sidebar. Refined background, layout styling, and color schemes across various components. Removed sidebar case in Page component switch statement.

* Add Chart Components and Integrate into Dashboard: Introduced `recharts` library and created `Chart` components. Updated dashboard to use the new components and enhanced UI/UX with descriptions and restructured cards.
* Enhance dashboard demo UI layout: Refactor the layout by adjusting flex properties and spacing classes to improve component alignment. Update dummy data generation and Figure font size for better visual consistency.

* Update localization keys for navigation labels: Changed localization keys for tab labels to use 'routes' prefix for consistency. Adjusted corresponding component references and added missing keys for routes. This ensures better organization and uniformity in the code.

* Add EmptyState component and enhance account handling: Introduced a new EmptyState component for UI consistency and updated JSON locales with 'account' route. Modified HomeAddAccountButton to accept className prop and refactored HomeAccountsListEmptyState to use the new EmptyState component. Updated navigation config to align labels in locales.

* Add locale support and enhance currency formatting: This commit introduces locale-based currency formatting across billing components by utilizing the `useTranslation` hook to fetch the current language. It also refactors the `formatCurrency` function to accept an object parameter for better readability and reusability.

* Fix typo in devDependencies section of template generator: Corrected a syntax error in `package.json.hbs` template affecting the `@kit/tsconfig` entry. The change ensures that the dependency is properly defined and prevents potential issues during package management.

* Update heading levels and add tracking-tight class in auth shell: Changed Heading components from level 4 to level 5 and added the 'tracking-tight' class in multiple auth-related pages. This improves visual consistency and better aligns the typography across the application.
This commit is contained in:
Giancarlo Buomprisco
2024-08-04 23:25:28 +08:00
committed by GitHub
parent 23154c366d
commit e696f1aed0
53 changed files with 1795 additions and 515 deletions

View File

@@ -1,19 +1,27 @@
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
export function SitePageHeader(props: { export function SitePageHeader({
title,
subtitle,
container = true,
className = ''
}: {
title: string; title: string;
subtitle: string; subtitle: string;
container?: boolean;
className?: string; className?: string;
}) { }) {
const containerClass = container ? 'container' : '';
return ( return (
<div className={cn('border-b py-8 xl:py-10 2xl:py-12', props.className)}> <div className={cn('border-b py-8 xl:py-10 2xl:py-12', className)}>
<div className={'container flex flex-col space-y-2 lg:space-y-4'}> <div className={cn('flex flex-col space-y-2 lg:space-y-4', containerClass)}>
<h1 <h1
className={ className={
'font-heading text-3xl font-medium tracking-tighter dark:text-white xl:text-5xl' 'font-heading text-3xl font-medium tracking-tighter dark:text-white xl:text-5xl'
} }
> >
{props.title} {title}
</h1> </h1>
<h2 <h2
@@ -21,7 +29,7 @@ export function SitePageHeader(props: {
'text-lg tracking-tight text-muted-foreground 2xl:text-2xl' 'text-lg tracking-tight text-muted-foreground 2xl:text-2xl'
} }
> >
{props.subtitle} {subtitle}
</h2> </h2>
</div> </div>
</div> </div>

View File

@@ -37,11 +37,11 @@ For more info: https://github.com/tailwindlabs/tailwindcss/issues/3258#issuecomm
} }
.HTML p { .HTML p {
@apply mb-4 mt-2 text-base leading-7; @apply mb-4 mt-2 text-base leading-7 text-muted-foreground;
} }
.HTML li { .HTML li {
@apply relative my-1.5 text-base leading-7; @apply relative my-1.5 text-base leading-7 text-muted-foreground;
} }
.HTML ul > li:before { .HTML ul > li:before {

View File

@@ -52,9 +52,14 @@ async function DocumentationPage({ params }: PageParams) {
return ( return (
<div className={'flex flex-1 flex-col'}> <div className={'flex flex-1 flex-col'}>
<SitePageHeader title={page.title} subtitle={description} /> <SitePageHeader
className={'lg:px-8'}
container={false}
title={page.title}
subtitle={description}
/>
<div className={'container flex max-w-5xl flex-col space-y-4 py-6'}> <div className={'flex flex-col space-y-4 py-6 lg:px-8'}>
<article className={styles.HTML}> <article className={styles.HTML}>
<ContentRenderer content={page.content} /> <ContentRenderer content={page.content} />
</article> </article>

View File

@@ -1,5 +1,3 @@
import { PageBody } from '@kit/ui/page';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
@@ -23,20 +21,18 @@ async function DocsPage() {
const cards = items.filter((item) => !item.parentId); const cards = items.filter((item) => !item.parentId);
return ( return (
<PageBody> <div className={'flex flex-col space-y-6 xl:space-y-10'}>
<div className={'flex flex-col space-y-8 xl:space-y-16'}> <SitePageHeader
<SitePageHeader title={t('marketing:documentation')}
title={t('marketing:documentation')} subtitle={t('marketing:documentationSubtitle')}
subtitle={t('marketing:documentationSubtitle')} />
/>
<div className={'flex flex-col items-center'}> <div className={'flex flex-col items-center'}>
<div className={'container mx-auto max-w-5xl'}> <div className={'container mx-auto max-w-5xl'}>
<DocsCards cards={cards} /> <DocsCards cards={cards} />
</div>
</div> </div>
</div> </div>
</PageBody> </div>
); );
} }

View File

@@ -23,7 +23,7 @@ const redirectPath = `${callback}?next=${passwordUpdate}`;
function PasswordResetPage() { function PasswordResetPage() {
return ( return (
<> <>
<Heading level={4}> <Heading level={5} className={'tracking-tight'}>
<Trans i18nKey={'auth:passwordResetLabel'} /> <Trans i18nKey={'auth:passwordResetLabel'} />
</Heading> </Heading>

View File

@@ -39,7 +39,7 @@ function SignInPage({ searchParams }: SignInPageProps) {
return ( return (
<> <>
<Heading level={4}> <Heading level={5} className={'tracking-tight'}>
<Trans i18nKey={'auth:signInHeading'} /> <Trans i18nKey={'auth:signInHeading'} />
</Heading> </Heading>

View File

@@ -38,7 +38,7 @@ function SignUpPage({ searchParams }: Props) {
return ( return (
<> <>
<Heading level={4}> <Heading level={5} className={'tracking-tight'}>
<Trans i18nKey={'auth:signUpHeading'} /> <Trans i18nKey={'auth:signUpHeading'} />
</Heading> </Heading>

View File

@@ -7,7 +7,12 @@ import {
CardButtonHeader, CardButtonHeader,
CardButtonTitle, CardButtonTitle,
} from '@kit/ui/card-button'; } from '@kit/ui/card-button';
import { Heading } from '@kit/ui/heading'; import {
EmptyState,
EmptyStateButton,
EmptyStateHeading,
EmptyStateText,
} from '@kit/ui/empty-state';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace'; import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
@@ -39,21 +44,18 @@ export function HomeAccountsList() {
function HomeAccountsListEmptyState() { function HomeAccountsListEmptyState() {
return ( return (
<div className="flex flex-col items-center justify-center space-y-8 py-24"> <div className={'flex flex-1'}>
<div className="flex flex-col items-center space-y-1"> <EmptyState>
<Heading level={2}> <EmptyStateButton asChild>
<HomeAddAccountButton className={'mt-4'} />
</EmptyStateButton>
<EmptyStateHeading>
<Trans i18nKey={'account:noTeamsYet'} /> <Trans i18nKey={'account:noTeamsYet'} />
</Heading> </EmptyStateHeading>
<EmptyStateText>
<Heading
className="font-sans font-medium text-muted-foreground"
level={4}
>
<Trans i18nKey={'account:createTeam'} /> <Trans i18nKey={'account:createTeam'} />
</Heading> </EmptyStateText>
</div> </EmptyState>
<HomeAddAccountButton />
</div> </div>
); );
} }

View File

@@ -6,12 +6,15 @@ import { CreateTeamAccountDialog } from '@kit/team-accounts/components';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
export function HomeAddAccountButton() { export function HomeAddAccountButton(props: { className?: string }) {
const [isAddingAccount, setIsAddingAccount] = useState(false); const [isAddingAccount, setIsAddingAccount] = useState(false);
return ( return (
<> <>
<Button size="sm" onClick={() => setIsAddingAccount(true)}> <Button
className={props.className}
onClick={() => setIsAddingAccount(true)}
>
<Trans i18nKey={'account:createTeamButtonLabel'} /> <Trans i18nKey={'account:createTeamButtonLabel'} />
</Button> </Button>

View File

@@ -7,6 +7,7 @@ import { If } from '@kit/ui/if';
import { PageBody } from '@kit/ui/page'; import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import billingConfig from '~/config/billing.config'; import billingConfig from '~/config/billing.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
@@ -35,8 +36,8 @@ async function PersonalAccountBillingPage() {
return ( return (
<> <>
<HomeLayoutPageHeader <HomeLayoutPageHeader
title={<Trans i18nKey={'common:billingTabLabel'} />} title={<Trans i18nKey={'common:routes.billing'} />}
description={<Trans i18nKey={'common:billingTabDescription'} />} description={<AppBreadcrumbs />}
/> />
<PageBody> <PageBody>

View File

@@ -20,7 +20,7 @@ function UserHomePage() {
return ( return (
<> <>
<HomeLayoutPageHeader <HomeLayoutPageHeader
title={<Trans i18nKey={'common:homeTabLabel'} />} title={<Trans i18nKey={'common:routes.home'} />}
description={<Trans i18nKey={'common:homeTabDescription'} />} description={<Trans i18nKey={'common:homeTabDescription'} />}
/> />

View File

@@ -1,5 +1,6 @@
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
// local imports // local imports
@@ -9,8 +10,8 @@ function UserSettingsLayout(props: React.PropsWithChildren) {
return ( return (
<> <>
<HomeLayoutPageHeader <HomeLayoutPageHeader
title={<Trans i18nKey={'account:accountTabLabel'} />} title={<Trans i18nKey={'account:routes.settings'} />}
description={<Trans i18nKey={'account:accountTabDescription'} />} description={<AppBreadcrumbs />}
/> />
{props.children} {props.children}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useCaptureException } from '@kit/monitoring/hooks'; import { useCaptureException } from '@kit/monitoring/hooks';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
@@ -20,8 +21,8 @@ export default function BillingErrorPage({
return ( return (
<> <>
<PageHeader <PageHeader
title={<Trans i18nKey={'common:billingTabLabel'} />} title={<Trans i18nKey={'common:routes.billing'} />}
description={<Trans i18nKey={'common:billingTabDescription'} />} description={<AppBreadcrumbs />}
/> />
<PageBody> <PageBody>

View File

@@ -6,11 +6,13 @@ import {
CurrentSubscriptionCard, CurrentSubscriptionCard,
} from '@kit/billing-gateway/components'; } from '@kit/billing-gateway/components';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { PageBody } from '@kit/ui/page'; import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import billingConfig from '~/config/billing.config'; import billingConfig from '~/config/billing.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
@@ -75,8 +77,8 @@ async function TeamAccountBillingPage({ params }: Params) {
<> <>
<TeamAccountLayoutPageHeader <TeamAccountLayoutPageHeader
account={params.account} account={params.account}
title={<Trans i18nKey={'common:billingTabLabel'} />} title={<Trans i18nKey={'common:routes.billing'} />}
description={<Trans i18nKey={'common:billingTabDescription'} />} description={<AppBreadcrumbs />}
/> />
<PageBody> <PageBody>

View File

@@ -18,6 +18,7 @@ import { If } from '@kit/ui/if';
import { PageBody } from '@kit/ui/page'; import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
@@ -55,18 +56,18 @@ async function TeamAccountMembersPage({ params }: Params) {
return ( return (
<> <>
<TeamAccountLayoutPageHeader <TeamAccountLayoutPageHeader
title={<Trans i18nKey={'common:membersTabLabel'} />} title={<Trans i18nKey={'common:routes.members'} />}
description={<Trans i18nKey={'common:membersTabDescription'} />} description={<AppBreadcrumbs />}
account={account.slug} account={account.slug}
/> />
<PageBody> <PageBody>
<div className={'flex w-full max-w-4xl flex-col space-y-6 pb-32'}> <div className={'flex w-full max-w-4xl flex-col space-y-4 pb-32'}>
<Card> <Card>
<CardHeader className={'flex flex-row justify-between'}> <CardHeader className={'flex flex-row justify-between'}>
<div className={'flex flex-col space-y-1.5'}> <div className={'flex flex-col space-y-1.5'}>
<CardTitle> <CardTitle>
<Trans i18nKey={'common:membersTabLabel'} /> <Trans i18nKey={'common:accountMembers'} />
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>

View File

@@ -1,10 +1,8 @@
import loadDynamic from 'next/dynamic'; import loadDynamic from 'next/dynamic';
import { PlusCircle } from 'lucide-react'; import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Button } from '@kit/ui/button';
import { PageBody } from '@kit/ui/page'; import { PageBody } from '@kit/ui/page';
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
@@ -21,18 +19,11 @@ const DashboardDemo = loadDynamic(
{ {
ssr: false, ssr: false,
loading: () => ( loading: () => (
<div <LoadingOverlay>
className={ <span className={'text-muted-foreground'}>
'flex h-full flex-1 flex-col items-center justify-center space-y-4' +
' py-24'
}
>
<Spinner />
<div>
<Trans i18nKey={'common:loading'} /> <Trans i18nKey={'common:loading'} />
</div> </span>
</div> </LoadingOverlay>
), ),
}, },
); );
@@ -51,14 +42,9 @@ function TeamAccountHomePage({ params }: { params: Params }) {
<> <>
<TeamAccountLayoutPageHeader <TeamAccountLayoutPageHeader
account={params.account} account={params.account}
title={<Trans i18nKey={'common:dashboardTabLabel'} />} title={<Trans i18nKey={'common:routes.dashboard'} />}
description={<Trans i18nKey={'common:dashboardTabDescription'} />} description={<AppBreadcrumbs />}
> />
<Button>
<PlusCircle className={'mr-1 h-4'} />
<span>Add Widget</span>
</Button>
</TeamAccountLayoutPageHeader>
<PageBody> <PageBody>
<DashboardDemo /> <DashboardDemo />

View File

@@ -4,6 +4,7 @@ import { TeamAccountSettingsContainer } from '@kit/team-accounts/components';
import { PageBody } from '@kit/ui/page'; import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import featuresFlagConfig from '~/config/feature-flags.config'; import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
@@ -51,7 +52,7 @@ async function TeamAccountSettingsPage(props: Props) {
<TeamAccountLayoutPageHeader <TeamAccountLayoutPageHeader
account={account.slug} account={account.slug}
title={<Trans i18nKey={'teams:settings.pageTitle'} />} title={<Trans i18nKey={'teams:settings.pageTitle'} />}
description={<Trans i18nKey={'teams:settings.pageDescription'} />} description={<AppBreadcrumbs />}
/> />
<PageBody> <PageBody>

View File

@@ -128,7 +128,7 @@ export default createBillingSchema({
{ {
id: 'price_enterprise_yearly', id: 'price_enterprise_yearly',
name: 'Base', name: 'Base',
cost: 299.99, cost: 299.90,
type: 'flat', type: 'flat',
}, },
], ],

View File

@@ -9,13 +9,13 @@ const iconClasses = 'w-4';
const routes = [ const routes = [
{ {
label: 'common:homeTabLabel', label: 'common:routes.home',
path: pathsConfig.app.home, path: pathsConfig.app.home,
Icon: <Home className={iconClasses} />, Icon: <Home className={iconClasses} />,
end: true, end: true,
}, },
{ {
label: 'account:accountTabLabel', label: 'common:routes.account',
path: pathsConfig.app.personalAccountSettings, path: pathsConfig.app.personalAccountSettings,
Icon: <User className={iconClasses} />, Icon: <User className={iconClasses} />,
}, },
@@ -23,7 +23,7 @@ const routes = [
if (featureFlagsConfig.enablePersonalAccountBilling) { if (featureFlagsConfig.enablePersonalAccountBilling) {
routes.push({ routes.push({
label: 'common:billingTabLabel', label: 'common:routes.billing',
path: pathsConfig.app.personalAccountBilling, path: pathsConfig.app.personalAccountBilling,
Icon: <CreditCard className={iconClasses} />, Icon: <CreditCard className={iconClasses} />,
}); });

View File

@@ -9,28 +9,28 @@ const iconClasses = 'w-4';
const getRoutes = (account: string) => [ const getRoutes = (account: string) => [
{ {
label: 'common:dashboardTabLabel', label: 'common:routes.dashboard',
path: pathsConfig.app.accountHome.replace('[account]', account), path: pathsConfig.app.accountHome.replace('[account]', account),
Icon: <LayoutDashboard className={iconClasses} />, Icon: <LayoutDashboard className={iconClasses} />,
end: true, end: true,
}, },
{ {
label: 'common:settingsTabLabel', label: 'common:routes.settings',
collapsible: false, collapsible: false,
children: [ children: [
{ {
label: 'common:settingsTabLabel', label: 'common:routes.settings',
path: createPath(pathsConfig.app.accountSettings, account), path: createPath(pathsConfig.app.accountSettings, account),
Icon: <Settings className={iconClasses} />, Icon: <Settings className={iconClasses} />,
}, },
{ {
label: 'common:accountMembers', label: 'common:routes.members',
path: createPath(pathsConfig.app.accountMembers, account), path: createPath(pathsConfig.app.accountMembers, account),
Icon: <Users className={iconClasses} />, Icon: <Users className={iconClasses} />,
}, },
featureFlagsConfig.enableTeamAccountBilling featureFlagsConfig.enableTeamAccountBilling
? { ? {
label: 'common:billingTabLabel', label: 'common:routes.billing',
path: createPath(pathsConfig.app.accountBilling, account), path: createPath(pathsConfig.app.accountBilling, account),
Icon: <CreditCard className={iconClasses} />, Icon: <CreditCard className={iconClasses} />,
} }

View File

@@ -8,8 +8,6 @@ publishedAt: 2024-04-12
status: "published" status: "published"
--- ---
# Brainstorming Ideas for Your Next Micro SaaS
## Niche Service Solutions ## Niche Service Solutions
Consider identifying a specific niche or industry with underserved needs. Whether it's project management tools tailored for creative freelancers or appointment scheduling software for niche healthcare practitioners, targeting a specific market can lead to a loyal customer base hungry for tailored solutions. Consider identifying a specific niche or industry with underserved needs. Whether it's project management tools tailored for creative freelancers or appointment scheduling software for niche healthcare practitioners, targeting a specific market can lead to a loyal customer base hungry for tailored solutions.

View File

@@ -26,6 +26,7 @@
"month": "Billed monthly", "month": "Billed monthly",
"year": "Billed yearly" "year": "Billed yearly"
}, },
"perMonth": "per month",
"custom": "Custom Plan", "custom": "Custom Plan",
"lifetime": "Lifetime", "lifetime": "Lifetime",
"trialPeriod": "{{period}} day trial", "trialPeriod": "{{period}} day trial",

View File

@@ -1,7 +1,7 @@
{ {
"homeTabLabel": "Home", "homeTabLabel": "Home",
"homeTabDescription": "Welcome to your home page", "homeTabDescription": "Welcome to your home page",
"accountMembers": "Members", "accountMembers": "Team Members",
"membersTabDescription": "Here you can manage the members of your team.", "membersTabDescription": "Here you can manage the members of your team.",
"billingTabLabel": "Billing", "billingTabLabel": "Billing",
"billingTabDescription": "Manage your billing and subscription", "billingTabDescription": "Manage your billing and subscription",
@@ -55,6 +55,15 @@
"newVersionAvailableDescription": "A new version of the app is available. It is recommended to refresh the page to get the latest updates and avoid any issues.", "newVersionAvailableDescription": "A new version of the app is available. It is recommended to refresh the page to get the latest updates and avoid any issues.",
"newVersionSubmitButton": "Reload and Update", "newVersionSubmitButton": "Reload and Update",
"back": "Back", "back": "Back",
"routes": {
"home": "Home",
"account": "Account",
"members": "Members",
"billing": "Billing",
"dashboard": "Dashboard",
"settings": "Settings",
"profile": "Profile"
},
"roles": { "roles": {
"owner": { "owner": {
"label": "Owner" "label": "Owner"

View File

@@ -4,69 +4,58 @@
@layer base { @layer base {
:root { :root {
--background: 0deg 0% 100%; --background: 0 0% 100%;
--foreground: 222.2deg 47.4% 11.2%; --foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--muted: 210deg 40% 96.1%; --card-foreground: 224 71.4% 4.1%;
--muted-foreground: 215.4deg 16.3% 46.9%; --popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--popover: 0deg 0% 100%; --primary: 220.9 39.3% 11%;
--popover-foreground: 222.2deg 47.4% 11.2%; --primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--border: 214.3deg 31.8% 94.4%; --secondary-foreground: 220.9 39.3% 11%;
--input: 214.3deg 31.8% 91.4%; --muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--card: 0deg 0% 100%; --accent: 220 14.3% 95.9%;
--card-foreground: 222.2deg 47.4% 11.2%; --accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--primary: 222.2deg 47.4% 11.2%; --destructive-foreground: 210 20% 98%;
--primary-foreground: 210deg 40% 98%; --border: 214.3 31.8% 94.4%;
--input: 214.3 31.8% 91.4%;
--secondary: 210deg 40% 96.1%; --ring: 224 71.4% 4.1%;
--secondary-foreground: 222.2deg 47.4% 11.2%;
--accent: 210deg 40% 96.1%;
--accent-foreground: 222.2deg 47.4% 11.2%;
--destructive: 0deg 100% 50%;
--destructive-foreground: 210deg 40% 98%;
--ring: 215deg 20.2% 65.1%;
--radius: 0.5rem; --radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
} }
.dark { .dark {
--background: 224 71% 4%; --background: 224 71.4% 4.1%;
--foreground: 213 31% 91%; --foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--muted: 223 47% 11%; --card-foreground: 210 20% 98%;
--muted-foreground: 215.4 16.3% 56.9%; --popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--accent: 216 34% 10%; --primary: 210 20% 98%;
--accent-foreground: 210 40% 98%; --primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 13%;
--popover: 224 71% 4%; --secondary-foreground: 210 20% 98%;
--popover-foreground: 215 20.2% 65.1%; --muted: 215 27.9% 13%;
--muted-foreground: 217.9 10.6% 64.9%;
--border: 216 34% 17%; --accent: 215 27.9% 13%;
--input: 216 34% 17%; --accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--card: 224 71% 4%; --destructive-foreground: 210 20% 98%;
--card-foreground: 213 31% 91%; --border: 215 27.9% 13%;
--input: 215 27.9% 13%;
--primary: 210 40% 98%; --ring: 216 12.2% 83.9%;
--primary-foreground: 222.2 47.4% 1.2%; --chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--secondary: 216 34% 10%; --chart-3: 30 80% 55%;
--secondary-foreground: 210 40% 98%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
--radius: 0.5rem;
} }
} }

View File

@@ -90,3 +90,6 @@ content_path = "./supabase/templates/change-email-address.html"
[auth.email.template.magic_link] [auth.email.template.magic_link]
subject = "Sign in to Makerkit" subject = "Sign in to Makerkit"
content_path = "./supabase/templates/magic-link.html" content_path = "./supabase/templates/magic-link.html"
[analytics]
enabled = false

View File

@@ -1,4 +1,7 @@
'use client';
import { PlusSquare } from 'lucide-react'; import { PlusSquare } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { z } from 'zod'; import { z } from 'zod';
import type { LineItemSchema } from '@kit/billing'; import type { LineItemSchema } from '@kit/billing';
@@ -16,6 +19,9 @@ export function LineItemDetails(
selectedInterval?: string | undefined; selectedInterval?: string | undefined;
}>, }>,
) { ) {
const locale = useTranslation().i18n.language;
const currencyCode = props?.currency.toLowerCase();
return ( return (
<div className={'flex flex-col space-y-1'}> <div className={'flex flex-col space-y-1'}>
{props.lineItems.map((item, index) => { {props.lineItems.map((item, index) => {
@@ -48,10 +54,11 @@ export function LineItemDetails(
<Trans <Trans
i18nKey={'billing:setupFee'} i18nKey={'billing:setupFee'}
values={{ values={{
setupFee: formatCurrency( setupFee: formatCurrency({
props?.currency.toLowerCase(), currencyCode,
item.setupFee as number, value: item.setupFee as number,
), locale,
}),
}} }}
/> />
</span> </span>
@@ -89,7 +96,11 @@ export function LineItemDetails(
<span>-</span> <span>-</span>
<span className={'text-xs font-semibold'}> <span className={'text-xs font-semibold'}>
{formatCurrency(props?.currency.toLowerCase(), item.cost)} {formatCurrency({
currencyCode,
value: item.cost,
locale,
})}
</span> </span>
</div> </div>
@@ -129,7 +140,11 @@ export function LineItemDetails(
<If condition={!item.tiers?.length}> <If condition={!item.tiers?.length}>
<span className={'font-semibold'}> <span className={'font-semibold'}>
{formatCurrency(props.currency.toLowerCase(), item.cost)} {formatCurrency({
currencyCode,
value: item.cost,
locale,
})}
</span> </span>
</If> </If>
</div> </div>
@@ -165,7 +180,11 @@ export function LineItemDetails(
{/* If there are no tiers, there is a flat cost for usage */} {/* If there are no tiers, there is a flat cost for usage */}
<If condition={!item.tiers?.length}> <If condition={!item.tiers?.length}>
<span className={'font-semibold'}> <span className={'font-semibold'}>
{formatCurrency(props?.currency.toLowerCase(), item.cost)} {formatCurrency({
currencyCode,
value: item.cost,
locale,
})}
</span> </span>
</If> </If>
</div> </div>
@@ -203,6 +222,7 @@ function Tiers({
item: z.infer<typeof LineItemSchema>; item: z.infer<typeof LineItemSchema>;
}) { }) {
const unit = item.unit; const unit = item.unit;
const locale = useTranslation().i18n.language;
const tiers = item.tiers?.map((tier, index) => { const tiers = item.tiers?.map((tier, index) => {
const tiersLength = item.tiers?.length ?? 0; const tiersLength = item.tiers?.length ?? 0;
@@ -228,7 +248,11 @@ function Tiers({
<If condition={isLastTier}> <If condition={isLastTier}>
<span className={'font-bold'}> <span className={'font-bold'}>
{formatCurrency(currency.toLowerCase(), tier.cost)} {formatCurrency({
currencyCode: currency.toLowerCase(),
value: tier.cost,
locale,
})}
</span> </span>
<If condition={tiersLength > 1}> <If condition={tiersLength > 1}>
@@ -264,7 +288,11 @@ function Tiers({
<If condition={!isIncluded}> <If condition={!isIncluded}>
<span className={'font-bold'}> <span className={'font-bold'}>
{formatCurrency(currency.toLowerCase(), tier.cost)} {formatCurrency({
currencyCode: currency.toLowerCase(),
value: tier.cost,
locale,
})}
</span> </span>
<span> <span>

View File

@@ -26,7 +26,6 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
import { import {
@@ -106,6 +105,8 @@ export function PlanPicker(
const isRecurringPlan = const isRecurringPlan =
selectedPlan?.paymentType === 'recurring' || !selectedPlan; selectedPlan?.paymentType === 'recurring' || !selectedPlan;
const locale = useTranslation().i18n.language;
return ( return (
<Form {...form}> <Form {...form}>
<div <div
@@ -316,10 +317,12 @@ export function PlanPicker(
<div> <div>
<Price key={plan.id}> <Price key={plan.id}>
<span> <span>
{formatCurrency( {formatCurrency({
product.currency.toLowerCase(), currencyCode:
primaryLineItem.cost, product.currency.toLowerCase(),
)} value: primaryLineItem.cost,
locale,
})}
</span> </span>
</Price> </Price>
@@ -424,7 +427,7 @@ function PlanDetails({
} }
> >
<div className={'flex flex-col space-y-0.5'}> <div className={'flex flex-col space-y-0.5'}>
<Heading level={5}> <span className={'text-sm font-medium'}>
<b> <b>
<Trans <Trans
i18nKey={`billing:plans.${selectedProduct.id}.name`} i18nKey={`billing:plans.${selectedProduct.id}.name`}
@@ -434,10 +437,10 @@ function PlanDetails({
<If condition={isRecurring}> <If condition={isRecurring}>
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} /> / <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
</If> </If>
</Heading> </span>
<p> <p>
<span className={'text-muted-foreground'}> <span className={'text-muted-foreground text-sm'}>
<Trans <Trans
i18nKey={`billing:plans.${selectedProduct.id}.description`} i18nKey={`billing:plans.${selectedProduct.id}.description`}
defaults={selectedProduct.description} defaults={selectedProduct.description}
@@ -489,7 +492,7 @@ function Price(props: React.PropsWithChildren) {
return ( return (
<span <span
className={ className={
'animate-in slide-in-from-left-4 fade-in text-xl font-bold duration-500' 'animate-in slide-in-from-left-4 fade-in text-xl font-semibold tracking-tight duration-500'
} }
> >
{props.children} {props.children}

View File

@@ -29,6 +29,8 @@ interface Paths {
return: string; return: string;
} }
type Interval = 'month' | 'year';
export function PricingTable({ export function PricingTable({
config, config,
paths, paths,
@@ -48,7 +50,7 @@ export function PricingTable({
highlighted?: boolean; highlighted?: boolean;
}>; }>;
}) { }) {
const intervals = getPlanIntervals(config).filter(Boolean) as string[]; const intervals = getPlanIntervals(config).filter(Boolean) as Interval[];
const [interval, setInterval] = useState(intervals[0]!); const [interval, setInterval] = useState(intervals[0]!);
return ( return (
@@ -123,7 +125,7 @@ function PricingItem(
plan: { plan: {
id: string; id: string;
lineItems: z.infer<typeof LineItemSchema>[]; lineItems: z.infer<typeof LineItemSchema>[];
interval?: string; interval?: Interval;
name?: string; name?: string;
href?: string; href?: string;
label?: string; label?: string;
@@ -156,6 +158,8 @@ function PricingItem(
return item.type !== 'flat'; return item.type !== 'flat';
}); });
const interval = props.plan.interval as Interval;
return ( return (
<div <div
data-cy={'subscription-plan'} data-cy={'subscription-plan'}
@@ -209,15 +213,14 @@ function PricingItem(
<Separator /> <Separator />
<div className={'flex flex-col space-y-1'}> <div className={'flex flex-col space-y-2'}>
<Price> <Price>
{lineItem ? ( <LineItemPrice
formatCurrency(props.product.currency, lineItem.cost) plan={props.plan}
) : props.plan.label ? ( product={props.product}
<Trans i18nKey={props.plan.label} defaults={props.plan.label} /> interval={interval}
) : ( lineItem={lineItem}
<Trans i18nKey={'billing:custom'} /> />
)}
</Price> </Price>
<If condition={props.plan.name}> <If condition={props.plan.name}>
@@ -337,15 +340,19 @@ function FeaturesList(
function Price({ children }: React.PropsWithChildren) { function Price({ children }: React.PropsWithChildren) {
return ( return (
<div <div
className={`animate-in slide-in-from-left-4 fade-in items-center duration-500`} className={`animate-in slide-in-from-left-4 fade-in flex items-end gap-2 duration-500`}
> >
<span <span
className={ className={
'font-heading flex items-center text-3xl font-bold tracking-tighter lg:text-4xl' 'font-heading flex items-center text-3xl font-semibold tracking-tighter'
} }
> >
{children} {children}
</span> </span>
<span className={'text-muted-foreground text-sm leading-loose'}>
<Trans i18nKey={'billing:perMonth'} />
</span>
</div> </div>
); );
} }
@@ -368,9 +375,9 @@ function ListItem({ children }: React.PropsWithChildren) {
function PlanIntervalSwitcher( function PlanIntervalSwitcher(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
intervals: string[]; intervals: Interval[];
interval: string; interval: Interval;
setInterval: (interval: string) => void; setInterval: (interval: Interval) => void;
}>, }>,
) { ) {
return ( return (
@@ -470,3 +477,40 @@ function DefaultCheckoutButton(
</Link> </Link>
); );
} }
function LineItemPrice({
lineItem,
plan,
interval,
product,
}: {
lineItem: z.infer<typeof LineItemSchema> | undefined;
plan: {
label?: string;
};
interval: Interval | undefined;
product: {
currency: string;
};
}) {
const { i18n } = useTranslation();
const isYearlyPricing = interval === 'year';
const cost = lineItem
? isYearlyPricing
? Number(lineItem.cost / 12).toFixed(2)
: lineItem?.cost
: 0;
const costString = lineItem && formatCurrency({
currencyCode: product.currency,
locale: i18n.language,
value: cost,
});
const labelString = plan.label && (
<Trans i18nKey={plan.label} defaults={plan.label} />
);
return costString ?? labelString ?? <Trans i18nKey={'billing:custom'} />;
}

View File

@@ -1,7 +1,8 @@
{ {
"extends": "@kit/tsconfig/base.json", "extends": "@kit/tsconfig/base.json",
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"types": ["react/experimental"]
}, },
"include": ["*.ts", "src"], "include": ["*.ts", "src"],
"exclude": ["node_modules"] "exclude": ["node_modules"]

View File

@@ -103,7 +103,7 @@ export function AccountSelector({
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className={cn( className={cn(
'dark:shadow-primary/10 group w-auto min-w-0 max-w-fit px-2', 'dark:shadow-primary/10 group w-full lg:w-auto min-w-0 lg:max-w-fit px-2',
{ {
'justify-start': !collapsed, 'justify-start': !collapsed,
'justify-center': collapsed, 'justify-center': collapsed,

View File

@@ -93,6 +93,7 @@ export function PersonalAccountDropdown({
)} )}
> >
<ProfileAvatar <ProfileAvatar
className={'border border-transparent group-hover:border-primary/10'}
displayName={displayName ?? user?.email ?? ''} displayName={displayName ?? user?.email ?? ''}
pictureUrl={personalAccountData?.picture_url} pictureUrl={personalAccountData?.picture_url}
/> />
@@ -153,7 +154,7 @@ export function PersonalAccountDropdown({
<Home className={'h-5'} /> <Home className={'h-5'} />
<span> <span>
<Trans i18nKey={'common:homeTabLabel'} /> <Trans i18nKey={'common:routes.home'} />
</span> </span>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -43,7 +43,7 @@ export function PersonalAccountSettingsContainer(
} }
return ( return (
<div className={'flex w-full flex-col space-y-6 pb-32'}> <div className={'flex w-full flex-col space-y-4 pb-32'}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
@@ -148,7 +148,7 @@ export function PersonalAccountSettingsContainer(
</Card> </Card>
<If condition={props.features.enableAccountDeletion}> <If condition={props.features.enableAccountDeletion}>
<Card className={'border-destructive border-2'}> <Card className={'border-destructive'}>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
<Trans i18nKey={'account:dangerZone'} /> <Trans i18nKey={'account:dangerZone'} />

View File

@@ -1,8 +1,15 @@
import { BadgeX, Ban, ShieldPlus, VenetianMask } from 'lucide-react'; import {
BadgeX,
Ban,
CreditCardIcon,
ShieldPlus,
VenetianMask,
} from 'lucide-react';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
@@ -58,6 +65,13 @@ async function PersonalAccountPage(props: { account: Account }) {
return ( return (
<div className={'flex flex-col space-y-4'}> <div className={'flex flex-col space-y-4'}>
<AppBreadcrumbs
values={{
[props.account.id]:
props.account.name ?? props.account.email ?? 'Account',
}}
/>
<div className={'flex items-center justify-between'}> <div className={'flex items-center justify-between'}>
<div className={'flex items-center space-x-4'}> <div className={'flex items-center space-x-4'}>
<div className={'flex items-center space-x-2.5'}> <div className={'flex items-center space-x-2.5'}>
@@ -66,7 +80,9 @@ async function PersonalAccountPage(props: { account: Account }) {
displayName={props.account.name} displayName={props.account.name}
/> />
<span>{props.account.name}</span> <span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div> </div>
<Badge variant={'outline'}>Personal Account</Badge> <Badge variant={'outline'}>Personal Account</Badge>
@@ -115,9 +131,7 @@ async function PersonalAccountPage(props: { account: Account }) {
<SubscriptionsTable accountId={props.account.id} /> <SubscriptionsTable accountId={props.account.id} />
<div className={'divider-divider-x flex flex-col space-y-2.5'}> <div className={'divider-divider-x flex flex-col space-y-2.5'}>
<Heading className={'font-bold'} level={5}> <Heading level={6}>Teams</Heading>
Teams
</Heading>
<div> <div>
<AdminMembershipsTable memberships={memberships} /> <AdminMembershipsTable memberships={memberships} />
@@ -135,6 +149,13 @@ async function TeamAccountPage(props: {
return ( return (
<div className={'flex flex-col space-y-4'}> <div className={'flex flex-col space-y-4'}>
<AppBreadcrumbs
values={{
[props.account.id]:
props.account.name ?? props.account.email ?? 'Account',
}}
/>
<div className={'flex justify-between'}> <div className={'flex justify-between'}>
<div className={'flex items-center space-x-4'}> <div className={'flex items-center space-x-4'}>
<div className={'flex items-center space-x-2.5'}> <div className={'flex items-center space-x-2.5'}>
@@ -143,7 +164,9 @@ async function TeamAccountPage(props: {
displayName={props.account.name} displayName={props.account.name}
/> />
<span>{props.account.name}</span> <span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div> </div>
<Badge variant={'outline'}>Team Account</Badge> <Badge variant={'outline'}>Team Account</Badge>
@@ -162,9 +185,7 @@ async function TeamAccountPage(props: {
<SubscriptionsTable accountId={props.account.id} /> <SubscriptionsTable accountId={props.account.id} />
<div className={'flex flex-col space-y-2.5'}> <div className={'flex flex-col space-y-2.5'}>
<Heading className={'font-bold'} level={5}> <Heading level={6}>Team Members</Heading>
Team Members
</Heading>
<AdminMembersTable members={members} /> <AdminMembersTable members={members} />
</div> </div>
@@ -199,14 +220,14 @@ async function SubscriptionsTable(props: { accountId: string }) {
return ( return (
<div className={'flex flex-col space-y-2.5'}> <div className={'flex flex-col space-y-2.5'}>
<Heading className={'font-bold'} level={5}> <Heading level={6}>Subscription</Heading>
Subscription
</Heading>
<If <If
condition={subscription} condition={subscription}
fallback={ fallback={
<Alert> <Alert variant={'warning'}>
<CreditCardIcon className={'h-4'} />
<AlertTitle>No subscription found for this account.</AlertTitle> <AlertTitle>No subscription found for this account.</AlertTitle>
<AlertDescription> <AlertDescription>

View File

@@ -58,7 +58,9 @@ export function AdminAccountsTable(
) { ) {
return ( return (
<div className={'flex flex-col space-y-4'}> <div className={'flex flex-col space-y-4'}>
<AccountsTableFilters filters={props.filters} /> <div className={'flex justify-end'}>
<AccountsTableFilters filters={props.filters} />
</div>
<DataTable <DataTable
pageSize={props.pageSize} pageSize={props.pageSize}
@@ -99,62 +101,58 @@ function AccountsTableFilters(props: {
}; };
return ( return (
<div className={'flex items-center justify-between space-x-4'}> <Form {...form}>
<div className={'flex space-x-4'}> <form
<Form {...form}> className={'flex gap-2.5'}
<form onSubmit={form.handleSubmit((data) => onSubmit(data))}
className={'flex space-x-4'} >
onSubmit={form.handleSubmit((data) => onSubmit(data))} <Select
> value={form.watch('type')}
<Select onValueChange={(value) => {
value={form.watch('type')} form.setValue(
onValueChange={(value) => { 'type',
form.setValue( value as z.infer<typeof FiltersSchema>['type'],
'type', {
value as z.infer<typeof FiltersSchema>['type'], shouldValidate: true,
{ shouldDirty: true,
shouldValidate: true, shouldTouch: true,
shouldDirty: true, },
shouldTouch: true, );
},
);
return onSubmit(form.getValues()); return onSubmit(form.getValues());
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={'Account Type'} /> <SelectValue placeholder={'Account Type'} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel>Account Type</SelectLabel> <SelectLabel>Account Type</SelectLabel>
<SelectItem value={'all'}>All accounts</SelectItem> <SelectItem value={'all'}>All accounts</SelectItem>
<SelectItem value={'team'}>Team</SelectItem> <SelectItem value={'team'}>Team</SelectItem>
<SelectItem value={'personal'}>Personal</SelectItem> <SelectItem value={'personal'}>Personal</SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<FormField <FormField
name={'query'} name={'query'}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl className={'w-full min-w-36 md:min-w-72'}> <FormControl className={'w-full min-w-36 md:min-w-80'}>
<Input <Input
className={'w-full'} className={'w-full'}
placeholder={`Search account...`} placeholder={`Search account...`}
{...field} {...field}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
</form> </form>
</Form> </Form>
</div>
</div>
); );
} }

View File

@@ -47,7 +47,7 @@ export function UpdatePasswordForm(params: { redirectTo: string }) {
return ( return (
<div className={'flex w-full flex-col space-y-6'}> <div className={'flex w-full flex-col space-y-6'}>
<div className={'flex justify-center'}> <div className={'flex justify-center'}>
<Heading level={5}> <Heading level={5} className={'tracking-tight'}>
<Trans i18nKey={'auth:passwordResetLabel'} /> <Trans i18nKey={'auth:passwordResetLabel'} />
</Heading> </Heading>
</div> </div>

View File

@@ -31,7 +31,7 @@ export function TeamAccountSettingsContainer(props: {
} }
}) { }) {
return ( return (
<div className={'flex w-full flex-col space-y-6'}> <div className={'flex w-full flex-col space-y-4'}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
@@ -67,7 +67,7 @@ export function TeamAccountSettingsContainer(props: {
</CardContent> </CardContent>
</Card> </Card>
<Card className={'border-destructive border-2'}> <Card className={'border-destructive border'}>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
<Trans i18nKey={'teams:settings.dangerZone'} /> <Trans i18nKey={'teams:settings.dangerZone'} />

View File

@@ -6,12 +6,16 @@ export function isBrowser() {
} }
/** /**
*@name formatCurrency * @name formatCurrency
* @description Format the currency based on the currency code * @description Format the currency based on the currency code
*/ */
export function formatCurrency(currencyCode: string, value: string | number) { export function formatCurrency(params: {
return new Intl.NumberFormat('en-US', { currencyCode: string;
locale: string;
value: string | number;
}) {
return new Intl.NumberFormat(params.locale, {
style: 'currency', style: 'currency',
currency: currencyCode, currency: params.currencyCode,
}).format(Number(value)); }).format(Number(params.value));
} }

View File

@@ -11,8 +11,16 @@ const ASSURANCE_LEVEL_2 = 'aal2';
export async function checkRequiresMultiFactorAuthentication( export async function checkRequiresMultiFactorAuthentication(
client: SupabaseClient, client: SupabaseClient,
) { ) {
// Suppress the getSession warning. Remove when the issue is fixed.
// https://github.com/supabase/auth-js/issues/873
// @ts-expect-error: suppressGetSessionWarning is not part of the public API
client.auth.suppressGetSessionWarning = true;
const assuranceLevel = await client.auth.mfa.getAuthenticatorAssuranceLevel(); const assuranceLevel = await client.auth.mfa.getAuthenticatorAssuranceLevel();
// @ts-expect-error: suppressGetSessionWarning is not part of the public API
client.auth.suppressGetSessionWarning = false;
if (assuranceLevel.error) { if (assuranceLevel.error) {
throw new Error(assuranceLevel.error.message); throw new Error(assuranceLevel.error.message);
} }

View File

@@ -33,6 +33,7 @@
"input-otp": "1.2.4", "input-otp": "1.2.4",
"lucide-react": "^0.418.0", "lucide-react": "^0.418.0",
"react-top-loading-bar": "2.3.1", "react-top-loading-bar": "2.3.1",
"recharts": "^2.12.7",
"tailwind-merge": "^2.4.0" "tailwind-merge": "^2.4.0"
}, },
"devDependencies": { "devDependencies": {
@@ -100,6 +101,8 @@
"./input-otp": "./src/shadcn/input-otp.tsx", "./input-otp": "./src/shadcn/input-otp.tsx",
"./textarea": "./src/shadcn/textarea.tsx", "./textarea": "./src/shadcn/textarea.tsx",
"./switch": "./src/shadcn/switch.tsx", "./switch": "./src/shadcn/switch.tsx",
"./breadcrumb": "./src/shadcn/breadcrumb.tsx",
"./chart": "./src/shadcn/chart.tsx",
"./utils": "./src/utils/index.ts", "./utils": "./src/utils/index.ts",
"./if": "./src/makerkit/if.tsx", "./if": "./src/makerkit/if.tsx",
"./trans": "./src/makerkit/trans.tsx", "./trans": "./src/makerkit/trans.tsx",
@@ -122,6 +125,8 @@
"./card-button": "./src/makerkit/card-button.tsx", "./card-button": "./src/makerkit/card-button.tsx",
"./version-updater": "./src/makerkit/version-updater.tsx", "./version-updater": "./src/makerkit/version-updater.tsx",
"./multi-step-form": "./src/makerkit/multi-step-form.tsx", "./multi-step-form": "./src/makerkit/multi-step-form.tsx",
"./app-breadcrumbs": "./src/makerkit/app-breadcrumbs.tsx",
"./empty-state": "./src/makerkit/empty-state.tsx",
"./marketing": "./src/makerkit/marketing/index.tsx" "./marketing": "./src/makerkit/marketing/index.tsx"
}, },
"typesVersions": { "typesVersions": {

View File

@@ -0,0 +1,88 @@
'use client';
import { usePathname } from 'next/navigation';
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from '../shadcn/breadcrumb';
import { If } from './if';
import { Trans } from './trans';
import { Fragment } from 'react';
const unslugify = (slug: string) => slug.replace(/-/g, ' ');
export function AppBreadcrumbs(props: {
values?: Record<string, string>;
maxDepth?: number;
}) {
const pathName = usePathname();
const splitPath = pathName.split('/').filter(Boolean);
const values = props.values ?? {};
const maxDepth = props.maxDepth ?? 6;
const Ellipsis = (
<BreadcrumbItem>
<BreadcrumbEllipsis className="h-4 w-4" />
</BreadcrumbItem>
);
const showEllipsis = splitPath.length > maxDepth;
const visiblePaths = showEllipsis
? ([splitPath[0], ...splitPath.slice(-maxDepth + 1)] as string[])
: splitPath;
return (
<Breadcrumb>
<BreadcrumbList>
{visiblePaths.map((path, index) => {
const label =
path in values ? (
values[path]
) : (
<Trans
i18nKey={`common.routes.${unslugify(path)}`}
defaults={unslugify(path)}
/>
);
return (
<Fragment key={index}>
<BreadcrumbItem className={'capitalize lg:text-xs'}>
<If
condition={index < visiblePaths.length - 1}
fallback={label}
>
<BreadcrumbLink
href={
'/' +
splitPath.slice(0, splitPath.indexOf(path) + 1).join('/')
}
>
{label}
</BreadcrumbLink>
</If>
</BreadcrumbItem>
{index === 0 && showEllipsis && (
<>
<BreadcrumbSeparator />
{Ellipsis}
</>
)}
<If condition={index !== visiblePaths.length - 1}>
<BreadcrumbSeparator />
</If>
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Button } from '../shadcn/button';
import { cn } from '../utils';
const EmptyStateHeading = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-bold tracking-tight', className)}
{...props}
/>
));
EmptyStateHeading.displayName = 'EmptyStateHeading';
const EmptyStateText = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
EmptyStateText.displayName = 'EmptyStateText';
const EmptyStateButton = React.forwardRef<
HTMLButtonElement,
React.ComponentPropsWithoutRef<typeof Button>
>(({ className, ...props }, ref) => (
<Button ref={ref} className={cn('mt-4', className)} {...props} />
));
EmptyStateButton.displayName = 'EmptyStateButton';
const EmptyState = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ children, className, ...props }, ref) => {
const childrenArray = React.Children.toArray(children);
const heading = childrenArray.find(
(child) => React.isValidElement(child) && child.type === EmptyStateHeading,
);
const text = childrenArray.find(
(child) => React.isValidElement(child) && child.type === EmptyStateText,
);
const button = childrenArray.find(
(child) => React.isValidElement(child) && child.type === EmptyStateButton,
);
const cmps = [EmptyStateHeading, EmptyStateText, EmptyStateButton];
const otherChildren = childrenArray.filter(
(child) =>
React.isValidElement(child) &&
!cmps.includes(child.type as (typeof cmps)[number]),
);
return (
<div
ref={ref}
className={cn(
'flex flex-1 items-center justify-center rounded-lg border border-dashed shadow-sm',
className,
)}
{...props}
>
<div className="flex flex-col items-center gap-1 text-center">
{heading}
{text}
{button}
{otherChildren}
</div>
</div>
);
});
EmptyState.displayName = 'EmptyState';
export { EmptyState, EmptyStateHeading, EmptyStateText, EmptyStateButton };

View File

@@ -7,6 +7,7 @@ export function GlobalLoader({
fullPage = false, fullPage = false,
displaySpinner = true, displaySpinner = true,
displayTopLoadingBar = true, displayTopLoadingBar = true,
children,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
displayLogo?: boolean; displayLogo?: boolean;
fullPage?: boolean; fullPage?: boolean;
@@ -26,6 +27,8 @@ export function GlobalLoader({
} }
> >
<LoadingOverlay displayLogo={displayLogo} fullPage={fullPage} /> <LoadingOverlay displayLogo={displayLogo} fullPage={fullPage} />
{children}
</div> </div>
</If> </If>
</> </>

View File

@@ -27,7 +27,7 @@ export function LoadingOverlay({
> >
<Spinner className={spinnerClassName} /> <Spinner className={spinnerClassName} />
<div>{children}</div> <div className={'text-sm text-muted-foreground'}>{children}</div>
</div> </div>
); );
} }

View File

@@ -13,9 +13,6 @@ type PageProps = React.PropsWithChildren<{
export function Page(props: PageProps) { export function Page(props: PageProps) {
switch (props.style) { switch (props.style) {
case 'sidebar':
return <PageWithSidebar {...props} />;
case 'header': case 'header':
return <PageWithHeader {...props} />; return <PageWithHeader {...props} />;
@@ -31,7 +28,9 @@ function PageWithSidebar(props: PageProps) {
const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props); const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props);
return ( return (
<div className={cn('flex', props.className)}> <div
className={cn('flex bg-gray-50/50 dark:bg-background', props.className)}
>
{Navigation} {Navigation}
<div <div
@@ -42,7 +41,13 @@ function PageWithSidebar(props: PageProps) {
> >
{MobileNavigation} {MobileNavigation}
<div className={'flex flex-1 flex-col space-y-4'}>{Children}</div> <div
className={
'flex flex-1 flex-col overflow-y-auto bg-background lg:m-1.5 lg:ml-0 lg:rounded-lg lg:border'
}
>
{Children}
</div>
</div> </div>
</div> </div>
); );
@@ -54,7 +59,7 @@ export function PageMobileNavigation(
}>, }>,
) { ) {
return ( return (
<div className={cn('w-full py-2 lg:hidden', props.className)}> <div className={cn('w-full py-2 lg:hidden flex items-center border-b', props.className)}>
{props.children} {props.children}
</div> </div>
); );
@@ -72,7 +77,7 @@ function PageWithHeader(props: PageProps) {
> >
<div <div
className={cn( className={cn(
'dark:border-primary-900 flex h-14 items-center justify-between bg-muted/30 px-4 shadow-sm dark:shadow-primary/10 lg:justify-start', 'flex h-14 items-center justify-between bg-muted/40 px-4 lg:shadow-sm dark:border-border dark:shadow-primary/10 lg:justify-start',
{ {
'sticky top-0 z-10 backdrop-blur-md': props.sticky ?? true, 'sticky top-0 z-10 backdrop-blur-md': props.sticky ?? true,
}, },
@@ -87,13 +92,7 @@ function PageWithHeader(props: PageProps) {
{MobileNavigation} {MobileNavigation}
</div> </div>
<div <div className={'container flex flex-1 flex-col'}>{Children}</div>
className={
'flex h-screen flex-1 flex-col space-y-8 px-4 py-4 lg:container'
}
>
{Children}
</div>
</div> </div>
</div> </div>
); );
@@ -115,13 +114,11 @@ export function PageNavigation(props: React.PropsWithChildren) {
export function PageDescription(props: React.PropsWithChildren) { export function PageDescription(props: React.PropsWithChildren) {
return ( return (
<h2 className={'hidden lg:block'}> <div className={'h-6'}>
<span <div className={'text-xs font-normal leading-none text-muted-foreground'}>
className={'text-base font-medium leading-none text-muted-foreground'}
>
{props.children} {props.children}
</span> </div>
</h2> </div>
); );
} }
@@ -129,7 +126,7 @@ export function PageTitle(props: React.PropsWithChildren) {
return ( return (
<h1 <h1
className={ className={
'font-heading text-2xl font-semibold leading-none dark:text-white' 'h-6 font-heading font-bold leading-none tracking-tight dark:text-white'
} }
> >
{props.children} {props.children}
@@ -137,6 +134,10 @@ export function PageTitle(props: React.PropsWithChildren) {
); );
} }
export function PageHeaderActions(props: React.PropsWithChildren) {
return <div className={'flex items-center space-x-2'}>{props.children}</div>;
}
export function PageHeader({ export function PageHeader({
children, children,
title, title,
@@ -150,17 +151,14 @@ export function PageHeader({
return ( return (
<div <div
className={cn( className={cn(
'flex h-20 items-center justify-between lg:px-4', 'flex items-center justify-between lg:px-4 py-4',
className, className,
)} )}
> >
{title ? ( <div className={'flex flex-col'}>
<div className={'flex flex-col space-y-1.5'}> <PageDescription>{description}</PageDescription>
<PageTitle>{title}</PageTitle> <PageTitle>{title}</PageTitle>
</div>
<PageDescription>{description}</PageDescription>
</div>
) : null}
{children} {children}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from '../shadcn/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '../shadcn/avatar';
import {cn} from "../utils";
type SessionProps = { type SessionProps = {
displayName: string | null; displayName: string | null;
@@ -9,10 +10,12 @@ type TextProps = {
text: string; text: string;
}; };
type ProfileAvatarProps = SessionProps | TextProps; type ProfileAvatarProps = (SessionProps | TextProps) & {
className?: string;
};
export function ProfileAvatar(props: ProfileAvatarProps) { export function ProfileAvatar(props: ProfileAvatarProps) {
const avatarClassName = 'mx-auto w-9 h-9 group-focus:ring-2'; const avatarClassName = cn(props.className, 'mx-auto w-9 h-9 group-focus:ring-2');
if ('text' in props) { if ('text' in props) {
return ( return (

View File

@@ -60,7 +60,7 @@ export function SidebarContent({
className?: string; className?: string;
}>) { }>) {
return ( return (
<div className={cn('flex w-full flex-col space-y-1.5 px-4', className)}> <div className={cn('flex w-full flex-col space-y-1.5 px-4 py-1', className)}>
{children} {children}
</div> </div>
); );
@@ -167,8 +167,9 @@ export function SidebarItem({
return ( return (
<Button <Button
asChild asChild
className={cn('flex w-full text-sm shadow-none', { className={cn('flex w-full text-sm shadow-none active:bg-secondary/60', {
'justify-start space-x-2.5': !collapsed, 'justify-start space-x-2.5': !collapsed,
'hover:bg-initial': active,
})} })}
size={size} size={size}
variant={variant} variant={variant}
@@ -196,7 +197,7 @@ function getClassNameBuilder(className: string) {
return cva( return cva(
[ [
cn( cn(
'flex box-content h-screen flex-col relative shadow-sm border-r', 'flex box-content h-screen flex-col relative',
className, className,
), ),
], ],

View File

@@ -10,7 +10,7 @@ export function Spinner(
<svg <svg
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
`h-10 w-10 animate-spin fill-primary-foreground text-primary dark:fill-primary dark:text-primary/30`, `h-8 w-8 animate-spin fill-primary-foreground text-primary dark:fill-primary dark:text-primary/30`,
props.className, props.className,
)} )}
viewBox="0 0 100 101" viewBox="0 0 100 101"

View File

@@ -0,0 +1,116 @@
import * as React from 'react';
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '../utils';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground',
className,
)}
{...props}
/>
));
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
));
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn('transition-colors text-foreground hover:underline', className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
));
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
'rounded-xl border bg-card text-card-foreground shadow-sm', 'rounded-xl border bg-card text-card-foreground',
className, className,
)} )}
{...props} {...props}

View File

@@ -0,0 +1,366 @@
'use client';
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from '../utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = 'Chart';
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme ?? config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel ?? !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? (config[label]?.label ?? label)
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active ?? !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
ref={ref}
className={cn(
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color ?? item.payload.fill ?? item.color;
return (
<div
key={item.dataKey}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = 'ChartTooltip';
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
/* eslint-disable @typescript-eslint/restrict-template-expressions */
const key = `${nameKey ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = 'ChartLegend';
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || !payload) {
return undefined;
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

15
pnpm-lock.yaml generated
View File

@@ -1254,6 +1254,9 @@ importers:
react-top-loading-bar: react-top-loading-bar:
specifier: 2.3.1 specifier: 2.3.1
version: 2.3.1(react@18.3.1) version: 2.3.1(react@18.3.1)
recharts:
specifier: ^2.12.7
version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge: tailwind-merge:
specifier: ^2.4.0 specifier: ^2.4.0
version: 2.4.0 version: 2.4.0
@@ -1771,6 +1774,7 @@ packages:
'@humanwhocodes/config-array@0.11.14': '@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
deprecated: Use @eslint/config-array instead
'@humanwhocodes/module-importer@1.0.1': '@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
@@ -1778,6 +1782,7 @@ packages:
'@humanwhocodes/object-schema@2.0.3': '@humanwhocodes/object-schema@2.0.3':
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
deprecated: Use @eslint/object-schema instead
'@ianvs/prettier-plugin-sort-imports@4.3.1': '@ianvs/prettier-plugin-sort-imports@4.3.1':
resolution: {integrity: sha512-ZHwbyjkANZOjaBm3ZosADD2OUYGFzQGxfy67HmGZU94mHqe7g1LCMA7YYKB1Cq+UTPCBqlAYapY0KXAjKEw8Sg==} resolution: {integrity: sha512-ZHwbyjkANZOjaBm3ZosADD2OUYGFzQGxfy67HmGZU94mHqe7g1LCMA7YYKB1Cq+UTPCBqlAYapY0KXAjKEw8Sg==}
@@ -4010,9 +4015,6 @@ packages:
'@types/eslint@8.56.10': '@types/eslint@8.56.10':
resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==} resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==}
'@types/eslint@9.6.0':
resolution: {integrity: sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg==}
'@types/estree-jsx@1.0.5': '@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -11341,7 +11343,7 @@ snapshots:
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
dependencies: dependencies:
'@types/eslint': 9.6.0 '@types/eslint': 8.56.10
'@types/estree': 1.0.5 '@types/estree': 1.0.5
'@types/eslint@8.56.10': '@types/eslint@8.56.10':
@@ -11349,11 +11351,6 @@ snapshots:
'@types/estree': 1.0.5 '@types/estree': 1.0.5
'@types/json-schema': 7.0.15 '@types/json-schema': 7.0.15
'@types/eslint@9.6.0':
dependencies:
'@types/estree': 1.0.5
'@types/json-schema': 7.0.15
'@types/estree-jsx@1.0.5': '@types/estree-jsx@1.0.5':
dependencies: dependencies:
'@types/estree': 1.0.5 '@types/estree': 1.0.5

View File

@@ -19,12 +19,10 @@
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"", "format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": {
},
"devDependencies": { "devDependencies": {
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": ""workspace:*" "@kit/tsconfig": "workspace:*"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [