@olwiba/ui 0.0.28

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/dist/index.d.ts +762 -0
  4. package/dist/index.js +2102 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +88 -0
  7. package/src/app/AppShell.tsx +365 -0
  8. package/src/app/AuthSection.tsx +141 -0
  9. package/src/app/EmptyState.tsx +34 -0
  10. package/src/app/ErrorPage.tsx +78 -0
  11. package/src/app/UpgradePrompt.tsx +203 -0
  12. package/src/blog/PostCard.tsx +66 -0
  13. package/src/blog/PostList.tsx +27 -0
  14. package/src/components/ConfirmDialog.tsx +43 -0
  15. package/src/components/ContextMenu.tsx +86 -0
  16. package/src/components/DevBanner.tsx +22 -0
  17. package/src/components/Dock.tsx +94 -0
  18. package/src/components/FeatureCard.tsx +45 -0
  19. package/src/components/GlassCard.tsx +30 -0
  20. package/src/components/ImageCard.tsx +60 -0
  21. package/src/components/PageHeader.tsx +101 -0
  22. package/src/components/PricingCard.tsx +90 -0
  23. package/src/components/RegisterHotkeys.tsx +43 -0
  24. package/src/components/RootErrorFallback.tsx +29 -0
  25. package/src/components/Spinner.tsx +14 -0
  26. package/src/components/Spotlight.tsx +104 -0
  27. package/src/components/StatCard.tsx +44 -0
  28. package/src/components/Suspensed.tsx +17 -0
  29. package/src/components/TestimonialCard.tsx +64 -0
  30. package/src/components/ThemeColorUpdater.tsx +23 -0
  31. package/src/components/ThemeSwitchMinimal.tsx +28 -0
  32. package/src/components/VersionBanner.tsx +38 -0
  33. package/src/context/OlwibaUIContext.tsx +58 -0
  34. package/src/hooks/use-confirm.ts +64 -0
  35. package/src/hooks/use-controlled-open.ts +33 -0
  36. package/src/hooks/use-copy-to-clipboard.ts +20 -0
  37. package/src/hooks/use-debounce.ts +14 -0
  38. package/src/hooks/use-intersection-observer.ts +25 -0
  39. package/src/hooks/use-local-storage.ts +31 -0
  40. package/src/hooks/use-media-query.ts +21 -0
  41. package/src/hooks/use-mounted.ts +11 -0
  42. package/src/hooks/use-pagination.ts +31 -0
  43. package/src/hooks/use-scrolled-past.ts +27 -0
  44. package/src/index.ts +113 -0
  45. package/src/lib/utils.ts +6 -0
  46. package/src/marketing/ContactSection.tsx +110 -0
  47. package/src/marketing/CtaSection.tsx +74 -0
  48. package/src/marketing/FaqSection.tsx +44 -0
  49. package/src/marketing/FeaturesSection.tsx +42 -0
  50. package/src/marketing/Footer.tsx +87 -0
  51. package/src/marketing/HeroSection.tsx +112 -0
  52. package/src/marketing/LogoStrip.tsx +94 -0
  53. package/src/marketing/Navbar.tsx +142 -0
  54. package/src/marketing/NewsletterSection.tsx +64 -0
  55. package/src/marketing/PricingSection.tsx +120 -0
  56. package/src/marketing/SectionTitle.tsx +30 -0
  57. package/src/marketing/StatsSection.tsx +63 -0
  58. package/src/marketing/TeamSection.tsx +110 -0
  59. package/src/marketing/TestimonialsSection.tsx +56 -0
  60. package/src/motion/CountUp.tsx +64 -0
  61. package/src/motion/FadeIn.tsx +69 -0
  62. package/src/motion/PageTransition.tsx +49 -0
  63. package/src/motion/StaggerChildren.tsx +74 -0
  64. package/src/overlays/Overlay.tsx +88 -0
  65. package/src/overlays/Underlay.tsx +114 -0
  66. package/src/primitives/Badge.tsx +11 -0
  67. package/src/primitives/Button.tsx +16 -0
  68. package/src/primitives/Card.tsx +22 -0
  69. package/src/primitives/Checkbox.tsx +17 -0
  70. package/src/primitives/Input.tsx +16 -0
  71. package/src/primitives/Switch.tsx +16 -0
  72. package/src/primitives/Textarea.tsx +16 -0
  73. package/src/primitives/index.ts +7 -0
  74. package/src/types/external-packages.d.ts +79 -0
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { X } from 'lucide-react';
5
+ import { cn } from '../lib/utils';
6
+
7
+ interface VersionBannerProps {
8
+ version: string;
9
+ message?: string;
10
+ className?: string;
11
+ onDismiss?: () => void;
12
+ }
13
+
14
+ export function VersionBanner({ version, message, className, onDismiss }: VersionBannerProps) {
15
+ const [dismissed, setDismissed] = React.useState(false);
16
+
17
+ if (dismissed) return null;
18
+
19
+ return (
20
+ <div className={cn('flex items-center justify-between gap-4 bg-primary/10 px-4 py-2 text-sm', className)}>
21
+ <div className="flex items-center gap-2">
22
+ <span className="rounded bg-primary/20 px-1.5 py-0.5 font-mono text-xs font-semibold text-primary">
23
+ v{version}
24
+ </span>
25
+ {message && <span className="text-muted-foreground">{message}</span>}
26
+ </div>
27
+ {onDismiss && (
28
+ <button
29
+ onClick={() => { setDismissed(true); onDismiss(); }}
30
+ className="text-muted-foreground transition-colors hover:text-foreground"
31
+ aria-label="Dismiss"
32
+ >
33
+ <X className="size-4" />
34
+ </button>
35
+ )}
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { useIsMobile, UIVariantProvider, type UIVariant } from '@olwiba/cn';
5
+
6
+ export type UIMode = 'default' | 'playful' | 'smooth';
7
+
8
+ interface OlwibaUIContextValue {
9
+ isMobile: boolean;
10
+ mode: UIMode;
11
+ }
12
+
13
+ const OlwibaUIContext = React.createContext<OlwibaUIContextValue>({
14
+ isMobile: false,
15
+ mode: 'default',
16
+ });
17
+
18
+ export interface OlwibaUIProviderProps {
19
+ children: React.ReactNode;
20
+ /**
21
+ * Override the mobile detection. If omitted, uses useIsMobile() from @olwiba/cn
22
+ * which detects based on a 768px viewport breakpoint.
23
+ */
24
+ isMobile?: boolean;
25
+ /**
26
+ * Global component mode. Primitives imported from @olwiba/ui will automatically
27
+ * apply this mode unless overridden at the component level.
28
+ *
29
+ * - `'default'` — standard square shadcn/Radix appearance
30
+ * - `'playful'` — slight rotation + offset drop shadow backdrop
31
+ * - `'smooth'` — softer, larger border-radius across all components
32
+ */
33
+ mode?: UIMode;
34
+ }
35
+
36
+ export function OlwibaUIProvider({ children, isMobile: isMobileProp, mode = 'default' }: OlwibaUIProviderProps) {
37
+ const detectedMobile = useIsMobile();
38
+ const isMobile = isMobileProp ?? detectedMobile;
39
+
40
+ const variant = mode !== 'default' ? (mode as UIVariant) : undefined;
41
+
42
+ return (
43
+ <OlwibaUIContext.Provider value={{ isMobile, mode }}>
44
+ <UIVariantProvider mode={variant}>
45
+ {children}
46
+ </UIVariantProvider>
47
+ </OlwibaUIContext.Provider>
48
+ );
49
+ }
50
+
51
+ export function useOlwibaUI() {
52
+ return React.useContext(OlwibaUIContext);
53
+ }
54
+
55
+ /** Returns just the current UI mode from context. */
56
+ export function useUIMode(): UIMode {
57
+ return React.useContext(OlwibaUIContext).mode;
58
+ }
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ export interface ConfirmOptions {
6
+ title?: string;
7
+ description?: string;
8
+ confirmLabel?: string;
9
+ cancelLabel?: string;
10
+ }
11
+
12
+ export interface UseConfirmReturn {
13
+ confirm: (options?: ConfirmOptions) => Promise<boolean>;
14
+ isOpen: boolean;
15
+ options: ConfirmOptions;
16
+ handleConfirm: () => void;
17
+ handleCancel: () => void;
18
+ }
19
+
20
+ /**
21
+ * Headless confirmation hook. Returns state and handlers for building a confirm dialog.
22
+ *
23
+ * @example
24
+ * const { confirm, isOpen, options, handleConfirm, handleCancel } = useConfirm();
25
+ *
26
+ * // Trigger confirmation
27
+ * const ok = await confirm({ title: 'Delete item?', description: 'This cannot be undone.' });
28
+ *
29
+ * // Wire up your dialog
30
+ * <AlertDialog open={isOpen}>
31
+ * <AlertDialogContent>
32
+ * <AlertDialogTitle>{options.title}</AlertDialogTitle>
33
+ * <AlertDialogFooter>
34
+ * <AlertDialogCancel onClick={handleCancel} />
35
+ * <AlertDialogAction onClick={handleConfirm} />
36
+ * </AlertDialogFooter>
37
+ * </AlertDialogContent>
38
+ * </AlertDialog>
39
+ */
40
+ export function useConfirm(): UseConfirmReturn {
41
+ const [isOpen, setIsOpen] = React.useState(false);
42
+ const [options, setOptions] = React.useState<ConfirmOptions>({});
43
+ const resolveRef = React.useRef<((value: boolean) => void) | null>(null);
44
+
45
+ const confirm = React.useCallback((opts: ConfirmOptions = {}): Promise<boolean> => {
46
+ setOptions(opts);
47
+ setIsOpen(true);
48
+ return new Promise<boolean>((resolve) => {
49
+ resolveRef.current = resolve;
50
+ });
51
+ }, []);
52
+
53
+ const handleConfirm = React.useCallback(() => {
54
+ resolveRef.current?.(true);
55
+ setIsOpen(false);
56
+ }, []);
57
+
58
+ const handleCancel = React.useCallback(() => {
59
+ resolveRef.current?.(false);
60
+ setIsOpen(false);
61
+ }, []);
62
+
63
+ return { confirm, isOpen, options, handleConfirm, handleCancel };
64
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ export interface UseControlledOpenReturn {
6
+ isOpen: boolean;
7
+ open: () => void;
8
+ close: () => void;
9
+ toggle: () => void;
10
+ setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
11
+ }
12
+
13
+ /**
14
+ * Simple open/close state hook for modals, drawers, dropdowns, and any toggleable UI.
15
+ *
16
+ * @example
17
+ * const { isOpen, open, close } = useControlledOpen();
18
+ * return (
19
+ * <>
20
+ * <Button onClick={open}>Open</Button>
21
+ * <Dialog open={isOpen} onOpenChange={setIsOpen}>...</Dialog>
22
+ * </>
23
+ * );
24
+ */
25
+ export function useControlledOpen(defaultOpen = false): UseControlledOpenReturn {
26
+ const [isOpen, setIsOpen] = React.useState(defaultOpen);
27
+
28
+ const open = React.useCallback(() => setIsOpen(true), []);
29
+ const close = React.useCallback(() => setIsOpen(false), []);
30
+ const toggle = React.useCallback(() => setIsOpen((prev) => !prev), []);
31
+
32
+ return { isOpen, open, close, toggle, setIsOpen };
33
+ }
@@ -0,0 +1,20 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ export function useCopyToClipboard(): [boolean, (text: string) => void] {
6
+ const [copied, setCopied] = React.useState(false);
7
+ const timeoutRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);
8
+
9
+ const copy = React.useCallback((text: string) => {
10
+ navigator.clipboard.writeText(text).then(() => {
11
+ setCopied(true);
12
+ clearTimeout(timeoutRef.current);
13
+ timeoutRef.current = setTimeout(() => setCopied(false), 2000);
14
+ });
15
+ }, []);
16
+
17
+ React.useEffect(() => () => clearTimeout(timeoutRef.current), []);
18
+
19
+ return [copied, copy];
20
+ }
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ export function useDebounce<T>(value: T, delay: number): T {
6
+ const [debounced, setDebounced] = React.useState(value);
7
+
8
+ React.useEffect(() => {
9
+ const timer = setTimeout(() => setDebounced(value), delay);
10
+ return () => clearTimeout(timer);
11
+ }, [value, delay]);
12
+
13
+ return debounced;
14
+ }
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ export function useIntersectionObserver(
6
+ options?: IntersectionObserverInit,
7
+ ): [React.RefObject<Element | null>, boolean] {
8
+ const ref = React.useRef<Element | null>(null);
9
+ const [isIntersecting, setIsIntersecting] = React.useState(false);
10
+
11
+ React.useEffect(() => {
12
+ const el = ref.current;
13
+ if (!el) return;
14
+
15
+ const observer = new IntersectionObserver(([entry]) => {
16
+ setIsIntersecting(entry.isIntersecting);
17
+ }, options);
18
+
19
+ observer.observe(el);
20
+ return () => observer.disconnect();
21
+ // eslint-disable-next-line react-hooks/exhaustive-deps
22
+ }, []);
23
+
24
+ return [ref, isIntersecting];
25
+ }
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
6
+ const [storedValue, setStoredValue] = React.useState<T>(() => {
7
+ if (typeof window === 'undefined') return initialValue;
8
+ try {
9
+ const item = window.localStorage.getItem(key);
10
+ return item ? (JSON.parse(item) as T) : initialValue;
11
+ } catch {
12
+ return initialValue;
13
+ }
14
+ });
15
+
16
+ const setValue = React.useCallback(
17
+ (value: T) => {
18
+ try {
19
+ setStoredValue(value);
20
+ if (typeof window !== 'undefined') {
21
+ window.localStorage.setItem(key, JSON.stringify(value));
22
+ }
23
+ } catch {
24
+ // Silently ignore — private browsing may block localStorage writes
25
+ }
26
+ },
27
+ [key],
28
+ );
29
+
30
+ return [storedValue, setValue];
31
+ }
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ export function useMediaQuery(query: string): boolean {
6
+ const [matches, setMatches] = React.useState(() => {
7
+ if (typeof window === 'undefined') return false;
8
+ return window.matchMedia(query).matches;
9
+ });
10
+
11
+ React.useEffect(() => {
12
+ if (typeof window === 'undefined') return;
13
+ const mql = window.matchMedia(query);
14
+ setMatches(mql.matches);
15
+ const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
16
+ mql.addEventListener('change', handler);
17
+ return () => mql.removeEventListener('change', handler);
18
+ }, [query]);
19
+
20
+ return matches;
21
+ }
@@ -0,0 +1,11 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ export function useMounted() {
4
+ const [mounted, setMounted] = useState(false);
5
+
6
+ useEffect(() => {
7
+ setMounted(true);
8
+ }, []);
9
+
10
+ return mounted;
11
+ }
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ export interface UsePaginationReturn {
6
+ page: number;
7
+ pageSize: number;
8
+ totalPages: number;
9
+ offset: number;
10
+ hasPrev: boolean;
11
+ hasNext: boolean;
12
+ goTo: (page: number) => void;
13
+ next: () => void;
14
+ prev: () => void;
15
+ }
16
+
17
+ export function usePagination(total: number, pageSize: number): UsePaginationReturn {
18
+ const [page, setPage] = React.useState(1);
19
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
20
+ const offset = (page - 1) * pageSize;
21
+
22
+ const goTo = React.useCallback(
23
+ (p: number) => setPage(Math.min(Math.max(1, p), totalPages)),
24
+ [totalPages],
25
+ );
26
+
27
+ const next = React.useCallback(() => goTo(page + 1), [page, goTo]);
28
+ const prev = React.useCallback(() => goTo(page - 1), [page, goTo]);
29
+
30
+ return { page, pageSize, totalPages, offset, hasPrev: page > 1, hasNext: page < totalPages, goTo, next, prev };
31
+ }
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ /**
6
+ * Returns true when the page has scrolled past a given Y threshold in pixels.
7
+ * Useful for showing/hiding sticky headers, back-to-top buttons, etc.
8
+ *
9
+ * @example
10
+ * const scrolledPast = useScrolledPast(100);
11
+ * return <header className={scrolledPast ? 'shadow-md' : ''}>...</header>;
12
+ */
13
+ export function useScrolledPast(threshold: number): boolean {
14
+ const [scrolledPast, setScrolledPast] = React.useState(false);
15
+
16
+ React.useEffect(() => {
17
+ const handleScroll = () => {
18
+ setScrolledPast(window.scrollY > threshold);
19
+ };
20
+
21
+ handleScroll();
22
+ window.addEventListener('scroll', handleScroll, { passive: true });
23
+ return () => window.removeEventListener('scroll', handleScroll);
24
+ }, [threshold]);
25
+
26
+ return scrolledPast;
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,113 @@
1
+ // @olwiba/ui — App-level components, sections, and hooks built on @olwiba/cn
2
+
3
+ export { cn } from './lib/utils';
4
+
5
+ // ─── Context ──────────────────────────────────────────────────────────────────
6
+ export {
7
+ OlwibaUIProvider,
8
+ useOlwibaUI,
9
+ useUIMode,
10
+ type OlwibaUIProviderProps,
11
+ type UIMode,
12
+ } from './context/OlwibaUIContext';
13
+
14
+ // ─── Primitives — mode-aware re-exports of @olwiba/cn components ─────────────
15
+ export * from './primitives';
16
+
17
+ // ─── App — shells, auth, feedback ────────────────────────────────────────────
18
+ export {
19
+ AppShell,
20
+ type AppShellProps,
21
+ type AppShellBrand,
22
+ type AppShellUser,
23
+ type AppShellAction,
24
+ type AppShellRenderLink,
25
+ type AppNavItem,
26
+ } from './app/AppShell';
27
+
28
+ export {
29
+ AuthSection,
30
+ type AuthSectionProps,
31
+ type AuthFormProps,
32
+ } from './app/AuthSection';
33
+
34
+ export {
35
+ EmptyState,
36
+ type EmptyStateProps,
37
+ } from './app/EmptyState';
38
+
39
+ export {
40
+ ErrorPage,
41
+ type ErrorPageProps,
42
+ } from './app/ErrorPage';
43
+
44
+ export {
45
+ UpgradePrompt,
46
+ type UpgradePromptProps,
47
+ type UpgradeComparisonRow,
48
+ } from './app/UpgradePrompt';
49
+
50
+ // ─── Marketing — page sections ────────────────────────────────────────────────
51
+ export { SectionTitle, type SectionTitleProps } from './marketing/SectionTitle';
52
+ export { HeroSection, type HeroSectionProps } from './marketing/HeroSection';
53
+ export { FeaturesSection, type FeaturesSectionProps } from './marketing/FeaturesSection';
54
+ export { CtaSection, type CtaSectionProps } from './marketing/CtaSection';
55
+ export { PricingSection, type PricingSectionProps, type PricingPlan } from './marketing/PricingSection';
56
+ export { TestimonialsSection, type TestimonialsSectionProps } from './marketing/TestimonialsSection';
57
+ export { TeamSection } from './marketing/TeamSection';
58
+ export { FaqSection, type FaqSectionProps } from './marketing/FaqSection';
59
+ export { StatsSection, type StatsSectionProps } from './marketing/StatsSection';
60
+ export { NewsletterSection } from './marketing/NewsletterSection';
61
+ export { ContactSection } from './marketing/ContactSection';
62
+
63
+ // ─── Marketing — elements ─────────────────────────────────────────────────────
64
+ export { Navbar, type NavbarProps } from './marketing/Navbar';
65
+ export { Footer, type FooterProps } from './marketing/Footer';
66
+ export { LogoStrip, type LogoStripProps } from './marketing/LogoStrip';
67
+
68
+ // ─── Layering ─────────────────────────────────────────────────────────────────
69
+ export { Underlay, type UnderlayProps, type UnderlayVariant } from './overlays/Underlay';
70
+ export { Overlay, type OverlayProps, type OverlayVariant } from './overlays/Overlay';
71
+
72
+ // ─── Motion ───────────────────────────────────────────────────────────────────
73
+ export { FadeIn, type FadeInProps } from './motion/FadeIn';
74
+ export { StaggerChildren, type StaggerChildrenProps } from './motion/StaggerChildren';
75
+ export { CountUp, type CountUpProps } from './motion/CountUp';
76
+ export { PageTransition, type PageTransitionProps } from './motion/PageTransition';
77
+
78
+ // ─── Components — interactive ────────────────────────────────────────────────
79
+ export { Spotlight, type SpotlightProps, type SpotlightGroup, type SpotlightItem } from './components/Spotlight';
80
+ export { Dock, type DockProps, type DockItem } from './components/Dock';
81
+ export { ContextMenu, type ContextMenuProps, type ContextMenuDef } from './components/ContextMenu';
82
+ export { ConfirmDialog, type ConfirmDialogProps } from './components/ConfirmDialog';
83
+
84
+ // ─── Components — cards ───────────────────────────────────────────────────────
85
+ export { GlassCard, type GlassCardProps } from './components/GlassCard';
86
+ export { FeatureCard, type FeatureCardProps } from './components/FeatureCard';
87
+ export { StatCard, type StatCardProps } from './components/StatCard';
88
+ export { TestimonialCard, type TestimonialCardProps } from './components/TestimonialCard';
89
+ export { PricingCard, type PricingCardProps, type PricingFeature } from './components/PricingCard';
90
+ export { ImageCard, type ImageCardProps } from './components/ImageCard';
91
+
92
+ // ─── Components — utility ────────────────────────────────────────────────────
93
+ export { FullPageSpinner } from './components/Spinner';
94
+ export { PageHeader, type PageHeaderProps, type PageHeaderBreadcrumb, type PageHeaderBackButton } from './components/PageHeader';
95
+ export { Suspensed } from './components/Suspensed';
96
+ export { ThemeSwitchMinimal } from './components/ThemeSwitchMinimal';
97
+ export { ThemeColorUpdater } from './components/ThemeColorUpdater';
98
+ export { VersionBanner } from './components/VersionBanner';
99
+ export { DevBanner, type DevBannerProps } from './components/DevBanner';
100
+ export { RegisterHotkeys, type Hotkey } from './components/RegisterHotkeys';
101
+ export { RootErrorFallback } from './components/RootErrorFallback';
102
+
103
+ // ─── Hooks ────────────────────────────────────────────────────────────────────
104
+ export { useMounted } from './hooks/use-mounted';
105
+ export { useConfirm, type ConfirmOptions, type UseConfirmReturn } from './hooks/use-confirm';
106
+ export { useControlledOpen, type UseControlledOpenReturn } from './hooks/use-controlled-open';
107
+ export { useScrolledPast } from './hooks/use-scrolled-past';
108
+ export { useCopyToClipboard } from './hooks/use-copy-to-clipboard';
109
+ export { useDebounce } from './hooks/use-debounce';
110
+ export { useIntersectionObserver } from './hooks/use-intersection-observer';
111
+ export { useLocalStorage } from './hooks/use-local-storage';
112
+ export { useMediaQuery } from './hooks/use-media-query';
113
+ export { usePagination, type UsePaginationReturn } from './hooks/use-pagination';
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,110 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Mail, MapPin, MessageSquare, Phone, Send } from 'lucide-react';
5
+ import { Badge, Button, Input, Label, Separator, Textarea } from '@olwiba/cn';
6
+
7
+ const contactInfo = [
8
+ { Icon: Mail, label: 'Email', value: 'hello@olwiba.com' },
9
+ { Icon: Phone, label: 'Phone', value: '+1 (555) 000-0000' },
10
+ { Icon: MapPin, label: 'Office', value: 'San Francisco, CA' },
11
+ ];
12
+
13
+ export function ContactSection() {
14
+ const [submitted, setSubmitted] = React.useState(false);
15
+
16
+ function handleSubmit(e: React.FormEvent) {
17
+ e.preventDefault();
18
+ setSubmitted(true);
19
+ }
20
+
21
+ return (
22
+ <section className="overflow-hidden rounded-2xl border bg-card">
23
+ <div className="grid lg:grid-cols-[1fr_1.4fr]">
24
+ {/* Left panel */}
25
+ <div className="relative overflow-hidden bg-primary px-8 py-12 text-primary-foreground">
26
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary-foreground)/0.1),transparent_60%)]" />
27
+ <div className="relative space-y-6">
28
+ <div>
29
+ <Badge variant="secondary" className="mb-3 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/20">
30
+ Contact us
31
+ </Badge>
32
+ <h2 className="text-2xl font-semibold">Let's talk</h2>
33
+ <p className="mt-2 text-sm text-primary-foreground/70">
34
+ Have a question or want to work together? Fill in the form and we'll get back to you within one business day.
35
+ </p>
36
+ </div>
37
+
38
+ <Separator className="bg-primary-foreground/20" />
39
+
40
+ <div className="space-y-4">
41
+ {contactInfo.map(({ Icon, label, value }) => (
42
+ <div key={label} className="flex items-center gap-3">
43
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-foreground/10">
44
+ <Icon className="size-4" />
45
+ </div>
46
+ <div>
47
+ <div className="text-xs text-primary-foreground/60">{label}</div>
48
+ <div className="text-sm font-medium">{value}</div>
49
+ </div>
50
+ </div>
51
+ ))}
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ {/* Form */}
57
+ <div className="p-8">
58
+ {submitted ? (
59
+ <div className="flex h-full min-h-[320px] flex-col items-center justify-center gap-4 text-center">
60
+ <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
61
+ <MessageSquare className="size-6" />
62
+ </div>
63
+ <div>
64
+ <h3 className="font-semibold">Message sent</h3>
65
+ <p className="mt-1 text-sm text-muted-foreground">
66
+ Thanks for reaching out. We'll get back to you within one business day.
67
+ </p>
68
+ </div>
69
+ <Button variant="outline" onClick={() => setSubmitted(false)}>Send another</Button>
70
+ </div>
71
+ ) : (
72
+ <form onSubmit={handleSubmit} className="space-y-5">
73
+ <div className="grid gap-4 sm:grid-cols-2">
74
+ <div className="space-y-1.5">
75
+ <Label htmlFor="contact-first">First name</Label>
76
+ <Input id="contact-first" placeholder="Olivia" required />
77
+ </div>
78
+ <div className="space-y-1.5">
79
+ <Label htmlFor="contact-last">Last name</Label>
80
+ <Input id="contact-last" placeholder="Reed" required />
81
+ </div>
82
+ </div>
83
+ <div className="space-y-1.5">
84
+ <Label htmlFor="contact-email">Email</Label>
85
+ <Input id="contact-email" type="email" placeholder="olivia@company.com" required />
86
+ </div>
87
+ <div className="space-y-1.5">
88
+ <Label htmlFor="contact-subject">Subject</Label>
89
+ <Input id="contact-subject" placeholder="How can we help?" required />
90
+ </div>
91
+ <div className="space-y-1.5">
92
+ <Label htmlFor="contact-message">Message</Label>
93
+ <Textarea
94
+ id="contact-message"
95
+ placeholder="Tell us what you're working on..."
96
+ className="min-h-28 resize-none"
97
+ required
98
+ />
99
+ </div>
100
+ <Button type="submit" className="w-full">
101
+ Send message
102
+ <Send className="ml-2 size-4" />
103
+ </Button>
104
+ </form>
105
+ )}
106
+ </div>
107
+ </div>
108
+ </section>
109
+ );
110
+ }