@olwiba/ui 0.2.3 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -64,7 +64,7 @@
64
64
  "devDependencies": {
65
65
  "@content-collections/mdx": "^0.2.2",
66
66
  "@olwiba/cn": "0.1.26",
67
- "@olwiba/docs": "0.1.35",
67
+ "@olwiba/docs": "0.1.38",
68
68
  "@tailwindcss/vite": "^4.1.18",
69
69
  "@tanstack/react-router": "1.154.8",
70
70
  "@tanstack/react-router-devtools": "1.154.8",
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { RefreshCw, Sparkles } from 'lucide-react';
5
+ import { Button, Card, cn } from '@olwiba/cn';
6
+
7
+ export interface UpdateBannerProps {
8
+ /** Version baked into the running client bundle (e.g. a git short SHA embedded at build time). */
9
+ currentVersion: string;
10
+ /**
11
+ * Fetches the currently deployed version from the server. The host app wires
12
+ * its own endpoint — the component stays app-agnostic.
13
+ */
14
+ fetchVersion: () => Promise<{ version: string }>;
15
+ /** Poll interval in ms. */
16
+ intervalMs?: number;
17
+ /** Force the banner to show (demos/testing). */
18
+ forceShow?: boolean;
19
+ /** Called on refresh click; defaults to a full page reload. Use it to hook a celebration first. */
20
+ onRefresh?: () => void;
21
+ message?: React.ReactNode;
22
+ buttonLabel?: string;
23
+ className?: string;
24
+ }
25
+
26
+ export function UpdateBanner({
27
+ currentVersion,
28
+ fetchVersion,
29
+ intervalMs = 5 * 60 * 1000,
30
+ forceShow = false,
31
+ onRefresh,
32
+ message = (
33
+ <>
34
+ <span className="font-semibold">App update available!</span> Refresh to get the latest features
35
+ and improvements.
36
+ </>
37
+ ),
38
+ buttonLabel = 'Refresh now',
39
+ className,
40
+ }: UpdateBannerProps) {
41
+ const [show, setShow] = React.useState(forceShow);
42
+ const [entered, setEntered] = React.useState(false);
43
+ const [refreshing, setRefreshing] = React.useState(false);
44
+
45
+ React.useEffect(() => {
46
+ if (forceShow) {
47
+ setShow(true);
48
+ return;
49
+ }
50
+ setShow(false);
51
+
52
+ let cancelled = false;
53
+
54
+ const check = async () => {
55
+ try {
56
+ const server = await fetchVersion();
57
+ if (!cancelled && server.version && server.version !== currentVersion) {
58
+ setShow(true);
59
+ }
60
+ } catch {
61
+ // Version checks are best-effort; stay quiet on failure.
62
+ }
63
+ };
64
+
65
+ void check();
66
+ const id = setInterval(check, intervalMs);
67
+ return () => {
68
+ cancelled = true;
69
+ clearInterval(id);
70
+ };
71
+ }, [currentVersion, fetchVersion, forceShow, intervalMs]);
72
+
73
+ // Two-frame mount so the enter transition actually plays.
74
+ React.useEffect(() => {
75
+ if (!show) {
76
+ setEntered(false);
77
+ return;
78
+ }
79
+ const id = requestAnimationFrame(() => setEntered(true));
80
+ return () => cancelAnimationFrame(id);
81
+ }, [show]);
82
+
83
+ const handleRefresh = () => {
84
+ setRefreshing(true);
85
+ setTimeout(() => {
86
+ if (onRefresh) {
87
+ onRefresh();
88
+ setRefreshing(false);
89
+ if (forceShow) setShow(false);
90
+ } else {
91
+ window.location.reload();
92
+ }
93
+ }, 400);
94
+ };
95
+
96
+ if (!show) return null;
97
+
98
+ return (
99
+ <div className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4">
100
+ <div
101
+ className={cn(
102
+ 'pointer-events-auto transition-all duration-500 ease-out',
103
+ entered ? 'translate-y-0 opacity-100' : '-translate-y-24 opacity-0',
104
+ )}
105
+ >
106
+ <Card
107
+ className={cn(
108
+ 'max-w-3xl border-amber-300/60 bg-amber-50 dark:border-amber-700/60 dark:bg-amber-950',
109
+ className,
110
+ )}
111
+ >
112
+ <div className="flex items-center justify-between gap-6 p-4">
113
+ <div className="flex items-center gap-3">
114
+ <Sparkles className="size-5 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
115
+ <p className="text-sm text-amber-800 dark:text-amber-200">{message}</p>
116
+ </div>
117
+ <Button
118
+ onClick={handleRefresh}
119
+ disabled={refreshing}
120
+ size="sm"
121
+ className="shrink-0 bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700"
122
+ >
123
+ {refreshing ? <RefreshCw className="size-4 animate-spin" /> : buttonLabel}
124
+ </Button>
125
+ </div>
126
+ </Card>
127
+ </div>
128
+ </div>
129
+ );
130
+ }
package/src/index.ts CHANGED
@@ -76,13 +76,15 @@ export {
76
76
  type UpgradeComparisonRow,
77
77
  } from './app/UpgradePrompt';
78
78
 
79
+ export { UpdateBanner, type UpdateBannerProps } from './app/UpdateBanner';
80
+
79
81
  // ─── Marketing — page sections ────────────────────────────────────────────────
80
82
  export { SectionTitle, type SectionTitleProps } from './marketing/SectionTitle';
81
83
  export { marketingSectionSpacing, type MarketingSectionSpacing } from './marketing/section-spacing';
82
84
  export { HeroSection, type HeroSectionProps } from './marketing/HeroSection';
83
85
  export { FeaturesSection, type FeaturesSectionProps } from './marketing/FeaturesSection';
84
86
  export { GroupedFeaturesSection, type GroupedFeaturesSectionProps, type GroupedFeatureGroup } from './marketing/GroupedFeaturesSection';
85
- export { StepsSection, type StepsSectionProps, type StepItem } from './marketing/StepsSection';
87
+ export { StepsSection, type StepsSectionProps, type StepItem, type StepGroup } from './marketing/StepsSection';
86
88
  export { TechStackSection, type TechStackSectionProps, type TechStackItem } from './marketing/TechStackSection';
87
89
  export { FeatureMarqueeSection, type FeatureMarqueeSectionProps, type FeatureMarqueeRow, type FeatureMarqueeItem } from './marketing/FeatureMarqueeSection';
88
90
  export { CtaSection, type CtaSectionProps } from './marketing/CtaSection';
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import * as React from 'react';
3
4
  import { cn, useUIVariant } from '@olwiba/cn';
4
5
  import { SectionTitle } from './SectionTitle';
5
6
  import { FadeIn } from '../motion/FadeIn';
@@ -7,14 +8,77 @@ import { FadeIn } from '../motion/FadeIn';
7
8
  export interface StepItem {
8
9
  emoji?: string;
9
10
  title: string;
10
- description: string;
11
+ description: React.ReactNode;
12
+ }
13
+
14
+ export interface StepGroup {
15
+ steps: StepItem[];
16
+ /** Content rendered after this group, outside the timeline (e.g. a CTA card). */
17
+ after?: React.ReactNode;
11
18
  }
12
19
 
13
20
  export interface StepsSectionProps {
14
21
  badge?: string;
15
22
  title?: string;
16
23
  description?: string;
24
+ steps?: StepItem[];
25
+ /** 'default': horizontal row (vertical on mobile). 'timeline': vertical numbered timeline with a fading accent line. */
26
+ variant?: 'default' | 'timeline';
27
+ /** Timeline only — step groups with optional interstitial content; overrides `steps`. Numbering continues across groups. */
28
+ groups?: StepGroup[];
29
+ }
30
+
31
+ function TimelineGroup({
32
+ steps,
33
+ startNumber,
34
+ fadeIn,
35
+ }: {
17
36
  steps: StepItem[];
37
+ startNumber: number;
38
+ fadeIn: boolean;
39
+ }) {
40
+ const fadePx = 48;
41
+ return (
42
+ <div className="relative">
43
+ {/* Accent line: optional fade-in head, solid middle, fade-out tail */}
44
+ {fadeIn && (
45
+ <div
46
+ aria-hidden="true"
47
+ className="absolute left-[19px] w-1 bg-gradient-to-b from-transparent to-primary"
48
+ style={{ top: 0, height: fadePx }}
49
+ />
50
+ )}
51
+ <div
52
+ aria-hidden="true"
53
+ className="absolute left-[19px] w-1 bg-primary"
54
+ style={{
55
+ top: fadeIn ? fadePx : 20,
56
+ height: fadeIn ? `calc(100% - ${fadePx}px)` : 'calc(100% - 20px)',
57
+ }}
58
+ />
59
+ <div
60
+ aria-hidden="true"
61
+ className="absolute left-[19px] w-1 bg-gradient-to-b from-primary to-transparent"
62
+ style={{ top: '100%', height: fadePx }}
63
+ />
64
+
65
+ <div className="space-y-10" style={fadeIn ? { paddingTop: fadePx } : undefined}>
66
+ {steps.map((step, i) => (
67
+ <FadeIn key={step.title} direction="up" delay={i * 80}>
68
+ <div className="relative flex gap-5">
69
+ <div className="relative z-10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border-2 border-primary bg-primary text-lg font-bold text-primary-foreground">
70
+ {step.emoji ?? startNumber + i}
71
+ </div>
72
+ <div className="flex-1 pt-1 pb-2">
73
+ <h3 className="mb-2 text-xl font-semibold text-foreground">{step.title}</h3>
74
+ <div className="text-sm leading-relaxed text-muted-foreground">{step.description}</div>
75
+ </div>
76
+ </div>
77
+ </FadeIn>
78
+ ))}
79
+ </div>
80
+ </div>
81
+ );
18
82
  }
19
83
 
20
84
  export function StepsSection({
@@ -22,6 +86,8 @@ export function StepsSection({
22
86
  title = 'From zero to shipped',
23
87
  description,
24
88
  steps,
89
+ variant = 'default',
90
+ groups,
25
91
  }: StepsSectionProps) {
26
92
  const mode = useUIVariant();
27
93
  const sectionClasses = cn(
@@ -31,6 +97,37 @@ export function StepsSection({
31
97
  !mode && 'rounded-2xl border',
32
98
  );
33
99
 
100
+ if (variant === 'timeline') {
101
+ const resolvedGroups: StepGroup[] = groups ?? (steps ? [{ steps }] : []);
102
+ let nextNumber = 1;
103
+
104
+ return (
105
+ <section className={sectionClasses}>
106
+ <div className="px-6 py-14 sm:px-10 sm:py-20">
107
+ <div className="mx-auto max-w-3xl">
108
+ <SectionTitle badge={badge} title={title} description={description} className="text-left [&>p]:mx-0" />
109
+ <div className="mt-12">
110
+ {resolvedGroups.map((group, groupIndex) => {
111
+ const startNumber = nextNumber;
112
+ nextNumber += group.steps.length;
113
+ return (
114
+ <React.Fragment key={groupIndex}>
115
+ <div className={cn(groupIndex > 0 && 'mt-10')}>
116
+ <TimelineGroup steps={group.steps} startNumber={startNumber} fadeIn={groupIndex > 0} />
117
+ </div>
118
+ {group.after && <div className="mt-16">{group.after}</div>}
119
+ </React.Fragment>
120
+ );
121
+ })}
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </section>
126
+ );
127
+ }
128
+
129
+ const resolvedSteps = steps ?? [];
130
+
34
131
  return (
35
132
  <section className={sectionClasses}>
36
133
  <div className="px-6 py-14 sm:px-10 sm:py-20">
@@ -39,12 +136,12 @@ export function StepsSection({
39
136
 
40
137
  <div className="mt-12">
41
138
  {/* Desktop: horizontal row */}
42
- <div className="hidden sm:grid" style={{ gridTemplateColumns: `repeat(${steps.length}, 1fr)` }}>
43
- {steps.map((step, i) => (
139
+ <div className="hidden sm:grid" style={{ gridTemplateColumns: `repeat(${resolvedSteps.length}, 1fr)` }}>
140
+ {resolvedSteps.map((step, i) => (
44
141
  <FadeIn key={step.title} direction="up" delay={i * 80}>
45
142
  <div className="relative flex flex-col items-center text-center px-4">
46
143
  {/* Dashed connector to next step */}
47
- {i < steps.length - 1 && (
144
+ {i < resolvedSteps.length - 1 && (
48
145
  <div
49
146
  aria-hidden="true"
50
147
  className="absolute top-6 left-1/2 w-full border-t border-dashed border-border"
@@ -57,7 +154,7 @@ export function StepsSection({
57
154
  )}
58
155
  </div>
59
156
  <h3 className="font-semibold text-foreground">{step.title}</h3>
60
- <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{step.description}</p>
157
+ <div className="mt-2 text-sm leading-relaxed text-muted-foreground">{step.description}</div>
61
158
  </div>
62
159
  </FadeIn>
63
160
  ))}
@@ -65,11 +162,11 @@ export function StepsSection({
65
162
 
66
163
  {/* Mobile: vertical timeline */}
67
164
  <div className="flex flex-col gap-0 sm:hidden">
68
- {steps.map((step, i) => (
165
+ {resolvedSteps.map((step, i) => (
69
166
  <FadeIn key={step.title} direction="up" delay={i * 80}>
70
167
  <div className="relative flex gap-4 pb-8 last:pb-0">
71
168
  {/* Vertical connector */}
72
- {i < steps.length - 1 && (
169
+ {i < resolvedSteps.length - 1 && (
73
170
  <div
74
171
  aria-hidden="true"
75
172
  className="absolute left-[23px] top-14 bottom-0 w-px border-l border-dashed border-border"
@@ -83,7 +180,7 @@ export function StepsSection({
83
180
  </div>
84
181
  <div className="pt-3">
85
182
  <h3 className="font-semibold text-foreground">{step.title}</h3>
86
- <p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{step.description}</p>
183
+ <div className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{step.description}</div>
87
184
  </div>
88
185
  </div>
89
186
  </FadeIn>