B2B-88: add starter kit structure and elements
This commit is contained in:
89
packages/ui/src/makerkit/app-breadcrumbs.tsx
Normal file
89
packages/ui/src/makerkit/app-breadcrumbs.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from '../shadcn/breadcrumb';
|
||||
import { If } from './if';
|
||||
import { Trans } from './trans';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
17
packages/ui/src/makerkit/authenticity-token.tsx
Normal file
17
packages/ui/src/makerkit/authenticity-token.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
export function AuthenticityToken() {
|
||||
const token = useCsrfToken();
|
||||
|
||||
return <input type="hidden" name="csrf_token" value={token} />;
|
||||
}
|
||||
|
||||
function useCsrfToken() {
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
return (
|
||||
document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content') ?? ''
|
||||
);
|
||||
}
|
||||
69
packages/ui/src/makerkit/bordered-navigation-menu.tsx
Normal file
69
packages/ui/src/makerkit/bordered-navigation-menu.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cn, isRouteActive } from '../lib/utils';
|
||||
import { Button } from '../shadcn/button';
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuList,
|
||||
} from '../shadcn/navigation-menu';
|
||||
import { Trans } from './trans';
|
||||
|
||||
export function BorderedNavigationMenu(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className={'relative h-full space-x-2'}>
|
||||
{props.children}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderedNavigationMenuItem(props: {
|
||||
path: string;
|
||||
label: React.ReactNode | string;
|
||||
end?: boolean | ((path: string) => boolean);
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const active = props.active ?? isRouteActive(props.path, pathname, props.end);
|
||||
|
||||
return (
|
||||
<NavigationMenuItem className={props.className}>
|
||||
<Button
|
||||
asChild
|
||||
variant={'ghost'}
|
||||
className={cn('relative active:shadow-xs', props.buttonClassName)}
|
||||
>
|
||||
<Link
|
||||
href={props.path}
|
||||
className={cn('text-sm', {
|
||||
'text-secondary-foreground': active,
|
||||
'text-secondary-foreground/80 hover:text-secondary-foreground':
|
||||
!active,
|
||||
})}
|
||||
>
|
||||
{typeof props.label === 'string' ? (
|
||||
<Trans i18nKey={props.label} defaults={props.label} />
|
||||
) : (
|
||||
props.label
|
||||
)}
|
||||
|
||||
{active ? (
|
||||
<span
|
||||
className={cn(
|
||||
'bg-primary animate-in fade-in zoom-in-90 absolute -bottom-2.5 left-0 h-0.5 w-full',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</Link>
|
||||
</Button>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
}
|
||||
117
packages/ui/src/makerkit/card-button.tsx
Normal file
117
packages/ui/src/makerkit/card-button.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Slot, Slottable } from '@radix-ui/react-slot';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export const CardButton: React.FC<
|
||||
{
|
||||
asChild?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
> = function CardButton({ className, asChild, ...props }) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
'group hover:bg-secondary/20 active:bg-secondary active:bg-secondary/50 dark:shadow-primary/20 relative flex h-36 flex-col rounded-lg border transition-all hover:shadow-xs active:shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Slottable>{props.children}</Slottable>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardButtonTitle: React.FC<
|
||||
{
|
||||
asChild?: boolean;
|
||||
children: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
> = function CardButtonTitle({ className, asChild, ...props }) {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
className,
|
||||
'text-muted-foreground group-hover:text-secondary-foreground align-super text-sm font-medium transition-colors',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Slottable>{props.children}</Slottable>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardButtonHeader: React.FC<
|
||||
{
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
displayArrow?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
> = function CardButtonHeader({
|
||||
className,
|
||||
asChild,
|
||||
displayArrow = true,
|
||||
...props
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Comp className={cn(className, 'p-4')} {...props}>
|
||||
<Slottable>
|
||||
{props.children}
|
||||
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'text-muted-foreground group-hover:text-secondary-foreground absolute top-4 right-2 h-4 transition-colors',
|
||||
{
|
||||
hidden: !displayArrow,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</Slottable>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardButtonContent: React.FC<
|
||||
{
|
||||
asChild?: boolean;
|
||||
children: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
> = function CardButtonContent({ className, asChild, ...props }) {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Comp className={cn(className, 'flex flex-1 flex-col px-4')} {...props}>
|
||||
<Slottable>{props.children}</Slottable>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardButtonFooter: React.FC<
|
||||
{
|
||||
asChild?: boolean;
|
||||
children: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
> = function CardButtonFooter({ className, asChild, ...props }) {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
className,
|
||||
'mt-auto flex h-0 w-full flex-col justify-center border-t px-4',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Slottable>{props.children}</Slottable>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
11
packages/ui/src/makerkit/context/sidebar.context.ts
Normal file
11
packages/ui/src/makerkit/context/sidebar.context.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
const SidebarContext = createContext<{
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
}>({
|
||||
collapsed: false,
|
||||
setCollapsed: (_) => _,
|
||||
});
|
||||
|
||||
export { SidebarContext };
|
||||
118
packages/ui/src/makerkit/cookie-banner.tsx
Normal file
118
packages/ui/src/makerkit/cookie-banner.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
import { Button } from '../shadcn/button';
|
||||
import { Heading } from '../shadcn/heading';
|
||||
import { Trans } from './trans';
|
||||
|
||||
// configure this as you wish
|
||||
const COOKIE_CONSENT_STATUS = 'cookie_consent_status';
|
||||
|
||||
enum ConsentStatus {
|
||||
Accepted = 'accepted',
|
||||
Rejected = 'rejected',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
export function CookieBanner() {
|
||||
const { status, accept, reject } = useCookieConsent();
|
||||
|
||||
if (!isBrowser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status !== ConsentStatus.Unknown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open modal={false}>
|
||||
<DialogPrimitive.Content
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={`dark:shadow-primary-500/40 bg-background animate-in fade-in zoom-in-95 slide-in-from-bottom-16 fill-mode-both fixed bottom-0 w-full max-w-lg border p-6 shadow-2xl delay-1000 duration-1000 lg:bottom-[2rem] lg:left-[2rem] lg:h-48 lg:rounded-lg`}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div>
|
||||
<Heading level={3}>
|
||||
<Trans i18nKey={'cookieBanner.title'} />
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div className={'text-gray-500 dark:text-gray-400'}>
|
||||
<Trans i18nKey={'cookieBanner.description'} />
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<Button variant={'ghost'} onClick={reject}>
|
||||
<Trans i18nKey={'cookieBanner.reject'} />
|
||||
</Button>
|
||||
|
||||
<Button autoFocus onClick={accept}>
|
||||
<Trans i18nKey={'cookieBanner.accept'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCookieConsent() {
|
||||
const initialState = getStatusFromLocalStorage();
|
||||
const [status, setStatus] = useState<ConsentStatus>(initialState);
|
||||
|
||||
const accept = useCallback(() => {
|
||||
const status = ConsentStatus.Accepted;
|
||||
|
||||
setStatus(status);
|
||||
storeStatusInLocalStorage(status);
|
||||
}, []);
|
||||
|
||||
const reject = useCallback(() => {
|
||||
const status = ConsentStatus.Rejected;
|
||||
|
||||
setStatus(status);
|
||||
storeStatusInLocalStorage(status);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
const status = ConsentStatus.Unknown;
|
||||
|
||||
setStatus(status);
|
||||
storeStatusInLocalStorage(status);
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
clear,
|
||||
status,
|
||||
accept,
|
||||
reject,
|
||||
};
|
||||
}, [clear, status, accept, reject]);
|
||||
}
|
||||
|
||||
function storeStatusInLocalStorage(status: ConsentStatus) {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(COOKIE_CONSENT_STATUS, status);
|
||||
}
|
||||
|
||||
function getStatusFromLocalStorage() {
|
||||
if (!isBrowser()) {
|
||||
return ConsentStatus.Unknown;
|
||||
}
|
||||
|
||||
const status = localStorage.getItem(COOKIE_CONSENT_STATUS) as ConsentStatus;
|
||||
|
||||
return status ?? ConsentStatus.Unknown;
|
||||
}
|
||||
|
||||
function isBrowser() {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
285
packages/ui/src/makerkit/data-table.tsx
Normal file
285
packages/ui/src/makerkit/data-table.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
PaginationState,
|
||||
Table as ReactTable,
|
||||
Row,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '../shadcn/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../shadcn/table';
|
||||
import { Trans } from './trans';
|
||||
|
||||
interface ReactTableProps<T extends object> {
|
||||
data: T[];
|
||||
columns: ColumnDef<T>[];
|
||||
renderSubComponent?: (props: { row: Row<T> }) => React.ReactElement;
|
||||
pageIndex?: number;
|
||||
pageSize?: number;
|
||||
pageCount?: number;
|
||||
onPaginationChange?: (pagination: PaginationState) => void;
|
||||
onSortingChange?: (sorting: SortingState) => void;
|
||||
manualPagination?: boolean;
|
||||
manualSorting?: boolean;
|
||||
sorting?: SortingState;
|
||||
tableProps?: React.ComponentProps<typeof Table> &
|
||||
Record<`data-${string}`, string>;
|
||||
}
|
||||
|
||||
export function DataTable<T extends object>({
|
||||
data,
|
||||
columns,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount,
|
||||
onPaginationChange,
|
||||
onSortingChange,
|
||||
tableProps,
|
||||
manualPagination = true,
|
||||
manualSorting = false,
|
||||
sorting: initialSorting,
|
||||
}: ReactTableProps<T>) {
|
||||
'use no memo';
|
||||
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: pageIndex ?? 0,
|
||||
pageSize: pageSize ?? 15,
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? []);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const navigateToPage = useNavigateToNewPage();
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
manualPagination,
|
||||
manualSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
pageCount,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
if (typeof updater === 'function') {
|
||||
const nextState = updater(sorting);
|
||||
|
||||
setSorting(nextState);
|
||||
|
||||
if (onSortingChange) {
|
||||
onSortingChange(nextState);
|
||||
}
|
||||
} else {
|
||||
setSorting(updater);
|
||||
|
||||
if (onSortingChange) {
|
||||
onSortingChange(updater);
|
||||
}
|
||||
}
|
||||
},
|
||||
onPaginationChange: (updater) => {
|
||||
const navigate = (page: number) => setTimeout(() => navigateToPage(page));
|
||||
|
||||
if (typeof updater === 'function') {
|
||||
setPagination((prevState) => {
|
||||
const nextState = updater(prevState);
|
||||
|
||||
if (onPaginationChange) {
|
||||
onPaginationChange(nextState);
|
||||
} else {
|
||||
navigate(nextState.pageIndex);
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
} else {
|
||||
setPagination(updater);
|
||||
|
||||
if (onPaginationChange) {
|
||||
onPaginationChange(updater);
|
||||
} else {
|
||||
navigate(updater.pageIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={'rounded-lg border'}>
|
||||
<Table {...tableProps}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
colSpan={header.colSpan}
|
||||
style={{
|
||||
width: header.column.getSize(),
|
||||
}}
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<Trans i18nKey={'common:noData'} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
|
||||
<TableFooter className={'bg-background'}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length}>
|
||||
<Pagination table={table} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pagination<T>({
|
||||
table,
|
||||
}: React.PropsWithChildren<{
|
||||
table: ReactTable<T>;
|
||||
}>) {
|
||||
return (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<span className="text-muted-foreground flex items-center text-sm">
|
||||
<Trans
|
||||
i18nKey={'common:pageOfPages'}
|
||||
values={{
|
||||
page: table.getState().pagination.pageIndex + 1,
|
||||
total: table.getPageCount(),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Button
|
||||
size={'icon'}
|
||||
variant={'ghost'}
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft className={'h-4'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size={'icon'}
|
||||
variant={'ghost'}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className={'h-4'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size={'icon'}
|
||||
variant={'ghost'}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight className={'h-4'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size={'icon'}
|
||||
variant={'ghost'}
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight className={'h-4'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to a new page using the provided page index and optional page parameter.
|
||||
*/
|
||||
function useNavigateToNewPage(
|
||||
props: { pageParam?: string } = {
|
||||
pageParam: 'page',
|
||||
},
|
||||
) {
|
||||
const router = useRouter();
|
||||
const param = props.pageParam ?? 'page';
|
||||
|
||||
return useCallback(
|
||||
(pageIndex: number) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(param, String(pageIndex + 1));
|
||||
|
||||
router.push(url.pathname + url.search);
|
||||
},
|
||||
[param, router],
|
||||
);
|
||||
}
|
||||
79
packages/ui/src/makerkit/empty-state.tsx
Normal file
79
packages/ui/src/makerkit/empty-state.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from '../shadcn/button';
|
||||
|
||||
const EmptyStateHeading: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<h3
|
||||
className={cn('text-2xl font-bold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
EmptyStateHeading.displayName = 'EmptyStateHeading';
|
||||
|
||||
const EmptyStateText: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<p className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||
);
|
||||
EmptyStateText.displayName = 'EmptyStateText';
|
||||
|
||||
const EmptyStateButton: React.FC<
|
||||
React.ComponentPropsWithoutRef<typeof Button>
|
||||
> = ({ className, ...props }) => (
|
||||
<Button className={cn('mt-4', className)} {...props} />
|
||||
);
|
||||
|
||||
EmptyStateButton.displayName = 'EmptyStateButton';
|
||||
|
||||
const EmptyState: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
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
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center rounded-lg border border-dashed shadow-xs',
|
||||
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 };
|
||||
36
packages/ui/src/makerkit/global-loader.tsx
Normal file
36
packages/ui/src/makerkit/global-loader.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { If } from './if';
|
||||
import { LoadingOverlay } from './loading-overlay';
|
||||
import { TopLoadingBarIndicator } from './top-loading-bar-indicator';
|
||||
|
||||
export function GlobalLoader({
|
||||
displayLogo = false,
|
||||
fullPage = false,
|
||||
displaySpinner = true,
|
||||
displayTopLoadingBar = true,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
displayLogo?: boolean;
|
||||
fullPage?: boolean;
|
||||
displaySpinner?: boolean;
|
||||
displayTopLoadingBar?: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
<If condition={displayTopLoadingBar}>
|
||||
<TopLoadingBarIndicator />
|
||||
</If>
|
||||
|
||||
<If condition={displaySpinner}>
|
||||
<div
|
||||
className={
|
||||
'zoom-in-80 animate-in fade-in slide-in-from-bottom-12 flex flex-1 flex-col items-center justify-center duration-500'
|
||||
}
|
||||
>
|
||||
<LoadingOverlay displayLogo={displayLogo} fullPage={fullPage} />
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
packages/ui/src/makerkit/if.tsx
Normal file
29
packages/ui/src/makerkit/if.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type Condition<Value = unknown> = Value | false | null | undefined | 0 | '';
|
||||
|
||||
export function If<Value = unknown>({
|
||||
condition,
|
||||
children,
|
||||
fallback,
|
||||
}: React.PropsWithoutRef<{
|
||||
condition: Condition<Value>;
|
||||
children: React.ReactNode | ((value: Value) => React.ReactNode);
|
||||
fallback?: React.ReactNode;
|
||||
}>) {
|
||||
return useMemo(() => {
|
||||
if (condition) {
|
||||
if (typeof children === 'function') {
|
||||
return <>{children(condition)}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [condition, fallback, children]);
|
||||
}
|
||||
200
packages/ui/src/makerkit/image-upload-input.tsx
Normal file
200
packages/ui/src/makerkit/image-upload-input.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import type { FormEvent, MouseEventHandler } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { UploadCloud, X } from 'lucide-react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from '../shadcn/button';
|
||||
import { Label } from '../shadcn/label';
|
||||
import { If } from './if';
|
||||
|
||||
type Props = Omit<React.InputHTMLAttributes<unknown>, 'value'> & {
|
||||
image?: string | null;
|
||||
onClear?: () => void;
|
||||
onValueChange?: (props: { image: string; file: File }) => void;
|
||||
visible?: boolean;
|
||||
} & React.ComponentPropsWithRef<'input'>;
|
||||
|
||||
const IMAGE_SIZE = 22;
|
||||
|
||||
export const ImageUploadInput: React.FC<Props> =
|
||||
function ImageUploadInputComponent({
|
||||
children,
|
||||
image,
|
||||
onClear,
|
||||
onInput,
|
||||
onValueChange,
|
||||
ref: forwardedRef,
|
||||
visible = true,
|
||||
...props
|
||||
}) {
|
||||
const localRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [state, setState] = useState({
|
||||
image,
|
||||
fileName: '',
|
||||
});
|
||||
|
||||
const onInputChange = useCallback(
|
||||
(e: FormEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const files = e.currentTarget.files;
|
||||
|
||||
if (files?.length) {
|
||||
const file = files[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = URL.createObjectURL(file);
|
||||
|
||||
setState({
|
||||
image: data,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
if (onValueChange) {
|
||||
onValueChange({
|
||||
image: data,
|
||||
file,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (onInput) {
|
||||
onInput(e);
|
||||
}
|
||||
},
|
||||
[onInput, onValueChange],
|
||||
);
|
||||
|
||||
const onRemove = useCallback(() => {
|
||||
setState({
|
||||
image: '',
|
||||
fileName: '',
|
||||
});
|
||||
|
||||
if (localRef.current) {
|
||||
localRef.current.value = '';
|
||||
}
|
||||
|
||||
if (onClear) {
|
||||
onClear();
|
||||
}
|
||||
}, [onClear]);
|
||||
|
||||
const imageRemoved: MouseEventHandler = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
onRemove();
|
||||
},
|
||||
[onRemove],
|
||||
);
|
||||
|
||||
const setRef = useCallback(
|
||||
(input: HTMLInputElement) => {
|
||||
localRef.current = input;
|
||||
|
||||
if (typeof forwardedRef === 'function') {
|
||||
forwardedRef(localRef.current);
|
||||
}
|
||||
},
|
||||
[forwardedRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setState((state) => ({ ...state, image }));
|
||||
}, [image]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!image) {
|
||||
onRemove();
|
||||
}
|
||||
}, [image, onRemove]);
|
||||
|
||||
const Input = () => (
|
||||
<input
|
||||
{...props}
|
||||
className={cn('hidden', props.className)}
|
||||
ref={setRef}
|
||||
type={'file'}
|
||||
onInput={onInputChange}
|
||||
accept="image/*"
|
||||
aria-labelledby={'image-upload-input'}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!visible) {
|
||||
return <Input />;
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
id={'image-upload-input'}
|
||||
className={`border-input bg-background ring-primary ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring relative flex h-10 w-full cursor-pointer rounded-md border border-dashed px-3 py-2 text-sm ring-offset-2 outline-hidden transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
<Input />
|
||||
|
||||
<div className={'flex items-center space-x-4'}>
|
||||
<div className={'flex'}>
|
||||
<If condition={!state.image}>
|
||||
<UploadCloud className={'text-muted-foreground h-5'} />
|
||||
</If>
|
||||
|
||||
<If condition={state.image}>
|
||||
<Image
|
||||
loading={'lazy'}
|
||||
style={{
|
||||
width: IMAGE_SIZE,
|
||||
height: IMAGE_SIZE,
|
||||
}}
|
||||
className={'object-contain'}
|
||||
width={IMAGE_SIZE}
|
||||
height={IMAGE_SIZE}
|
||||
src={state.image!}
|
||||
alt={props.alt ?? ''}
|
||||
/>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<If condition={!state.image}>
|
||||
<div className={'flex flex-auto'}>
|
||||
<Label className={'cursor-pointer text-xs'}>{children}</Label>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={state.image}>
|
||||
<div className={'flex flex-auto'}>
|
||||
<If
|
||||
condition={state.fileName}
|
||||
fallback={
|
||||
<Label className={'cursor-pointer truncate text-xs'}>
|
||||
{children}
|
||||
</Label>
|
||||
}
|
||||
>
|
||||
<Label className={'truncate text-xs'}>{state.fileName}</Label>
|
||||
</If>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={state.image}>
|
||||
<Button
|
||||
size={'icon'}
|
||||
className={'h-5! w-5!'}
|
||||
onClick={imageRemoved}
|
||||
>
|
||||
<X className="h-4" />
|
||||
</Button>
|
||||
</If>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
113
packages/ui/src/makerkit/image-uploader.tsx
Normal file
113
packages/ui/src/makerkit/image-uploader.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Image as ImageIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Button } from '../shadcn/button';
|
||||
import { ImageUploadInput } from './image-upload-input';
|
||||
import { Trans } from './trans';
|
||||
|
||||
export function ImageUploader(
|
||||
props: React.PropsWithChildren<{
|
||||
value: string | null | undefined;
|
||||
onValueChange: (value: File | null) => unknown;
|
||||
}>,
|
||||
) {
|
||||
const [image, setImage] = useState(props.value);
|
||||
|
||||
const { setValue, register } = useForm<{
|
||||
value: string | null | FileList;
|
||||
}>({
|
||||
defaultValues: {
|
||||
value: props.value,
|
||||
},
|
||||
mode: 'onChange',
|
||||
reValidateMode: 'onChange',
|
||||
});
|
||||
|
||||
const control = register('value');
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
props.onValueChange(null);
|
||||
setValue('value', null);
|
||||
setImage('');
|
||||
}, [props, setValue]);
|
||||
|
||||
const onValueChange = useCallback(
|
||||
({ image, file }: { image: string; file: File }) => {
|
||||
props.onValueChange(file);
|
||||
|
||||
setImage(image);
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
const Input = () => (
|
||||
<ImageUploadInput
|
||||
{...control}
|
||||
accept={'image/*'}
|
||||
className={'absolute h-full w-full'}
|
||||
visible={false}
|
||||
multiple={false}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setImage(props.value);
|
||||
}, [props.value]);
|
||||
|
||||
if (!image) {
|
||||
return (
|
||||
<FallbackImage descriptionSection={props.children}>
|
||||
<Input />
|
||||
</FallbackImage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex items-center space-x-4'}>
|
||||
<label className={'animate-in fade-in zoom-in-50 relative h-20 w-20'}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
decoding="async"
|
||||
className={'h-20 w-20 rounded-full object-cover'}
|
||||
src={image}
|
||||
alt={''}
|
||||
/>
|
||||
|
||||
<Input />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Button onClick={onClear} size={'sm'} variant={'ghost'}>
|
||||
<Trans i18nKey={'common:clear'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FallbackImage(
|
||||
props: React.PropsWithChildren<{
|
||||
descriptionSection?: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex items-center space-x-4'}>
|
||||
<label
|
||||
className={
|
||||
'border-border animate-in fade-in zoom-in-50 hover:border-primary relative flex h-20 w-20 cursor-pointer flex-col items-center justify-center rounded-full border'
|
||||
}
|
||||
>
|
||||
<ImageIcon className={'text-primary h-8'} />
|
||||
|
||||
{props.children}
|
||||
</label>
|
||||
|
||||
{props.descriptionSection}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
packages/ui/src/makerkit/language-selector.tsx
Normal file
79
packages/ui/src/makerkit/language-selector.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../shadcn/select';
|
||||
|
||||
export function LanguageSelector({
|
||||
onChange,
|
||||
}: {
|
||||
onChange?: (locale: string) => unknown;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const { language: currentLanguage, options } = i18n;
|
||||
|
||||
const locales = (options.supportedLngs as string[]).filter(
|
||||
(locale) => locale.toLowerCase() !== 'cimode',
|
||||
);
|
||||
|
||||
const languageNames = useMemo(() => {
|
||||
return new Intl.DisplayNames([currentLanguage], {
|
||||
type: 'language',
|
||||
});
|
||||
}, [currentLanguage]);
|
||||
|
||||
const [value, setValue] = useState(i18n.language);
|
||||
|
||||
const languageChanged = useCallback(
|
||||
async (locale: string) => {
|
||||
setValue(locale);
|
||||
|
||||
if (onChange) {
|
||||
onChange(locale);
|
||||
}
|
||||
|
||||
await i18n.changeLanguage(locale);
|
||||
|
||||
// refresh cached translations
|
||||
window.location.reload();
|
||||
},
|
||||
[i18n, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={languageChanged}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{locales.map((locale) => {
|
||||
const label = capitalize(languageNames.of(locale) ?? locale);
|
||||
|
||||
const option = {
|
||||
value: locale,
|
||||
label,
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectItem value={option.value} key={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function capitalize(lang: string) {
|
||||
return lang.slice(0, 1).toUpperCase() + lang.slice(1);
|
||||
}
|
||||
62
packages/ui/src/makerkit/lazy-render.tsx
Normal file
62
packages/ui/src/makerkit/lazy-render.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { createRef, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
/**
|
||||
* @description Render a component lazily based on the IntersectionObserver
|
||||
* appConfig provided.
|
||||
* Full documentation at: https://makerkit.dev/docs/components-utilities#lazyrender
|
||||
* @param children
|
||||
* @param threshold
|
||||
* @param rootMargin
|
||||
* @param onVisible
|
||||
* @constructor
|
||||
*/
|
||||
export function LazyRender({
|
||||
children,
|
||||
threshold,
|
||||
rootMargin,
|
||||
onVisible,
|
||||
}: React.PropsWithChildren<{
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
onVisible?: () => void;
|
||||
}>) {
|
||||
const ref = useMemo(() => createRef<HTMLDivElement>(), []);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
rootMargin: rootMargin ?? '0px',
|
||||
threshold: threshold ?? 1,
|
||||
};
|
||||
|
||||
const isIntersecting = (entry: IntersectionObserverEntry) =>
|
||||
entry.isIntersecting || entry.intersectionRatio > 0;
|
||||
|
||||
const observer = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach((entry) => {
|
||||
if (isIntersecting(entry)) {
|
||||
setIsVisible(true);
|
||||
observer.disconnect();
|
||||
|
||||
if (onVisible) {
|
||||
onVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, options);
|
||||
|
||||
observer.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [threshold, rootMargin, ref, onVisible]);
|
||||
|
||||
return <div ref={ref}>{isVisible ? children : null}</div>;
|
||||
}
|
||||
33
packages/ui/src/makerkit/loading-overlay.tsx
Normal file
33
packages/ui/src/makerkit/loading-overlay.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Spinner } from './spinner';
|
||||
|
||||
export function LoadingOverlay({
|
||||
children,
|
||||
className,
|
||||
fullPage = true,
|
||||
spinnerClassName,
|
||||
}: PropsWithChildren<{
|
||||
className?: string;
|
||||
spinnerClassName?: string;
|
||||
fullPage?: boolean;
|
||||
displayLogo?: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center space-y-4',
|
||||
className,
|
||||
{
|
||||
[`bg-background fixed top-0 left-0 z-100 h-screen w-screen`]:
|
||||
fullPage,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Spinner className={spinnerClassName} />
|
||||
|
||||
<div className={'text-muted-foreground text-sm'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
packages/ui/src/makerkit/marketing/coming-soon.tsx
Normal file
107
packages/ui/src/makerkit/marketing/coming-soon.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { CtaButton } from './cta-button';
|
||||
import { GradientSecondaryText } from './gradient-secondary-text';
|
||||
import { HeroTitle } from './hero-title';
|
||||
|
||||
const ComingSoonHeading: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => <HeroTitle className={cn(className)} {...props} />;
|
||||
|
||||
ComingSoonHeading.displayName = 'ComingSoonHeading';
|
||||
|
||||
const ComingSoonText: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<GradientSecondaryText
|
||||
className={cn('text-muted-foreground text-lg md:text-xl', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
ComingSoonText.displayName = 'ComingSoonText';
|
||||
|
||||
const ComingSoonButton: React.FC<
|
||||
React.ComponentPropsWithoutRef<typeof Button>
|
||||
> = ({ className, ...props }) => (
|
||||
<CtaButton className={cn('mt-8', className)} {...props} />
|
||||
);
|
||||
ComingSoonButton.displayName = 'ComingSoonButton';
|
||||
|
||||
const ComingSoon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
const logo = childrenArray.find(
|
||||
(child) => React.isValidElement(child) && child.type === ComingSoonLogo,
|
||||
);
|
||||
|
||||
const heading = childrenArray.find(
|
||||
(child) => React.isValidElement(child) && child.type === ComingSoonHeading,
|
||||
);
|
||||
|
||||
const text = childrenArray.find(
|
||||
(child) => React.isValidElement(child) && child.type === ComingSoonText,
|
||||
);
|
||||
|
||||
const button = childrenArray.find(
|
||||
(child) => React.isValidElement(child) && child.type === ComingSoonButton,
|
||||
);
|
||||
|
||||
const cmps = [
|
||||
ComingSoonHeading,
|
||||
ComingSoonText,
|
||||
ComingSoonButton,
|
||||
ComingSoonLogo,
|
||||
];
|
||||
|
||||
const otherChildren = childrenArray.filter(
|
||||
(child) =>
|
||||
React.isValidElement(child) &&
|
||||
!cmps.includes(child.type as (typeof cmps)[number]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'container flex min-h-screen flex-col items-center justify-center space-y-12 p-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{logo}
|
||||
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col items-center justify-center space-y-8 text-center">
|
||||
{heading}
|
||||
|
||||
<div className={'mx-auto max-w-2xl'}>{text}</div>
|
||||
|
||||
{button}
|
||||
|
||||
{otherChildren}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
ComingSoon.displayName = 'ComingSoon';
|
||||
|
||||
const ComingSoonLogo: React.FC<React.HTMLAttributes<HTMLImageElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => <div className={cn(className, 'fixed top-8 left-8')} {...props} />;
|
||||
ComingSoonLogo.displayName = 'ComingSoonLogo';
|
||||
|
||||
export {
|
||||
ComingSoon,
|
||||
ComingSoonHeading,
|
||||
ComingSoonText,
|
||||
ComingSoonButton,
|
||||
ComingSoonLogo,
|
||||
};
|
||||
22
packages/ui/src/makerkit/marketing/cta-button.tsx
Normal file
22
packages/ui/src/makerkit/marketing/cta-button.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../../shadcn/button';
|
||||
|
||||
export const CtaButton: React.FC<React.ComponentProps<typeof Button>> =
|
||||
function CtaButtonComponent({ className, children, ...props }) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'h-12 rounded-xl px-4 text-base font-semibold',
|
||||
className,
|
||||
{
|
||||
['dark:shadow-primary/30 transition-all hover:shadow-2xl']:
|
||||
props.variant === 'default' || !props.variant,
|
||||
},
|
||||
)}
|
||||
asChild
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
28
packages/ui/src/makerkit/marketing/feature-card.tsx
Normal file
28
packages/ui/src/makerkit/marketing/feature-card.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { CardDescription, CardHeader, CardTitle } from '../../shadcn/card';
|
||||
|
||||
interface FeatureCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||
className,
|
||||
label,
|
||||
description,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn('rounded-xl border p-4', className)} {...props}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">{label}</CardTitle>
|
||||
|
||||
<CardDescription className="text-muted-foreground max-w-xs text-sm font-normal">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
packages/ui/src/makerkit/marketing/feature-grid.tsx
Normal file
18
packages/ui/src/makerkit/marketing/feature-grid.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const FeatureGrid: React.FC<React.HTMLAttributes<HTMLDivElement>> =
|
||||
function FeatureGridComponent({ className, children, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 md:grid-cols-3 lg:grid-cols-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
packages/ui/src/makerkit/marketing/feature-showcase.tsx
Normal file
51
packages/ui/src/makerkit/marketing/feature-showcase.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface FeatureShowcaseProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
heading: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FeatureShowcase: React.FC<FeatureShowcaseProps> =
|
||||
function FeatureShowcaseComponent({
|
||||
className,
|
||||
heading,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col justify-between space-y-8', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex w-full max-w-5xl flex-col gap-y-4">
|
||||
{icon && <div className="flex">{icon}</div>}
|
||||
<h3 className="text-3xl font-normal tracking-tight xl:text-5xl">
|
||||
{heading}
|
||||
</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function FeatureShowcaseIconContainer(
|
||||
props: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex'}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center space-x-4 rounded-lg p-3 font-medium',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
packages/ui/src/makerkit/marketing/footer.tsx
Normal file
98
packages/ui/src/makerkit/marketing/footer.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface FooterSection {
|
||||
heading: React.ReactNode;
|
||||
links: Array<{
|
||||
href: string;
|
||||
label: React.ReactNode;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FooterProps extends React.HTMLAttributes<HTMLElement> {
|
||||
logo: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
copyright: React.ReactNode;
|
||||
sections: FooterSection[];
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = ({
|
||||
className,
|
||||
logo,
|
||||
description,
|
||||
copyright,
|
||||
sections,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'site-footer relative mt-auto w-full py-8 2xl:py-20',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container">
|
||||
<div className="flex flex-col space-y-8 lg:flex-row lg:space-y-0">
|
||||
<div className="flex w-full gap-x-3 lg:w-4/12 xl:w-4/12 xl:space-x-6 2xl:space-x-8">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div>{logo}</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm tracking-tight">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex text-xs">
|
||||
<p>{copyright}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-y-4 lg:flex-row lg:justify-end lg:gap-x-6 lg:gap-y-0 xl:gap-x-12">
|
||||
{sections.map((section, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex flex-col gap-y-2.5">
|
||||
<FooterSectionHeading>{section.heading}</FooterSectionHeading>
|
||||
|
||||
<FooterSectionList>
|
||||
{section.links.map((link, linkIndex) => (
|
||||
<FooterLink key={linkIndex} href={link.href}>
|
||||
{link.label}
|
||||
</FooterLink>
|
||||
))}
|
||||
</FooterSectionList>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
function FooterSectionHeading(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<span className="font-heading text-sm font-semibold tracking-tight">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterSectionList(props: React.PropsWithChildren) {
|
||||
return <ul className="flex flex-col gap-y-2">{props.children}</ul>;
|
||||
}
|
||||
|
||||
function FooterLink({
|
||||
href,
|
||||
children,
|
||||
}: React.PropsWithChildren<{ href: string }>) {
|
||||
return (
|
||||
<li className="text-muted-foreground text-sm tracking-tight hover:underline [&>a]:transition-colors">
|
||||
<a href={href}>{children}</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Slot, Slottable } from '@radix-ui/react-slot';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const GradientSecondaryText: React.FC<
|
||||
React.HTMLAttributes<HTMLSpanElement> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
> = function GradientSecondaryTextComponent({ className, ...props }) {
|
||||
const Comp = props.asChild ? Slot : 'span';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
'dark:from-foreground/60 dark:to-foreground text-secondary-foreground dark:bg-linear-to-r dark:bg-clip-text dark:text-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Slottable>{props.children}</Slottable>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
18
packages/ui/src/makerkit/marketing/gradient-text.tsx
Normal file
18
packages/ui/src/makerkit/marketing/gradient-text.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const GradientText: React.FC<React.HTMLAttributes<HTMLSpanElement>> =
|
||||
function GradientTextComponent({ className, children, ...props }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'bg-linear-to-r bg-clip-text text-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
33
packages/ui/src/makerkit/marketing/header.tsx
Normal file
33
packages/ui/src/makerkit/marketing/header.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
logo?: React.ReactNode;
|
||||
navigation?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = function ({
|
||||
className,
|
||||
logo,
|
||||
navigation,
|
||||
actions,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'site-header bg-background/80 dark:bg-background/50 sticky top-0 z-10 w-full py-1 backdrop-blur-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container">
|
||||
<div className="grid h-14 grid-cols-3 items-center">
|
||||
<div className={'mx-auto md:mx-0'}>{logo}</div>
|
||||
<div className="order-first md:order-none">{navigation}</div>
|
||||
<div className="flex items-center justify-end gap-x-2">{actions}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
packages/ui/src/makerkit/marketing/hero-title.tsx
Normal file
23
packages/ui/src/makerkit/marketing/hero-title.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Slot, Slottable } from '@radix-ui/react-slot';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const HeroTitle: React.FC<
|
||||
React.HTMLAttributes<HTMLHeadingElement> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
> = function HeroTitleComponent({ children, className, ...props }) {
|
||||
const Comp = props.asChild ? Slot : 'h1';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
'hero-title flex flex-col text-center font-sans text-4xl font-semibold tracking-tighter sm:text-6xl lg:max-w-5xl lg:text-7xl xl:text-[4.5rem] dark:text-white',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Slottable>{children}</Slottable>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
90
packages/ui/src/makerkit/marketing/hero.tsx
Normal file
90
packages/ui/src/makerkit/marketing/hero.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { HeroTitle } from './hero-title';
|
||||
|
||||
interface HeroProps {
|
||||
pill?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
cta?: React.ReactNode;
|
||||
image?: React.ReactNode;
|
||||
className?: string;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export function Hero({
|
||||
pill,
|
||||
title,
|
||||
subtitle,
|
||||
cta,
|
||||
image,
|
||||
className,
|
||||
animate = true,
|
||||
}: HeroProps) {
|
||||
return (
|
||||
<div className={cn('mx-auto flex flex-col space-y-20', className)}>
|
||||
<div
|
||||
style={{
|
||||
MozAnimationDuration: '100ms',
|
||||
}}
|
||||
className={cn(
|
||||
'mx-auto flex flex-1 flex-col items-center justify-center duration-800 md:flex-row',
|
||||
{
|
||||
['animate-in fade-in zoom-in-90 slide-in-from-top-24']: animate,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-1 flex-col items-center gap-y-6 xl:gap-y-8 2xl:gap-y-12">
|
||||
{pill && (
|
||||
<div
|
||||
className={cn({
|
||||
['animate-in fade-in fill-mode-both delay-300 duration-700']:
|
||||
animate,
|
||||
})}
|
||||
>
|
||||
{pill}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-y-6">
|
||||
<HeroTitle>{title}</HeroTitle>
|
||||
|
||||
{subtitle && (
|
||||
<div className="flex max-w-lg">
|
||||
<h3 className="text-muted-foreground p-0 text-center font-sans text-2xl font-normal tracking-tight">
|
||||
{subtitle}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cta && (
|
||||
<div
|
||||
className={cn({
|
||||
['animate-in fade-in fill-mode-both delay-500 duration-1000']:
|
||||
animate,
|
||||
})}
|
||||
>
|
||||
{cta}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{image && (
|
||||
<div
|
||||
style={{
|
||||
MozAnimationDuration: '100ms',
|
||||
}}
|
||||
className={cn('container mx-auto flex justify-center py-8', {
|
||||
['animate-in fade-in zoom-in-90 slide-in-from-top-32 fill-mode-both delay-600 duration-1000']:
|
||||
animate,
|
||||
})}
|
||||
>
|
||||
{image}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
packages/ui/src/makerkit/marketing/index.tsx
Normal file
15
packages/ui/src/makerkit/marketing/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export * from './hero-title';
|
||||
export * from './pill';
|
||||
export * from './gradient-secondary-text';
|
||||
export * from './gradient-text';
|
||||
export * from './hero';
|
||||
export * from './secondary-hero';
|
||||
export * from './cta-button';
|
||||
export * from './header';
|
||||
export * from './footer';
|
||||
export * from './feature-showcase';
|
||||
export * from './feature-grid';
|
||||
export * from './feature-card';
|
||||
export * from './newsletter-signup';
|
||||
export * from './newsletter-signup-container';
|
||||
export * from './coming-soon';
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '../../shadcn/alert';
|
||||
import { Heading } from '../../shadcn/heading';
|
||||
import { Spinner } from '../spinner';
|
||||
import { NewsletterSignup } from './newsletter-signup';
|
||||
|
||||
interface NewsletterSignupContainerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onSignup: (email: string) => Promise<void>;
|
||||
heading?: string;
|
||||
description?: string;
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function NewsletterSignupContainer({
|
||||
onSignup,
|
||||
heading = 'Subscribe to our newsletter',
|
||||
description = 'Get the latest updates and offers directly to your inbox.',
|
||||
successMessage = 'Thank you for subscribing!',
|
||||
errorMessage = 'An error occurred. Please try again.',
|
||||
className,
|
||||
...props
|
||||
}: NewsletterSignupContainerProps) {
|
||||
const [status, setStatus] = useState<
|
||||
'idle' | 'loading' | 'success' | 'error'
|
||||
>('idle');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: { email: string }) => {
|
||||
setStatus('loading');
|
||||
|
||||
try {
|
||||
await onSignup(data.email);
|
||||
|
||||
setStatus('success');
|
||||
} catch (error) {
|
||||
console.error('Newsletter signup error:', error);
|
||||
setStatus('error');
|
||||
}
|
||||
},
|
||||
[onSignup],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col items-center space-y-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="text-center">
|
||||
<Heading level={4}>{heading}</Heading>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
{status === 'idle' && <NewsletterSignup onSignup={handleSubmit} />}
|
||||
|
||||
{status === 'loading' && (
|
||||
<div className="flex justify-center">
|
||||
<Spinner className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div>
|
||||
<Alert variant="success">
|
||||
<AlertTitle>Success!</AlertTitle>
|
||||
<AlertDescription>{successMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
packages/ui/src/makerkit/marketing/newsletter-signup.tsx
Normal file
71
packages/ui/src/makerkit/marketing/newsletter-signup.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../../shadcn/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '../../shadcn/form';
|
||||
import { Input } from '../../shadcn/input';
|
||||
|
||||
const NewsletterFormSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
});
|
||||
|
||||
type NewsletterFormValues = z.infer<typeof NewsletterFormSchema>;
|
||||
|
||||
interface NewsletterSignupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onSignup: (data: NewsletterFormValues) => void;
|
||||
buttonText?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function NewsletterSignup({
|
||||
onSignup,
|
||||
buttonText = 'Subscribe',
|
||||
placeholder = 'Enter your email',
|
||||
className,
|
||||
...props
|
||||
}: NewsletterSignupProps) {
|
||||
const form = useForm<NewsletterFormValues>({
|
||||
resolver: zodResolver(NewsletterFormSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('w-full max-w-sm', className)} {...props}>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSignup)}
|
||||
className="flex flex-col gap-y-3"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder={placeholder} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
{buttonText}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
packages/ui/src/makerkit/marketing/pill.tsx
Normal file
59
packages/ui/src/makerkit/marketing/pill.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Slot, Slottable } from '@radix-ui/react-slot';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { GradientSecondaryText } from './gradient-secondary-text';
|
||||
|
||||
export const Pill: React.FC<
|
||||
React.HTMLAttributes<HTMLHeadingElement> & {
|
||||
label?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}
|
||||
> = function PillComponent({ className, asChild, ...props }) {
|
||||
const Comp = asChild ? Slot : 'h3';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
'bg-muted/50 flex items-center gap-x-1.5 rounded-full border px-2 py-1 pr-2 text-center text-sm font-medium text-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{props.label && (
|
||||
<span
|
||||
className={
|
||||
'text-primary-foreground bg-primary rounded-2xl border px-1.5 py-0.5 text-xs font-bold tracking-tight'
|
||||
}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
)}
|
||||
<Slottable>
|
||||
<GradientSecondaryText
|
||||
className={'flex items-center gap-x-2 font-semibold tracking-tight'}
|
||||
>
|
||||
{props.children}
|
||||
</GradientSecondaryText>
|
||||
</Slottable>
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
export const PillActionButton: React.FC<
|
||||
React.HTMLAttributes<HTMLButtonElement> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
> = ({ asChild, ...props }) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
{...props}
|
||||
className={
|
||||
'text-secondary-foreground bg-input active:bg-primary active:text-primary-foreground hover:ring-muted-foreground/50 rounded-full px-1.5 py-1.5 text-center text-sm font-medium ring ring-transparent transition-colors'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
42
packages/ui/src/makerkit/marketing/secondary-hero.tsx
Normal file
42
packages/ui/src/makerkit/marketing/secondary-hero.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Heading } from '../../shadcn/heading';
|
||||
|
||||
interface SecondaryHeroProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
pill?: React.ReactNode;
|
||||
heading: React.ReactNode;
|
||||
subheading: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SecondaryHero: React.FC<SecondaryHeroProps> =
|
||||
function SecondaryHeroComponent({
|
||||
className,
|
||||
pill,
|
||||
heading,
|
||||
subheading,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center space-y-6 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{pill}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Heading level={2} className="tracking-tighter">
|
||||
{heading}
|
||||
</Heading>
|
||||
|
||||
<h3 className="text-muted-foreground font-sans text-xl font-normal tracking-tight">
|
||||
{subheading}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
packages/ui/src/makerkit/mobile-navigation-dropdown.tsx
Normal file
72
packages/ui/src/makerkit/mobile-navigation-dropdown.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { Button } from '../shadcn/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../shadcn/dropdown-menu';
|
||||
import { Trans } from './trans';
|
||||
|
||||
function MobileNavigationDropdown({
|
||||
links,
|
||||
}: {
|
||||
links: {
|
||||
path: string;
|
||||
label: string;
|
||||
}[];
|
||||
}) {
|
||||
const path = usePathname();
|
||||
|
||||
const currentPathName = useMemo(() => {
|
||||
return Object.values(links).find((link) => link.path === path)?.label;
|
||||
}, [links, path]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'secondary'} className={'w-full'}>
|
||||
<span
|
||||
className={'flex w-full items-center justify-between space-x-2'}
|
||||
>
|
||||
<span>
|
||||
<Trans i18nKey={currentPathName} defaults={currentPathName} />
|
||||
</span>
|
||||
|
||||
<ChevronDown className={'h-5'} />
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={
|
||||
'dark:divide-dark-700 w-screen divide-y divide-gray-100' +
|
||||
' rounded-none'
|
||||
}
|
||||
>
|
||||
{Object.values(links).map((link) => {
|
||||
return (
|
||||
<DropdownMenuItem asChild key={link.path}>
|
||||
<Link
|
||||
className={'flex h-12 w-full items-center'}
|
||||
href={link.path}
|
||||
>
|
||||
<Trans i18nKey={link.label} defaults={link.label} />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileNavigationDropdown;
|
||||
77
packages/ui/src/makerkit/mobile-navigation-menu.tsx
Normal file
77
packages/ui/src/makerkit/mobile-navigation-menu.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../shadcn/dropdown-menu';
|
||||
import { Trans } from './trans';
|
||||
|
||||
function MobileNavigationDropdown({
|
||||
links,
|
||||
}: {
|
||||
links: {
|
||||
path: string;
|
||||
label: string;
|
||||
}[];
|
||||
}) {
|
||||
const path = usePathname();
|
||||
|
||||
const items = useMemo(
|
||||
function MenuItems() {
|
||||
return Object.values(links).map((link) => {
|
||||
return (
|
||||
<DropdownMenuItem key={link.path}>
|
||||
<Link
|
||||
className={'flex h-full w-full items-center'}
|
||||
href={link.path}
|
||||
>
|
||||
<Trans i18nKey={link.label} defaults={link.label} />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
});
|
||||
},
|
||||
[links],
|
||||
);
|
||||
|
||||
const currentPathName = useMemo(() => {
|
||||
return Object.values(links).find((link) => link.path === path)?.label;
|
||||
}, [links, path]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={'w-full'}>
|
||||
<div
|
||||
className={
|
||||
'Button dark:ring-dark-700 w-full justify-start ring-2 ring-gray-100'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'ButtonNormal flex w-full items-center justify-between space-x-2'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Trans i18nKey={currentPathName} defaults={currentPathName} />
|
||||
</span>
|
||||
|
||||
<ChevronDown className={'h-5'} />
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>{items}</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileNavigationDropdown;
|
||||
141
packages/ui/src/makerkit/mode-toggle.tsx
Normal file
141
packages/ui/src/makerkit/mode-toggle.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Computer, Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from '../shadcn/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '../shadcn/dropdown-menu';
|
||||
import { Trans } from './trans';
|
||||
|
||||
const MODES = ['light', 'dark', 'system'];
|
||||
|
||||
export function ModeToggle(props: { className?: string }) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
const Items = useMemo(() => {
|
||||
return MODES.map((mode) => {
|
||||
const isSelected = theme === mode;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn('space-x-2', {
|
||||
'bg-muted': isSelected,
|
||||
})}
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
setTheme(mode);
|
||||
setCookeTheme(mode);
|
||||
}}
|
||||
>
|
||||
<Icon theme={mode} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={`common:${mode}Theme`} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
});
|
||||
}, [setTheme, theme]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className={props.className}>
|
||||
<Sun className="h-[0.9rem] w-[0.9rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute h-[0.9rem] w-[0.9rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">{Items}</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubMenuModeToggle() {
|
||||
const { setTheme, theme, resolvedTheme } = useTheme();
|
||||
|
||||
const MenuItems = useMemo(
|
||||
() =>
|
||||
MODES.map((mode) => {
|
||||
const isSelected = theme === mode;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn('flex cursor-pointer items-center space-x-2', {
|
||||
'bg-muted': isSelected,
|
||||
})}
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
setTheme(mode);
|
||||
setCookeTheme(mode);
|
||||
}}
|
||||
>
|
||||
<Icon theme={mode} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={`common:${mode}Theme`} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}),
|
||||
[setTheme, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className={
|
||||
'hidden w-full items-center justify-between gap-x-3 lg:flex'
|
||||
}
|
||||
>
|
||||
<span className={'flex space-x-2'}>
|
||||
<Icon theme={resolvedTheme} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common:theme'} />
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>{MenuItems}</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<div className={'lg:hidden'}>
|
||||
<DropdownMenuLabel>
|
||||
<Trans i18nKey={'common:theme'} />
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{MenuItems}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function setCookeTheme(theme: string) {
|
||||
document.cookie = `theme=${theme}; path=/; max-age=31536000`;
|
||||
}
|
||||
|
||||
function Icon({ theme }: { theme: string | undefined }) {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return <Sun className="h-4" />;
|
||||
case 'dark':
|
||||
return <Moon className="h-4" />;
|
||||
case 'system':
|
||||
return <Computer className="h-4" />;
|
||||
}
|
||||
}
|
||||
435
packages/ui/src/makerkit/multi-step-form.tsx
Normal file
435
packages/ui/src/makerkit/multi-step-form.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
HTMLProps,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { Slot, Slottable } from '@radix-ui/react-slot';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Path, UseFormReturn } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface MultiStepFormProps<T extends z.ZodType> {
|
||||
schema: T;
|
||||
form: UseFormReturn<z.infer<T>>;
|
||||
onSubmit: (data: z.infer<T>) => void;
|
||||
useStepTransition?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type StepProps = React.PropsWithChildren<
|
||||
{
|
||||
name: string;
|
||||
asChild?: boolean;
|
||||
} & React.HTMLProps<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const MultiStepFormContext = createContext<ReturnType<
|
||||
typeof useMultiStepForm
|
||||
> | null>(null);
|
||||
|
||||
/**
|
||||
* @name MultiStepForm
|
||||
* @description Multi-step form component for React
|
||||
* @param schema
|
||||
* @param form
|
||||
* @param onSubmit
|
||||
* @param children
|
||||
* @param className
|
||||
* @constructor
|
||||
*/
|
||||
export function MultiStepForm<T extends z.ZodType>({
|
||||
schema,
|
||||
form,
|
||||
onSubmit,
|
||||
children,
|
||||
className,
|
||||
}: React.PropsWithChildren<MultiStepFormProps<T>>) {
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
React.Children.toArray(children).filter(
|
||||
(child): child is React.ReactElement<StepProps> =>
|
||||
React.isValidElement(child) && child.type === MultiStepFormStep,
|
||||
),
|
||||
[children],
|
||||
);
|
||||
|
||||
const header = useMemo(() => {
|
||||
return React.Children.toArray(children).find(
|
||||
(child) =>
|
||||
React.isValidElement(child) && child.type === MultiStepFormHeader,
|
||||
);
|
||||
}, [children]);
|
||||
|
||||
const footer = useMemo(() => {
|
||||
return React.Children.toArray(children).find(
|
||||
(child) =>
|
||||
React.isValidElement(child) && child.type === MultiStepFormFooter,
|
||||
);
|
||||
}, [children]);
|
||||
|
||||
const stepNames = steps.map((step) => step.props.name);
|
||||
const multiStepForm = useMultiStepForm(schema, form, stepNames, onSubmit);
|
||||
|
||||
return (
|
||||
<MultiStepFormContext.Provider value={multiStepForm}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn(className, 'flex size-full flex-col overflow-hidden')}
|
||||
>
|
||||
{header}
|
||||
|
||||
<div className="relative transition-transform duration-500">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index === multiStepForm.currentStepIndex;
|
||||
|
||||
return (
|
||||
<AnimatedStep
|
||||
key={step.props.name}
|
||||
direction={multiStepForm.direction}
|
||||
isActive={isActive}
|
||||
index={index}
|
||||
currentIndex={multiStepForm.currentStepIndex}
|
||||
>
|
||||
{step}
|
||||
</AnimatedStep>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
</form>
|
||||
</MultiStepFormContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiStepFormContextProvider(props: {
|
||||
children: (context: ReturnType<typeof useMultiStepForm>) => React.ReactNode;
|
||||
}) {
|
||||
const ctx = useMultiStepFormContext();
|
||||
|
||||
if (Array.isArray(props.children)) {
|
||||
const [child] = props.children;
|
||||
|
||||
return (
|
||||
child as (context: ReturnType<typeof useMultiStepForm>) => React.ReactNode
|
||||
)(ctx);
|
||||
}
|
||||
|
||||
return props.children(ctx);
|
||||
}
|
||||
|
||||
export const MultiStepFormStep: React.FC<
|
||||
React.PropsWithChildren<
|
||||
{
|
||||
asChild?: boolean;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
} & HTMLProps<HTMLDivElement>
|
||||
>
|
||||
> = function MultiStepFormStep({ children, asChild, ...props }) {
|
||||
const Cmp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Cmp {...props}>
|
||||
<Slottable>{children}</Slottable>
|
||||
</Cmp>
|
||||
);
|
||||
};
|
||||
|
||||
export function useMultiStepFormContext<Schema extends z.ZodType>() {
|
||||
const context = useContext(MultiStepFormContext) as ReturnType<
|
||||
typeof useMultiStepForm<Schema>
|
||||
>;
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useMultiStepFormContext must be used within a MultiStepForm',
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useMultiStepForm
|
||||
* @description Hook for multi-step forms
|
||||
* @param schema
|
||||
* @param form
|
||||
* @param stepNames
|
||||
* @param onSubmit
|
||||
*/
|
||||
export function useMultiStepForm<Schema extends z.ZodType>(
|
||||
schema: Schema,
|
||||
form: UseFormReturn<z.infer<Schema>>,
|
||||
stepNames: string[],
|
||||
onSubmit: (data: z.infer<Schema>) => void,
|
||||
) {
|
||||
const [state, setState] = useState({
|
||||
currentStepIndex: 0,
|
||||
direction: undefined as 'forward' | 'backward' | undefined,
|
||||
});
|
||||
|
||||
const isStepValid = useCallback(() => {
|
||||
const currentStepName = stepNames[state.currentStepIndex] as Path<
|
||||
z.TypeOf<Schema>
|
||||
>;
|
||||
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const currentStepSchema = schema.shape[currentStepName] as z.ZodType;
|
||||
|
||||
// the user may not want to validate the current step
|
||||
// or the step doesn't contain any form field
|
||||
if (!currentStepSchema) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentStepData = form.getValues(currentStepName) ?? {};
|
||||
const result = currentStepSchema.safeParse(currentStepData);
|
||||
|
||||
return result.success;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported schema type: ${schema.constructor.name}`);
|
||||
}, [schema, form, stepNames, state.currentStepIndex]);
|
||||
|
||||
const nextStep = useCallback(
|
||||
<Ev extends React.SyntheticEvent>(e: Ev) => {
|
||||
// prevent form submission when the user presses Enter
|
||||
// or if the user forgets [type="button"] on the button
|
||||
e.preventDefault();
|
||||
|
||||
const isValid = isStepValid();
|
||||
|
||||
if (!isValid) {
|
||||
const currentStepName = stepNames[state.currentStepIndex] as Path<
|
||||
z.TypeOf<Schema>
|
||||
>;
|
||||
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const currentStepSchema = schema.shape[currentStepName] as z.ZodType;
|
||||
|
||||
if (currentStepSchema) {
|
||||
const fields = Object.keys(
|
||||
(currentStepSchema as z.ZodObject<never>).shape,
|
||||
);
|
||||
|
||||
const keys = fields.map((field) => `${currentStepName}.${field}`);
|
||||
|
||||
// trigger validation for all fields in the current step
|
||||
for (const key of keys) {
|
||||
void form.trigger(key as Path<z.TypeOf<Schema>>);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid && state.currentStepIndex < stepNames.length - 1) {
|
||||
setState((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
direction: 'forward',
|
||||
currentStepIndex: prevState.currentStepIndex + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[isStepValid, state.currentStepIndex, stepNames, schema, form],
|
||||
);
|
||||
|
||||
const prevStep = useCallback(
|
||||
<Ev extends React.SyntheticEvent>(e: Ev) => {
|
||||
// prevent form submission when the user presses Enter
|
||||
// or if the user forgets [type="button"] on the button
|
||||
e.preventDefault();
|
||||
|
||||
if (state.currentStepIndex > 0) {
|
||||
setState((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
direction: 'backward',
|
||||
currentStepIndex: prevState.currentStepIndex - 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[state.currentStepIndex],
|
||||
);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= 0 && index < stepNames.length && isStepValid()) {
|
||||
setState((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
direction:
|
||||
index > prevState.currentStepIndex ? 'forward' : 'backward',
|
||||
currentStepIndex: index,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[isStepValid, stepNames.length],
|
||||
);
|
||||
|
||||
const isValid = form.formState.isValid;
|
||||
const errors = form.formState.errors;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => {
|
||||
return form.handleSubmit(onSubmit)();
|
||||
},
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
form,
|
||||
currentStep: stepNames[state.currentStepIndex] as string,
|
||||
currentStepIndex: state.currentStepIndex,
|
||||
totalSteps: stepNames.length,
|
||||
isFirstStep: state.currentStepIndex === 0,
|
||||
isLastStep: state.currentStepIndex === stepNames.length - 1,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
direction: state.direction,
|
||||
isStepValid,
|
||||
isValid,
|
||||
errors,
|
||||
mutation,
|
||||
}),
|
||||
[
|
||||
form,
|
||||
mutation,
|
||||
stepNames,
|
||||
state.currentStepIndex,
|
||||
state.direction,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
isStepValid,
|
||||
isValid,
|
||||
errors,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export const MultiStepFormHeader: React.FC<
|
||||
React.PropsWithChildren<
|
||||
{
|
||||
asChild?: boolean;
|
||||
} & HTMLProps<HTMLDivElement>
|
||||
>
|
||||
> = function MultiStepFormHeader({ children, asChild, ...props }) {
|
||||
const Cmp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Cmp {...props}>
|
||||
<Slottable>{children}</Slottable>
|
||||
</Cmp>
|
||||
);
|
||||
};
|
||||
|
||||
export const MultiStepFormFooter: React.FC<
|
||||
React.PropsWithChildren<
|
||||
{
|
||||
asChild?: boolean;
|
||||
} & HTMLProps<HTMLDivElement>
|
||||
>
|
||||
> = function MultiStepFormFooter({ children, asChild, ...props }) {
|
||||
const Cmp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Cmp {...props}>
|
||||
<Slottable>{children}</Slottable>
|
||||
</Cmp>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @name createStepSchema
|
||||
* @description Create a schema for a multi-step form
|
||||
* @param steps
|
||||
*/
|
||||
export function createStepSchema<T extends Record<string, z.ZodType>>(
|
||||
steps: T,
|
||||
) {
|
||||
return z.object(steps);
|
||||
}
|
||||
|
||||
interface AnimatedStepProps {
|
||||
direction: 'forward' | 'backward' | undefined;
|
||||
isActive: boolean;
|
||||
index: number;
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
function AnimatedStep({
|
||||
isActive,
|
||||
direction,
|
||||
children,
|
||||
index,
|
||||
currentIndex,
|
||||
}: React.PropsWithChildren<AnimatedStepProps>) {
|
||||
const [shouldRender, setShouldRender] = useState(isActive);
|
||||
const stepRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setShouldRender(true);
|
||||
} else {
|
||||
const timer = setTimeout(() => setShouldRender(false), 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && stepRef.current) {
|
||||
const focusableElement = stepRef.current.querySelector(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
|
||||
if (focusableElement) {
|
||||
(focusableElement as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseClasses =
|
||||
' top-0 left-0 w-full h-full transition-all duration-300 ease-in-out animate-in fade-in zoom-in-95';
|
||||
|
||||
const visibilityClasses = isActive ? 'opacity-100' : 'opacity-0 absolute';
|
||||
|
||||
const transformClasses = cn(
|
||||
'translate-x-0',
|
||||
isActive
|
||||
? {}
|
||||
: {
|
||||
'-translate-x-full': direction === 'forward' || index < currentIndex,
|
||||
'translate-x-full': direction === 'backward' || index > currentIndex,
|
||||
},
|
||||
);
|
||||
|
||||
const className = cn(baseClasses, visibilityClasses, transformClasses);
|
||||
|
||||
return (
|
||||
<div ref={stepRef} className={className} aria-hidden={!isActive}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
packages/ui/src/makerkit/navigation-config.schema.ts
Normal file
48
packages/ui/src/makerkit/navigation-config.schema.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const RouteMatchingEnd = z
|
||||
.union([z.boolean(), z.function().args(z.string()).returns(z.boolean())])
|
||||
.default(false)
|
||||
.optional();
|
||||
|
||||
const Divider = z.object({
|
||||
divider: z.literal(true),
|
||||
});
|
||||
|
||||
const RouteSubChild = z.object({
|
||||
label: z.string(),
|
||||
path: z.string(),
|
||||
Icon: z.custom<React.ReactNode>().optional(),
|
||||
end: RouteMatchingEnd,
|
||||
renderAction: z.custom<React.ReactNode>().optional(),
|
||||
});
|
||||
|
||||
const RouteChild = z.object({
|
||||
label: z.string(),
|
||||
path: z.string(),
|
||||
Icon: z.custom<React.ReactNode>().optional(),
|
||||
end: RouteMatchingEnd,
|
||||
children: z.array(RouteSubChild).default([]).optional(),
|
||||
collapsible: z.boolean().default(false).optional(),
|
||||
collapsed: z.boolean().default(false).optional(),
|
||||
renderAction: z.custom<React.ReactNode>().optional(),
|
||||
});
|
||||
|
||||
const RouteGroup = z.object({
|
||||
label: z.string(),
|
||||
collapsible: z.boolean().optional(),
|
||||
collapsed: z.boolean().optional(),
|
||||
children: z.array(RouteChild),
|
||||
renderAction: z.custom<React.ReactNode>().optional(),
|
||||
});
|
||||
|
||||
export const NavigationConfigSchema = z.object({
|
||||
style: z.enum(['custom', 'sidebar', 'header']).default('sidebar'),
|
||||
sidebarCollapsed: z
|
||||
.enum(['false', 'true'])
|
||||
.default('true')
|
||||
.optional()
|
||||
.transform((value) => value === `true`),
|
||||
sidebarCollapsedStyle: z.enum(['offcanvas', 'icon', 'none']).default('icon'),
|
||||
routes: z.array(z.union([RouteGroup, Divider])),
|
||||
});
|
||||
234
packages/ui/src/makerkit/page.tsx
Normal file
234
packages/ui/src/makerkit/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Separator } from '../shadcn/separator';
|
||||
import { SidebarTrigger } from '../shadcn/sidebar';
|
||||
import { If } from './if';
|
||||
|
||||
export type PageLayoutStyle = 'sidebar' | 'header' | 'custom';
|
||||
|
||||
type PageProps = React.PropsWithChildren<{
|
||||
style?: PageLayoutStyle;
|
||||
contentContainerClassName?: string;
|
||||
className?: string;
|
||||
sticky?: boolean;
|
||||
}>;
|
||||
|
||||
const ENABLE_SIDEBAR_TRIGGER = process.env.NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER
|
||||
? process.env.NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER === 'true'
|
||||
: true;
|
||||
|
||||
export function Page(props: PageProps) {
|
||||
switch (props.style) {
|
||||
case 'header':
|
||||
return <PageWithHeader {...props} />;
|
||||
|
||||
case 'custom':
|
||||
return props.children;
|
||||
|
||||
default:
|
||||
return <PageWithSidebar {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function PageWithSidebar(props: PageProps) {
|
||||
const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props);
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-1', props.className)}>
|
||||
{Navigation}
|
||||
|
||||
<div
|
||||
className={
|
||||
props.contentContainerClassName ??
|
||||
'mx-auto flex h-screen w-full flex-col overflow-y-auto bg-inherit'
|
||||
}
|
||||
>
|
||||
{MobileNavigation}
|
||||
|
||||
<div
|
||||
className={
|
||||
'bg-background flex flex-1 flex-col overflow-y-auto px-4 lg:px-0'
|
||||
}
|
||||
>
|
||||
{Children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageMobileNavigation(
|
||||
props: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full items-center border-b px-4 py-2 lg:hidden lg:px-0',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageWithHeader(props: PageProps) {
|
||||
const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props);
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-screen flex-1 flex-col', props.className)}>
|
||||
<div
|
||||
className={
|
||||
props.contentContainerClassName ?? 'flex flex-1 flex-col space-y-4'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted/40 dark:border-border dark:shadow-primary/10 flex h-14 items-center justify-between px-4 lg:justify-start lg:shadow-xs',
|
||||
{
|
||||
'sticky top-0 z-10 backdrop-blur-md': props.sticky ?? true,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={'hidden w-full flex-1 items-center space-x-8 lg:flex'}
|
||||
>
|
||||
{Navigation}
|
||||
</div>
|
||||
|
||||
{MobileNavigation}
|
||||
</div>
|
||||
|
||||
<div className={'container flex flex-1 flex-col'}>{Children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageBody(
|
||||
props: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
}>,
|
||||
) {
|
||||
const className = cn('flex w-full flex-1 flex-col lg:px-4', props.className);
|
||||
|
||||
return <div className={className}>{props.children}</div>;
|
||||
}
|
||||
|
||||
export function PageNavigation(props: React.PropsWithChildren) {
|
||||
return <div className={'flex-1 bg-inherit'}>{props.children}</div>;
|
||||
}
|
||||
|
||||
export function PageDescription(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className={'flex h-6 items-center'}>
|
||||
<div className={'text-muted-foreground text-xs leading-none font-normal'}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTitle(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<h1
|
||||
className={
|
||||
'font-heading text-base leading-none font-bold tracking-tight dark:text-white'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeaderActions(props: React.PropsWithChildren) {
|
||||
return <div className={'flex items-center space-x-2'}>{props.children}</div>;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
displaySidebarTrigger = ENABLE_SIDEBAR_TRIGGER,
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
title?: string | React.ReactNode;
|
||||
description?: string | React.ReactNode;
|
||||
displaySidebarTrigger?: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between py-5 lg:px-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={'flex flex-col gap-y-2'}>
|
||||
<div className="flex items-center gap-x-2.5">
|
||||
{displaySidebarTrigger ? (
|
||||
<SidebarTrigger className="text-muted-foreground hover:text-secondary-foreground hidden h-4.5 w-4.5 cursor-pointer lg:inline-flex" />
|
||||
) : null}
|
||||
|
||||
<If condition={description}>
|
||||
<If condition={displaySidebarTrigger}>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="hidden h-4 w-px lg:group-data-[minimized]:block"
|
||||
/>
|
||||
</If>
|
||||
|
||||
<PageDescription>{description}</PageDescription>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<If condition={title}>
|
||||
<PageTitle>{title}</PageTitle>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSlotsFromPage(props: React.PropsWithChildren) {
|
||||
return React.Children.toArray(props.children).reduce<{
|
||||
Children: React.ReactElement | null;
|
||||
Navigation: React.ReactElement | null;
|
||||
MobileNavigation: React.ReactElement | null;
|
||||
}>(
|
||||
(acc, child) => {
|
||||
if (!React.isValidElement(child)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (child.type === PageNavigation) {
|
||||
return {
|
||||
...acc,
|
||||
Navigation: child,
|
||||
};
|
||||
}
|
||||
|
||||
if (child.type === PageMobileNavigation) {
|
||||
return {
|
||||
...acc,
|
||||
MobileNavigation: child,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
Children: child,
|
||||
};
|
||||
},
|
||||
{
|
||||
Children: null,
|
||||
Navigation: null,
|
||||
MobileNavigation: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
54
packages/ui/src/makerkit/profile-avatar.tsx
Normal file
54
packages/ui/src/makerkit/profile-avatar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { cn } from '../lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../shadcn/avatar';
|
||||
|
||||
type SessionProps = {
|
||||
displayName: string | null;
|
||||
pictureUrl?: string | null;
|
||||
};
|
||||
|
||||
type TextProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ProfileAvatarProps = (SessionProps | TextProps) & {
|
||||
className?: string;
|
||||
fallbackClassName?: string;
|
||||
};
|
||||
|
||||
export function ProfileAvatar(props: ProfileAvatarProps) {
|
||||
const avatarClassName = cn(
|
||||
props.className,
|
||||
'mx-auto h-9 w-9 group-focus:ring-2',
|
||||
);
|
||||
|
||||
if ('text' in props) {
|
||||
return (
|
||||
<Avatar className={avatarClassName}>
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
props.fallbackClassName,
|
||||
'animate-in fade-in uppercase',
|
||||
)}
|
||||
>
|
||||
{props.text.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
const initials = props.displayName?.slice(0, 1);
|
||||
|
||||
return (
|
||||
<Avatar className={avatarClassName}>
|
||||
<AvatarImage src={props.pictureUrl ?? undefined} />
|
||||
|
||||
<AvatarFallback
|
||||
className={cn(props.fallbackClassName, 'animate-in fade-in')}
|
||||
>
|
||||
<span suppressHydrationWarning className={'uppercase'}>
|
||||
{initials}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
357
packages/ui/src/makerkit/sidebar.tsx
Normal file
357
packages/ui/src/makerkit/sidebar.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
'use client';
|
||||
|
||||
import { useContext, useId, useRef, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { cn, isRouteActive } from '../lib/utils';
|
||||
import { Button } from '../shadcn/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '../shadcn/tooltip';
|
||||
import { SidebarContext } from './context/sidebar.context';
|
||||
import { If } from './if';
|
||||
import type { NavigationConfigSchema } from './navigation-config.schema';
|
||||
import { Trans } from './trans';
|
||||
|
||||
export type SidebarConfig = z.infer<typeof NavigationConfigSchema>;
|
||||
|
||||
export { SidebarContext };
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* This component is deprecated and will be removed in a future version.
|
||||
* Please use the Shadcn Sidebar component instead.
|
||||
*/
|
||||
export function Sidebar(props: {
|
||||
collapsed?: boolean;
|
||||
expandOnHover?: boolean;
|
||||
className?: string;
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((props: {
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
}) => React.ReactNode);
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(props.collapsed ?? false);
|
||||
const isExpandedRef = useRef<boolean>(false);
|
||||
|
||||
const expandOnHover =
|
||||
props.expandOnHover ??
|
||||
process.env.NEXT_PUBLIC_EXPAND_SIDEBAR_ON_HOVER === 'true';
|
||||
|
||||
const sidebarSizeClassName = getSidebarSizeClassName(
|
||||
collapsed,
|
||||
isExpandedRef.current,
|
||||
);
|
||||
|
||||
const className = getClassNameBuilder(
|
||||
cn(props.className ?? '', sidebarSizeClassName, {}),
|
||||
)();
|
||||
|
||||
const containerClassName = cn(sidebarSizeClassName, 'bg-inherit', {
|
||||
'max-w-[4rem]': expandOnHover && isExpandedRef.current,
|
||||
});
|
||||
|
||||
const ctx = { collapsed, setCollapsed };
|
||||
|
||||
const onMouseEnter =
|
||||
props.collapsed && expandOnHover
|
||||
? () => {
|
||||
setCollapsed(false);
|
||||
isExpandedRef.current = true;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const onMouseLeave =
|
||||
props.collapsed && expandOnHover
|
||||
? () => {
|
||||
if (!isRadixPopupOpen()) {
|
||||
setCollapsed(true);
|
||||
isExpandedRef.current = false;
|
||||
} else {
|
||||
onRadixPopupClose(() => {
|
||||
setCollapsed(true);
|
||||
isExpandedRef.current = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={ctx}>
|
||||
<div
|
||||
className={containerClassName}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<div aria-expanded={!collapsed} className={className}>
|
||||
{typeof props.children === 'function'
|
||||
? props.children(ctx)
|
||||
: props.children}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarContent({
|
||||
children,
|
||||
className: customClassName,
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
}>) {
|
||||
const { collapsed } = useContext(SidebarContext);
|
||||
|
||||
const className = cn(
|
||||
'flex w-full flex-col space-y-1.5 py-1',
|
||||
customClassName,
|
||||
{
|
||||
'px-4': !collapsed,
|
||||
'px-2': collapsed,
|
||||
},
|
||||
);
|
||||
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SidebarGroup({
|
||||
label,
|
||||
collapsed = false,
|
||||
collapsible = true,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
label: string | React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
collapsed?: boolean;
|
||||
}>) {
|
||||
const { collapsed: sidebarCollapsed } = useContext(SidebarContext);
|
||||
const [isGroupCollapsed, setIsGroupCollapsed] = useState(collapsed);
|
||||
const id = useId();
|
||||
|
||||
const Title = (props: React.PropsWithChildren) => {
|
||||
if (sidebarCollapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={'text-muted-foreground text-xs font-semibold uppercase'}>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = () => {
|
||||
const className = cn(
|
||||
'px-container group flex items-center justify-between space-x-2.5',
|
||||
{
|
||||
'py-2.5': !sidebarCollapsed,
|
||||
},
|
||||
);
|
||||
|
||||
if (collapsible) {
|
||||
return (
|
||||
<button
|
||||
aria-expanded={!isGroupCollapsed}
|
||||
aria-controls={id}
|
||||
onClick={() => setIsGroupCollapsed(!isGroupCollapsed)}
|
||||
className={className}
|
||||
>
|
||||
<Title>{label}</Title>
|
||||
|
||||
<If condition={collapsible}>
|
||||
<ChevronDown
|
||||
className={cn(`h-3 transition duration-300`, {
|
||||
'rotate-180': !isGroupCollapsed,
|
||||
})}
|
||||
/>
|
||||
</If>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Title>{label}</Title>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col', {
|
||||
'gap-y-2 py-1': !collapsed,
|
||||
})}
|
||||
>
|
||||
<Wrapper />
|
||||
|
||||
<If condition={collapsible ? !isGroupCollapsed : true}>
|
||||
<div id={id} className={'flex flex-col space-y-1.5'}>
|
||||
{children}
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarDivider() {
|
||||
return (
|
||||
<div className={'dark:border-dark-800 my-2 border-t border-gray-100'} />
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarItem({
|
||||
end,
|
||||
path,
|
||||
children,
|
||||
Icon,
|
||||
}: React.PropsWithChildren<{
|
||||
path: string;
|
||||
Icon: React.ReactNode;
|
||||
end?: boolean | ((path: string) => boolean);
|
||||
}>) {
|
||||
const { collapsed } = useContext(SidebarContext);
|
||||
const currentPath = usePathname() ?? '';
|
||||
|
||||
const active = isRouteActive(path, currentPath, end ?? false);
|
||||
const variant = active ? 'secondary' : 'ghost';
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
'active:bg-secondary/60 flex w-full text-sm shadow-none',
|
||||
{
|
||||
'justify-start space-x-2.5': !collapsed,
|
||||
'hover:bg-initial': active,
|
||||
},
|
||||
)}
|
||||
size={'sm'}
|
||||
variant={variant}
|
||||
>
|
||||
<Link href={path}>
|
||||
{Icon}
|
||||
<span
|
||||
className={cn('w-auto transition-opacity duration-300', {
|
||||
'w-0 opacity-0': collapsed,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<If condition={collapsed}>
|
||||
<TooltipContent side={'right'} sideOffset={10}>
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</If>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function getClassNameBuilder(className: string) {
|
||||
return cva([
|
||||
cn(
|
||||
'group/sidebar transition-width fixed box-content flex h-screen w-2/12 flex-col bg-inherit backdrop-blur-xs duration-200',
|
||||
className,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
function getSidebarSizeClassName(collapsed: boolean, isExpanded: boolean) {
|
||||
return cn(['z-50 flex w-full flex-col'], {
|
||||
'dark:shadow-primary/20 lg:w-[17rem]': !collapsed,
|
||||
'lg:w-[4rem]': collapsed,
|
||||
shadow: isExpanded,
|
||||
});
|
||||
}
|
||||
|
||||
function getRadixPopup() {
|
||||
return document.querySelector('[data-radix-popper-content-wrapper]');
|
||||
}
|
||||
|
||||
function isRadixPopupOpen() {
|
||||
return getRadixPopup() !== null;
|
||||
}
|
||||
|
||||
function onRadixPopupClose(callback: () => void) {
|
||||
const element = getRadixPopup();
|
||||
|
||||
if (element) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!getRadixPopup()) {
|
||||
callback();
|
||||
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(element.parentElement!, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function SidebarNavigation({
|
||||
config,
|
||||
}: React.PropsWithChildren<{
|
||||
config: SidebarConfig;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
{config.routes.map((item, index) => {
|
||||
if ('divider' in item) {
|
||||
return <SidebarDivider key={index} />;
|
||||
}
|
||||
|
||||
if ('children' in item) {
|
||||
return (
|
||||
<SidebarGroup
|
||||
key={item.label}
|
||||
label={<Trans i18nKey={item.label} defaults={item.label} />}
|
||||
collapsible={item.collapsible}
|
||||
collapsed={item.collapsed}
|
||||
>
|
||||
{item.children.map((child) => {
|
||||
if ('collapsible' in child && child.collapsible) {
|
||||
throw new Error(
|
||||
'Collapsible groups are not supported in the old Sidebar. Please migrate to the new Sidebar.',
|
||||
);
|
||||
}
|
||||
|
||||
if ('path' in child) {
|
||||
return (
|
||||
<SidebarItem
|
||||
key={child.path}
|
||||
end={child.end}
|
||||
path={child.path}
|
||||
Icon={child.Icon}
|
||||
>
|
||||
<Trans i18nKey={child.label} defaults={child.label} />
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
packages/ui/src/makerkit/spinner.tsx
Normal file
30
packages/ui/src/makerkit/spinner.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export function Spinner(
|
||||
props: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
`fill-primary-foreground text-primary dark:fill-primary dark:text-primary/30 h-8 w-8 animate-spin`,
|
||||
props.className,
|
||||
)}
|
||||
viewBox="0 0 100 101"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
packages/ui/src/makerkit/stepper.tsx
Normal file
217
packages/ui/src/makerkit/stepper.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment, useCallback } from 'react';
|
||||
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { If } from './if';
|
||||
import { Trans } from './trans';
|
||||
|
||||
type Variant = 'numbers' | 'default' | 'dots';
|
||||
|
||||
const classNameBuilder = getClassNameBuilder();
|
||||
|
||||
/**
|
||||
* Renders a stepper component with multiple steps.
|
||||
*
|
||||
* @param {Object} props - The props object containing the following properties:
|
||||
* - steps {string[]} - An array of strings representing the step labels.
|
||||
* - currentStep {number} - The index of the currently active step.
|
||||
* - variant {string} (optional) - The variant of the stepper component (default: 'default').
|
||||
**/
|
||||
export function Stepper(props: {
|
||||
steps: string[];
|
||||
currentStep: number;
|
||||
variant?: Variant;
|
||||
}) {
|
||||
const variant = props.variant ?? 'default';
|
||||
|
||||
const Steps = useCallback(() => {
|
||||
return props.steps.map((labelOrKey, index) => {
|
||||
const selected = props.currentStep === index;
|
||||
const complete = props.currentStep > index;
|
||||
|
||||
const className = classNameBuilder({
|
||||
selected,
|
||||
variant,
|
||||
complete,
|
||||
});
|
||||
|
||||
const isNumberVariant = variant === 'numbers';
|
||||
const isDotsVariant = variant === 'dots';
|
||||
|
||||
const labelClassName = cn({
|
||||
['px-1.5 py-2 text-xs']: !isNumberVariant,
|
||||
['hidden']: isDotsVariant,
|
||||
});
|
||||
|
||||
const { label, number } = getStepLabel(labelOrKey, index);
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<div aria-selected={selected} className={className}>
|
||||
<span className={labelClassName}>
|
||||
{number}
|
||||
<If condition={!isNumberVariant}>. {label}</If>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<If condition={isNumberVariant}>
|
||||
<StepDivider selected={selected} complete={complete}>
|
||||
{label}
|
||||
</StepDivider>
|
||||
</If>
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
}, [props.steps, props.currentStep, variant]);
|
||||
|
||||
// If there are no steps, don't render anything.
|
||||
if (props.steps.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerClassName = cn('w-full', {
|
||||
['flex justify-between']: variant === 'numbers',
|
||||
['flex space-x-0.5']: variant === 'default',
|
||||
['flex gap-x-4 self-center']: variant === 'dots',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<Steps />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getClassNameBuilder() {
|
||||
return cva(``, {
|
||||
variants: {
|
||||
variant: {
|
||||
default: `flex h-[2.5px] w-full flex-col transition-all duration-500`,
|
||||
numbers:
|
||||
'flex h-9 w-9 items-center justify-center rounded-full border text-sm font-bold',
|
||||
dots: 'bg-muted h-2.5 w-2.5 rounded-full transition-colors',
|
||||
},
|
||||
selected: {
|
||||
true: '',
|
||||
false: 'hidden sm:flex',
|
||||
},
|
||||
complete: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: 'default',
|
||||
selected: false,
|
||||
className: 'text-muted-foreground',
|
||||
},
|
||||
{
|
||||
variant: 'default',
|
||||
selected: true,
|
||||
className: 'bg-primary font-medium',
|
||||
},
|
||||
{
|
||||
variant: 'default',
|
||||
selected: false,
|
||||
complete: false,
|
||||
className: 'bg-muted',
|
||||
},
|
||||
{
|
||||
variant: 'default',
|
||||
selected: false,
|
||||
complete: true,
|
||||
className: 'bg-primary',
|
||||
},
|
||||
{
|
||||
variant: 'numbers',
|
||||
selected: false,
|
||||
complete: true,
|
||||
className: 'border-primary text-primary',
|
||||
},
|
||||
{
|
||||
variant: 'numbers',
|
||||
selected: true,
|
||||
className: 'border-primary bg-primary text-primary-foreground',
|
||||
},
|
||||
{
|
||||
variant: 'numbers',
|
||||
selected: false,
|
||||
className: 'text-muted-foreground',
|
||||
},
|
||||
{
|
||||
variant: 'dots',
|
||||
selected: true,
|
||||
complete: true,
|
||||
className: 'bg-primary',
|
||||
},
|
||||
{
|
||||
variant: 'dots',
|
||||
selected: false,
|
||||
complete: true,
|
||||
className: 'bg-primary',
|
||||
},
|
||||
{
|
||||
variant: 'dots',
|
||||
selected: true,
|
||||
complete: false,
|
||||
className: 'bg-primary',
|
||||
},
|
||||
{
|
||||
variant: 'dots',
|
||||
selected: false,
|
||||
complete: false,
|
||||
className: 'bg-muted',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
selected: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function StepDivider({
|
||||
selected,
|
||||
complete,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
selected: boolean;
|
||||
complete: boolean;
|
||||
}>) {
|
||||
const spanClassName = cn('min-w-max text-sm font-medium', {
|
||||
['text-muted-foreground hidden sm:flex']: !selected,
|
||||
['text-secondary-foreground']: selected || complete,
|
||||
['font-medium']: selected,
|
||||
});
|
||||
|
||||
const className = cn(
|
||||
'flex h-9 flex-1 items-center justify-center last:flex-[0_0_0]' +
|
||||
' group flex w-full items-center space-x-3 px-3',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<span className={spanClassName}>{children}</span>
|
||||
|
||||
<div
|
||||
className={
|
||||
'divider h-[1px] w-full bg-gray-200 transition-colors' +
|
||||
' dark:bg-border hidden group-last:hidden sm:flex'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStepLabel(labelOrKey: string, index: number) {
|
||||
const number = (index + 1).toString();
|
||||
|
||||
return {
|
||||
number,
|
||||
label: <Trans i18nKey={labelOrKey} defaults={labelOrKey} />,
|
||||
};
|
||||
}
|
||||
40
packages/ui/src/makerkit/top-loading-bar-indicator.tsx
Normal file
40
packages/ui/src/makerkit/top-loading-bar-indicator.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { createRef, useEffect } from 'react';
|
||||
|
||||
import type { LoadingBarRef } from 'react-top-loading-bar';
|
||||
import LoadingBar from 'react-top-loading-bar';
|
||||
|
||||
let running = false;
|
||||
|
||||
export function TopLoadingBarIndicator() {
|
||||
const ref = createRef<LoadingBarRef>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || running) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
|
||||
const loadingBarRef = ref.current;
|
||||
|
||||
loadingBarRef.continuousStart(0, 300);
|
||||
|
||||
return () => {
|
||||
loadingBarRef.complete();
|
||||
running = false;
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<LoadingBar
|
||||
className={'bg-primary'}
|
||||
height={4}
|
||||
waitingTime={0}
|
||||
shadow
|
||||
color={''}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
packages/ui/src/makerkit/trans.tsx
Normal file
5
packages/ui/src/makerkit/trans.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Trans as TransComponent } from 'react-i18next/TransWithoutContext';
|
||||
|
||||
export function Trans(props: React.ComponentProps<typeof TransComponent>) {
|
||||
return <TransComponent {...props} />;
|
||||
}
|
||||
118
packages/ui/src/makerkit/version-updater.tsx
Normal file
118
packages/ui/src/makerkit/version-updater.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { RocketIcon } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '../shadcn/alert-dialog';
|
||||
import { Button } from '../shadcn/button';
|
||||
import { Trans } from './trans';
|
||||
|
||||
/**
|
||||
* Current version of the app that is running
|
||||
*/
|
||||
let version: string | null = null;
|
||||
|
||||
/**
|
||||
* Default interval time in seconds to check for new version
|
||||
* By default, it is set to 120 seconds
|
||||
*/
|
||||
const DEFAULT_REFETCH_INTERVAL = 120;
|
||||
|
||||
/**
|
||||
* Default interval time in seconds to check for new version
|
||||
*/
|
||||
const VERSION_UPDATER_REFETCH_INTERVAL_SECONDS =
|
||||
process.env.NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS;
|
||||
|
||||
export function VersionUpdater(props: { intervalTimeInSecond?: number }) {
|
||||
const { data } = useVersionUpdater(props);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowDialog(data?.didChange ?? false);
|
||||
}, [data?.didChange]);
|
||||
|
||||
if (!data?.didChange || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className={'flex items-center gap-x-2'}>
|
||||
<RocketIcon className={'h-4'} />
|
||||
<span>
|
||||
<Trans i18nKey="common:newVersionAvailable" />
|
||||
</span>
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans i18nKey="common:newVersionAvailableDescription" />
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
onClick={() => {
|
||||
setShowDialog(false);
|
||||
setDismissed(true);
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="common:back" />
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<Trans i18nKey="common:newVersionSubmitButton" />
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function useVersionUpdater(props: { intervalTimeInSecond?: number } = {}) {
|
||||
const interval = VERSION_UPDATER_REFETCH_INTERVAL_SECONDS
|
||||
? Number(VERSION_UPDATER_REFETCH_INTERVAL_SECONDS)
|
||||
: DEFAULT_REFETCH_INTERVAL;
|
||||
|
||||
const refetchInterval = (props.intervalTimeInSecond ?? interval) * 1000;
|
||||
|
||||
// start fetching new version after half of the interval time
|
||||
const staleTime = refetchInterval / 2;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['version-updater'],
|
||||
staleTime,
|
||||
gcTime: refetchInterval,
|
||||
refetchIntervalInBackground: true,
|
||||
refetchInterval,
|
||||
initialData: null,
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/version');
|
||||
const currentVersion = await response.text();
|
||||
const oldVersion = version;
|
||||
|
||||
version = currentVersion;
|
||||
|
||||
const didChange = oldVersion !== null && currentVersion !== oldVersion;
|
||||
|
||||
return {
|
||||
currentVersion,
|
||||
oldVersion,
|
||||
didChange,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user