@umituz/web-landing 0.0.0-stage → 1.0.1
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/README.md +35 -2
- package/package.json +75 -4
- package/src/index.ts +1 -0
- package/src/layouts/components/PublicSiteLayout.test.tsx +62 -0
- package/src/layouts/components/PublicSiteLayout.tsx +86 -0
- package/src/layouts/components/index.ts +2 -0
- package/src/layouts/index.ts +2 -0
- package/src/layouts/types/index.ts +1 -0
- package/src/layouts/types/layout.ts +23 -0
- package/src/styles/site.css +107 -0
- package/src/test/setup.ts +23 -0
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @umituz/web-landing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Public marketing-site shell for the umituz fleet — the public twin of
|
|
4
|
+
[`@umituz/web-dashboard`](https://www.npmjs.com/package/@umituz/web-dashboard).
|
|
5
|
+
Dashboard owns the authenticated app chrome; this package owns the landing
|
|
6
|
+
site chrome. Nothing public ever lives in the dashboard package.
|
|
7
|
+
|
|
8
|
+
## What's inside
|
|
9
|
+
|
|
10
|
+
- `PublicSiteLayout` — skip link (first focusable element), header with
|
|
11
|
+
brand slot + NavLink nav (active-state class) + CTA pair (DS `Button`),
|
|
12
|
+
main landmark, footer brand/note/columns/legal, and scroll-restore on
|
|
13
|
+
navigation (owned here — apps don't duplicate it).
|
|
14
|
+
- `./site.css` — `ul-*` structural stylesheet on `@umituz/web-design-system`
|
|
15
|
+
semantic color variables. Import after the design-system stylesheet;
|
|
16
|
+
skin via your own `ul-site-*` overrides.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { PublicSiteLayout } from '@umituz/web-landing/layouts/components';
|
|
22
|
+
import '@umituz/web-design-system/styles.css';
|
|
23
|
+
import '@umituz/web-landing/site.css';
|
|
24
|
+
|
|
25
|
+
<PublicSiteLayout
|
|
26
|
+
config={{
|
|
27
|
+
navItems: [{ to: '/features', label: 'Features' }],
|
|
28
|
+
brand: <Wordmark />,
|
|
29
|
+
ctaPrimary: { to: '/signup', label: 'Open the demo' },
|
|
30
|
+
footerColumns: [{ heading: 'Legal', links: [{ to: '/terms', label: 'Terms' }] }],
|
|
31
|
+
footerLegal: '© 2026',
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</PublicSiteLayout>
|
|
36
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/web-landing",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Public marketing-site shell for the fleet — PublicSiteLayout (header nav, CTA pair, footer columns, skip link, scroll restore); ul-* stylesheet on design-system tokens. The public twin of @umituz/web-dashboard.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"sideEffects": [
|
|
9
|
+
"*.css"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.ts",
|
|
13
|
+
"./layouts": "./src/layouts/index.ts",
|
|
14
|
+
"./layouts/components": "./src/layouts/components/index.ts",
|
|
15
|
+
"./layouts/types": "./src/layouts/types/index.ts",
|
|
16
|
+
"./site.css": "./src/styles/site.css",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"lint": "eslint src",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest",
|
|
28
|
+
"version:patch": "npm version patch -m 'chore: release v%s'",
|
|
29
|
+
"version:minor": "npm version minor -m 'chore: release v%s'",
|
|
30
|
+
"version:major": "npm version major -m 'chore: release v%s'"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
34
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
35
|
+
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@umituz/web-design-system": "^4.20.0",
|
|
39
|
+
"@umituz/web-localization": "^1.2.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.20.5",
|
|
43
|
+
"@types/react": "^19.2.14",
|
|
44
|
+
"@types/react-dom": "^19.2.3",
|
|
45
|
+
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
|
46
|
+
"@typescript-eslint/parser": "^8.57.2",
|
|
47
|
+
"eslint": "^10.0.2",
|
|
48
|
+
"react-router-dom": "^7.13.1",
|
|
49
|
+
"typescript": "^5.9.3",
|
|
50
|
+
"vitest": "^5.0.1",
|
|
51
|
+
"@testing-library/dom": "^10.4.2",
|
|
52
|
+
"@testing-library/react": "^16.3.3",
|
|
53
|
+
"jsdom": "^29.1.1"
|
|
54
|
+
},
|
|
55
|
+
"keywords": [
|
|
56
|
+
"landing",
|
|
57
|
+
"marketing-site",
|
|
58
|
+
"public-shell",
|
|
59
|
+
"layout",
|
|
60
|
+
"react",
|
|
61
|
+
"typescript",
|
|
62
|
+
"package-driven"
|
|
63
|
+
],
|
|
64
|
+
"author": "umituz",
|
|
65
|
+
"license": "MIT",
|
|
66
|
+
"publishConfig": {
|
|
67
|
+
"access": "public"
|
|
68
|
+
},
|
|
69
|
+
"repository": {
|
|
70
|
+
"type": "git",
|
|
71
|
+
"url": "git+https://github.com/umituz/web-landing.git"
|
|
72
|
+
},
|
|
73
|
+
"bugs": {
|
|
74
|
+
"url": "https://github.com/umituz/web-landing/issues"
|
|
75
|
+
},
|
|
76
|
+
"homepage": "https://github.com/umituz/web-landing#readme"
|
|
77
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './layouts';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
|
4
|
+
import { PublicSiteLayout } from './PublicSiteLayout';
|
|
5
|
+
import type { PublicLayoutConfig } from '../types/layout';
|
|
6
|
+
|
|
7
|
+
const config: PublicLayoutConfig = {
|
|
8
|
+
navItems: [
|
|
9
|
+
{ to: '/features', label: 'Features' },
|
|
10
|
+
{ to: '/pricing', label: 'Pricing' },
|
|
11
|
+
],
|
|
12
|
+
brand: <a href="/">Brand</a>,
|
|
13
|
+
ctaPrimary: { to: '/signup', label: 'Open the demo' },
|
|
14
|
+
ctaSecondary: { to: '/signin', label: 'Sign in' },
|
|
15
|
+
footerBrand: <a href="/">Brand</a>,
|
|
16
|
+
footerNote: 'An independent workspace.',
|
|
17
|
+
footerColumns: [
|
|
18
|
+
{ heading: 'Product', links: [{ to: '/features', label: 'Features' }, { to: '/pricing', label: 'Pricing' }] },
|
|
19
|
+
{ heading: 'Legal', links: [{ to: '/terms', label: 'Terms' }] },
|
|
20
|
+
],
|
|
21
|
+
footerLegal: '© 2026',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function renderAt(path: string) {
|
|
25
|
+
return render(
|
|
26
|
+
<MemoryRouter initialEntries={[path]}>
|
|
27
|
+
<Routes>
|
|
28
|
+
<Route path="*" element={<PublicSiteLayout config={config}>page-content</PublicSiteLayout>} />
|
|
29
|
+
</Routes>
|
|
30
|
+
</MemoryRouter>,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('PublicSiteLayout', () => {
|
|
35
|
+
it('renders a skip link targeting the main landmark', () => {
|
|
36
|
+
renderAt('/features');
|
|
37
|
+
expect(screen.getByRole('link', { name: /skip to content/i }).getAttribute('href')).toBe('#site-main');
|
|
38
|
+
expect(document.getElementById('site-main')).not.toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('marks the active nav route and leaves others plain', () => {
|
|
42
|
+
renderAt('/pricing');
|
|
43
|
+
const active = screen.getByRole('link', { name: 'Pricing', current: 'page' }) as HTMLElement;
|
|
44
|
+
expect(active.className).toContain('ul-site-link--active');
|
|
45
|
+
const idle = screen.getAllByRole('link', { name: 'Features' }).find((el) => el.className.includes('ul-site-link')) as HTMLElement;
|
|
46
|
+
expect(idle.className).not.toContain('ul-site-link--active');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('renders CTA buttons, footer columns and the legal line', () => {
|
|
50
|
+
renderAt('/features');
|
|
51
|
+
expect(screen.getByRole('link', { name: 'Open the demo' })).toBeTruthy();
|
|
52
|
+
expect(screen.getByRole('link', { name: 'Sign in' })).toBeTruthy();
|
|
53
|
+
expect(screen.getByRole('contentinfo')).toBeTruthy();
|
|
54
|
+
expect(screen.getByRole('navigation', { name: 'Legal' })).toBeTruthy();
|
|
55
|
+
expect(screen.getByText('© 2026')).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders children inside the main landmark', () => {
|
|
59
|
+
renderAt('/features');
|
|
60
|
+
expect(document.getElementById('site-main')?.textContent).toContain('page-content');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from 'react';
|
|
2
|
+
import { Link, NavLink, useLocation } from 'react-router-dom';
|
|
3
|
+
import { useT } from '@umituz/web-localization/react';
|
|
4
|
+
import { Button } from '@umituz/web-design-system/atoms';
|
|
5
|
+
import type { PublicLayoutConfig } from '../types/layout';
|
|
6
|
+
|
|
7
|
+
export interface PublicSiteLayoutProps {
|
|
8
|
+
config: PublicLayoutConfig;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PublicSiteLayout = ({ config, children }: PublicSiteLayoutProps) => {
|
|
13
|
+
const location = useLocation();
|
|
14
|
+
const t = useT();
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
window.scrollTo({ top: 0 });
|
|
18
|
+
}, [location.pathname]);
|
|
19
|
+
|
|
20
|
+
const mainId = config.mainId ?? 'site-main';
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="ul-site">
|
|
24
|
+
<a href={`#${mainId}`} className="ul-skip-link">
|
|
25
|
+
{config.skipLabel ?? t('Skip to content')}
|
|
26
|
+
</a>
|
|
27
|
+
|
|
28
|
+
<header className="ul-site-header">
|
|
29
|
+
{config.brand && <div className="ul-site-brand">{config.brand}</div>}
|
|
30
|
+
<nav className="ul-site-nav" aria-label={config.navLabel ?? t('Site')}>
|
|
31
|
+
{config.navItems.map((item) => (
|
|
32
|
+
<NavLink
|
|
33
|
+
key={item.to}
|
|
34
|
+
to={item.to}
|
|
35
|
+
className={({ isActive }) => `ul-site-link ${isActive ? 'ul-site-link--active' : ''}`}
|
|
36
|
+
>
|
|
37
|
+
{item.label}
|
|
38
|
+
</NavLink>
|
|
39
|
+
))}
|
|
40
|
+
</nav>
|
|
41
|
+
{(config.ctaSecondary || config.ctaPrimary) && (
|
|
42
|
+
<div className="ul-site-cta">
|
|
43
|
+
{config.ctaSecondary && (
|
|
44
|
+
<Button asChild variant="ghost" size="sm">
|
|
45
|
+
<Link to={config.ctaSecondary.to}>{config.ctaSecondary.label}</Link>
|
|
46
|
+
</Button>
|
|
47
|
+
)}
|
|
48
|
+
{config.ctaPrimary && (
|
|
49
|
+
<Button asChild variant="ink" size="sm">
|
|
50
|
+
<Link to={config.ctaPrimary.to}>{config.ctaPrimary.label}</Link>
|
|
51
|
+
</Button>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
</header>
|
|
56
|
+
|
|
57
|
+
<main id={mainId} className="ul-site-main">
|
|
58
|
+
{children}
|
|
59
|
+
</main>
|
|
60
|
+
|
|
61
|
+
<footer className="ul-site-footer">
|
|
62
|
+
<div className="ul-site-footer-grid">
|
|
63
|
+
{(config.footerBrand || config.footerNote) && (
|
|
64
|
+
<div className="ul-site-footer-brand">
|
|
65
|
+
{config.footerBrand}
|
|
66
|
+
{config.footerNote && <div className="ul-site-note">{config.footerNote}</div>}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
{config.footerColumns.map((col) => (
|
|
70
|
+
<nav key={col.heading} className="ul-site-col" aria-label={col.heading}>
|
|
71
|
+
<h4>{col.heading}</h4>
|
|
72
|
+
{col.links.map((link) => (
|
|
73
|
+
<Link key={link.to} to={link.to}>
|
|
74
|
+
{link.label}
|
|
75
|
+
</Link>
|
|
76
|
+
))}
|
|
77
|
+
</nav>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
{config.footerLegal && <p className="ul-site-legal">{config.footerLegal}</p>}
|
|
81
|
+
</footer>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export default PublicSiteLayout;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { PublicLayoutConfig, PublicNavLink, PublicFooterColumn } from './layout';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface PublicNavLink {
|
|
2
|
+
to: string;
|
|
3
|
+
label: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface PublicFooterColumn {
|
|
7
|
+
heading: string;
|
|
8
|
+
links: PublicNavLink[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PublicLayoutConfig {
|
|
12
|
+
navItems: PublicNavLink[];
|
|
13
|
+
navLabel?: string;
|
|
14
|
+
brand?: React.ReactNode;
|
|
15
|
+
ctaPrimary?: PublicNavLink;
|
|
16
|
+
ctaSecondary?: PublicNavLink;
|
|
17
|
+
footerBrand?: React.ReactNode;
|
|
18
|
+
footerNote?: React.ReactNode;
|
|
19
|
+
footerColumns: PublicFooterColumn[];
|
|
20
|
+
footerLegal?: React.ReactNode;
|
|
21
|
+
mainId?: string;
|
|
22
|
+
skipLabel?: string;
|
|
23
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
.ul-site {
|
|
2
|
+
--ul-space-1: 4px;
|
|
3
|
+
--ul-space-2: 8px;
|
|
4
|
+
--ul-space-3: 12px;
|
|
5
|
+
--ul-space-6: 24px;
|
|
6
|
+
--ul-space-8: 32px;
|
|
7
|
+
--ul-space-10: 40px;
|
|
8
|
+
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
min-height: 100vh;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.ul-skip-link {
|
|
15
|
+
position: fixed;
|
|
16
|
+
top: var(--ul-space-2);
|
|
17
|
+
left: var(--ul-space-2);
|
|
18
|
+
z-index: 100;
|
|
19
|
+
transform: translateY(-200%);
|
|
20
|
+
padding: var(--ul-space-2) var(--ul-space-6);
|
|
21
|
+
border-radius: var(--radius-md);
|
|
22
|
+
background: var(--color-primary);
|
|
23
|
+
color: var(--color-primary-foreground);
|
|
24
|
+
font-size: 0.875rem;
|
|
25
|
+
font-weight: 500;
|
|
26
|
+
text-decoration: none;
|
|
27
|
+
transition: transform 0.1s ease;
|
|
28
|
+
}
|
|
29
|
+
.ul-skip-link:focus {
|
|
30
|
+
transform: translateY(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.ul-site-header {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: var(--ul-space-6);
|
|
37
|
+
padding: var(--ul-space-3) var(--ul-space-6);
|
|
38
|
+
}
|
|
39
|
+
.ul-site-brand {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
}
|
|
43
|
+
.ul-site-nav {
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: var(--ul-space-1);
|
|
47
|
+
flex: 1;
|
|
48
|
+
}
|
|
49
|
+
.ul-site-link {
|
|
50
|
+
padding: var(--ul-space-2) var(--ul-space-3);
|
|
51
|
+
border-radius: var(--radius-md);
|
|
52
|
+
text-decoration: none;
|
|
53
|
+
color: var(--color-muted-foreground);
|
|
54
|
+
}
|
|
55
|
+
.ul-site-link:hover,
|
|
56
|
+
.ul-site-link--active {
|
|
57
|
+
color: var(--color-foreground);
|
|
58
|
+
}
|
|
59
|
+
.ul-site-cta {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: var(--ul-space-2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.ul-site-main {
|
|
66
|
+
flex: 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.ul-site-footer {
|
|
70
|
+
padding: var(--ul-space-10) var(--ul-space-6) var(--ul-space-6);
|
|
71
|
+
}
|
|
72
|
+
.ul-site-footer-grid {
|
|
73
|
+
display: grid;
|
|
74
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
75
|
+
gap: var(--ul-space-8);
|
|
76
|
+
}
|
|
77
|
+
.ul-site-footer-brand {
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
gap: var(--ul-space-3);
|
|
81
|
+
}
|
|
82
|
+
.ul-site-note {
|
|
83
|
+
font-size: 0.875rem;
|
|
84
|
+
color: var(--color-muted-foreground);
|
|
85
|
+
max-width: 32ch;
|
|
86
|
+
}
|
|
87
|
+
.ul-site-col {
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: column;
|
|
90
|
+
gap: var(--ul-space-2);
|
|
91
|
+
}
|
|
92
|
+
.ul-site-col h4 {
|
|
93
|
+
margin: 0 0 var(--ul-space-2);
|
|
94
|
+
font-size: 0.875rem;
|
|
95
|
+
}
|
|
96
|
+
.ul-site-col a {
|
|
97
|
+
text-decoration: none;
|
|
98
|
+
color: var(--color-muted-foreground);
|
|
99
|
+
}
|
|
100
|
+
.ul-site-col a:hover {
|
|
101
|
+
color: var(--color-foreground);
|
|
102
|
+
}
|
|
103
|
+
.ul-site-legal {
|
|
104
|
+
margin: var(--ul-space-8) 0 0;
|
|
105
|
+
font-size: 0.75rem;
|
|
106
|
+
color: var(--color-muted-foreground);
|
|
107
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { afterEach, vi } from 'vitest';
|
|
2
|
+
import { cleanup } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
5
|
+
|
|
6
|
+
if (typeof window !== 'undefined')
|
|
7
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
8
|
+
writable: true,
|
|
9
|
+
value: (query: string): MediaQueryList =>
|
|
10
|
+
({
|
|
11
|
+
matches: query.includes('prefers-reduced-motion'),
|
|
12
|
+
media: query,
|
|
13
|
+
onchange: null,
|
|
14
|
+
addListener: vi.fn(), removeListener: vi.fn(),
|
|
15
|
+
addEventListener: vi.fn(),
|
|
16
|
+
removeEventListener: vi.fn(),
|
|
17
|
+
dispatchEvent: vi.fn(),
|
|
18
|
+
}) as unknown as MediaQueryList,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
cleanup();
|
|
23
|
+
});
|