@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,51 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { Section, SectionHeader } from "./Section";
4
+
5
+ describe("Section", () => {
6
+ it("renders a section element with padding for the size", () => {
7
+ render(<Section size="lg">Body</Section>);
8
+ const section = screen.getByText("Body").closest("section");
9
+ expect(section).toBeInTheDocument();
10
+ expect(section).toHaveClass("py-24");
11
+ });
12
+
13
+ it("renders children", () => {
14
+ render(<Section>Body</Section>);
15
+ expect(screen.getByText("Body")).toBeInTheDocument();
16
+ });
17
+
18
+ it("forwards an id", () => {
19
+ render(<Section id="pricing">Pricing</Section>);
20
+ expect(screen.getByText("Pricing").closest("section")).toHaveAttribute("id", "pricing");
21
+ });
22
+
23
+ it("renders a SectionHeader with eyebrow/title/description when provided", () => {
24
+ render(
25
+ <Section eyebrow="E" title="T" description="D">
26
+ Body
27
+ </Section>,
28
+ );
29
+ expect(screen.getByText("E")).toBeInTheDocument();
30
+ expect(screen.getByText("T")).toBeInTheDocument();
31
+ expect(screen.getByText("D")).toBeInTheDocument();
32
+ });
33
+ });
34
+
35
+ describe("SectionHeader", () => {
36
+ it("renders eyebrow, title and description", () => {
37
+ render(<SectionHeader eyebrow="E" title="T" description="D" />);
38
+ expect(screen.getByText("E")).toBeInTheDocument();
39
+ expect(screen.getByText("T")).toBeInTheDocument();
40
+ expect(screen.getByText("D")).toBeInTheDocument();
41
+ });
42
+
43
+ it("centers by default and aligns left when requested", () => {
44
+ const { rerender } = render(<SectionHeader title="T" />);
45
+ expect(screen.getByText("T").parentElement).toHaveClass("text-center");
46
+
47
+ rerender(<SectionHeader title="T" align="left" />);
48
+ // left is the base; center adds mx-auto text-center.
49
+ expect(screen.getByText("T").parentElement).not.toHaveClass("text-center");
50
+ });
51
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { Slot } from "./Slot";
4
+
5
+ describe("Slot", () => {
6
+ it("renders the single child with merged props", () => {
7
+ render(
8
+ <Slot className="btn" data-testid="slot">
9
+ <span>Hello</span>
10
+ </Slot>,
11
+ );
12
+ const el = screen.getByTestId("slot");
13
+ expect(el.tagName).toBe("SPAN");
14
+ expect(el).toHaveClass("btn");
15
+ });
16
+
17
+ it("forwards an onClick to the child and calls both handlers", () => {
18
+ const slotClick = vi.fn();
19
+ const childClick = vi.fn();
20
+ render(
21
+ <Slot onClick={slotClick}>
22
+ <button onClick={childClick}>Go</button>
23
+ </Slot>,
24
+ );
25
+ const btn = screen.getByRole("button", { name: "Go" });
26
+ btn.click();
27
+ expect(childClick).toHaveBeenCalledTimes(1);
28
+ expect(slotClick).toHaveBeenCalledTimes(1);
29
+ });
30
+
31
+ it("renders nothing when there is no single valid child", () => {
32
+ const { container } = render(<Slot>text only</Slot>);
33
+ // A plain string is not a valid React element, so Slot returns null.
34
+ expect(container).toBeEmptyDOMElement();
35
+ });
36
+
37
+ it("merges className with the child", () => {
38
+ render(
39
+ <Slot className="slot-class">
40
+ <div className="child-class">x</div>
41
+ </Slot>,
42
+ );
43
+ expect(screen.getByText("x")).toHaveClass("slot-class child-class");
44
+ });
45
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { Stats } from "./Stats";
4
+
5
+ const stats = [
6
+ { value: "12.4K", label: "Active users", delta: 18.2 },
7
+ { value: "98", suffix: "%", label: "Satisfaction", accent: true },
8
+ { value: "1.8", suffix: "ms", label: "Response" },
9
+ ];
10
+
11
+ describe("Stats", () => {
12
+ it("renders labels and values with the editorial option by default", () => {
13
+ render(<Stats stats={stats} />);
14
+ expect(screen.getByText("Active users")).toBeInTheDocument();
15
+ expect(screen.getByText("12.4K")).toBeInTheDocument();
16
+ expect(screen.getByText("Satisfaction")).toBeInTheDocument();
17
+ });
18
+
19
+ it("renders a suffix inline with the value", () => {
20
+ render(<Stats stats={[stats[1]]} />);
21
+ expect(screen.getByText("%")).toBeInTheDocument();
22
+ });
23
+
24
+ it("renders delta badges for statistics that have a delta", () => {
25
+ render(<Stats stats={[stats[0]]} />);
26
+ expect(screen.getByText("18.2%")).toBeInTheDocument();
27
+ });
28
+
29
+ it("renders all option layouts", () => {
30
+ const { rerender } = render(<Stats stats={stats} option="editorial" />);
31
+ expect(screen.getByText("Active users")).toBeInTheDocument();
32
+
33
+ rerender(<Stats stats={stats} option="hairline" />);
34
+ expect(screen.getByText("Active users")).toBeInTheDocument();
35
+
36
+ rerender(<Stats stats={stats} option="cells" />);
37
+ expect(screen.getByText("Active users")).toBeInTheDocument();
38
+
39
+ rerender(<Stats stats={stats} option="ticker" />);
40
+ expect(screen.getByText("Active users")).toBeInTheDocument();
41
+ });
42
+
43
+ it("uses a downward delta badge for negative changes", () => {
44
+ render(<Stats stats={[{ value: "1.8", label: "Response", delta: -24.6 }]} />);
45
+ expect(screen.getByText("24.6%")).toBeInTheDocument();
46
+ });
47
+ });
@@ -0,0 +1,16 @@
1
+ import { forwardRef } from "react";
2
+ import type { StatsProps } from "./types";
3
+ import { StatsEditorial } from "./variants/Editorial";
4
+ import { StatsHairline } from "./variants/Hairline";
5
+ import { StatsCells } from "./variants/Cells";
6
+ import { StatsTicker } from "./variants/Ticker";
7
+
8
+ export const Stats = forwardRef<HTMLDListElement, StatsProps>(
9
+ ({ option = "editorial", ...props }, ref) => {
10
+ if (option === "ticker") return <StatsTicker ref={ref} {...props} />;
11
+ if (option === "cells") return <StatsCells ref={ref} {...props} />;
12
+ if (option === "hairline") return <StatsHairline ref={ref} {...props} />;
13
+ return <StatsEditorial ref={ref} {...props} />;
14
+ },
15
+ );
16
+ Stats.displayName = "Stats";
@@ -0,0 +1,2 @@
1
+ export { Stats } from "./Stats";
2
+ export type { StatsProps, StatsOption, Stat } from "./types";
@@ -0,0 +1,29 @@
1
+ import { cn } from "../../utils/cn";
2
+ import { ArrowUp, ArrowDown } from "../../icons";
3
+
4
+ export const valueBase =
5
+ "font-display font-semibold tabular-nums tracking-[-0.02em] text-foreground";
6
+
7
+ export const suffixBase = "ml-1 font-sans text-sm font-normal tracking-normal text-muted-foreground";
8
+
9
+ export const gridCols: Record<2 | 3 | 4, string> = {
10
+ 2: "sm:grid-cols-2",
11
+ 3: "sm:grid-cols-2 lg:grid-cols-3",
12
+ 4: "sm:grid-cols-2 lg:grid-cols-4",
13
+ };
14
+
15
+ export function DeltaBadge({ delta }: { delta: number }) {
16
+ const isUp = delta >= 0;
17
+ const Icon = isUp ? ArrowUp : ArrowDown;
18
+ return (
19
+ <span
20
+ className={cn(
21
+ "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums",
22
+ isUp ? "bg-accent/10 text-accent" : "bg-danger-soft text-danger",
23
+ )}
24
+ >
25
+ <Icon className="h-3 w-3" />
26
+ {Math.abs(delta)}%
27
+ </span>
28
+ );
29
+ }
@@ -0,0 +1,23 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+
3
+ export type StatsOption = "editorial" | "hairline" | "cells" | "ticker";
4
+
5
+ export interface Stat {
6
+ value: string;
7
+ label: string;
8
+ /** Small unit rendered inline with the value, e.g. "ms" or "₺". */
9
+ suffix?: string;
10
+ /** Trend change in percent — renders an up/down badge. */
11
+ delta?: number;
12
+ /** Optional supporting copy. */
13
+ sub?: string;
14
+ icon?: ReactNode;
15
+ /** Highlights the value with the primary token. */
16
+ accent?: boolean;
17
+ }
18
+
19
+ export interface StatsProps extends HTMLAttributes<HTMLDListElement> {
20
+ stats: Stat[];
21
+ option?: StatsOption;
22
+ columns?: 2 | 3 | 4;
23
+ }
@@ -0,0 +1,34 @@
1
+ import { forwardRef, type HTMLAttributes } from "react";
2
+ import { cn } from "../../../utils/cn";
3
+ import { valueBase, suffixBase, gridCols } from "../parts";
4
+ import type { Stat } from "../types";
5
+
6
+ interface CellsProps extends HTMLAttributes<HTMLDListElement> {
7
+ stats: Stat[];
8
+ columns?: 2 | 3 | 4;
9
+ }
10
+
11
+ export const StatsCells = forwardRef<HTMLDListElement, CellsProps>(
12
+ ({ className, stats, columns = 4, ...props }, ref) => (
13
+ <dl ref={ref} className={cn("grid grid-cols-1 gap-4", gridCols[columns], className)} {...props}>
14
+ {stats.map((stat) => (
15
+ <div
16
+ key={stat.label}
17
+ className="flex min-h-[8.5rem] flex-col justify-between rounded-xl border border-border bg-surface p-6"
18
+ >
19
+ <dd className={cn(valueBase, "text-3xl", stat.accent ? "text-primary" : undefined)}>
20
+ {stat.value}
21
+ {stat.suffix ? <span className={suffixBase}>{stat.suffix}</span> : null}
22
+ </dd>
23
+ <div className="mt-5 flex items-center justify-between gap-3">
24
+ <dt className="text-sm text-muted-foreground">{stat.label}</dt>
25
+ {stat.icon ? (
26
+ <span className="text-foreground/30">{stat.icon}</span>
27
+ ) : null}
28
+ </div>
29
+ </div>
30
+ ))}
31
+ </dl>
32
+ ),
33
+ );
34
+ StatsCells.displayName = "StatsCells";
@@ -0,0 +1,41 @@
1
+ import { forwardRef, type HTMLAttributes } from "react";
2
+ import { cn } from "../../../utils/cn";
3
+ import { valueBase, suffixBase, gridCols, DeltaBadge } from "../parts";
4
+ import type { Stat } from "../types";
5
+
6
+ interface EditorialProps extends HTMLAttributes<HTMLDListElement> {
7
+ stats: Stat[];
8
+ columns?: 2 | 3 | 4;
9
+ }
10
+
11
+ /** editorial — label above, oversized number, hairline rule under. */
12
+ export const StatsEditorial = forwardRef<HTMLDListElement, EditorialProps>(
13
+ ({ className, stats, columns = 4, ...props }, ref) => (
14
+ <dl ref={ref} className={cn("grid grid-cols-1 gap-x-10 gap-y-12", gridCols[columns], className)} {...props}>
15
+ {stats.map((stat) => (
16
+ <div key={stat.label} className="border-t border-border pt-6">
17
+ <div className="flex items-center justify-between gap-3">
18
+ <dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
19
+ {stat.label}
20
+ </dt>
21
+ {typeof stat.delta === "number" ? <DeltaBadge delta={stat.delta} /> : null}
22
+ </div>
23
+ <dd
24
+ className={cn(
25
+ valueBase,
26
+ "mt-4 text-5xl sm:text-6xl",
27
+ stat.accent ? "text-primary" : undefined,
28
+ )}
29
+ >
30
+ {stat.value}
31
+ {stat.suffix ? <span className={suffixBase}>{stat.suffix}</span> : null}
32
+ </dd>
33
+ {stat.sub ? (
34
+ <p className="mt-3 max-w-xs text-[13px] leading-5 text-muted-foreground">{stat.sub}</p>
35
+ ) : null}
36
+ </div>
37
+ ))}
38
+ </dl>
39
+ ),
40
+ );
41
+ StatsEditorial.displayName = "StatsEditorial";
@@ -0,0 +1,31 @@
1
+ import { forwardRef, type HTMLAttributes } from "react";
2
+ import { cn } from "../../../utils/cn";
3
+ import { valueBase, suffixBase, gridCols } from "../parts";
4
+ import type { Stat } from "../types";
5
+
6
+ interface HairlineProps extends HTMLAttributes<HTMLDListElement> {
7
+ stats: Stat[];
8
+ columns?: 2 | 3 | 4;
9
+ }
10
+
11
+ export const StatsHairline = forwardRef<HTMLDListElement, HairlineProps>(
12
+ ({ className, stats, columns = 4, ...props }, ref) => (
13
+ <dl ref={ref} className={cn("grid grid-cols-1 gap-y-10", gridCols[columns], className)} {...props}>
14
+ {stats.map((stat, i) => (
15
+ <div
16
+ key={stat.label}
17
+ className={cn("text-center", columns > 1 && i > 0 && "sm:border-l sm:border-border")}
18
+ >
19
+ <dd className={cn(valueBase, "text-5xl", stat.accent ? "text-primary" : undefined)}>
20
+ {stat.value}
21
+ {stat.suffix ? <span className={suffixBase}>{stat.suffix}</span> : null}
22
+ </dd>
23
+ <dt className="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
24
+ {stat.label}
25
+ </dt>
26
+ </div>
27
+ ))}
28
+ </dl>
29
+ ),
30
+ );
31
+ StatsHairline.displayName = "StatsHairline";
@@ -0,0 +1,36 @@
1
+ import { forwardRef, type HTMLAttributes } from "react";
2
+ import { cn } from "../../../utils/cn";
3
+ import { valueBase, suffixBase, DeltaBadge } from "../parts";
4
+ import type { Stat } from "../types";
5
+
6
+ interface TickerProps extends HTMLAttributes<HTMLDListElement> {
7
+ stats: Stat[];
8
+ }
9
+
10
+ export const StatsTicker = forwardRef<HTMLDListElement, TickerProps>(
11
+ ({ className, stats, ...props }, ref) => (
12
+ <dl ref={ref} className={cn("flex w-full items-stretch overflow-x-auto rounded-2xl border border-border bg-surface", className)} {...props}>
13
+ {stats.map((stat, i) => (
14
+ <div
15
+ key={stat.label}
16
+ className={cn("flex min-w-[11rem] flex-1 flex-col gap-3 p-6", i > 0 && "border-l border-border")}
17
+ >
18
+ <div className="flex items-center justify-between gap-3">
19
+ <dt className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
20
+ {stat.label}
21
+ </dt>
22
+ {typeof stat.delta === "number" ? <DeltaBadge delta={stat.delta} /> : null}
23
+ </div>
24
+ <dd className={cn(valueBase, "text-3xl", stat.accent ? "text-primary" : undefined)}>
25
+ {stat.value}
26
+ {stat.suffix ? <span className={suffixBase}>{stat.suffix}</span> : null}
27
+ </dd>
28
+ {stat.sub ? (
29
+ <p className="text-[13px] leading-5 text-muted-foreground">{stat.sub}</p>
30
+ ) : null}
31
+ </div>
32
+ ))}
33
+ </dl>
34
+ ),
35
+ );
36
+ StatsTicker.displayName = "StatsTicker";
@@ -0,0 +1,61 @@
1
+ import { forwardRef } from "react";
2
+ import { cn } from "../../utils/cn";
3
+ import { getContrastText } from "../../utils/contrast";
4
+ import { Quote, Star } from "../../icons";
5
+ import { initials } from "./parts";
6
+ import type { TestimonialCardProps } from "./types";
7
+
8
+ export const TestimonialCard = forwardRef<HTMLElement, TestimonialCardProps>(
9
+ function TestimonialCard({ className, testimonial, ...props }, ref) {
10
+ const { quote, author, role, company, avatar, accent, rating } = testimonial;
11
+ const badgeStyle = accent
12
+ ? { backgroundColor: accent, color: getContrastText(accent, "#111111", "#ffffff") }
13
+ : undefined;
14
+
15
+ return (
16
+ <figure
17
+ ref={ref}
18
+ className={cn(
19
+ "mb-4 flex flex-col break-inside-avoid rounded-2xl border border-border bg-surface p-7",
20
+ className,
21
+ )}
22
+ {...props}
23
+ >
24
+ <div className="mb-5 flex items-center justify-between">
25
+ <Quote className={cn("h-6 w-6", accent ? undefined : "text-primary/30")} style={accent ? { color: accent } : undefined} />
26
+ {rating ? (
27
+ <span className="flex items-center gap-0.5 text-amber-500" aria-label={`${rating} / 5`}>
28
+ {Array.from({ length: 5 }).map((_, i) => (
29
+ <Star key={i} className="h-3.5 w-3.5" />
30
+ ))}
31
+ </span>
32
+ ) : null}
33
+ </div>
34
+ <blockquote className="flex-1 text-[15px] leading-7 text-foreground/85">{quote}</blockquote>
35
+ <figcaption className="mt-6 flex items-center gap-3">
36
+ {avatar ? (
37
+ avatar
38
+ ) : (
39
+ <span
40
+ style={badgeStyle}
41
+ className={cn(
42
+ "inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold",
43
+ !accent && "bg-primary-soft text-primary",
44
+ )}
45
+ >
46
+ {initials(author)}
47
+ </span>
48
+ )}
49
+ <div>
50
+ <div className="text-sm font-semibold text-foreground">{author}</div>
51
+ {(role || company) && (
52
+ <div className="text-xs text-muted-foreground">
53
+ {[role, company].filter(Boolean).join(" · ")}
54
+ </div>
55
+ )}
56
+ </div>
57
+ </figcaption>
58
+ </figure>
59
+ );
60
+ },
61
+ );
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { TestimonialCard } from "./TestimonialCard";
4
+ import { Testimonials } from "./Testimonials";
5
+
6
+ const items = [
7
+ { quote: "Great product.", author: "Ayşe Yıldız", role: "Founder", company: "Lumina", rating: 5 },
8
+ { quote: "Saved us hours.", author: "Mehmet Demir", company: "Nova" },
9
+ ];
10
+
11
+ describe("TestimonialCard", () => {
12
+ it("renders quote, author, role and company", () => {
13
+ render(<TestimonialCard testimonial={items[0]} />);
14
+ expect(screen.getByText("Great product.")).toBeInTheDocument();
15
+ expect(screen.getByText("Ayşe Yıldız")).toBeInTheDocument();
16
+ expect(screen.getByText("Founder · Lumina")).toBeInTheDocument();
17
+ });
18
+
19
+ it("renders initials for the default avatar", () => {
20
+ render(<TestimonialCard testimonial={items[0]} />);
21
+ expect(screen.getByText("AY")).toBeInTheDocument();
22
+ });
23
+ });
24
+
25
+ describe("Testimonials", () => {
26
+ it("renders all items in the grid (default) option", () => {
27
+ render(<Testimonials items={items} option="grid" />);
28
+ expect(screen.getByText("Great product.")).toBeInTheDocument();
29
+ expect(screen.getByText("Saved us hours.")).toBeInTheDocument();
30
+ });
31
+
32
+ it("renders the marquee option (items are duplicated for the loop)", () => {
33
+ render(<Testimonials items={items} option="marquee" />);
34
+ expect(screen.getAllByText("Great product.").length).toBeGreaterThan(0);
35
+ expect(screen.getAllByText("Saved us hours.").length).toBeGreaterThan(0);
36
+ });
37
+
38
+ it("renders a carousel that advances on next and cycles", () => {
39
+ render(<Testimonials items={items} option="carousel" />);
40
+ expect(screen.getByText("Great product.")).toBeInTheDocument();
41
+
42
+ fireEvent.click(screen.getByRole("button", { name: "Next testimonial" }));
43
+ expect(screen.getByText("Saved us hours.")).toBeInTheDocument();
44
+
45
+ fireEvent.click(screen.getByRole("button", { name: "Next testimonial" }));
46
+ expect(screen.getByText("Great product.")).toBeInTheDocument();
47
+ });
48
+ });
@@ -0,0 +1,14 @@
1
+ import { forwardRef } from "react";
2
+ import type { TestimonialsProps } from "./types";
3
+ import { TestimonialsGrid } from "./variants/Grid";
4
+ import { TestimonialsMarquee } from "./variants/Marquee";
5
+ import { TestimonialsCarousel } from "./variants/Carousel";
6
+
7
+ export const Testimonials = forwardRef<HTMLDivElement, TestimonialsProps>(
8
+ ({ option = "grid", ...props }, ref) => {
9
+ if (option === "carousel") return <TestimonialsCarousel ref={ref} {...props} />;
10
+ if (option === "marquee") return <TestimonialsMarquee ref={ref} {...props} />;
11
+ return <TestimonialsGrid ref={ref} {...props} />;
12
+ },
13
+ );
14
+ Testimonials.displayName = "Testimonials";
@@ -0,0 +1,3 @@
1
+ export { Testimonials } from "./Testimonials";
2
+ export { TestimonialCard } from "./TestimonialCard";
3
+ export type { TestimonialsProps, TestimonialCardProps, TestimonialsOption, Testimonial } from "./types";
@@ -0,0 +1,7 @@
1
+ export const initials = (name: string) =>
2
+ name
3
+ .split(" ")
4
+ .map((p) => p[0])
5
+ .join("")
6
+ .slice(0, 2)
7
+ .toUpperCase();
@@ -0,0 +1,24 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+
3
+ export type TestimonialsOption = "grid" | "carousel" | "marquee";
4
+
5
+ export interface Testimonial {
6
+ quote: string;
7
+ author: string;
8
+ role?: string;
9
+ company?: string;
10
+ avatar?: ReactNode;
11
+ /** Arbitrary CSS color for the initials avatar. */
12
+ accent?: string;
13
+ rating?: number;
14
+ }
15
+
16
+ export interface TestimonialCardProps extends HTMLAttributes<HTMLElement> {
17
+ testimonial: Testimonial;
18
+ }
19
+
20
+ export interface TestimonialsProps extends HTMLAttributes<HTMLDivElement> {
21
+ items: Testimonial[];
22
+ option?: TestimonialsOption;
23
+ title?: string;
24
+ }
@@ -0,0 +1,94 @@
1
+ "use client";
2
+ import { forwardRef, useState, type HTMLAttributes } from "react";
3
+ import { cn } from "../../../utils/cn";
4
+ import { getContrastText } from "../../../utils/contrast";
5
+ import { ChevronLeft, ChevronRight, Quote } from "../../../icons";
6
+ import { initials } from "../parts";
7
+ import type { Testimonial } from "../types";
8
+
9
+ interface CarouselProps extends HTMLAttributes<HTMLDivElement> {
10
+ items: Testimonial[];
11
+ }
12
+
13
+ export const TestimonialsCarousel = forwardRef<HTMLDivElement, CarouselProps>(
14
+ ({ className, items, ...props }, ref) => {
15
+ const [index, setIndex] = useState(0);
16
+ const item = items[index % items.length];
17
+ const badgeStyle = item.accent
18
+ ? { backgroundColor: item.accent, color: getContrastText(item.accent, "#111111", "#ffffff") }
19
+ : undefined;
20
+
21
+ const go = (dir: 1 | -1) => setIndex((i) => (i + dir + items.length) % items.length);
22
+
23
+ return (
24
+ <div className={cn("mx-auto w-full max-w-3xl", className)} ref={ref} {...props}>
25
+ <figure className="relative rounded-2xl border border-border bg-surface p-10 text-center sm:p-14">
26
+ <Quote
27
+ className={cn("mx-auto mb-6 h-8 w-8", item.accent ? undefined : "text-primary/25")}
28
+ style={item.accent ? { color: item.accent } : undefined}
29
+ aria-hidden
30
+ />
31
+ <blockquote className="font-display text-balance text-xl font-medium leading-relaxed tracking-tight text-foreground sm:text-2xl">
32
+ {item.quote}
33
+ </blockquote>
34
+ <figcaption className="mt-8 flex items-center justify-center gap-3">
35
+ {item.avatar ? (
36
+ item.avatar
37
+ ) : (
38
+ <span
39
+ style={badgeStyle}
40
+ className={cn(
41
+ "inline-flex h-11 w-11 items-center justify-center rounded-full text-xs font-bold",
42
+ !item.accent && "bg-foreground text-background",
43
+ )}
44
+ >
45
+ {initials(item.author)}
46
+ </span>
47
+ )}
48
+ <div className="text-left">
49
+ <div className="text-sm font-semibold text-foreground">{item.author}</div>
50
+ <div className="text-xs text-muted-foreground">
51
+ {[item.role, item.company].filter(Boolean).join(" · ")}
52
+ </div>
53
+ </div>
54
+ </figcaption>
55
+ </figure>
56
+
57
+ <div className="mt-6 flex items-center justify-center gap-4">
58
+ <button
59
+ type="button"
60
+ aria-label="Previous testimonial"
61
+ onClick={() => go(-1)}
62
+ className="inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-border bg-surface text-foreground transition-colors duration-150 hover:border-foreground/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
63
+ >
64
+ <ChevronLeft className="h-4 w-4" />
65
+ </button>
66
+ <div className="flex items-center gap-1.5">
67
+ {items.map((_, i) => (
68
+ <button
69
+ key={i}
70
+ type="button"
71
+ aria-label={`Testimonial ${i + 1}`}
72
+ aria-current={i === index}
73
+ onClick={() => setIndex(i)}
74
+ className={cn(
75
+ "h-1.5 cursor-pointer rounded-full transition-all duration-200",
76
+ i === index ? "w-6 bg-foreground" : "w-1.5 bg-foreground/25 hover:bg-foreground/50",
77
+ )}
78
+ />
79
+ ))}
80
+ </div>
81
+ <button
82
+ type="button"
83
+ aria-label="Next testimonial"
84
+ onClick={() => go(1)}
85
+ className="inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-border bg-surface text-foreground transition-colors duration-150 hover:border-foreground/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
86
+ >
87
+ <ChevronRight className="h-4 w-4" />
88
+ </button>
89
+ </div>
90
+ </div>
91
+ );
92
+ },
93
+ );
94
+ TestimonialsCarousel.displayName = "TestimonialsCarousel";
@@ -0,0 +1,19 @@
1
+ import { forwardRef, type HTMLAttributes } from "react";
2
+ import { cn } from "../../../utils/cn";
3
+ import { TestimonialCard } from "../TestimonialCard";
4
+ import type { Testimonial } from "../types";
5
+
6
+ interface GridProps extends HTMLAttributes<HTMLDivElement> {
7
+ items: Testimonial[];
8
+ }
9
+
10
+ export const TestimonialsGrid = forwardRef<HTMLDivElement, GridProps>(
11
+ ({ className, items, ...props }, ref) => (
12
+ <div className={cn("columns-1 gap-4 md:columns-2 lg:columns-3", className)} ref={ref} {...props}>
13
+ {items.map((item, i) => (
14
+ <TestimonialCard key={i} testimonial={item} />
15
+ ))}
16
+ </div>
17
+ ),
18
+ );
19
+ TestimonialsGrid.displayName = "TestimonialsGrid";