First Commit. Duplicate bunext-sqlite preset

This commit is contained in:
2026-10-18 16:47:11 +01:00
commit 81e4a8aecc
718 changed files with 41695 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import H1 from "@moduletrace/twui/layout/H1";
import Section from "@moduletrace/twui/layout/Section";
import { useContext, type ReactNode } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
import { AppContext } from "@/src/pages/__root";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
description?: string | ReactNode;
buttons?: ReactNode;
};
export default function AdminHero({ title, description, buttons }: Props) {
const { pageProps } = useContext(AppContext);
return (
<Section className="pb-0! pt-10! xl:pt-14!">
<Stack className="w-full items-stretch gap-0!">
<Breadcrumbs
currentLinkProps={{
className: "text-slate-400! pointer-events-none",
}}
pageUrl={pageProps?.url?.pathname}
backButton
skipHome
/>
<Row className="w-full justify-between items-end gap-6! mt-6 pb-8 border-b border-slate-200 dark:border-white/10">
<Stack className="gap-3! max-w-2xl">
<H1>{title}</H1>
{description ? (
typeof description == "string" ? (
<Span className="text-slate-500 dark:text-slate-400 text-[15px] leading-relaxed">
{description}
</Span>
) : (
description
)
) : null}
</Stack>
{buttons ? <Row className="gap-2!">{buttons}</Row> : null}
</Row>
</Stack>
</Section>
);
}
+13
View File
@@ -0,0 +1,13 @@
import Row from "@moduletrace/twui/layout/Row";
import Divider from "@moduletrace/twui/layout/Divider";
import Span from "@moduletrace/twui/layout/Span";
export default function AuthDivider() {
return (
<Row className="w-full flex-nowrap gap-4 -my-2">
<Divider />
<Span className="text-sm font-semibold opacity-50">OR</Span>
<Divider />
</Row>
);
}
@@ -0,0 +1,25 @@
import { twMerge } from "tailwind-merge";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Link from "@moduletrace/twui/layout/Link";
type Props = {
prompt: string;
title: string;
url: string;
className?: string;
};
export default function AuthFooterLink({
prompt,
title,
url,
className,
}: Props) {
return (
<Row className={twMerge("justify-center gap-1.5", className)}>
<Span variant="faded">{prompt}</Span>
<Link href={url}>{title}</Link>
</Row>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
eyebrow: string;
title: string;
description?: ReactNode;
};
export default function AuthHero({ eyebrow, title, description }: Props) {
return (
<Stack className="w-full items-center gap-4!">
<Span className="htag">{eyebrow}</Span>
<H1 className="text-center text-3xl! sm:text-4xl! tracking-[-0.035em]">
{title}
</H1>
{description ? (
<Span className="text-base text-slate-500 dark:text-slate-400 leading-snug text-center max-w-md">
{description}
</Span>
) : null}
</Stack>
);
}
+24
View File
@@ -0,0 +1,24 @@
import Img from "@moduletrace/twui/layout/Img";
import { twMerge } from "tailwind-merge";
type Props = {
src: string;
opacity?: number;
};
export default function BlurredImageBG({ src, opacity = 0.1 }: Props) {
/**
* Render component
*/
return (
<Img
alt="bg image"
src={src}
className={twMerge(
`absolute top-0 left-0 w-[150vw] h-full blur-xs object-cover`,
"z-0",
)}
style={{ opacity }}
/>
);
}
@@ -0,0 +1,131 @@
import Loading from "@moduletrace/twui/elements/Loading";
import Select from "@moduletrace/twui/form/Select";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import type { TableType } from "@/src/types";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import Card from "@moduletrace/twui/elements/Card";
import H3 from "@moduletrace/twui/layout/H3";
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import H4 from "@moduletrace/twui/layout/H4";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import Row from "@moduletrace/twui/layout/Row";
import dayjs from "dayjs";
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
export type CopyableRecord = {
id?: number | "";
title?: string;
subject?: string;
};
type Props<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = {
table: TableType;
title: string;
onSelect?: (record: T) => void;
onClear?: () => void;
};
export default function CopyFromExistingSelect<
T extends BUN_SQLITE_BSQLITE_ALL_TYPEDEFS =
BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
>({ table, title, onSelect, onClear }: Props<T>) {
const { res: records, ready } = useAdminCrudGet<T>({
table,
});
const { open, setOpen } = useStatus();
return (
<Row>
<Button
title="Clear Form"
onClick={(e) => {
e.preventDefault();
onClear?.();
}}
variant="outlined"
color="gray"
>
Clear
</Button>
<Modal
open={open}
setOpen={setOpen}
target={
<Button
title="Duplicate Existing Record"
onClick={(e) => {
e.preventDefault();
}}
variant="outlined"
>
Create from an Existing Record
</Button>
}
className="xl:p-6"
>
<Stack className="gap-6 w-full">
<Stack className="gap-1">
<H3 className="text-2xl! font-semibold">
{title || "Select Record"}
</H3>
<Span>Select a record to duplicate</Span>
</Stack>
<Stack className="w-full items-stretch">
{records?.[0] ? (
records?.map((rec, index) => {
const date = dayjs(
twuiNumberfy(rec.created_at),
);
return (
<Card
key={index}
onClick={() => {
onSelect?.(rec);
setOpen(false);
}}
className="w-full cursor-pointer"
>
<Row className="w-full justify-between">
<Row>
<LucideIcon
name="Database"
size={17}
opacity={0.5}
/>
<H4 className="text-base! font-semibold">
{rec.title}
</H4>
</Row>
<Row>
<Span
size="smaller"
variant="faded"
className="text-nowrap"
>
{date
.toString()
.substring(0, 16)}
</Span>
</Row>
</Row>
</Card>
);
})
) : (
<EmptyContent title="No Records Found!" />
)}
</Stack>
</Stack>
</Modal>
</Row>
);
}
+33
View File
@@ -0,0 +1,33 @@
import H3 from "@moduletrace/twui/layout/H3";
import Span from "@moduletrace/twui/layout/Span";
import Row from "@moduletrace/twui/layout/Row";
import type { FeatureItem } from "@/src/types";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = FeatureItem & {
index: number;
};
export default function FeatureCard({
Icon,
title,
description,
index,
}: Props) {
return (
<Stack className="h-full w-full gap-14! justify-between bg-light dark:bg-background-dark p-8 xl:p-10 transition-colors hover:bg-white dark:hover:bg-white/5">
<Row className="w-full justify-between">
<Span className="text-xs text-slate-400">
{String(index + 1).padStart(2, "0")}
</Span>
<Icon size={20} strokeWidth={1.5} className="text-slate-700 dark:text-slate-300" />
</Row>
<Stack className="gap-3!">
<H3>{title}</H3>
<Span className="text-slate-500 dark:text-slate-400 leading-relaxed">
{description}
</Span>
</Stack>
</Stack>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { FeatureItem } from "@/src/types";
import FeatureCard from "./feature-card";
type Props = {
items: FeatureItem[];
};
export default function FeatureGrid({ items }: Props) {
return (
<div className="grid w-full grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-px bg-slate-200 dark:bg-white/10 border border-slate-200 dark:border-white/10 rounded-default-lg overflow-hidden">
{items.map((item, index) => (
<FeatureCard key={item.title} {...item} index={index} />
))}
</div>
);
}
@@ -0,0 +1,67 @@
import Button from "@moduletrace/twui/layout/Button";
import Img from "@moduletrace/twui/layout/Img";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import type useFormInit from "@/src/hooks/use-form-init";
import { twMerge } from "tailwind-merge";
import useGoogleAuth, {
type GoogleAuthMode,
} from "@/src/hooks/use-google-auth";
type Props = Pick<
ReturnType<typeof useFormInit>,
"loading" | "setLoading" | "setStatus"
> & {
mode: GoogleAuthMode;
};
/**
* Themed button with Google's own (transparent) button laid over it. Once
* Google's button is ready it takes keyboard focus and clicks, and the
* themed button below is hidden from assistive tech.
*/
export default function GoogleAuthButton({
mode,
loading,
setLoading,
setStatus,
}: Props) {
const { handleButtonClick, googleButtonRef, googleReady } = useGoogleAuth({
mode,
loading,
setLoading,
setStatus,
});
const overlayActive = googleReady && !loading;
const label =
mode == "signup" ? "Sign up with Google" : "Log in with Google";
return (
<Row className="w-full relative justify-center rounded-default focus-within:ring-2 focus-within:ring-primary/70">
<Button
title={label}
type="button"
variant="outlined"
color="gray"
className="w-full py-3 relative"
disabled={loading}
onClick={handleButtonClick}
tabIndex={overlayActive ? -1 : undefined}
aria-hidden={overlayActive || undefined}
>
<Img alt="" src={`/icons/google.png`} size={17} />
<Span>{label}</Span>
</Button>
<div
ref={googleButtonRef}
className={twMerge(
"absolute inset-0 z-10 opacity-0",
overlayActive
? "pointer-events-auto"
: "pointer-events-none",
)}
/>
</Row>
);
}
@@ -0,0 +1,25 @@
import { useContext, type ComponentProps } from "react";
import { AppContext } from "@/src/pages/__root";
import GoogleAuthButton from "./google-auth-button";
import AuthDivider from "./auth-divider";
/**
* Google button plus the "OR" divider. Renders nothing when Google
* sign-in isn't configured.
*/
export default function GoogleAuthSection(
props: ComponentProps<typeof GoogleAuthButton>,
) {
const { pageProps } = useContext(AppContext);
if (!pageProps?.envs?.GOOGLE_CLIENT_ID) {
return null;
}
return (
<>
<GoogleAuthButton {...props} />
<AuthDivider />
</>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Img from "@moduletrace/twui/layout/Img";
import type { ComponentProps } from "react";
import { twMerge } from "tailwind-merge";
type Props = {
src: string;
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
};
export default function ImageBG({ src, img_props }: Props) {
return (
<Img
{...img_props}
alt="bg image"
src={src}
className={twMerge(
`absolute top-0 left-0 w-full h-full object-cover`,
"z-0",
img_props?.className,
)}
/>
);
}
@@ -0,0 +1,35 @@
import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
sub_title?: string;
description?: string;
};
export default function InnerPageHeroSection({
title,
description,
sub_title,
}: Props) {
return (
<Section className="border-b border-slate-200/60 dark:border-white/10">
<Container>
<Stack className="w-full items-center max-w-3xl mx-auto gap-6!">
{sub_title ? (
<Span className="htag">{sub_title}</Span>
) : null}
<H1 className="text-center">{title}</H1>
{description ? (
<Span className="text-lg sm:text-xl leading-snug text-center text-slate-500 dark:text-slate-400">
{description}
</Span>
) : null}
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,26 @@
import Input from "@moduletrace/twui/form/Input";
type Props = {
onCodeChange: (code: string) => void;
};
export default function LoginCodeInput({ onCodeChange }: Props) {
return (
<Input
id="code"
name="code"
type="text"
label="Code"
placeholder="123456"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
autoFocus
onChange={(e) => {
onCodeChange(e.target.value);
}}
showLabel
required
/>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { twMerge } from "tailwind-merge";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row";
import { TriangleAlert, CircleCheck } from "lucide-react";
import type { PropsWithChildren } from "react";
import type { TWUIStatusType } from "@moduletrace/twui/hooks/useStatus";
import Paper from "@moduletrace/twui/elements/Paper";
type Props = PropsWithChildren & {
title?: string;
className?: string;
status?: TWUIStatusType;
};
export default function LoginShell({ children, className, status }: Props) {
return (
<>
<Paper
className={twMerge(
"w-full flex-col items-center",
"px-4 sm:px-6 py-8 shadow-md relative",
className,
)}
>
{status?.error && status.msg ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<span>{status.msg}</span>
</Row>
</Tag>
) : null}
{status?.success && status.msg ? (
<Tag
variant="outlined"
color="success"
className="w-full py-1.5 outline-success/50 bg-success/5!"
>
<Row>
<CircleCheck size={15} />
<span>{status.msg}</span>
</Row>
</Tag>
) : null}
{children}
</Paper>
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ComponentProps } from "react";
import { twMerge } from "tailwind-merge";
type Props = {
icon_size?: number;
img_props?: Pick<ComponentProps<"svg">, "className">;
};
export default function LogoIcon({ icon_size = 40, img_props }: Props) {
return (
<svg
width={icon_size}
height={icon_size}
viewBox="0 0 512 512"
role="img"
aria-label="Logo Icon"
className={twMerge("shrink-0", img_props?.className)}
>
<rect
x="16"
y="16"
width="480"
height="480"
rx="120"
className="fill-slate-900 dark:fill-slate-800"
/>
<path
d="M290 100 L144 286 H244 L222 412 L368 226 H268 Z"
strokeWidth="28"
strokeLinejoin="round"
className="fill-white stroke-white"
/>
</svg>
);
}
+56
View File
@@ -0,0 +1,56 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import type { ComponentProps, PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
import Stack from "@moduletrace/twui/layout/Stack";
import LogoIcon from "./logo-icon";
import { SiteData } from "@/src/data/site-data";
type Props = {
icon_size?: number;
text_wrapper_props?: ComponentProps<typeof Stack>;
text_props?: ComponentProps<typeof Span>;
};
export default function Logo({
icon_size = 36,
text_props,
text_wrapper_props,
}: Props) {
return (
<a href="/" className="logo-link">
<Row className="gap-3! flex-nowrap items-center">
<LogoIcon icon_size={icon_size} />
<Stack
{...text_wrapper_props}
className={twMerge("gap-1!", text_wrapper_props?.className)}
>
<Stack className="gap-0!">
<HeroText text_props={text_props}>
{SiteData["SiteName"]}
</HeroText>
</Stack>
</Stack>
</Row>
</a>
);
}
function HeroText({
text_props,
children,
}: PropsWithChildren & {
text_props?: ComponentProps<typeof Span>;
}) {
return (
<Span
{...text_props}
className={twMerge(
"text-[17px] font-semibold tracking-tight text-dark dark:text-white heading leading-[24px] whitespace-nowrap",
text_props?.className,
)}
>
{children}
</Span>
);
}
+38
View File
@@ -0,0 +1,38 @@
import React, { type ComponentProps, type ReactNode } from "react";
import Container from "@moduletrace/twui/layout/Container";
import H1 from "@moduletrace/twui/layout/H1";
import P from "@moduletrace/twui/layout/P";
import Section from "@moduletrace/twui/layout/Section";
import Span from "@moduletrace/twui/layout/Span";
import type Img from "@moduletrace/twui/layout/Img";
import { SiteData } from "@/src/data/site-data";
type Props = {
title: string;
image_url?: string;
sub_title?: string;
cta_buttons?: ReactNode;
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
img_component?: ReactNode;
};
export default function MainHeroSection({
title,
cta_buttons,
sub_title,
}: Props) {
return (
<Section className="mt-hero-section">
<Container className="mt-container flex-col! gap-0">
<Span className="htag">{SiteData["SiteName"]}</Span>
<H1 className="mt-hero-title">{title}</H1>
{sub_title ? (
<P className="mt-hero-desc">{sub_title}</P>
) : null}
{cta_buttons ? (
<div className="mt-hero-ctas">{cta_buttons}</div>
) : null}
</Container>
</Section>
);
}
@@ -0,0 +1,31 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import ColorSchemeSelector from "@moduletrace/twui/elements/ColorSchemeSelector";
import { twMerge } from "tailwind-merge";
type Props = {
className?: string;
};
/**
* Theme switch row pinned to the bottom of the mobile navs.
*/
export default function MobileNavThemeRow({ className }: Props) {
return (
<Row
className={twMerge(
"w-full justify-between items-center flex-nowrap",
"border-t border-slate-200/70 dark:border-white/10",
className,
)}
>
<Span className="text-sm text-slate-500 dark:text-slate-400">
Theme
</Span>
<ColorSchemeSelector
className="text-slate-500 hover:text-foreground-light dark:text-slate-400 dark:hover:text-foreground-dark transition-colors"
size="small"
/>
</Row>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Input from "@moduletrace/twui/form/Input";
import type { AutocompleteOptions } from "@moduletrace/twui/types";
import type { ReactNode } from "react";
type Props = {
id: string;
label: string;
autoComplete: (typeof AutocompleteOptions)[number];
onValueChange: (value: string) => void;
required?: boolean;
info?: ReactNode;
};
export default function PasswordInput({
id,
label,
autoComplete,
onValueChange,
required,
info,
}: Props) {
return (
<Input
id={id}
name={id}
type="password"
label={label}
autoComplete={autoComplete}
info={info}
onChange={(e) => {
onValueChange(e.target.value);
}}
showLabel
required={required}
/>
);
}
+107
View File
@@ -0,0 +1,107 @@
import Loading from "@moduletrace/twui/elements/Loading";
import Center from "@moduletrace/twui/layout/Center";
import Span from "@moduletrace/twui/layout/Span";
import { useEffect, useState } from "react";
type Props = {
url: string;
body: any;
download_name?: string;
height?: number;
};
export default function PdfPreview({
url,
body,
download_name,
height = 1200,
}: Props) {
const [blob_url, setBlobUrl] = useState<string>();
const [error, setError] = useState<string>();
useEffect(() => {
let object_url: string | undefined;
let cancelled = false;
setBlobUrl(undefined);
setError(undefined);
(async () => {
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body || {}),
});
const content_type = res.headers.get("Content-Type") || "";
if (content_type.includes("application/json")) {
const json = await res.json();
throw new Error(json.msg || "Couldn't generate PDF");
}
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
const blob = await res.blob();
if (cancelled) return;
object_url = URL.createObjectURL(blob);
setBlobUrl(object_url);
} catch (error: any) {
if (!cancelled) {
setError(error.message || "Couldn't generate PDF preview");
}
}
})();
return () => {
cancelled = true;
if (object_url) {
URL.revokeObjectURL(object_url);
}
};
}, [url]);
if (error) {
return (
<Center className="py-10">
<Span className="text-warning">{error}</Span>
</Center>
);
}
if (!blob_url) {
return (
<Center className="py-10">
<Loading />
</Center>
);
}
return (
<div className="w-full flex flex-col gap-2 items-end">
{download_name ? (
<a
href={blob_url}
download={download_name}
className="text-sm text-link-500 hover:opacity-70 underline"
>
Download PDF
</a>
) : null}
<iframe
src={blob_url}
title="PDF Preview"
className="w-full rounded-default border border-slate-300 dark:border-white/20 bg-white"
style={{ height }}
/>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import Img from "@moduletrace/twui/layout/Img";
import { twMerge } from "tailwind-merge";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
src: string;
width?: number;
height?: number;
alt?: string;
};
export default function RectImage({
src,
height = 300,
width = 200,
alt,
}: Props) {
const img_component = (
<Img
alt={alt || "image"}
src={src}
className={twMerge(`w-full h-full object-cover`)}
/>
);
return (
<>
<Stack
className={twMerge(`overflow-hidden hidden xl:flex`)}
style={{
width: `${width}px`,
height: `${height}px`,
}}
>
{img_component}
</Stack>
<Stack className={twMerge(`overflow-hidden flex xl:hidden w-full`)}>
{img_component}
</Stack>
</>
);
}
@@ -0,0 +1,34 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import type useFormInit from "@/src/hooks/use-form-init";
import resendLoginCode from "@/src/functions/frontend/resend-login-code";
type Props = Pick<
ReturnType<typeof useFormInit>,
"loading" | "setLoading" | "setStatus"
>;
export default function ResendCodeButton({
loading,
setLoading,
setStatus,
}: Props) {
return (
<Row className="w-full justify-center gap-1.5 text-sm">
<Span variant="faded">Didn't get a code?</Span>
<Button
title="Resend code"
type="button"
variant="ghost"
size="small"
loading={loading}
onClick={() => {
resendLoginCode({ setLoading, setStatus });
}}
>
Resend code
</Button>
</Row>
);
}
@@ -0,0 +1,185 @@
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import Input from "@moduletrace/twui/form/Input";
import Button from "@moduletrace/twui/layout/Button";
import Paper from "@moduletrace/twui/elements/Paper";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Border from "@moduletrace/twui/elements/Border";
import { formatNaira } from "@/src/utils/money";
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
export type SortableLineItemFormObject = {
description?: string;
quantity?: string;
/** Naira amount as a plain number string (converted to kobo on submit). */
unit_price?: string;
/** Client-side row identity for stable React keys during reorder. */
_key?: string;
};
type Props<T extends SortableLineItemFormObject> = {
item: T;
index: number;
placeholder: string;
is_dragging: boolean;
is_drop_target: boolean;
/** Whether a drag is currently in progress anywhere in the list. */
drag_active: boolean;
can_move_up: boolean;
can_move_down: boolean;
onChange: (item: T) => void;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onDragStart: () => void;
onDragOver: () => void;
onDragLeave: () => void;
/** `drop_after` is true when dropped on the lower half of this row. */
onDrop: (drop_after: boolean) => void;
onDragEnd: () => void;
};
export default function SortableLineItemRow<
T extends SortableLineItemFormObject,
>({
item,
index,
placeholder,
is_dragging,
is_drop_target,
drag_active,
can_move_up,
can_move_down,
onChange,
onRemove,
onMoveUp,
onMoveDown,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
}: Props<T>) {
const line_total_kobo =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0) * 100;
return (
<Paper
className={[
"w-full gap-3!",
is_dragging && "opacity-60",
is_drop_target && "ring-2 ring-primary/70",
]
.filter(Boolean)
.join(" ")}
onDragOver={(e) => {
if (!drag_active) return;
e.preventDefault();
onDragOver();
}}
onDragLeave={() => {
if (drag_active) onDragLeave();
}}
onDrop={(e) => {
if (!drag_active) return;
e.preventDefault();
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
onDrop(e.clientY - rect.top > rect.height / 2);
}}
>
<Row className="w-full justify-between items-center gap-2!">
<Row className="gap-2! items-center">
<Span
title="Drag to reorder"
className="cursor-grab select-none p-1 -m-1!"
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<LucideIcon name="GripVertical" size={16} />
</Span>
<Span className="text-xs font-semibold opacity-60">
Item #{index + 1}
</Span>
</Row>
<Row className="gap-1! items-center">
<Button
title="Move Up"
variant="ghost"
color="gray"
size="smaller"
disabled={!can_move_up}
onClick={onMoveUp}
>
<LucideIcon name="ArrowUp" size={14} />
</Button>
<Button
title="Move Down"
variant="ghost"
color="gray"
size="smaller"
disabled={!can_move_down}
onClick={onMoveDown}
>
<LucideIcon name="ArrowDown" size={14} />
</Button>
<Button
title="Remove Line Item"
variant="ghost"
color="error"
size="smaller"
onClick={onRemove}
>
<LucideIcon name="Trash2" size={14} />
</Button>
</Row>
</Row>
<Input
title="Description"
placeholder={placeholder}
defaultValue={item.description}
onChange={(e) => {
onChange({ ...item, description: e.target.value });
}}
showLabel
/>
<Row className="w-full grid xl:grid-cols-3 items-stretch">
<Input
title="Quantity"
type="number"
min={1}
defaultValue={item.quantity}
onChange={(e) => {
onChange({ ...item, quantity: e.target.value });
}}
showLabel
/>
<Input
title="Unit Price (₦)"
type="number"
step="0.01"
min={0}
defaultValue={item.unit_price}
onChange={(e) => {
onChange({
...item,
unit_price: Math.round(
twuiNumberfy(e.target.value),
).toString(),
});
}}
showLabel
/>
<Border className="py-1 px-4">
<Span className="text-sm font-semibold">
{formatNaira(line_total_kobo)}
</Span>
</Border>
</Row>
</Paper>
);
}
@@ -0,0 +1,155 @@
import Button from "@moduletrace/twui/layout/Button";
import H3 from "@moduletrace/twui/layout/H3";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { useEffect, useState } from "react";
import { formatNaira, sumInvoiceItemsKobo } from "@/src/utils/money";
import SortableLineItemRow, {
type SortableLineItemFormObject,
} from "./sortable-line-item-row";
let row_key_counter = 0;
export const makeRowKey = (): string =>
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `row-${Date.now()}-${row_key_counter++}`;
type Props<T extends SortableLineItemFormObject> = {
items: T[];
empty_item: T;
item_placeholder: string;
onChange: (items: T[]) => void;
};
export default function SortableLineItemsEditor<
T extends SortableLineItemFormObject,
>({ items, empty_item, item_placeholder, onChange }: Props<T>) {
const [drag_source, set_drag_source] = useState<number | undefined>();
const [drag_target, set_drag_target] = useState<number | undefined>();
useEffect(() => {
if (items.some((item) => !item._key)) {
onChange(
items.map((item) => ({ ...item, _key: makeRowKey() })),
);
}
}, []);
const display_items = items.length ? items : [empty_item];
const can_remove = items.length > 1;
const setItems = (next_items: T[]) => onChange(next_items);
const endDrag = () => {
set_drag_source(undefined);
set_drag_target(undefined);
};
const moveTo = (from: number | undefined, target: number, drop_after: boolean) => {
if (from === undefined || from === target) return;
let to = drop_after ? target + 1 : target;
if (from < to) to -= 1;
if (to === from) return;
const next_items = items.length ? [...items] : [{ ...empty_item }];
const moved_item: T = next_items[from] as T;
next_items.splice(from, 1);
next_items.splice(to, 0, moved_item);
setItems(next_items);
};
const moveBy = (index: number, delta: -1 | 1) => {
const target = index + delta;
if (target < 0 || target >= (items.length ? items.length : 1)) return;
moveTo(index, target, delta === 1);
};
const total_kobo = sumInvoiceItemsKobo(
items.map((item) => ({
quantity: item.quantity,
unit_price_kobo: (Number(item.unit_price) || 0) * 100,
})),
);
return (
<Stack className="w-full gap-4">
<H3 className="!mb-0">Line Items</H3>
{display_items.map((item, index) => (
<SortableLineItemRow
key={item._key || index}
item={item}
index={index}
placeholder={item_placeholder}
is_dragging={drag_source === index}
is_drop_target={
drag_source !== undefined &&
drag_source !== index &&
drag_target === index
}
drag_active={drag_source !== undefined}
can_move_up={index > 0}
can_move_down={index < items.length - 1}
onChange={(next_item) => {
const next_items = items.length
? [...items]
: [{ ...empty_item }];
next_items[index] = next_item;
setItems(next_items);
}}
onRemove={() => {
if (!can_remove) return;
setItems(items.filter((_, i) => i !== index));
}}
onMoveUp={() => moveBy(index, -1)}
onMoveDown={() => moveBy(index, 1)}
onDragStart={() => {
if (items.length < 2 || drag_source !== undefined) return;
set_drag_source(index);
}}
onDragOver={() => {
if (
drag_source === undefined ||
drag_source === index ||
drag_target === index
) return;
set_drag_target(index);
}}
onDragLeave={() => {
if (drag_target === index) {
set_drag_target(undefined);
}
}}
onDrop={(drop_after) => {
moveTo(drag_source, index, drop_after);
endDrag();
}}
onDragEnd={endDrag}
/>
))}
<Row className="w-full justify-between items-center">
<Button
title="Add Line Item"
color="gray"
onClick={(e) => {
e.preventDefault();
setItems([
...items,
{ ...empty_item, _key: makeRowKey() },
]);
}}
>
Add Line Item
</Button>
<Span className="text-base font-semibold">
Total: {formatNaira(total_kobo)}
</Span>
</Row>
</Stack>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Section from "@moduletrace/twui/layout/Section";
import Container from "@moduletrace/twui/layout/Container";
import H2 from "@moduletrace/twui/layout/H2";
import Stack from "@moduletrace/twui/layout/Stack";
import type { TWUILucideIconName } from "@moduletrace/twui/elements/lucide-icon";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import Span from "@moduletrace/twui/layout/Span";
import type { ReactNode } from "react";
type Props = {
tagline: string;
icon_name?: TWUILucideIconName;
sub_title?: string;
post_component?: ReactNode;
};
export default function TaglineSection({
tagline,
icon_name,
sub_title,
post_component,
}: Props) {
/**
* Render component
*/
return (
<Section className="bg-secondary relative">
<Container className="xl:py-10">
<Stack className="w-full items-center gap-6">
{icon_name && (
<LucideIcon
name={icon_name}
size={50}
className="text-accent"
/>
)}
<H2 className="text-center max-w-4xl xl:text-6xl! text-white">
{tagline}
</H2>
{sub_title ? (
<Span
className="text-white/80 max-w-4xl text-center"
size="larger"
>
{sub_title}
</Span>
) : null}
{post_component}
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,24 @@
/**
* Applies the saved color scheme to `<html>` before first paint, so pages
* never flash light before twui's `ColorSchemeSelector` mounts. Mirrors the
* selector's resolution order: `localStorage.theme`, then the OS preference.
*/
const ThemeInitScriptSource = `(function () {
try {
var theme = localStorage.getItem("theme");
if (theme !== "dark" && theme !== "light") {
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
var root = document.documentElement;
root.classList.remove("dark", "light");
root.classList.add(theme);
} catch (e) {}
})();`;
export default function ThemeInitScript() {
return (
<script dangerouslySetInnerHTML={{ __html: ThemeInitScriptSource }} />
);
}