@ugurdemirel/landcraft 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/dist/index.cjs +2 -2
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.ts +38 -21
  4. package/dist/index.js +2276 -1956
  5. package/dist/index.js.map +1 -1
  6. package/dist/styles.css +15 -0
  7. package/package.json +11 -4
  8. package/src/components/Badge.test.tsx +55 -0
  9. package/src/components/Blog/Blog.test.tsx +64 -0
  10. package/src/components/Blog/BlogCard.tsx +12 -0
  11. package/src/components/Blog/BlogSection.tsx +95 -0
  12. package/src/components/Blog/index.ts +8 -0
  13. package/src/components/Blog/parts.tsx +38 -0
  14. package/src/components/Blog/types.ts +42 -0
  15. package/src/components/Blog/variants/Card.tsx +62 -0
  16. package/src/components/Blog/variants/Row.tsx +41 -0
  17. package/src/components/Button.test.tsx +91 -0
  18. package/src/components/CTA/CTA.test.tsx +53 -0
  19. package/src/components/CTA/CTA.tsx +12 -0
  20. package/src/components/CTA/index.ts +2 -0
  21. package/src/components/CTA/parts.tsx +26 -0
  22. package/src/components/CTA/types.ts +12 -0
  23. package/src/components/CTA/variants/Inverse.tsx +50 -0
  24. package/src/components/CTA/variants/Panel.tsx +60 -0
  25. package/src/components/CTA/variants/Surface.tsx +34 -0
  26. package/src/components/Card.test.tsx +49 -0
  27. package/src/components/Container.test.tsx +52 -0
  28. package/src/components/FAQ/FAQ.test.tsx +54 -0
  29. package/src/components/FAQ/FAQ.tsx +48 -0
  30. package/src/components/FAQ/index.ts +2 -0
  31. package/src/components/FAQ/parts.tsx +74 -0
  32. package/src/components/FAQ/types.ts +18 -0
  33. package/src/components/FAQ/variants/Accordion.tsx +46 -0
  34. package/src/components/FAQ/variants/Cards.tsx +40 -0
  35. package/src/components/FAQ/variants/Split.tsx +44 -0
  36. package/src/components/Features/FeatureCard.tsx +44 -0
  37. package/src/components/Features/FeatureGrid.tsx +14 -0
  38. package/src/components/Features/Features.test.tsx +49 -0
  39. package/src/components/Features/index.ts +3 -0
  40. package/src/components/Features/parts.ts +15 -0
  41. package/src/components/Features/types.ts +25 -0
  42. package/src/components/Features/variants/Bento.tsx +32 -0
  43. package/src/components/Features/variants/Columns.tsx +21 -0
  44. package/src/components/Features/variants/EditorialRows.tsx +38 -0
  45. package/src/components/Footer/Footer.test.tsx +49 -0
  46. package/src/components/Footer/Footer.tsx +11 -0
  47. package/src/components/Footer/index.ts +2 -0
  48. package/src/components/Footer/parts.tsx +101 -0
  49. package/src/components/Footer/types.ts +32 -0
  50. package/src/components/Footer/variants/Classic.tsx +59 -0
  51. package/src/components/Footer/variants/Editorial.tsx +47 -0
  52. package/src/components/Footer/variants/Minimal.tsx +33 -0
  53. package/src/components/Hero/Hero.test.tsx +71 -0
  54. package/src/components/Hero/Hero.tsx +12 -0
  55. package/src/components/Hero/index.ts +2 -0
  56. package/src/components/Hero/parts.tsx +29 -0
  57. package/src/components/Hero/types.ts +20 -0
  58. package/src/components/Hero/variants/Centered.tsx +54 -0
  59. package/src/components/Hero/variants/Split.tsx +53 -0
  60. package/src/components/Hero/variants/Statement.tsx +60 -0
  61. package/src/components/LanguageSwitcher/LanguageSwitcher.test.tsx +64 -0
  62. package/src/components/LanguageSwitcher/LanguageSwitcher.tsx +37 -0
  63. package/src/components/LanguageSwitcher/index.ts +6 -0
  64. package/src/components/LanguageSwitcher/parts.tsx +144 -0
  65. package/src/components/LanguageSwitcher/types.ts +40 -0
  66. package/src/components/LanguageSwitcher/useLanguageSwitcher.ts +23 -0
  67. package/src/components/LanguageSwitcher/variants/Dropdown.tsx +88 -0
  68. package/src/components/LanguageSwitcher/variants/Modal.tsx +56 -0
  69. package/src/components/LogoCloud/LogoCloud.test.tsx +35 -0
  70. package/src/components/LogoCloud/LogoCloud.tsx +14 -0
  71. package/src/components/LogoCloud/index.ts +2 -0
  72. package/src/components/LogoCloud/parts.tsx +32 -0
  73. package/src/components/LogoCloud/types.ts +16 -0
  74. package/src/components/LogoCloud/variants/Marquee.tsx +30 -0
  75. package/src/components/LogoCloud/variants/Quiet.tsx +26 -0
  76. package/src/components/LogoCloud/variants/Strip.tsx +25 -0
  77. package/src/components/MegaMenu/MegaMenu.test.tsx +89 -0
  78. package/src/components/{MegaMenu.tsx → MegaMenu/MegaMenu.tsx} +100 -127
  79. package/src/components/MegaMenu/index.ts +9 -0
  80. package/src/components/MegaMenu/types.ts +56 -0
  81. package/src/components/Modal.test.tsx +80 -0
  82. package/src/components/Modal.tsx +1 -0
  83. package/src/components/Navbar/Navbar.test.tsx +91 -0
  84. package/src/components/Navbar/Navbar.tsx +185 -0
  85. package/src/components/Navbar/index.ts +2 -0
  86. package/src/components/Navbar/types.ts +30 -0
  87. package/src/components/Newsletter/Newsletter.test.tsx +45 -0
  88. package/src/components/Newsletter/Newsletter.tsx +12 -0
  89. package/src/components/Newsletter/index.ts +2 -0
  90. package/src/components/Newsletter/parts.tsx +23 -0
  91. package/src/components/Newsletter/types.ts +12 -0
  92. package/src/components/Newsletter/useNewsletterForm.ts +27 -0
  93. package/src/components/Newsletter/variants/Card.tsx +62 -0
  94. package/src/components/Newsletter/variants/Inline.tsx +54 -0
  95. package/src/components/Newsletter/variants/Underline.tsx +52 -0
  96. package/src/components/Pricing/Pricing.test.tsx +67 -0
  97. package/src/components/Pricing/Pricing.tsx +37 -0
  98. package/src/components/Pricing/index.ts +2 -0
  99. package/src/components/Pricing/parts.tsx +132 -0
  100. package/src/components/Pricing/types.ts +28 -0
  101. package/src/components/Pricing/variants/Bento.tsx +72 -0
  102. package/src/components/Pricing/variants/Cards.tsx +79 -0
  103. package/src/components/Pricing/variants/Compact.tsx +69 -0
  104. package/src/components/Prose.test.tsx +49 -0
  105. package/src/components/Section.test.tsx +51 -0
  106. package/src/components/Slot.test.tsx +45 -0
  107. package/src/components/Stats/Stats.test.tsx +47 -0
  108. package/src/components/Stats/Stats.tsx +16 -0
  109. package/src/components/Stats/index.ts +2 -0
  110. package/src/components/Stats/parts.tsx +29 -0
  111. package/src/components/Stats/types.ts +23 -0
  112. package/src/components/Stats/variants/Cells.tsx +34 -0
  113. package/src/components/Stats/variants/Editorial.tsx +41 -0
  114. package/src/components/Stats/variants/Hairline.tsx +31 -0
  115. package/src/components/Stats/variants/Ticker.tsx +36 -0
  116. package/src/components/Testimonials/TestimonialCard.tsx +61 -0
  117. package/src/components/Testimonials/Testimonials.test.tsx +48 -0
  118. package/src/components/Testimonials/Testimonials.tsx +14 -0
  119. package/src/components/Testimonials/index.ts +3 -0
  120. package/src/components/Testimonials/parts.ts +7 -0
  121. package/src/components/Testimonials/types.ts +24 -0
  122. package/src/components/Testimonials/variants/Carousel.tsx +94 -0
  123. package/src/components/Testimonials/variants/Grid.tsx +19 -0
  124. package/src/components/Testimonials/variants/Marquee.tsx +28 -0
  125. package/src/test/setup.ts +7 -0
  126. package/src/utils/cn.test.ts +21 -0
  127. package/src/utils/contrast.test.ts +71 -0
  128. package/src/utils/iso2Flag.test.ts +26 -0
  129. package/src/utils/useTokenForeground.test.ts +50 -0
  130. package/src/utils/useTokenForeground.ts +1 -0
  131. package/src/components/Blog.tsx +0 -251
  132. package/src/components/CTA.tsx +0 -155
  133. package/src/components/FAQ.tsx +0 -163
  134. package/src/components/Features.tsx +0 -153
  135. package/src/components/Footer.tsx +0 -185
  136. package/src/components/Hero.tsx +0 -169
  137. package/src/components/LanguageSwitcher.tsx +0 -223
  138. package/src/components/LogoCloud.tsx +0 -100
  139. package/src/components/Navbar.tsx +0 -195
  140. package/src/components/Newsletter.tsx +0 -180
  141. package/src/components/Pricing.tsx +0 -336
  142. package/src/components/Stats.tsx +0 -181
  143. package/src/components/Testimonials.tsx +0 -205
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../../utils/cn";
3
+
4
+ export function CTAActions({
5
+ action,
6
+ secondaryAction,
7
+ align,
8
+ actionClassName = "flex flex-wrap items-center gap-4",
9
+ }: {
10
+ action?: ReactNode;
11
+ secondaryAction?: ReactNode;
12
+ align?: "left" | "center";
13
+ actionClassName?: string;
14
+ }) {
15
+ return (
16
+ <div
17
+ className={cn(
18
+ actionClassName,
19
+ align === "center" && "justify-center",
20
+ )}
21
+ >
22
+ {action}
23
+ {secondaryAction}
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,12 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+
3
+ export type CTAOption = "panel" | "surface" | "inverse";
4
+
5
+ export interface CTAProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
6
+ title: ReactNode;
7
+ description?: ReactNode;
8
+ action?: ReactNode;
9
+ secondaryAction?: ReactNode;
10
+ option?: CTAOption;
11
+ align?: "left" | "center";
12
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { forwardRef } from "react";
3
+ import { cn } from "../../../utils/cn";
4
+ import { Container } from "../../Container";
5
+ import { useTokenForeground } from "../../../utils/useTokenForeground";
6
+ import { CTAActions } from "../parts";
7
+ import type { CTAProps } from "../types";
8
+
9
+ /** inverse — ink panel with a wide left-aligned split. */
10
+ export const CTAInverse = forwardRef<HTMLElement, CTAProps>(
11
+ ({ className, title, description, action, secondaryAction, align = "left", id, ...props }, ref) => {
12
+ const fg = useTokenForeground("--color-secondary");
13
+
14
+ return (
15
+ <section ref={ref} id={id} className={cn("w-full py-6 pb-20", className)} {...props}>
16
+ <Container>
17
+ <div
18
+ className="relative overflow-hidden rounded-[1.75rem] px-7 py-16 sm:px-16 sm:py-20"
19
+ style={{
20
+ backgroundColor: "rgb(var(--color-secondary))",
21
+ color: fg,
22
+ }}
23
+ >
24
+ <div aria-hidden className="pointer-events-none absolute inset-0 grain opacity-[0.09] mix-blend-overlay" />
25
+ <div
26
+ className={cn(
27
+ "relative flex flex-col items-start gap-10",
28
+ align === "center" && "items-center text-center",
29
+ "lg:flex-row lg:items-end lg:justify-between",
30
+ )}
31
+ >
32
+ <div className={cn("max-w-3xl", align === "center" && "mx-auto")}>
33
+ <h2 className="font-display text-balance text-4xl font-semibold leading-[1.02] tracking-[-0.03em] sm:text-6xl">
34
+ {title}
35
+ </h2>
36
+ {description ? (
37
+ <p className={cn("mt-5 max-w-xl text-pretty text-base leading-7 sm:text-lg", align === "center" && "mx-auto")}>
38
+ {description}
39
+ </p>
40
+ ) : null}
41
+ </div>
42
+ <CTAActions action={action} secondaryAction={secondaryAction} align={align} />
43
+ </div>
44
+ </div>
45
+ </Container>
46
+ </section>
47
+ );
48
+ },
49
+ );
50
+ CTAInverse.displayName = "CTAInverse";
@@ -0,0 +1,60 @@
1
+ "use client";
2
+ import { forwardRef } from "react";
3
+ import { cn } from "../../../utils/cn";
4
+ import { Container } from "../../Container";
5
+ import { useTokenForeground } from "../../../utils/useTokenForeground";
6
+ import { CTAActions } from "../parts";
7
+ import type { CTAProps } from "../types";
8
+
9
+ /** panel — brand gradient panel, rounded corners. */
10
+ export const CTAPanel = forwardRef<HTMLElement, CTAProps>(
11
+ ({ className, title, description, action, secondaryAction, align = "left", id, ...props }, ref) => {
12
+ const fg = useTokenForeground("--color-primary");
13
+
14
+ return (
15
+ <section ref={ref} id={id} className={cn("w-full py-6 pb-20", className)} {...props}>
16
+ <Container>
17
+ <div
18
+ className="relative overflow-hidden rounded-[1.75rem] px-7 py-14 sm:px-14 sm:py-20"
19
+ style={{
20
+ background:
21
+ "radial-gradient(120% 160% at 0% 0%, rgb(var(--color-primary)), rgb(var(--color-primary-hover)) 55%, rgb(var(--color-primary) / 0.85))",
22
+ color: fg,
23
+ }}
24
+ >
25
+ <div aria-hidden className="pointer-events-none absolute inset-0 grain opacity-[0.07] mix-blend-overlay" />
26
+ <div
27
+ aria-hidden
28
+ className="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full"
29
+ style={{ background: "rgb(var(--color-accent))", opacity: 0.35, filter: "blur(60px)" }}
30
+ />
31
+ <div
32
+ className={cn(
33
+ "relative flex flex-col gap-9",
34
+ align === "center" ? "items-center text-center" : "items-start",
35
+ )}
36
+ >
37
+ <div className={cn("max-w-2xl", align === "center" && "mx-auto")}>
38
+ <h2 className="font-display text-balance text-3xl font-semibold leading-[1.06] tracking-[-0.02em] sm:text-5xl">
39
+ {title}
40
+ </h2>
41
+ {description ? (
42
+ <p
43
+ className={cn(
44
+ "mt-4 text-pretty text-base leading-7 sm:text-lg",
45
+ align === "center" && "mx-auto max-w-xl",
46
+ )}
47
+ >
48
+ {description}
49
+ </p>
50
+ ) : null}
51
+ </div>
52
+ <CTAActions action={action} secondaryAction={secondaryAction} align={align} />
53
+ </div>
54
+ </div>
55
+ </Container>
56
+ </section>
57
+ );
58
+ },
59
+ );
60
+ CTAPanel.displayName = "CTAPanel";
@@ -0,0 +1,34 @@
1
+ import { forwardRef } from "react";
2
+ import { cn } from "../../../utils/cn";
3
+ import { Container } from "../../Container";
4
+ import { CTAActions } from "../parts";
5
+ import type { CTAProps } from "../types";
6
+
7
+ /** surface — quiet paper band: hairline rule, big display type, actions right. */
8
+ export const CTASurface = forwardRef<HTMLElement, CTAProps>(
9
+ ({ className, title, description, action, secondaryAction, align = "left", id, ...props }, ref) => (
10
+ <section ref={ref} id={id} className={cn("w-full", className)} {...props}>
11
+ <Container className="py-20 sm:py-24">
12
+ <div
13
+ className={cn(
14
+ "flex flex-col gap-10 border-t border-border pt-14 sm:flex-row sm:items-end sm:justify-between sm:pt-16",
15
+ align === "center" && "items-center text-center sm:flex-col sm:items-center",
16
+ )}
17
+ >
18
+ <div className={cn("max-w-3xl", align === "center" && "mx-auto")}>
19
+ <h2 className="font-display text-4xl font-semibold leading-[1.03] tracking-[-0.03em] text-balance text-foreground sm:text-6xl">
20
+ {title}
21
+ </h2>
22
+ {description ? (
23
+ <p className={cn("mt-5 max-w-xl text-pretty text-base leading-7 text-muted-foreground sm:text-lg", align === "center" && "mx-auto")}>
24
+ {description}
25
+ </p>
26
+ ) : null}
27
+ </div>
28
+ <CTAActions action={action} secondaryAction={secondaryAction} align={align} />
29
+ </div>
30
+ </Container>
31
+ </section>
32
+ ),
33
+ );
34
+ CTASurface.displayName = "CTASurface";
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import {
4
+ Card,
5
+ CardHeader,
6
+ CardTitle,
7
+ CardDescription,
8
+ CardContent,
9
+ CardFooter,
10
+ } from "./Card";
11
+
12
+ describe("Card & subcomponents", () => {
13
+ it("renders an outlined card by default", () => {
14
+ render(<Card>Content</Card>);
15
+ expect(screen.getByText("Content")).toHaveClass("border-border");
16
+ });
17
+
18
+ it("applies a custom variant", () => {
19
+ render(<Card variant="elevated">Elevated</Card>);
20
+ expect(screen.getByText("Elevated")).toHaveClass("shadow-soft");
21
+ });
22
+
23
+ it("adds interactive styling when interactive", () => {
24
+ render(<Card interactive>Interactive</Card>);
25
+ expect(screen.getByText("Interactive")).toHaveClass("hover:-translate-y-0.5");
26
+ });
27
+
28
+ it("renders the full composite header/content/footer", () => {
29
+ render(
30
+ <Card>
31
+ <CardHeader>
32
+ <CardTitle>Title</CardTitle>
33
+ <CardDescription>Description</CardDescription>
34
+ </CardHeader>
35
+ <CardContent>Body</CardContent>
36
+ <CardFooter>Footer</CardFooter>
37
+ </Card>,
38
+ );
39
+ expect(screen.getByText("Title")).toBeInTheDocument();
40
+ expect(screen.getByText("Description")).toBeInTheDocument();
41
+ expect(screen.getByText("Body")).toBeInTheDocument();
42
+ expect(screen.getByText("Footer")).toBeInTheDocument();
43
+ });
44
+
45
+ it("renders an icon in the header when provided", () => {
46
+ render(<CardHeader icon={<span data-testid="hdr-icon" />}>Header</CardHeader>);
47
+ expect(screen.getByTestId("hdr-icon")).toBeInTheDocument();
48
+ });
49
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { Container, Stack } from "./Container";
4
+
5
+ describe("Container", () => {
6
+ it("renders a div by default", () => {
7
+ render(<Container>Body</Container>);
8
+ const el = screen.getByText("Body");
9
+ expect(el.tagName).toBe("DIV");
10
+ expect(el).toHaveClass("mx-auto");
11
+ });
12
+
13
+ it("applies a size class", () => {
14
+ render(<Container size="sm">Body</Container>);
15
+ expect(screen.getByText("Body")).toHaveClass("max-w-xl");
16
+ });
17
+
18
+ it("does not cap width for the full size", () => {
19
+ render(<Container size="full">Body</Container>);
20
+ expect(screen.getByText("Body")).not.toHaveClass("max-w-xl", "max-w-6xl");
21
+ });
22
+
23
+ it("adds gutters by default and removes them with gutters=false", () => {
24
+ render(<Container>A</Container>);
25
+ expect(screen.getByText("A")).toHaveClass("px-5");
26
+
27
+ render(<Container gutters={false}>B</Container>);
28
+ expect(screen.getByText("B")).not.toHaveClass("px-5");
29
+ });
30
+
31
+ it("renders as the requested element via `as`", () => {
32
+ render(<Container as="section">Body</Container>);
33
+ expect(screen.getByText("Body").tagName).toBe("SECTION");
34
+ });
35
+ });
36
+
37
+ describe("Stack", () => {
38
+ it("renders a vertical flex by default", () => {
39
+ render(<Stack>A</Stack>);
40
+ expect(screen.getByText("A")).toHaveClass("flex", "flex-col");
41
+ });
42
+
43
+ it("renders horizontal when horizontal", () => {
44
+ render(<Stack horizontal>A</Stack>);
45
+ expect(screen.getByText("A")).toHaveClass("flex-row");
46
+ });
47
+
48
+ it("applies a gap", () => {
49
+ render(<Stack gap={6}>A</Stack>);
50
+ expect(screen.getByText("A")).toHaveClass("gap-6");
51
+ });
52
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { FAQ } from "./FAQ";
4
+
5
+ const items = [
6
+ { question: "How do I install it?", answer: "Run pnpm add." },
7
+ { question: "How do I theme it?", answer: "Set --color-primary." },
8
+ ];
9
+
10
+ describe("FAQ", () => {
11
+ it("renders all questions and the accordion option by default", () => {
12
+ render(<FAQ items={items} />);
13
+ expect(screen.getByText("How do I install it?")).toBeInTheDocument();
14
+ expect(screen.getByText("How do I theme it?")).toBeInTheDocument();
15
+ });
16
+
17
+ it("expands an answer when a question is activated", () => {
18
+ render(<FAQ items={items} />);
19
+ const q = screen.getByRole("button", { name: "How do I install it?" });
20
+ expect(q).toHaveAttribute("aria-expanded", "false");
21
+
22
+ fireEvent.click(q);
23
+ expect(q).toHaveAttribute("aria-expanded", "true");
24
+ expect(screen.getByText("Run pnpm add.")).toBeInTheDocument();
25
+ });
26
+
27
+ it("closes the first item when another is opened (single open by default)", () => {
28
+ render(<FAQ items={items} />);
29
+ fireEvent.click(screen.getByRole("button", { name: "How do I install it?" }));
30
+ fireEvent.click(screen.getByRole("button", { name: "How do I theme it?" }));
31
+
32
+ // Only the second question's answer is visible.
33
+ expect(screen.getByText("Set --color-primary.")).toBeInTheDocument();
34
+ expect(screen.queryByText("Run pnpm add.")).not.toBeInTheDocument();
35
+ });
36
+
37
+ it("keeps multiple items open when allowMultiple is set", () => {
38
+ render(<FAQ items={items} allowMultiple />);
39
+ fireEvent.click(screen.getByRole("button", { name: "How do I install it?" }));
40
+ fireEvent.click(screen.getByRole("button", { name: "How do I theme it?" }));
41
+
42
+ expect(screen.getByText("Run pnpm add.")).toBeInTheDocument();
43
+ expect(screen.getByText("Set --color-primary.")).toBeInTheDocument();
44
+ });
45
+
46
+ it("renders the split and cards options", () => {
47
+ const { rerender } = render(<FAQ items={items} option="split" />);
48
+ expect(screen.getByText("How do I install it?")).toBeInTheDocument();
49
+
50
+ rerender(<FAQ items={items} option="cards" />);
51
+ expect(screen.getByText("How do I install it?")).toBeInTheDocument();
52
+ expect(screen.getByText("Run pnpm add.")).toBeInTheDocument();
53
+ });
54
+ });
@@ -0,0 +1,48 @@
1
+ import type { FAQProps } from "./types";
2
+ import { FaqCards } from "./variants/Cards";
3
+ import { FaqAccordion } from "./variants/Accordion";
4
+ import { FaqSplit } from "./variants/Split";
5
+
6
+ export const FAQ = ({
7
+ className,
8
+ items,
9
+ option = "accordion",
10
+ allowMultiple = false,
11
+ defaultOpen = [],
12
+ eyebrow,
13
+ title,
14
+ description,
15
+ ...props
16
+ }: FAQProps) => {
17
+ if (option === "cards") {
18
+ return <FaqCards className={className} items={items} eyebrow={eyebrow} {...props} />;
19
+ }
20
+
21
+ if (option === "split") {
22
+ return (
23
+ <FaqSplit
24
+ className={className}
25
+ items={items}
26
+ allowMultiple={allowMultiple}
27
+ defaultOpen={defaultOpen}
28
+ eyebrow={eyebrow}
29
+ title={title}
30
+ description={description}
31
+ {...props}
32
+ />
33
+ );
34
+ }
35
+
36
+ // accordion
37
+ return (
38
+ <div className="mx-auto w-full max-w-3xl">
39
+ <FaqAccordion
40
+ className={className}
41
+ items={items}
42
+ allowMultiple={allowMultiple}
43
+ defaultOpen={defaultOpen}
44
+ />
45
+ </div>
46
+ );
47
+ };
48
+ FAQ.displayName = "FAQ";
@@ -0,0 +1,2 @@
1
+ export { FAQ } from "./FAQ";
2
+ export type { FAQProps, FAQOption, FaqItem } from "./types";
@@ -0,0 +1,74 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { cn } from "../../utils/cn";
4
+ import { Plus } from "../../icons";
5
+ import type { FaqItem } from "./types";
6
+
7
+ export function useFaqState(allowMultiple: boolean, defaultOpen: number[]) {
8
+ const [openSet, setOpenSet] = useState<Set<number>>(
9
+ () => new Set(allowMultiple ? defaultOpen : defaultOpen.slice(0, 1)),
10
+ );
11
+
12
+ const toggle = (index: number) => {
13
+ setOpenSet((prev) => {
14
+ const next = new Set(prev);
15
+ if (next.has(index)) {
16
+ next.delete(index);
17
+ } else {
18
+ if (!allowMultiple) next.clear();
19
+ next.add(index);
20
+ }
21
+ return next;
22
+ });
23
+ };
24
+
25
+ return { openSet, toggle, isOpen: (i: number) => openSet.has(i) };
26
+ }
27
+
28
+ export function FaqItemRow({
29
+ item,
30
+ open,
31
+ onToggle,
32
+ style,
33
+ }: {
34
+ item: FaqItem;
35
+ open: boolean;
36
+ onToggle: () => void;
37
+ style: "boxed" | "bare";
38
+ }) {
39
+ return (
40
+ <div>
41
+ <h3>
42
+ <button
43
+ type="button"
44
+ aria-expanded={open}
45
+ onClick={onToggle}
46
+ className={
47
+ style === "boxed"
48
+ ? "flex w-full cursor-pointer items-center justify-between gap-4 px-6 py-5 text-left text-[15px] font-medium text-foreground transition-colors duration-150 hover:bg-surface-strong/60 focus-visible:outline-none"
49
+ : "flex w-full cursor-pointer items-center justify-between gap-4 py-5 text-left text-base font-semibold text-foreground transition-colors duration-150 focus-visible:outline-none"
50
+ }
51
+ >
52
+ {item.question}
53
+ <Plus
54
+ className={cn(
55
+ "h-4.5 w-4.5 shrink-0 text-foreground/45 transition-transform duration-200 sm:h-5 sm:w-5",
56
+ open && "rotate-45 text-primary",
57
+ )}
58
+ />
59
+ </button>
60
+ </h3>
61
+ {open ? (
62
+ <p className={style === "boxed" ? "px-6 pb-6 text-sm leading-7 text-muted-foreground" : "pb-6 text-sm leading-7 text-muted-foreground"}>
63
+ {item.answer}
64
+ </p>
65
+ ) : null}
66
+ </div>
67
+ );
68
+ }
69
+
70
+ export function FaqEyebrow({ label }: { label: string }) {
71
+ return (
72
+ <p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">{label}</p>
73
+ );
74
+ }
@@ -0,0 +1,18 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+
3
+ export type FAQOption = "accordion" | "split" | "cards";
4
+
5
+ export interface FaqItem {
6
+ question: string;
7
+ answer: string;
8
+ }
9
+
10
+ export interface FAQProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
11
+ items: FaqItem[];
12
+ option?: FAQOption;
13
+ allowMultiple?: boolean;
14
+ defaultOpen?: number[];
15
+ eyebrow?: ReactNode;
16
+ title?: ReactNode;
17
+ description?: ReactNode;
18
+ }
@@ -0,0 +1,46 @@
1
+ "use client";
2
+ import { cn } from "../../../utils/cn";
3
+ import { useFaqState, FaqItemRow } from "../parts";
4
+ import type { FAQProps } from "../types";
5
+
6
+ interface AccordionProps extends FAQProps {
7
+ allowMultiple?: boolean;
8
+ defaultOpen?: number[];
9
+ bare?: boolean;
10
+ }
11
+
12
+ export const FaqAccordion = ({
13
+ className,
14
+ items,
15
+ allowMultiple = false,
16
+ defaultOpen = [],
17
+ bare = false,
18
+ option,
19
+ title,
20
+ eyebrow,
21
+ description,
22
+ ...props
23
+ }: AccordionProps) => {
24
+ const { isOpen, toggle } = useFaqState(allowMultiple, defaultOpen);
25
+
26
+ return (
27
+ <div
28
+ className={cn(
29
+ !bare && "divide-y divide-border rounded-2xl border border-border bg-surface",
30
+ className,
31
+ )}
32
+ {...props}
33
+ >
34
+ {items.map((item, index) => (
35
+ <FaqItemRow
36
+ key={item.question}
37
+ item={item}
38
+ open={isOpen(index)}
39
+ onToggle={() => toggle(index)}
40
+ style={bare ? "bare" : "boxed"}
41
+ />
42
+ ))}
43
+ </div>
44
+ );
45
+ };
46
+ FaqAccordion.displayName = "FaqAccordion";
@@ -0,0 +1,40 @@
1
+ import { cn } from "../../../utils/cn";
2
+ import { FaqEyebrow } from "../parts";
3
+ import type { FAQProps } from "../types";
4
+
5
+ interface CardsProps extends FAQProps {}
6
+
7
+ export const FaqCards = ({
8
+ className,
9
+ items,
10
+ eyebrow,
11
+ title,
12
+ description,
13
+ option,
14
+ ...props
15
+ }: CardsProps) => (
16
+ <div className={cn("w-full", className)} {...props}>
17
+ {eyebrow ? <div className="mb-10"><FaqEyebrow label={String(eyebrow)} /></div> : null}
18
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
19
+ {items.map((item, i) => (
20
+ <div
21
+ key={item.question}
22
+ className="flex flex-col rounded-xl border border-border bg-surface p-7"
23
+ >
24
+ <div className="flex items-baseline gap-4">
25
+ <span className="font-display text-sm font-semibold tabular-nums text-foreground/35">
26
+ {String(i + 1).padStart(2, "0")}
27
+ </span>
28
+ <h3 className="font-display text-lg font-semibold tracking-tight text-foreground">
29
+ {item.question}
30
+ </h3>
31
+ </div>
32
+ <p className="mt-4 border-t border-border pt-4 text-sm leading-6 text-muted-foreground">
33
+ {item.answer}
34
+ </p>
35
+ </div>
36
+ ))}
37
+ </div>
38
+ </div>
39
+ );
40
+ FaqCards.displayName = "FaqCards";
@@ -0,0 +1,44 @@
1
+ import { cn } from "../../../utils/cn";
2
+ import { FaqEyebrow } from "../parts";
3
+ import { FaqAccordion } from "./Accordion";
4
+ import type { FAQProps } from "../types";
5
+
6
+ interface SplitProps extends FAQProps {
7
+ allowMultiple?: boolean;
8
+ defaultOpen?: number[];
9
+ }
10
+
11
+ export const FaqSplit = ({
12
+ className,
13
+ items,
14
+ allowMultiple = false,
15
+ defaultOpen = [],
16
+ eyebrow,
17
+ title,
18
+ description,
19
+ option,
20
+ ...props
21
+ }: SplitProps) => (
22
+ <div className={cn("grid w-full gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:gap-20", className)} {...props}>
23
+ <div className="lg:sticky lg:top-24 lg:self-start">
24
+ {eyebrow ? <FaqEyebrow label={String(eyebrow)} /> : null}
25
+ {title ? (
26
+ <h2 className="mt-4 font-display text-3xl font-semibold leading-[1.08] tracking-tight text-foreground sm:text-4xl">
27
+ {title}
28
+ </h2>
29
+ ) : null}
30
+ {description ? (
31
+ <p className="mt-4 text-pretty text-base leading-7 text-muted-foreground">{description}</p>
32
+ ) : null}
33
+ </div>
34
+ <div className="divide-y divide-border">
35
+ <FaqAccordion
36
+ items={items}
37
+ allowMultiple={allowMultiple}
38
+ defaultOpen={defaultOpen}
39
+ bare
40
+ />
41
+ </div>
42
+ </div>
43
+ );
44
+ FaqSplit.displayName = "FaqSplit";
@@ -0,0 +1,44 @@
1
+ import { forwardRef } from "react";
2
+ import { cn } from "../../utils/cn";
3
+ import { getContrastText } from "../../utils/contrast";
4
+ import type { FeatureCardProps } from "./types";
5
+
6
+ export const FeatureCard = forwardRef<HTMLDivElement, FeatureCardProps>(
7
+ ({ className, icon, title, description, accent, large = false, ...props }, ref) => {
8
+ const chipStyle = accent
9
+ ? { backgroundColor: accent, color: getContrastText(accent, "#111111", "#ffffff") }
10
+ : undefined;
11
+
12
+ return (
13
+ <div
14
+ ref={ref}
15
+ className={cn(
16
+ "group relative flex h-full flex-col rounded-xl border border-border bg-surface p-7 transition-colors duration-200 hover:border-foreground/15",
17
+ className,
18
+ )}
19
+ {...props}
20
+ >
21
+ {icon ? (
22
+ <div
23
+ className={cn(
24
+ "mb-5 inline-flex h-10 w-10 items-center justify-center rounded-lg text-[1.05rem]",
25
+ !accent && "bg-primary-soft text-primary",
26
+ )}
27
+ style={chipStyle}
28
+ >
29
+ {icon}
30
+ </div>
31
+ ) : null}
32
+ <h3 className={cn("font-display font-semibold tracking-tight text-foreground", large ? "text-2xl" : "text-lg")}>
33
+ {title}
34
+ </h3>
35
+ {description ? (
36
+ <p className={cn("mt-2.5 text-pretty leading-relaxed text-muted-foreground", large ? "text-[15px]" : "text-sm")}>
37
+ {description}
38
+ </p>
39
+ ) : null}
40
+ </div>
41
+ );
42
+ },
43
+ );
44
+ FeatureCard.displayName = "FeatureCard";