Merge remote-tracking branch 'origin/main'

# Conflicts:
#	packages/ui/package.json
#	packages/ui/src/shadcn/breadcrumb.tsx
This commit is contained in:
gbuomprisco
2024-08-04 17:26:29 +02:00
53 changed files with 1678 additions and 509 deletions

View File

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

View File

@@ -37,11 +37,11 @@ For more info: https://github.com/tailwindlabs/tailwindcss/issues/3258#issuecomm
}
.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 {
@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 {

View File

@@ -52,9 +52,14 @@ async function DocumentationPage({ params }: PageParams) {
return (
<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}>
<ContentRenderer content={page.content} />
</article>

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,12 @@ import {
CardButtonHeader,
CardButtonTitle,
} 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 { loadUserWorkspace } from '../_lib/server/load-user-workspace';
@@ -39,21 +44,18 @@ export function HomeAccountsList() {
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}>
<div className={'flex flex-1'}>
<EmptyState>
<EmptyStateButton asChild>
<HomeAddAccountButton className={'mt-4'} />
</EmptyStateButton>
<EmptyStateHeading>
<Trans i18nKey={'account:noTeamsYet'} />
</Heading>
<Heading
className="font-sans font-medium text-muted-foreground"
level={4}
>
</EmptyStateHeading>
<EmptyStateText>
<Trans i18nKey={'account:createTeam'} />
</Heading>
</div>
<HomeAddAccountButton />
</EmptyStateText>
</EmptyState>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { Trans } from '@kit/ui/trans';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { withI18n } from '~/lib/i18n/with-i18n';
// local imports
@@ -9,8 +10,8 @@ function UserSettingsLayout(props: React.PropsWithChildren) {
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'account:accountTabLabel'} />}
description={<Trans i18nKey={'account:accountTabDescription'} />}
title={<Trans i18nKey={'account:routes.settings'} />}
description={<AppBreadcrumbs />}
/>
{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 { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
@@ -20,8 +21,8 @@ export default function BillingErrorPage({
return (
<>
<PageHeader
title={<Trans i18nKey={'common:billingTabLabel'} />}
description={<Trans i18nKey={'common:billingTabDescription'} />}
title={<Trans i18nKey={'common:routes.billing'} />}
description={<AppBreadcrumbs />}
/>
<PageBody>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,6 @@ publishedAt: 2024-04-12
status: "published"
---
# Brainstorming Ideas for Your Next Micro SaaS
## 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.

View File

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

View File

@@ -1,7 +1,7 @@
{
"homeTabLabel": "Home",
"homeTabDescription": "Welcome to your home page",
"accountMembers": "Members",
"accountMembers": "Team Members",
"membersTabDescription": "Here you can manage the members of your team.",
"billingTabLabel": "Billing",
"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.",
"newVersionSubmitButton": "Reload and Update",
"back": "Back",
"routes": {
"home": "Home",
"account": "Account",
"members": "Members",
"billing": "Billing",
"dashboard": "Dashboard",
"settings": "Settings",
"profile": "Profile"
},
"roles": {
"owner": {
"label": "Owner"

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,8 @@ interface Paths {
return: string;
}
type Interval = 'month' | 'year';
export function PricingTable({
config,
paths,
@@ -48,7 +50,7 @@ export function PricingTable({
highlighted?: boolean;
}>;
}) {
const intervals = getPlanIntervals(config).filter(Boolean) as string[];
const intervals = getPlanIntervals(config).filter(Boolean) as Interval[];
const [interval, setInterval] = useState(intervals[0]!);
return (
@@ -123,7 +125,7 @@ function PricingItem(
plan: {
id: string;
lineItems: z.infer<typeof LineItemSchema>[];
interval?: string;
interval?: Interval;
name?: string;
href?: string;
label?: string;
@@ -156,6 +158,8 @@ function PricingItem(
return item.type !== 'flat';
});
const interval = props.plan.interval as Interval;
return (
<div
data-cy={'subscription-plan'}
@@ -209,15 +213,14 @@ function PricingItem(
<Separator />
<div className={'flex flex-col space-y-1'}>
<div className={'flex flex-col space-y-2'}>
<Price>
{lineItem ? (
formatCurrency(props.product.currency, lineItem.cost)
) : props.plan.label ? (
<Trans i18nKey={props.plan.label} defaults={props.plan.label} />
) : (
<Trans i18nKey={'billing:custom'} />
)}
<LineItemPrice
plan={props.plan}
product={props.product}
interval={interval}
lineItem={lineItem}
/>
</Price>
<If condition={props.plan.name}>
@@ -337,15 +340,19 @@ function FeaturesList(
function Price({ children }: React.PropsWithChildren) {
return (
<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
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}
</span>
<span className={'text-muted-foreground text-sm leading-loose'}>
<Trans i18nKey={'billing:perMonth'} />
</span>
</div>
);
}
@@ -368,9 +375,9 @@ function ListItem({ children }: React.PropsWithChildren) {
function PlanIntervalSwitcher(
props: React.PropsWithChildren<{
intervals: string[];
interval: string;
setInterval: (interval: string) => void;
intervals: Interval[];
interval: Interval;
setInterval: (interval: Interval) => void;
}>,
) {
return (
@@ -470,3 +477,40 @@ function DefaultCheckoutButton(
</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",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"types": ["react/experimental"]
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]

View File

@@ -103,7 +103,7 @@ export function AccountSelector({
role="combobox"
aria-expanded={open}
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-center': collapsed,

View File

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

View File

@@ -43,7 +43,7 @@ export function PersonalAccountSettingsContainer(
}
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>
<CardHeader>
<CardTitle>
@@ -148,7 +148,7 @@ export function PersonalAccountSettingsContainer(
</Card>
<If condition={props.features.enableAccountDeletion}>
<Card className={'border-destructive border-2'}>
<Card className={'border-destructive'}>
<CardHeader>
<CardTitle>
<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 { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
@@ -58,6 +65,13 @@ async function PersonalAccountPage(props: { account: Account }) {
return (
<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 space-x-4'}>
<div className={'flex items-center space-x-2.5'}>
@@ -66,7 +80,9 @@ async function PersonalAccountPage(props: { account: Account }) {
displayName={props.account.name}
/>
<span>{props.account.name}</span>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<Badge variant={'outline'}>Personal Account</Badge>
@@ -115,9 +131,7 @@ async function PersonalAccountPage(props: { account: Account }) {
<SubscriptionsTable accountId={props.account.id} />
<div className={'divider-divider-x flex flex-col space-y-2.5'}>
<Heading className={'font-bold'} level={5}>
Teams
</Heading>
<Heading level={6}>Teams</Heading>
<div>
<AdminMembershipsTable memberships={memberships} />
@@ -135,6 +149,13 @@ async function TeamAccountPage(props: {
return (
<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 items-center space-x-4'}>
<div className={'flex items-center space-x-2.5'}>
@@ -143,7 +164,9 @@ async function TeamAccountPage(props: {
displayName={props.account.name}
/>
<span>{props.account.name}</span>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<Badge variant={'outline'}>Team Account</Badge>
@@ -162,9 +185,7 @@ async function TeamAccountPage(props: {
<SubscriptionsTable accountId={props.account.id} />
<div className={'flex flex-col space-y-2.5'}>
<Heading className={'font-bold'} level={5}>
Team Members
</Heading>
<Heading level={6}>Team Members</Heading>
<AdminMembersTable members={members} />
</div>
@@ -199,14 +220,14 @@ async function SubscriptionsTable(props: { accountId: string }) {
return (
<div className={'flex flex-col space-y-2.5'}>
<Heading className={'font-bold'} level={5}>
Subscription
</Heading>
<Heading level={6}>Subscription</Heading>
<If
condition={subscription}
fallback={
<Alert>
<Alert variant={'warning'}>
<CreditCardIcon className={'h-4'} />
<AlertTitle>No subscription found for this account.</AlertTitle>
<AlertDescription>

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ export function TeamAccountSettingsContainer(props: {
}
}) {
return (
<div className={'flex w-full flex-col space-y-6'}>
<div className={'flex w-full flex-col space-y-4'}>
<Card>
<CardHeader>
<CardTitle>
@@ -67,7 +67,7 @@ export function TeamAccountSettingsContainer(props: {
</CardContent>
</Card>
<Card className={'border-destructive border-2'}>
<Card className={'border-destructive border'}>
<CardHeader>
<CardTitle>
<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
*/
export function formatCurrency(currencyCode: string, value: string | number) {
return new Intl.NumberFormat('en-US', {
export function formatCurrency(params: {
currencyCode: string;
locale: string;
value: string | number;
}) {
return new Intl.NumberFormat(params.locale, {
style: 'currency',
currency: currencyCode,
}).format(Number(value));
currency: params.currencyCode,
}).format(Number(params.value));
}

View File

@@ -11,8 +11,16 @@ const ASSURANCE_LEVEL_2 = 'aal2';
export async function checkRequiresMultiFactorAuthentication(
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();
// @ts-expect-error: suppressGetSessionWarning is not part of the public API
client.auth.suppressGetSessionWarning = false;
if (assuranceLevel.error) {
throw new Error(assuranceLevel.error.message);
}

View File

@@ -33,6 +33,7 @@
"input-otp": "1.2.4",
"lucide-react": "^0.418.0",
"react-top-loading-bar": "2.3.1",
"recharts": "^2.12.7",
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
@@ -101,6 +102,7 @@
"./textarea": "./src/shadcn/textarea.tsx",
"./switch": "./src/shadcn/switch.tsx",
"./breadcrumb": "./src/shadcn/breadcrumb.tsx",
"./chart": "./src/shadcn/chart.tsx",
"./utils": "./src/utils/index.ts",
"./if": "./src/makerkit/if.tsx",
"./trans": "./src/makerkit/trans.tsx",
@@ -123,6 +125,8 @@
"./card-button": "./src/makerkit/card-button.tsx",
"./version-updater": "./src/makerkit/version-updater.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"
},
"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,
displaySpinner = true,
displayTopLoadingBar = true,
children,
}: React.PropsWithChildren<{
displayLogo?: boolean;
fullPage?: boolean;
@@ -26,6 +27,8 @@ export function GlobalLoader({
}
>
<LoadingOverlay displayLogo={displayLogo} fullPage={fullPage} />
{children}
</div>
</If>
</>

View File

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

View File

@@ -13,9 +13,6 @@ type PageProps = React.PropsWithChildren<{
export function Page(props: PageProps) {
switch (props.style) {
case 'sidebar':
return <PageWithSidebar {...props} />;
case 'header':
return <PageWithHeader {...props} />;
@@ -31,7 +28,9 @@ function PageWithSidebar(props: PageProps) {
const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props);
return (
<div className={cn('flex', props.className)}>
<div
className={cn('flex bg-gray-50/50 dark:bg-background', props.className)}
>
{Navigation}
<div
@@ -42,7 +41,13 @@ function PageWithSidebar(props: PageProps) {
>
{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>
);
@@ -54,7 +59,7 @@ export function PageMobileNavigation(
}>,
) {
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}
</div>
);
@@ -72,7 +77,7 @@ function PageWithHeader(props: PageProps) {
>
<div
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,
},
@@ -87,13 +92,7 @@ function PageWithHeader(props: PageProps) {
{MobileNavigation}
</div>
<div
className={
'flex h-screen flex-1 flex-col space-y-8 px-4 py-4 lg:container'
}
>
{Children}
</div>
<div className={'container flex flex-1 flex-col'}>{Children}</div>
</div>
</div>
);
@@ -115,13 +114,11 @@ export function PageNavigation(props: React.PropsWithChildren) {
export function PageDescription(props: React.PropsWithChildren) {
return (
<h2 className={'hidden lg:block'}>
<span
className={'text-base font-medium leading-none text-muted-foreground'}
>
<div className={'h-6'}>
<div className={'text-xs font-normal leading-none text-muted-foreground'}>
{props.children}
</span>
</h2>
</div>
</div>
);
}
@@ -129,7 +126,7 @@ export function PageTitle(props: React.PropsWithChildren) {
return (
<h1
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}
@@ -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({
children,
title,
@@ -150,17 +151,14 @@ export function PageHeader({
return (
<div
className={cn(
'flex h-20 items-center justify-between lg:px-4',
'flex items-center justify-between lg:px-4 py-4',
className,
)}
>
{title ? (
<div className={'flex flex-col space-y-1.5'}>
<PageTitle>{title}</PageTitle>
<PageDescription>{description}</PageDescription>
</div>
) : null}
<div className={'flex flex-col'}>
<PageDescription>{description}</PageDescription>
<PageTitle>{title}</PageTitle>
</div>
{children}
</div>

View File

@@ -1,4 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from '../shadcn/avatar';
import {cn} from "../utils";
type SessionProps = {
displayName: string | null;
@@ -9,10 +10,12 @@ type TextProps = {
text: string;
};
type ProfileAvatarProps = SessionProps | TextProps;
type ProfileAvatarProps = (SessionProps | TextProps) & {
className?: string;
};
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) {
return (

View File

@@ -60,7 +60,7 @@ export function SidebarContent({
className?: string;
}>) {
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}
</div>
);
@@ -167,8 +167,9 @@ export function SidebarItem({
return (
<Button
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,
'hover:bg-initial': active,
})}
size={size}
variant={variant}
@@ -196,7 +197,7 @@ function getClassNameBuilder(className: string) {
return cva(
[
cn(
'flex box-content h-screen flex-col relative shadow-sm border-r',
'flex box-content h-screen flex-col relative',
className,
),
],

View File

@@ -10,7 +10,7 @@ export function Spinner(
<svg
aria-hidden="true"
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,
)}
viewBox="0 0 100 101"
@@ -27,4 +27,4 @@ export function Spinner(
</svg>
</div>
);
}
}

View File

@@ -3,7 +3,7 @@ import * as React from 'react';
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@kit/ui/utils';
import { cn } from '../utils';
const Breadcrumb = React.forwardRef<
HTMLElement,
@@ -20,7 +20,7 @@ const BreadcrumbList = React.forwardRef<
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground',
className,
)}
{...props}
@@ -51,7 +51,7 @@ const BreadcrumbLink = React.forwardRef<
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
className={cn('transition-colors text-foreground hover:underline', className)}
{...props}
/>
);

View File

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

3
pnpm-lock.yaml generated
View File

@@ -1254,6 +1254,9 @@ importers:
react-top-loading-bar:
specifier: 2.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:
specifier: ^2.4.0
version: 2.4.0

View File

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