Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { headers } from 'next/headers';
import { Orbitron, Exo_2 } from 'next/font/google';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { OfflineBanner } from '../components/OfflineBanner';
import { AppShell } from '../components/AppShell';
import { WalletProvider } from '../lib/wallet/WalletProvider';
import { darkModeInitScript } from '../lib/darkMode';
import '../styles/tokens.css';
Expand Down Expand Up @@ -46,7 +47,9 @@ export default async function RootLayout({ children }: { children: ReactNode })
<body>
<OfflineBanner />
<ErrorBoundary section="main">
<WalletProvider>{children}</WalletProvider>
<WalletProvider>
<AppShell>{children}</AppShell>
</WalletProvider>
</ErrorBoundary>
</body>
</html>
Expand Down
136 changes: 136 additions & 0 deletions frontend/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
'use client';

/**
* AppShell — primary app-wide navigation (#1314).
*
* The landing page (`/`) already owns its own full marketing header/nav/
* footer (components/LandingPage.tsx — separate anchor-link nav for
* #features/#how-it-works/#about/#contact), so AppShell skips rendering
* on `/` to avoid a duplicate header there. Everywhere else (Markets,
* Statistics, Create Market, account, tx, and — conditionally, once an
* admin session exists — Admin) gets a persistent header with primary
* navigation and a minimal footer, matching the sub-nav pattern already
* established by app/admin/layout.tsx for its own section.
*/

import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

const NAV_ITEMS = [
{ href: '/markets', label: 'Markets' },
{ href: '/statistics', label: 'Statistics' },
{ href: '/markets/create', label: 'Create Market' },
];

export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [hasAdminSession, setHasAdminSession] = useState(false);

useEffect(() => {
setHasAdminSession(Boolean(sessionStorage.getItem('predictiq-admin-key')));
}, [pathname]);

const isLandingPage = pathname === '/';
const isAdminSection = pathname?.startsWith('/admin');

if (isLandingPage || isAdminSection) {
return <>{children}</>;
}

const navItems = hasAdminSession
? [...NAV_ITEMS, { href: '/admin/content', label: 'Admin' }]
: NAV_ITEMS;

return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<a href="#app-main-content" className="skip-link">
Skip to main content
</a>

<header
role="banner"
style={{
borderBottom: '1px solid var(--border)',
backgroundColor: 'var(--surface)',
position: 'sticky',
top: 0,
zIndex: 100,
}}
>
<div
style={{
maxWidth: 'var(--container)',
margin: '0 auto',
padding: '1rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1.5rem',
}}
>
<Link
href="/"
aria-label="PredictIQ Home"
style={{ textDecoration: 'none', fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: '1.2rem' }}
>
<span style={{ color: 'var(--fg)' }}>Predict</span>
<span style={{ color: 'var(--gold)' }}>IQ</span>
</Link>

<nav aria-label="Primary navigation">
<ul
style={{
display: 'flex',
gap: '1.5rem',
listStyle: 'none',
margin: 0,
padding: 0,
}}
>
{navItems.map((item) => {
const isActive = pathname === item.href || pathname?.startsWith(`${item.href}/`);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={isActive ? 'page' : undefined}
style={{
textDecoration: 'none',
fontSize: 'var(--text-sm)',
fontWeight: 500,
color: isActive ? 'var(--gold)' : 'var(--fg-muted)',
}}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
</div>
</header>

<main id="app-main-content" role="main" style={{ flex: 1 }}>
{children}
</main>

<footer
role="contentinfo"
style={{
borderTop: '1px solid var(--border)',
backgroundColor: 'var(--surface)',
padding: '1.5rem',
textAlign: 'center',
fontSize: 'var(--text-xs)',
color: 'var(--fg-muted)',
}}
>
© {new Date().getFullYear()} PredictIQ. Built on Stellar.
</footer>
</div>
);
}

export default AppShell;
99 changes: 99 additions & 0 deletions frontend/src/components/ui/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use client';

/**
* Button — shared design-system primitive (#1315).
*
* Every write action in this backlog (bet placement, market creation,
* resolution, admin actions) should funnel through this so loading/
* disabled states are handled consistently instead of ad hoc per form.
* Styling follows the existing Button in components/admin/Form.tsx (the
* closest prior art), generalized here to be usable outside the admin
* section too.
*/

import React from 'react';

export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}

function variantStyles(variant: ButtonVariant): React.CSSProperties {
switch (variant) {
case 'primary':
return { backgroundColor: 'var(--gold)', color: 'var(--on-primary)', border: 'none', fontWeight: 600 };
case 'danger':
return { backgroundColor: 'var(--destructive)', color: '#ffffff', border: 'none', fontWeight: 600 };
case 'secondary':
return {
backgroundColor: 'var(--surface-2)',
color: 'var(--fg)',
border: '1px solid var(--border-strong)',
fontWeight: 500,
};
case 'ghost':
return { backgroundColor: 'transparent', color: 'var(--fg-muted)', border: 'none', fontWeight: 500 };
}
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ variant = 'primary', isLoading = false, leftIcon, rightIcon, children, disabled, className = '', style, ...props },
ref
) => {
const isDisabled = disabled || isLoading;

return (
<button
ref={ref}
type={props.type ?? 'button'}
disabled={isDisabled}
aria-disabled={isDisabled}
aria-busy={isLoading || undefined}
className={`ui-btn ui-btn--${variant} ${className}`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '0.65rem 1.25rem',
fontSize: 'var(--text-sm)',
fontFamily: 'inherit',
borderRadius: 'var(--radius-sm)',
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.6 : 1,
transition: 'all var(--dur-fast)',
textDecoration: 'none',
...variantStyles(variant),
...style,
}}
{...props}
>
{isLoading && (
<span
aria-hidden="true"
style={{
display: 'inline-block',
width: '14px',
height: '14px',
border: '2px solid currentColor',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}}
/>
)}
{!isLoading && leftIcon}
<span>{children}</span>
{!isLoading && rightIcon}
</button>
);
}
);
Button.displayName = 'Button';

export default Button;
124 changes: 124 additions & 0 deletions frontend/src/components/ui/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Card — shared design-system container primitive (#1316).
*
* Market list items (#57), statistics tiles (#49), and admin panels
* (#89-97) each currently reinvent their own padding/border/shadow rules;
* this is the one shared container to converge on instead.
*/

import React from 'react';

export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
/** Renders with a hover elevation/border-highlight, for clickable cards. */
interactive?: boolean;
/** Removes the default padding, for cards that manage their own inner layout. */
noPadding?: boolean;
}

export const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ interactive = false, noPadding = false, className = '', style, children, ...props }, ref) => {
return (
<div
ref={ref}
className={`ui-card ${interactive ? 'ui-card--interactive' : ''} ${className}`}
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
boxShadow: 'var(--shadow-sm)',
padding: noPadding ? 0 : '1.25rem',
transition: interactive ? 'border-color var(--dur-fast), box-shadow var(--dur-fast)' : undefined,
cursor: interactive ? 'pointer' : undefined,
...style,
}}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';

export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}

export function CardHeader({ className = '', style, children, ...props }: CardHeaderProps) {
return (
<div
className={`ui-card__header ${className}`}
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '1rem',
marginBottom: '0.85rem',
...style,
}}
{...props}
>
{children}
</div>
);
}

export interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
as?: 'h2' | 'h3' | 'h4';
}

export function CardTitle({ as = 'h3', className = '', style, children, ...props }: CardTitleProps) {
const Heading = as;
return (
<Heading
className={`ui-card__title ${className}`}
style={{
margin: 0,
fontSize: 'var(--text-lg)',
fontFamily: 'var(--font-display)',
fontWeight: 600,
color: 'var(--fg)',
...style,
}}
{...props}
>
{children}
</Heading>
);
}

export interface CardBodyProps extends React.HTMLAttributes<HTMLDivElement> {}

export function CardBody({ className = '', style, children, ...props }: CardBodyProps) {
return (
<div
className={`ui-card__body ${className}`}
style={{ fontSize: 'var(--text-sm)', color: 'var(--fg-muted)', lineHeight: 1.5, ...style }}
{...props}
>
{children}
</div>
);
}

export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {}

export function CardFooter({ className = '', style, children, ...props }: CardFooterProps) {
return (
<div
className={`ui-card__footer ${className}`}
style={{
marginTop: '1rem',
paddingTop: '0.85rem',
borderTop: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
...style,
}}
{...props}
>
{children}
</div>
);
}

export default Card;
Loading