@ugurdemirel/landcraft 0.1.2 → 0.1.4
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.
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +41 -22
- package/dist/index.js +2261 -1937
- package/dist/index.js.map +1 -1
- package/dist/styles.css +3 -0
- package/package.json +1 -1
- package/src/components/Blog/BlogCard.tsx +12 -0
- package/src/components/Blog/BlogSection.tsx +95 -0
- package/src/components/Blog/index.ts +8 -0
- package/src/components/Blog/parts.tsx +38 -0
- package/src/components/Blog/types.ts +42 -0
- package/src/components/Blog/variants/Card.tsx +62 -0
- package/src/components/Blog/variants/Row.tsx +41 -0
- package/src/components/CTA/CTA.tsx +12 -0
- package/src/components/CTA/index.ts +2 -0
- package/src/components/CTA/parts.tsx +26 -0
- package/src/components/CTA/types.ts +12 -0
- package/src/components/CTA/variants/Inverse.tsx +50 -0
- package/src/components/CTA/variants/Panel.tsx +60 -0
- package/src/components/CTA/variants/Surface.tsx +34 -0
- package/src/components/FAQ/FAQ.tsx +48 -0
- package/src/components/FAQ/index.ts +2 -0
- package/src/components/FAQ/parts.tsx +74 -0
- package/src/components/FAQ/types.ts +18 -0
- package/src/components/FAQ/variants/Accordion.tsx +46 -0
- package/src/components/FAQ/variants/Cards.tsx +40 -0
- package/src/components/FAQ/variants/Split.tsx +44 -0
- package/src/components/Features/FeatureCard.tsx +44 -0
- package/src/components/Features/FeatureGrid.tsx +14 -0
- package/src/components/Features/index.ts +3 -0
- package/src/components/Features/parts.ts +15 -0
- package/src/components/Features/types.ts +25 -0
- package/src/components/Features/variants/Bento.tsx +32 -0
- package/src/components/Features/variants/Columns.tsx +21 -0
- package/src/components/Features/variants/EditorialRows.tsx +38 -0
- package/src/components/Footer/Footer.tsx +11 -0
- package/src/components/Footer/index.ts +2 -0
- package/src/components/Footer/parts.tsx +101 -0
- package/src/components/Footer/types.ts +32 -0
- package/src/components/Footer/variants/Classic.tsx +59 -0
- package/src/components/Footer/variants/Editorial.tsx +47 -0
- package/src/components/Footer/variants/Minimal.tsx +33 -0
- package/src/components/Hero/Hero.tsx +12 -0
- package/src/components/Hero/index.ts +2 -0
- package/src/components/Hero/parts.tsx +29 -0
- package/src/components/Hero/types.ts +20 -0
- package/src/components/Hero/variants/Centered.tsx +54 -0
- package/src/components/Hero/variants/Split.tsx +53 -0
- package/src/components/Hero/variants/Statement.tsx +60 -0
- package/src/components/LanguageSwitcher/LanguageSwitcher.tsx +37 -0
- package/src/components/LanguageSwitcher/index.ts +6 -0
- package/src/components/LanguageSwitcher/parts.tsx +144 -0
- package/src/components/LanguageSwitcher/types.ts +40 -0
- package/src/components/LanguageSwitcher/useLanguageSwitcher.ts +23 -0
- package/src/components/LanguageSwitcher/variants/Dropdown.tsx +88 -0
- package/src/components/LanguageSwitcher/variants/Modal.tsx +56 -0
- package/src/components/LogoCloud/LogoCloud.tsx +14 -0
- package/src/components/LogoCloud/index.ts +2 -0
- package/src/components/LogoCloud/parts.tsx +32 -0
- package/src/components/LogoCloud/types.ts +16 -0
- package/src/components/LogoCloud/variants/Marquee.tsx +30 -0
- package/src/components/LogoCloud/variants/Quiet.tsx +26 -0
- package/src/components/LogoCloud/variants/Strip.tsx +25 -0
- package/src/components/{MegaMenu.tsx → MegaMenu/MegaMenu.tsx} +108 -126
- package/src/components/MegaMenu/index.ts +9 -0
- package/src/components/MegaMenu/types.ts +56 -0
- package/src/components/Modal.tsx +1 -0
- package/src/components/Navbar/Navbar.tsx +185 -0
- package/src/components/Navbar/index.ts +2 -0
- package/src/components/Navbar/types.ts +30 -0
- package/src/components/Newsletter/Newsletter.tsx +12 -0
- package/src/components/Newsletter/index.ts +2 -0
- package/src/components/Newsletter/parts.tsx +23 -0
- package/src/components/Newsletter/types.ts +12 -0
- package/src/components/Newsletter/useNewsletterForm.ts +27 -0
- package/src/components/Newsletter/variants/Card.tsx +62 -0
- package/src/components/Newsletter/variants/Inline.tsx +54 -0
- package/src/components/Newsletter/variants/Underline.tsx +52 -0
- package/src/components/Pricing/Pricing.tsx +37 -0
- package/src/components/Pricing/index.ts +2 -0
- package/src/components/Pricing/parts.tsx +132 -0
- package/src/components/Pricing/types.ts +28 -0
- package/src/components/Pricing/variants/Bento.tsx +72 -0
- package/src/components/Pricing/variants/Cards.tsx +79 -0
- package/src/components/Pricing/variants/Compact.tsx +69 -0
- package/src/components/Stats/Stats.tsx +16 -0
- package/src/components/Stats/index.ts +2 -0
- package/src/components/Stats/parts.tsx +29 -0
- package/src/components/Stats/types.ts +23 -0
- package/src/components/Stats/variants/Cells.tsx +34 -0
- package/src/components/Stats/variants/Editorial.tsx +41 -0
- package/src/components/Stats/variants/Hairline.tsx +31 -0
- package/src/components/Stats/variants/Ticker.tsx +36 -0
- package/src/components/Testimonials/TestimonialCard.tsx +61 -0
- package/src/components/Testimonials/Testimonials.tsx +14 -0
- package/src/components/Testimonials/index.ts +3 -0
- package/src/components/Testimonials/parts.ts +7 -0
- package/src/components/Testimonials/types.ts +24 -0
- package/src/components/Testimonials/variants/Carousel.tsx +94 -0
- package/src/components/Testimonials/variants/Grid.tsx +19 -0
- package/src/components/Testimonials/variants/Marquee.tsx +28 -0
- package/src/utils/useTokenForeground.ts +1 -0
- package/src/components/Blog.tsx +0 -251
- package/src/components/CTA.tsx +0 -155
- package/src/components/FAQ.tsx +0 -163
- package/src/components/Features.tsx +0 -153
- package/src/components/Footer.tsx +0 -185
- package/src/components/Hero.tsx +0 -169
- package/src/components/LanguageSwitcher.tsx +0 -223
- package/src/components/LogoCloud.tsx +0 -100
- package/src/components/Navbar.tsx +0 -195
- package/src/components/Newsletter.tsx +0 -180
- package/src/components/Pricing.tsx +0 -336
- package/src/components/Stats.tsx +0 -181
- package/src/components/Testimonials.tsx +0 -205
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export const heroContainer =
|
|
4
|
+
"relative mx-auto flex w-full max-w-6xl flex-col items-center px-5 sm:px-8";
|
|
5
|
+
|
|
6
|
+
export const heroTitleBase =
|
|
7
|
+
"font-display font-semibold tracking-[-0.03em] text-balance text-foreground";
|
|
8
|
+
|
|
9
|
+
export function HeroEyebrow({ eyebrow }: { eyebrow?: ReactNode }) {
|
|
10
|
+
if (typeof eyebrow === "string") {
|
|
11
|
+
return (
|
|
12
|
+
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary">
|
|
13
|
+
{eyebrow}
|
|
14
|
+
</p>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return eyebrow ? <div>{eyebrow}</div> : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function HeroActions({
|
|
21
|
+
primaryAction,
|
|
22
|
+
secondaryAction,
|
|
23
|
+
}: {
|
|
24
|
+
primaryAction?: ReactNode;
|
|
25
|
+
secondaryAction?: ReactNode;
|
|
26
|
+
}) {
|
|
27
|
+
if (!primaryAction && !secondaryAction) return null;
|
|
28
|
+
return <div className="flex flex-wrap items-center gap-4">{primaryAction}{secondaryAction}</div>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type HeroVariant = "split" | "centered" | "statement";
|
|
4
|
+
|
|
5
|
+
export interface HeroMeta {
|
|
6
|
+
label: string;
|
|
7
|
+
icon?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HeroProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
11
|
+
variant?: HeroVariant;
|
|
12
|
+
eyebrow?: ReactNode;
|
|
13
|
+
title: ReactNode;
|
|
14
|
+
description?: ReactNode;
|
|
15
|
+
primaryAction?: ReactNode;
|
|
16
|
+
secondaryAction?: ReactNode;
|
|
17
|
+
media?: ReactNode;
|
|
18
|
+
/** Small trust/status row shown under the copy or CTAs. */
|
|
19
|
+
meta?: HeroMeta[];
|
|
20
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { cn } from "../../../utils/cn";
|
|
3
|
+
import { HeroActions, HeroEyebrow, heroContainer, heroTitleBase } from "../parts";
|
|
4
|
+
import type { HeroProps } from "../types";
|
|
5
|
+
|
|
6
|
+
/** Centered — understated, one statement, generous white space. */
|
|
7
|
+
export const HeroCentered = forwardRef<HTMLElement, HeroProps>(
|
|
8
|
+
(
|
|
9
|
+
{ className, eyebrow, title, description, primaryAction, secondaryAction, media, meta, id, ...props },
|
|
10
|
+
ref,
|
|
11
|
+
) => (
|
|
12
|
+
<section
|
|
13
|
+
ref={ref}
|
|
14
|
+
id={id}
|
|
15
|
+
className={cn("relative w-full border-b border-border bg-background", className)}
|
|
16
|
+
{...props}
|
|
17
|
+
>
|
|
18
|
+
<div className={cn(heroContainer, "pt-24 pb-20 text-center sm:pt-32 sm:pb-24")}>
|
|
19
|
+
<HeroEyebrow eyebrow={eyebrow} />
|
|
20
|
+
<h1 className={cn(heroTitleBase, "mt-7 max-w-4xl text-5xl leading-[1.04] sm:text-6xl lg:text-7xl")}>
|
|
21
|
+
{title}
|
|
22
|
+
</h1>
|
|
23
|
+
{description ? (
|
|
24
|
+
<p className="mx-auto mt-7 max-w-xl text-pretty leading-relaxed text-muted-foreground sm:text-lg">
|
|
25
|
+
{description}
|
|
26
|
+
</p>
|
|
27
|
+
) : null}
|
|
28
|
+
{primaryAction || secondaryAction ? (
|
|
29
|
+
<div className="mt-10 justify-center">
|
|
30
|
+
<HeroActions primaryAction={primaryAction} secondaryAction={secondaryAction} />
|
|
31
|
+
</div>
|
|
32
|
+
) : null}
|
|
33
|
+
{meta ? (
|
|
34
|
+
<ul className="mt-14 flex flex-wrap items-center justify-center gap-x-9 gap-y-3">
|
|
35
|
+
{meta.map((m, i) => (
|
|
36
|
+
<li
|
|
37
|
+
key={m.label}
|
|
38
|
+
className={cn(
|
|
39
|
+
"flex items-center gap-2 text-sm text-muted-foreground",
|
|
40
|
+
i > 0 && "sm:border-l sm:border-border sm:pl-9",
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
{m.icon ? <span className="text-foreground/35">{m.icon}</span> : null}
|
|
44
|
+
{m.label}
|
|
45
|
+
</li>
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
) : null}
|
|
49
|
+
{media ? <div className="mt-16 w-full">{media}</div> : null}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
HeroCentered.displayName = "HeroCentered";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { cn } from "../../../utils/cn";
|
|
3
|
+
import { HeroActions, HeroEyebrow, heroContainer, heroTitleBase } from "../parts";
|
|
4
|
+
import type { HeroProps } from "../types";
|
|
5
|
+
|
|
6
|
+
/** Split — editorial grid: huge type left, media right, meta rule under. */
|
|
7
|
+
export const HeroSplit = forwardRef<HTMLElement, HeroProps>(
|
|
8
|
+
(
|
|
9
|
+
{ className, eyebrow, title, description, primaryAction, secondaryAction, media, meta, id, ...props },
|
|
10
|
+
ref,
|
|
11
|
+
) => (
|
|
12
|
+
<section
|
|
13
|
+
ref={ref}
|
|
14
|
+
id={id}
|
|
15
|
+
className={cn("relative w-full border-b border-border bg-background", className)}
|
|
16
|
+
{...props}
|
|
17
|
+
>
|
|
18
|
+
<div className={cn(
|
|
19
|
+
heroContainer,
|
|
20
|
+
"items-start gap-14 pt-20 pb-20 sm:pt-28 sm:pb-28 lg:grid lg:grid-cols-[1.05fr_0.95fr] lg:gap-20",
|
|
21
|
+
)}>
|
|
22
|
+
<div className="max-w-xl">
|
|
23
|
+
<HeroEyebrow eyebrow={eyebrow} />
|
|
24
|
+
<h1 className={cn(heroTitleBase, "mt-6 text-5xl leading-[1.03] sm:text-6xl lg:text-7xl")}>
|
|
25
|
+
{title}
|
|
26
|
+
</h1>
|
|
27
|
+
{description ? (
|
|
28
|
+
<p className="mt-7 max-w-md text-pretty leading-relaxed text-muted-foreground sm:text-lg">
|
|
29
|
+
{description}
|
|
30
|
+
</p>
|
|
31
|
+
) : null}
|
|
32
|
+
{primaryAction || secondaryAction ? (
|
|
33
|
+
<div className="mt-10">
|
|
34
|
+
<HeroActions primaryAction={primaryAction} secondaryAction={secondaryAction} />
|
|
35
|
+
</div>
|
|
36
|
+
) : null}
|
|
37
|
+
{meta ? (
|
|
38
|
+
<ul className="mt-14 flex flex-wrap items-center gap-x-8 gap-y-3 border-t border-border pt-6">
|
|
39
|
+
{meta.map((m) => (
|
|
40
|
+
<li key={m.label} className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
41
|
+
{m.icon ? <span className="text-foreground/35">{m.icon}</span> : null}
|
|
42
|
+
{m.label}
|
|
43
|
+
</li>
|
|
44
|
+
))}
|
|
45
|
+
</ul>
|
|
46
|
+
) : null}
|
|
47
|
+
</div>
|
|
48
|
+
{media ? <div className="w-full self-center">{media}</div> : null}
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
HeroSplit.displayName = "HeroSplit";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import { cn } from "../../../utils/cn";
|
|
4
|
+
import { useTokenForeground } from "../../../utils/useTokenForeground";
|
|
5
|
+
import { HeroActions, heroContainer } from "../parts";
|
|
6
|
+
import type { HeroProps } from "../types";
|
|
7
|
+
|
|
8
|
+
/** Statement — full-bleed ink band. No eyebrow, pure message. */
|
|
9
|
+
export const HeroStatement = forwardRef<HTMLElement, HeroProps>(
|
|
10
|
+
(
|
|
11
|
+
{ className, title, description, primaryAction, secondaryAction, media, meta, id, ...props },
|
|
12
|
+
ref,
|
|
13
|
+
) => {
|
|
14
|
+
// Statement paints its own background from --color-secondary, so the
|
|
15
|
+
// readable text color must be derived from THAT token, not the brand.
|
|
16
|
+
const fg = useTokenForeground("--color-secondary");
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<section
|
|
20
|
+
ref={ref}
|
|
21
|
+
id={id}
|
|
22
|
+
className={cn("relative w-full overflow-hidden bg-secondary", className)}
|
|
23
|
+
style={{ color: fg }}
|
|
24
|
+
{...props}
|
|
25
|
+
>
|
|
26
|
+
<div aria-hidden className="pointer-events-none absolute inset-0 grain opacity-[0.07] mix-blend-overlay" />
|
|
27
|
+
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/10" />
|
|
28
|
+
<div className={cn(heroContainer, "pt-28 pb-24 text-center sm:pt-36 sm:pb-32")}>
|
|
29
|
+
<h1
|
|
30
|
+
className="max-w-5xl font-display text-[2.9rem] font-semibold leading-[1.02] tracking-[-0.04em] text-balance sm:text-7xl lg:text-8xl"
|
|
31
|
+
style={{ color: fg }}
|
|
32
|
+
>
|
|
33
|
+
{title}
|
|
34
|
+
</h1>
|
|
35
|
+
{description ? (
|
|
36
|
+
<p className="mx-auto mt-8 max-w-xl text-pretty leading-relaxed sm:text-lg" style={{ color: fg, opacity: 0.72 }}>
|
|
37
|
+
{description}
|
|
38
|
+
</p>
|
|
39
|
+
) : null}
|
|
40
|
+
{primaryAction || secondaryAction ? (
|
|
41
|
+
<div className="mt-11 justify-center">
|
|
42
|
+
<HeroActions primaryAction={primaryAction} secondaryAction={secondaryAction} />
|
|
43
|
+
</div>
|
|
44
|
+
) : null}
|
|
45
|
+
{meta ? (
|
|
46
|
+
<ul className="mt-16 flex flex-wrap items-center justify-center gap-x-10 gap-y-3">
|
|
47
|
+
{meta.map((m) => (
|
|
48
|
+
<li key={m.label} className="flex items-center gap-2 text-sm font-medium" style={{ color: fg, opacity: 0.72 }}>
|
|
49
|
+
{m.icon ? <span>{m.icon}</span> : null}
|
|
50
|
+
{m.label}
|
|
51
|
+
</li>
|
|
52
|
+
))}
|
|
53
|
+
</ul>
|
|
54
|
+
) : null}
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
HeroStatement.displayName = "HeroStatement";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import type { LanguageSwitcherProps } from "./types";
|
|
3
|
+
import { useLanguageSwitcher } from "./useLanguageSwitcher";
|
|
4
|
+
import { LanguageSwitcherDropdown } from "./variants/Dropdown";
|
|
5
|
+
import { LanguageSwitcherModal } from "./variants/Modal";
|
|
6
|
+
|
|
7
|
+
export const LanguageSwitcher = ({
|
|
8
|
+
option = "dropdown",
|
|
9
|
+
languages,
|
|
10
|
+
value,
|
|
11
|
+
defaultValue,
|
|
12
|
+
onChange,
|
|
13
|
+
...props
|
|
14
|
+
}: LanguageSwitcherProps) => {
|
|
15
|
+
const state = useLanguageSwitcher(languages, value, defaultValue, onChange);
|
|
16
|
+
|
|
17
|
+
if (languages.length === 0) return null;
|
|
18
|
+
|
|
19
|
+
const variantProps = {
|
|
20
|
+
languages,
|
|
21
|
+
value,
|
|
22
|
+
defaultValue,
|
|
23
|
+
onChange,
|
|
24
|
+
...props,
|
|
25
|
+
open: state.open,
|
|
26
|
+
onOpenChange: state.setOpen,
|
|
27
|
+
current: state.current,
|
|
28
|
+
currentLang: state.currentLang,
|
|
29
|
+
setCurrent: state.setCurrent,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (option === "modal") {
|
|
33
|
+
return <LanguageSwitcherModal {...variantProps} />;
|
|
34
|
+
}
|
|
35
|
+
return <LanguageSwitcherDropdown {...variantProps} />;
|
|
36
|
+
};
|
|
37
|
+
LanguageSwitcher.displayName = "LanguageSwitcher";
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { cn } from "../../utils/cn";
|
|
4
|
+
import { iso2ToFlagEmoji } from "../../utils/iso2Flag";
|
|
5
|
+
import { Check, ChevronDown, Globe } from "../../icons";
|
|
6
|
+
import type { LanguageOption } from "./types";
|
|
7
|
+
|
|
8
|
+
export function useOutsideClose(
|
|
9
|
+
open: boolean,
|
|
10
|
+
rootRef: { current: HTMLDivElement | null },
|
|
11
|
+
onClose: () => void,
|
|
12
|
+
) {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!open) return;
|
|
15
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
16
|
+
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
|
17
|
+
onClose();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
21
|
+
if (event.key === "Escape") onClose();
|
|
22
|
+
};
|
|
23
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
24
|
+
document.addEventListener("keydown", onKeyDown);
|
|
25
|
+
return () => {
|
|
26
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
27
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
28
|
+
};
|
|
29
|
+
}, [open, rootRef, onClose]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function renderFlagOrGlobe(
|
|
33
|
+
code: string,
|
|
34
|
+
{ size = "text-sm", globeClass = "text-muted-foreground" }: { size?: string; globeClass?: string } = {},
|
|
35
|
+
) {
|
|
36
|
+
const flag = iso2ToFlagEmoji(code);
|
|
37
|
+
return flag ? (
|
|
38
|
+
<span aria-hidden className={cn(size, "leading-none")}>
|
|
39
|
+
{flag}
|
|
40
|
+
</span>
|
|
41
|
+
) : (
|
|
42
|
+
<Globe className={cn("h-4 w-4 shrink-0", globeClass)} />
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function LanguageTrigger({
|
|
47
|
+
inverse,
|
|
48
|
+
open,
|
|
49
|
+
showFlag,
|
|
50
|
+
currentLang,
|
|
51
|
+
onClick,
|
|
52
|
+
ariaProps,
|
|
53
|
+
}: {
|
|
54
|
+
inverse?: boolean;
|
|
55
|
+
open: boolean;
|
|
56
|
+
showFlag?: boolean;
|
|
57
|
+
currentLang: LanguageOption;
|
|
58
|
+
onClick: () => void;
|
|
59
|
+
ariaProps: {
|
|
60
|
+
haspopup: "listbox" | "dialog";
|
|
61
|
+
expanded: boolean;
|
|
62
|
+
controls?: string;
|
|
63
|
+
};
|
|
64
|
+
}) {
|
|
65
|
+
return (
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
aria-haspopup={ariaProps.haspopup}
|
|
69
|
+
aria-expanded={ariaProps.expanded}
|
|
70
|
+
aria-controls={ariaProps.controls}
|
|
71
|
+
onClick={onClick}
|
|
72
|
+
className={cn(
|
|
73
|
+
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
74
|
+
inverse
|
|
75
|
+
? "border-white/10 text-on-secondary hover:bg-white/10"
|
|
76
|
+
: "border-border text-foreground hover:bg-surface",
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{showFlag ? (
|
|
80
|
+
renderFlagOrGlobe(currentLang.code, { globeClass: inverse ? "opacity-70" : "text-muted-foreground" })
|
|
81
|
+
) : (
|
|
82
|
+
<Globe
|
|
83
|
+
className={cn("h-4 w-4", inverse ? "opacity-70" : "text-muted-foreground")}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
<span className="tracking-tight">{currentLang.label}</span>
|
|
87
|
+
<ChevronDown
|
|
88
|
+
className={cn(
|
|
89
|
+
"h-3.5 w-3.5 transition-transform duration-150",
|
|
90
|
+
open && "rotate-180",
|
|
91
|
+
inverse ? "opacity-70" : "text-muted-foreground",
|
|
92
|
+
)}
|
|
93
|
+
/>
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function LanguageOptionRow({
|
|
99
|
+
lang,
|
|
100
|
+
active,
|
|
101
|
+
onSelect,
|
|
102
|
+
variant,
|
|
103
|
+
showFlag,
|
|
104
|
+
}: {
|
|
105
|
+
lang: LanguageOption;
|
|
106
|
+
active: boolean;
|
|
107
|
+
onSelect: () => void;
|
|
108
|
+
variant: "dropdown" | "modal";
|
|
109
|
+
showFlag?: boolean;
|
|
110
|
+
}) {
|
|
111
|
+
return (
|
|
112
|
+
<button
|
|
113
|
+
key={lang.code}
|
|
114
|
+
type="button"
|
|
115
|
+
role="option"
|
|
116
|
+
aria-selected={active}
|
|
117
|
+
onClick={onSelect}
|
|
118
|
+
className={
|
|
119
|
+
variant === "modal"
|
|
120
|
+
? cn(
|
|
121
|
+
"flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg border px-4 py-3 text-left transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
122
|
+
active
|
|
123
|
+
? "border-primary/40 bg-primary-soft text-foreground"
|
|
124
|
+
: "border-border text-foreground hover:bg-surface-strong",
|
|
125
|
+
)
|
|
126
|
+
: cn(
|
|
127
|
+
"flex w-full cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-[13px] font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
128
|
+
active
|
|
129
|
+
? "bg-surface-strong text-foreground"
|
|
130
|
+
: "text-muted-foreground hover:bg-surface-strong hover:text-foreground",
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
<span className={variant === "modal" ? "flex items-center gap-3" : "flex items-center gap-2.5"}>
|
|
135
|
+
{showFlag ? renderFlagOrGlobe(lang.code, variant === "modal" ? { size: "text-base" } : {}) : null}
|
|
136
|
+
<span className="text-sm font-medium">{lang.label}</span>
|
|
137
|
+
</span>
|
|
138
|
+
<span className={variant === "modal" ? "flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground" : undefined}>
|
|
139
|
+
{variant === "modal" ? lang.code : null}
|
|
140
|
+
{active ? <Check className={variant === "modal" ? "h-4 w-4 text-primary" : "h-3.5 w-3.5 text-primary"} /> : null}
|
|
141
|
+
</span>
|
|
142
|
+
</button>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface LanguageOption {
|
|
4
|
+
/** Language code (ISO 3166-1 alpha-2, e.g. `"tr"`) — this is the value passed to `onChange` and the flag source. */
|
|
5
|
+
code: string;
|
|
6
|
+
/** Native display name, e.g. `"Türkçe"`. */
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type LanguageSwitcherOption = "dropdown" | "modal";
|
|
11
|
+
|
|
12
|
+
export interface LanguageSwitcherProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
13
|
+
/** `dropdown` opens the anchored menu; `modal` opens a full dialog via the Modal component. */
|
|
14
|
+
option?: LanguageSwitcherOption;
|
|
15
|
+
/** Languages to switch between. Required — no built-in default language list. */
|
|
16
|
+
languages: LanguageOption[];
|
|
17
|
+
/** Controlled current language code. */
|
|
18
|
+
value?: string;
|
|
19
|
+
/** Uncontrolled initial language code. Defaults to the first language. */
|
|
20
|
+
defaultValue?: string;
|
|
21
|
+
/** Called with the selected language code. */
|
|
22
|
+
onChange?: (code: string) => void;
|
|
23
|
+
/** Dropdown menu alignment relative to its trigger. Defaults to `end`. */
|
|
24
|
+
align?: "start" | "end";
|
|
25
|
+
/** Heading rendered inside the `modal` option. Optional — pass it to show a title. */
|
|
26
|
+
modalTitle?: ReactNode;
|
|
27
|
+
/** Replace the globe icon with the selected language's flag emoji (and show flags in options). */
|
|
28
|
+
showFlag?: boolean;
|
|
29
|
+
/** Adapts colors for dark surfaces (inverse Navbar, classic Footer). */
|
|
30
|
+
inverse?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LanguageSwitcherVariantProps
|
|
34
|
+
extends Omit<LanguageSwitcherProps, "option"> {
|
|
35
|
+
open: boolean;
|
|
36
|
+
onOpenChange: (open: boolean) => void;
|
|
37
|
+
current: string;
|
|
38
|
+
currentLang: LanguageOption;
|
|
39
|
+
setCurrent: (code: string) => void;
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { LanguageOption } from "./types";
|
|
4
|
+
|
|
5
|
+
export function useLanguageSwitcher(
|
|
6
|
+
languages: LanguageOption[],
|
|
7
|
+
value?: string,
|
|
8
|
+
defaultValue?: string,
|
|
9
|
+
onChange?: (code: string) => void,
|
|
10
|
+
) {
|
|
11
|
+
const [open, setOpen] = useState(false);
|
|
12
|
+
const [internal, setInternal] = useState(defaultValue ?? languages[0]?.code ?? "");
|
|
13
|
+
const current = value ?? internal;
|
|
14
|
+
const currentLang = languages.find((l) => l.code === current) ?? languages[0];
|
|
15
|
+
|
|
16
|
+
const setCurrent = (code: string) => {
|
|
17
|
+
setInternal(code);
|
|
18
|
+
setOpen(false);
|
|
19
|
+
onChange?.(code);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return { open, setOpen, current, currentLang, setCurrent };
|
|
23
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useId, useRef, type CSSProperties } from "react";
|
|
3
|
+
import { cn } from "../../../utils/cn";
|
|
4
|
+
import {
|
|
5
|
+
useOutsideClose,
|
|
6
|
+
LanguageTrigger,
|
|
7
|
+
LanguageOptionRow,
|
|
8
|
+
} from "../parts";
|
|
9
|
+
import type { LanguageSwitcherVariantProps } from "../types";
|
|
10
|
+
|
|
11
|
+
export function LanguageSwitcherDropdown({
|
|
12
|
+
className,
|
|
13
|
+
languages,
|
|
14
|
+
open,
|
|
15
|
+
onOpenChange,
|
|
16
|
+
current,
|
|
17
|
+
currentLang,
|
|
18
|
+
setCurrent,
|
|
19
|
+
align = "end",
|
|
20
|
+
showFlag = false,
|
|
21
|
+
inverse = false,
|
|
22
|
+
modalTitle,
|
|
23
|
+
value,
|
|
24
|
+
defaultValue,
|
|
25
|
+
onChange,
|
|
26
|
+
...props
|
|
27
|
+
}: LanguageSwitcherVariantProps) {
|
|
28
|
+
const listboxId = useId();
|
|
29
|
+
const anchorName = `--ls-${listboxId.replace(/:/g, "")}`;
|
|
30
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
|
|
32
|
+
useOutsideClose(open, rootRef, () => onOpenChange(false));
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
ref={rootRef}
|
|
37
|
+
className={cn("relative inline-flex", className)}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
style={{ anchorName } as CSSProperties}
|
|
43
|
+
onClick={() => onOpenChange(!open)}
|
|
44
|
+
>
|
|
45
|
+
<LanguageTrigger
|
|
46
|
+
inverse={inverse}
|
|
47
|
+
open={open}
|
|
48
|
+
showFlag={showFlag}
|
|
49
|
+
currentLang={currentLang}
|
|
50
|
+
onClick={() => onOpenChange(!open)}
|
|
51
|
+
ariaProps={{ haspopup: "listbox", expanded: open, controls: listboxId }}
|
|
52
|
+
/>
|
|
53
|
+
</button>
|
|
54
|
+
|
|
55
|
+
<div
|
|
56
|
+
id={listboxId}
|
|
57
|
+
role="listbox"
|
|
58
|
+
aria-label="Select language"
|
|
59
|
+
style={
|
|
60
|
+
{
|
|
61
|
+
positionAnchor: anchorName,
|
|
62
|
+
top: "anchor(bottom)",
|
|
63
|
+
...(align === "start" ? { left: "anchor(left)" } : { right: "anchor(right)" }),
|
|
64
|
+
} as CSSProperties
|
|
65
|
+
}
|
|
66
|
+
className={cn(
|
|
67
|
+
"absolute top-full z-50 mt-2 min-w-[10rem] rounded-lg border border-border bg-surface p-1 shadow-raised transition-all duration-150",
|
|
68
|
+
align === "start" ? "left-0" : "right-0",
|
|
69
|
+
open
|
|
70
|
+
? "visible translate-y-0 opacity-100"
|
|
71
|
+
: "pointer-events-none invisible -translate-y-1 opacity-0",
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{languages.map((lang) => (
|
|
75
|
+
<LanguageOptionRow
|
|
76
|
+
key={lang.code}
|
|
77
|
+
lang={lang}
|
|
78
|
+
active={lang.code === current}
|
|
79
|
+
onSelect={() => setCurrent(lang.code)}
|
|
80
|
+
variant="dropdown"
|
|
81
|
+
showFlag={showFlag}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
LanguageSwitcherDropdown.displayName = "LanguageSwitcherDropdown";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useRef } from "react";
|
|
3
|
+
import { cn } from "../../../utils/cn";
|
|
4
|
+
import { Modal } from "../../Modal";
|
|
5
|
+
import { LanguageTrigger, LanguageOptionRow } from "../parts";
|
|
6
|
+
import type { LanguageSwitcherVariantProps } from "../types";
|
|
7
|
+
|
|
8
|
+
export function LanguageSwitcherModal({
|
|
9
|
+
className,
|
|
10
|
+
languages,
|
|
11
|
+
open,
|
|
12
|
+
onOpenChange,
|
|
13
|
+
current,
|
|
14
|
+
currentLang,
|
|
15
|
+
setCurrent,
|
|
16
|
+
modalTitle,
|
|
17
|
+
showFlag = false,
|
|
18
|
+
inverse = false,
|
|
19
|
+
align,
|
|
20
|
+
value,
|
|
21
|
+
defaultValue,
|
|
22
|
+
onChange,
|
|
23
|
+
...props
|
|
24
|
+
}: LanguageSwitcherVariantProps) {
|
|
25
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<div ref={rootRef} className={cn("relative inline-flex", className)} {...props}>
|
|
30
|
+
<LanguageTrigger
|
|
31
|
+
inverse={inverse}
|
|
32
|
+
open={open}
|
|
33
|
+
showFlag={showFlag}
|
|
34
|
+
currentLang={currentLang}
|
|
35
|
+
onClick={() => onOpenChange(!open)}
|
|
36
|
+
ariaProps={{ haspopup: "dialog", expanded: open }}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
<Modal open={open} onClose={() => onOpenChange(false)} title={modalTitle} size="sm">
|
|
40
|
+
<div role="listbox" aria-label="Select language" className="grid min-w-[19rem] gap-1.5">
|
|
41
|
+
{languages.map((lang) => (
|
|
42
|
+
<LanguageOptionRow
|
|
43
|
+
key={lang.code}
|
|
44
|
+
lang={lang}
|
|
45
|
+
active={lang.code === current}
|
|
46
|
+
onSelect={() => setCurrent(lang.code)}
|
|
47
|
+
variant="modal"
|
|
48
|
+
showFlag={showFlag}
|
|
49
|
+
/>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
</Modal>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
LanguageSwitcherModal.displayName = "LanguageSwitcherModal";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import type { LogoCloudProps } from "./types";
|
|
3
|
+
import { LogoCloudQuiet } from "./variants/Quiet";
|
|
4
|
+
import { LogoCloudMarquee } from "./variants/Marquee";
|
|
5
|
+
import { LogoCloudStrip } from "./variants/Strip";
|
|
6
|
+
|
|
7
|
+
export const LogoCloud = forwardRef<HTMLDivElement, LogoCloudProps>(
|
|
8
|
+
({ option = "quiet", ...props }, ref) => {
|
|
9
|
+
if (option === "marquee") return <LogoCloudMarquee ref={ref} {...props} />;
|
|
10
|
+
if (option === "strip") return <LogoCloudStrip ref={ref} {...props} />;
|
|
11
|
+
return <LogoCloudQuiet ref={ref} {...props} />;
|
|
12
|
+
},
|
|
13
|
+
);
|
|
14
|
+
LogoCloud.displayName = "LogoCloud";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { cn } from "../../utils/cn";
|
|
2
|
+
import type { LogoItem } from "./types";
|
|
3
|
+
|
|
4
|
+
const LogoText = ({ name }: { name: string }) => (
|
|
5
|
+
<span className="text-lg font-semibold tracking-tight text-foreground/45 transition-colors duration-200 group-hover:text-foreground">
|
|
6
|
+
{name}
|
|
7
|
+
</span>
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export const LogoMark = ({ logo }: { logo: LogoItem }) =>
|
|
11
|
+
logo.src ? (
|
|
12
|
+
<img
|
|
13
|
+
src={logo.src}
|
|
14
|
+
alt={logo.name}
|
|
15
|
+
loading="lazy"
|
|
16
|
+
className={cn(
|
|
17
|
+
"max-h-7 w-auto opacity-50 grayscale transition-all duration-200 group-hover:opacity-90 group-hover:grayscale-0",
|
|
18
|
+
"h-7",
|
|
19
|
+
)}
|
|
20
|
+
/>
|
|
21
|
+
) : (
|
|
22
|
+
<LogoText name={logo.name} />
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export function LogoCloudTitle({ title, className }: { title?: string; className?: string }) {
|
|
26
|
+
if (!title) return null;
|
|
27
|
+
return (
|
|
28
|
+
<p className={cn("mb-8 text-center text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground", className)}>
|
|
29
|
+
{title}
|
|
30
|
+
</p>
|
|
31
|
+
);
|
|
32
|
+
}
|