@stevederico/skateboard-ui 3.7.1 → 3.8.0
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/CHANGELOG.md +6 -0
- package/README.md +1 -2
- package/components/views/LandingView.jsx +156 -145
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
3.8.0
|
|
4
|
+
|
|
5
|
+
Redesign: `LandingView` now uses the SpecSheet layout — sticky header with nav + theme toggle, quiet hero with CTA buttons, icon-leading feature cards (responsive 1–3 columns), optional pricing card, dark CTA section, and footer. Reads all copy from `constants` (tagline, cta, navLinks, features, stripeProducts, pricing, ctaHeading, footerLinks, companyName, copyrightText)
|
|
6
|
+
Feature/app icons resolve via `DynamicIcon` (Lucide names); legacy emoji/text values fall back to rendering as raw text so they never silently disappear
|
|
7
|
+
Apps that vendored `LandingSpecSheet.jsx` can delete the local copy and rely on the package default (omit `landingPage`) or import `@stevederico/skateboard-ui/LandingView`
|
|
8
|
+
|
|
3
9
|
3.7.1
|
|
4
10
|
|
|
5
11
|
Fix: `Button` now bridges shadcn-style `asChild` onto Base UI's `render` prop instead of leaking it onto the DOM `<button>` — removes the "React does not recognize the `asChild` prop" warning for `<Button asChild><a/></Button>` (e.g. LandingSpecSheet "Learn more")
|
package/README.md
CHANGED
|
@@ -655,8 +655,7 @@ Effects: `isAuthenticated()` always returns `true`, ProtectedRoute allows all ac
|
|
|
655
655
|
|
|
656
656
|
| Component | Import | Description |
|
|
657
657
|
|-----------|--------|-------------|
|
|
658
|
-
| LandingView | `@stevederico/skateboard-ui/LandingView` | Landing page
|
|
659
|
-
| LandingViewSimple | `@stevederico/skateboard-ui/LandingViewSimple` | Minimal landing page |
|
|
658
|
+
| LandingView | `@stevederico/skateboard-ui/LandingView` | Landing page — sticky header, hero, features, pricing, CTA, footer |
|
|
660
659
|
| SignInView | `@stevederico/skateboard-ui/SignInView` | Sign in form with Card layout |
|
|
661
660
|
| SignUpView | `@stevederico/skateboard-ui/SignUpView` | Sign up form with password validation |
|
|
662
661
|
| SignOutView | `@stevederico/skateboard-ui/SignOutView` | Sign out handler with redirect |
|
|
@@ -1,184 +1,189 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
1
|
import { useNavigate } from 'react-router';
|
|
3
|
-
import {
|
|
4
|
-
import { getState } from "../core/Context.jsx";
|
|
2
|
+
import { getState } from '../core/Context.jsx';
|
|
5
3
|
import DynamicIcon from '../core/DynamicIcon.jsx';
|
|
6
|
-
import
|
|
4
|
+
import ThemeToggle from '../ThemeToggle.jsx';
|
|
5
|
+
import { Check, ArrowRight } from '../../icons';
|
|
7
6
|
import { Button } from '../../shadcn/ui/button.jsx';
|
|
8
7
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../../shadcn/ui/card.jsx';
|
|
9
|
-
import { Badge } from '../../shadcn/ui/badge.jsx';
|
|
10
8
|
import { Separator } from '../../shadcn/ui/separator.jsx';
|
|
9
|
+
import { cn } from '../../shadcn/lib/utils.js';
|
|
11
10
|
|
|
12
11
|
const CURRENT_YEAR = new Date().getFullYear();
|
|
13
12
|
|
|
14
|
-
const DEFAULT_NAV_LINKS = [
|
|
15
|
-
{ label: 'Features', href: '#features' },
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
const DEFAULT_NAV_LINKS_SUFFIX = [
|
|
19
|
-
{ label: 'Terms', href: '/terms' },
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
const PRICING_LINK = { label: 'Pricing', href: '#pricing' };
|
|
23
|
-
|
|
24
13
|
const PRIVACY_LINK = { label: 'Privacy', href: '/privacy' };
|
|
25
14
|
const TERMS_LINK = { label: 'Terms', href: '/terms' };
|
|
26
15
|
const EULA_LINK = { label: 'EULA', href: '/eula' };
|
|
27
16
|
|
|
28
17
|
/**
|
|
29
|
-
* Renders a
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* raw text (e.g. an emoji).
|
|
18
|
+
* Renders a constants icon value. Values that look like a Lucide name (ASCII
|
|
19
|
+
* letters, digits, hyphens) resolve via DynamicIcon; anything else (e.g. a
|
|
20
|
+
* legacy emoji icon) renders as raw text so it never silently disappears.
|
|
33
21
|
*
|
|
34
22
|
* @param {Object} props
|
|
35
|
-
* @param {string} props.name -
|
|
36
|
-
* @
|
|
23
|
+
* @param {string} props.name - Lucide icon name or emoji/text
|
|
24
|
+
* @param {number} props.size - Pixel size (icon dimension or emoji font size)
|
|
25
|
+
* @param {number} [props.strokeWidth] - Stroke width for Lucide icons
|
|
26
|
+
* @returns {JSX.Element|null}
|
|
37
27
|
*/
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
return <span>{name}</span>;
|
|
28
|
+
function ConstantIcon({ name, size, strokeWidth }) {
|
|
29
|
+
if (!name) return null;
|
|
30
|
+
if (/^[a-z][a-z0-9-]*$/i.test(name)) return <DynamicIcon name={name} size={size} strokeWidth={strokeWidth} />;
|
|
31
|
+
return <span className="leading-none" style={{ fontSize: size }}>{name}</span>;
|
|
42
32
|
}
|
|
43
33
|
|
|
44
34
|
/**
|
|
45
|
-
* Default landing page
|
|
46
|
-
*
|
|
35
|
+
* Default landing page (SpecSheet design). Sticky header, quiet hero,
|
|
36
|
+
* icon-leading feature cards, optional pricing card, CTA, and footer.
|
|
37
|
+
* Rendered at "/" whenever an app passes no custom landingPage to
|
|
38
|
+
* createSkateboardApp. Reads all copy from constants (appName, appIcon,
|
|
39
|
+
* tagline, cta, navLinks, features, stripeProducts, pricing, ctaHeading,
|
|
40
|
+
* footerLinks, companyName, copyrightText).
|
|
47
41
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
42
|
+
* Feature icons (constants.features.items[].icon) and appIcon are resolved
|
|
43
|
+
* via DynamicIcon as Lucide names (kebab/PascalCase). Emoji icons do NOT
|
|
44
|
+
* render — migrate legacy emoji icons in constants.json to Lucide names.
|
|
50
45
|
*
|
|
51
|
-
* @returns {JSX.Element}
|
|
52
|
-
*
|
|
53
|
-
* @example
|
|
54
|
-
* import LandingView from '@stevederico/skateboard-ui/LandingView';
|
|
55
|
-
*
|
|
56
|
-
* // Used automatically by createSkateboardApp, or pass as landingPage prop:
|
|
57
|
-
* createSkateboardApp({ constants, appRoutes, landingPage: <LandingView /> });
|
|
46
|
+
* @returns {JSX.Element}
|
|
58
47
|
*/
|
|
59
48
|
export default function LandingView() {
|
|
60
49
|
const { state } = getState();
|
|
61
|
-
const constants = state.constants;
|
|
50
|
+
const constants = state.constants || {};
|
|
62
51
|
const navigate = useNavigate();
|
|
63
|
-
const
|
|
64
|
-
|
|
52
|
+
const goApp = () => navigate('/app');
|
|
53
|
+
|
|
54
|
+
const navLinks = constants.navLinks || [
|
|
55
|
+
{ label: 'Features', href: '#features' },
|
|
56
|
+
...(constants.stripeProducts?.length > 0 ? [{ label: 'Pricing', href: '#pricing' }] : []),
|
|
57
|
+
];
|
|
58
|
+
const footerLinks = constants.footerLinks || [
|
|
59
|
+
...(constants.privacyPolicy ? [PRIVACY_LINK] : []),
|
|
60
|
+
...(constants.termsOfService ? [TERMS_LINK] : []),
|
|
61
|
+
...(constants.EULA ? [EULA_LINK] : []),
|
|
62
|
+
];
|
|
63
|
+
const items = constants.features?.items || [];
|
|
64
|
+
const sp = (constants.stripeProducts || [])[0];
|
|
65
65
|
|
|
66
66
|
return (
|
|
67
67
|
<div className="min-h-screen bg-background text-foreground">
|
|
68
|
-
|
|
69
68
|
{/* Header */}
|
|
70
|
-
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-
|
|
71
|
-
<nav className="max-w-6xl mx-auto px-6
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
...DEFAULT_NAV_LINKS_SUFFIX,
|
|
82
|
-
]).map((link, index) => (
|
|
83
|
-
<a key={index} href={link.href} className="text-muted-foreground hover:text-foreground transition-colors font-semibold">{link.label}</a>
|
|
69
|
+
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-md">
|
|
70
|
+
<nav className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
|
71
|
+
<a href="/" className="flex items-center gap-2.5">
|
|
72
|
+
<span className="size-8 rounded-md bg-app/15 text-app flex items-center justify-center">
|
|
73
|
+
<ConstantIcon name={constants.appIcon} size={18} strokeWidth={2.25} />
|
|
74
|
+
</span>
|
|
75
|
+
<span className="text-base font-semibold tracking-tight">{constants.appName}</span>
|
|
76
|
+
</a>
|
|
77
|
+
<div className="hidden md:flex items-center gap-7 text-sm text-muted-foreground">
|
|
78
|
+
{navLinks.map((l, i) => (
|
|
79
|
+
<a key={i} href={l.href} className="hover:text-foreground transition-colors">{l.label}</a>
|
|
84
80
|
))}
|
|
85
81
|
</div>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
<Button
|
|
89
|
-
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
|
90
|
-
</Button>
|
|
91
|
-
<Button variant="default" onClick={() => navigate('/app')}>
|
|
92
|
-
{constants.cta}
|
|
93
|
-
</Button>
|
|
82
|
+
<div className="flex items-center gap-2">
|
|
83
|
+
<ThemeToggle variant="landing" iconSize={14} />
|
|
84
|
+
<Button size="default" onClick={goApp}>{constants.cta}</Button>
|
|
94
85
|
</div>
|
|
95
86
|
</nav>
|
|
96
87
|
</header>
|
|
97
88
|
|
|
98
89
|
<main>
|
|
99
|
-
{/* Hero
|
|
100
|
-
<section className="relative
|
|
101
|
-
<div className="absolute inset-0 bg-app/
|
|
102
|
-
<div className="
|
|
103
|
-
|
|
104
|
-
<h1 className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold mb-8 text-foreground leading-tight">
|
|
90
|
+
{/* Hero */}
|
|
91
|
+
<section className="relative border-b border-border">
|
|
92
|
+
<div className="absolute inset-0 -z-10 bg-gradient-to-b from-app/5 via-background to-background" />
|
|
93
|
+
<div className="max-w-3xl mx-auto px-6 py-24 md:py-36 text-center">
|
|
94
|
+
<h1 className="text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tight leading-[1.05] text-balance mb-12">
|
|
105
95
|
{constants.tagline}
|
|
106
96
|
</h1>
|
|
107
|
-
<
|
|
108
|
-
{
|
|
109
|
-
|
|
97
|
+
<div className="flex flex-wrap gap-3 justify-center">
|
|
98
|
+
<Button onClick={goApp} className="gap-2 h-14 px-10 text-base font-medium">
|
|
99
|
+
{constants.cta} <ArrowRight size={18} />
|
|
100
|
+
</Button>
|
|
101
|
+
{navLinks[0] && (
|
|
102
|
+
<Button variant="outline" asChild className="h-14 px-8 text-base font-medium">
|
|
103
|
+
<a href={navLinks[0].href}>Learn more</a>
|
|
104
|
+
</Button>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
110
107
|
</div>
|
|
111
108
|
</section>
|
|
112
109
|
|
|
113
|
-
{/* Features
|
|
114
|
-
|
|
115
|
-
<
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
110
|
+
{/* Features */}
|
|
111
|
+
{items.length > 0 && (
|
|
112
|
+
<section id="features" className="border-b border-border bg-muted/30">
|
|
113
|
+
<div className="max-w-6xl mx-auto px-6 py-20 md:py-28">
|
|
114
|
+
<div className="text-center mb-14">
|
|
115
|
+
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-balance">
|
|
116
|
+
{constants.features?.title || 'Features'}
|
|
117
|
+
</h2>
|
|
118
|
+
</div>
|
|
119
|
+
<div className={cn('grid gap-6', items.length >= 3 ? 'md:grid-cols-3' : items.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-1 max-w-md mx-auto')}>
|
|
120
|
+
{items.map((it, i) => (
|
|
121
|
+
<Card key={i} className="border-border/60 transition-colors hover:border-border">
|
|
122
|
+
<CardHeader>
|
|
123
|
+
<span className="size-10 rounded-md bg-app/10 text-app flex items-center justify-center mb-3">
|
|
124
|
+
<ConstantIcon name={it.icon} size={20} strokeWidth={2} />
|
|
125
|
+
</span>
|
|
126
|
+
<CardTitle className="text-lg">{it.title}</CardTitle>
|
|
127
|
+
<CardDescription className="leading-relaxed">{it.description}</CardDescription>
|
|
128
|
+
</CardHeader>
|
|
129
|
+
</Card>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
131
132
|
</div>
|
|
132
|
-
</
|
|
133
|
-
|
|
133
|
+
</section>
|
|
134
|
+
)}
|
|
134
135
|
|
|
135
|
-
{/* Pricing
|
|
136
|
-
{
|
|
137
|
-
<section id="pricing" className="
|
|
138
|
-
<div className="max-w-
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
<CardTitle className="text-2xl font-bold">{constants.stripeProducts[0]?.title || 'Monthly Plan'}</CardTitle>
|
|
144
|
-
<CardDescription>per {constants.stripeProducts[0]?.interval || 'month'}</CardDescription>
|
|
145
|
-
</CardHeader>
|
|
146
|
-
<CardContent className="text-center">
|
|
147
|
-
<div className="text-5xl font-bold text-foreground mb-8">{constants.stripeProducts[0]?.price || '$5.00'}</div>
|
|
148
|
-
<ul className="text-left space-y-4 mb-8">
|
|
149
|
-
{(constants.stripeProducts[0]?.features || []).map((feature, index) => (
|
|
150
|
-
<li key={index} className="flex items-center gap-2">
|
|
151
|
-
<Check size={16} className="text-primary shrink-0" />
|
|
152
|
-
{feature}
|
|
153
|
-
</li>
|
|
154
|
-
))}
|
|
155
|
-
{(constants.pricing?.extras || []).map((extra, index) => (
|
|
156
|
-
<li key={`extra-${index}`} className="flex items-center gap-2">
|
|
157
|
-
<Check size={16} className="text-primary shrink-0" />
|
|
158
|
-
{extra}
|
|
159
|
-
</li>
|
|
160
|
-
))}
|
|
161
|
-
</ul>
|
|
162
|
-
</CardContent>
|
|
163
|
-
<CardFooter>
|
|
164
|
-
<Button variant="default" size="cta" className="w-full" onClick={() => navigate('/app')}>
|
|
165
|
-
{constants.cta}
|
|
166
|
-
</Button>
|
|
167
|
-
</CardFooter>
|
|
168
|
-
</Card>
|
|
136
|
+
{/* Pricing */}
|
|
137
|
+
{sp && (
|
|
138
|
+
<section id="pricing" className="border-b border-border">
|
|
139
|
+
<div className="max-w-6xl mx-auto px-6 py-20 md:py-28">
|
|
140
|
+
<div className="text-center mb-14">
|
|
141
|
+
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-balance">
|
|
142
|
+
{constants.pricing?.title || 'Pricing'}
|
|
143
|
+
</h2>
|
|
169
144
|
</div>
|
|
145
|
+
<Card className="max-w-md mx-auto border-app/30 shadow-sm">
|
|
146
|
+
<CardHeader className="text-center pb-4">
|
|
147
|
+
<CardTitle className="text-base font-medium text-muted-foreground">{sp.title}</CardTitle>
|
|
148
|
+
<div className="flex items-baseline justify-center gap-1.5 mt-2">
|
|
149
|
+
<span className="text-5xl font-semibold tracking-tight text-foreground">{sp.price}</span>
|
|
150
|
+
<span className="text-sm text-muted-foreground">/ {sp.interval}</span>
|
|
151
|
+
</div>
|
|
152
|
+
</CardHeader>
|
|
153
|
+
<CardContent>
|
|
154
|
+
<ul className="space-y-3 text-sm">
|
|
155
|
+
{(sp.features || []).map((f, i) => (
|
|
156
|
+
<li key={`sp-${i}`} className="flex items-start gap-2.5">
|
|
157
|
+
<Check size={16} className="text-app shrink-0 mt-0.5" />
|
|
158
|
+
<span>{f}</span>
|
|
159
|
+
</li>
|
|
160
|
+
))}
|
|
161
|
+
{(constants.pricing?.extras || []).map((f, i) => (
|
|
162
|
+
<li key={`x-${i}`} className="flex items-start gap-2.5">
|
|
163
|
+
<Check size={16} className="text-app shrink-0 mt-0.5" />
|
|
164
|
+
<span>{f}</span>
|
|
165
|
+
</li>
|
|
166
|
+
))}
|
|
167
|
+
</ul>
|
|
168
|
+
</CardContent>
|
|
169
|
+
<CardFooter>
|
|
170
|
+
<Button size="lg" className="w-full" onClick={goApp}>{constants.cta}</Button>
|
|
171
|
+
</CardFooter>
|
|
172
|
+
</Card>
|
|
170
173
|
</div>
|
|
171
174
|
</section>
|
|
172
175
|
)}
|
|
173
176
|
|
|
174
|
-
{/* CTA
|
|
175
|
-
<section className="
|
|
176
|
-
<div className="max-w-
|
|
177
|
-
<Card className="bg-
|
|
178
|
-
<CardContent>
|
|
179
|
-
<h2 className="text-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
{/* CTA */}
|
|
178
|
+
<section className="border-b border-border">
|
|
179
|
+
<div className="max-w-6xl mx-auto px-6 py-20 md:py-28">
|
|
180
|
+
<Card className="bg-foreground text-background border-0 py-16 md:py-20">
|
|
181
|
+
<CardContent className="text-center">
|
|
182
|
+
<h2 className="text-3xl md:text-5xl font-semibold tracking-tight text-balance mb-8 max-w-2xl mx-auto">
|
|
183
|
+
{constants.ctaHeading || 'Ready to Build?'}
|
|
184
|
+
</h2>
|
|
185
|
+
<Button size="lg" variant="secondary" onClick={goApp} className="gap-1.5">
|
|
186
|
+
{constants.cta} <ArrowRight size={16} />
|
|
182
187
|
</Button>
|
|
183
188
|
</CardContent>
|
|
184
189
|
</Card>
|
|
@@ -187,19 +192,25 @@ export default function LandingView() {
|
|
|
187
192
|
</main>
|
|
188
193
|
|
|
189
194
|
{/* Footer */}
|
|
190
|
-
<footer className="
|
|
191
|
-
<div className="max-w-
|
|
192
|
-
<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
195
|
+
<footer className="bg-background">
|
|
196
|
+
<div className="max-w-6xl mx-auto px-6 py-10">
|
|
197
|
+
<div className="flex flex-wrap items-center justify-between gap-6">
|
|
198
|
+
<div className="flex items-center gap-2.5">
|
|
199
|
+
<span className="size-7 rounded-md bg-app/15 text-app flex items-center justify-center">
|
|
200
|
+
<ConstantIcon name={constants.appIcon} size={16} strokeWidth={2.25} />
|
|
201
|
+
</span>
|
|
202
|
+
<span className="text-sm font-semibold tracking-tight">{constants.appName}</span>
|
|
203
|
+
</div>
|
|
204
|
+
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
|
205
|
+
{footerLinks.map((l, i) => (
|
|
206
|
+
<a key={i} href={l.href} className="hover:text-foreground transition-colors">{l.label}</a>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
201
209
|
</div>
|
|
202
|
-
<
|
|
210
|
+
<Separator className="my-6" />
|
|
211
|
+
<p className="text-xs text-muted-foreground">
|
|
212
|
+
© {CURRENT_YEAR} {constants.companyName}. {constants.copyrightText || 'All rights reserved.'}
|
|
213
|
+
</p>
|
|
203
214
|
</div>
|
|
204
215
|
</footer>
|
|
205
216
|
</div>
|