Modern UI port, render-based @ui tests, dev seed, layer contracts
- Port modern clean theme (DM Sans, orange accent, app-* CSS classes) and screen redesigns from festipod-mockups; replace sketchy Ubuntu theme. New shared components: BottomNav, EventCover, EventMeetingPoints, Toast, AvatarStack, Tag, RelevanceIcon. - Restructure from prototyping shell to real mobile web app: path-based routing (History API), Gallery/DemoMode/PhoneFrame removed, Storybook setup for screen/component browsing. - ConnectScreen ported from mockup (QR-based user connection); routed at /profile/connect, wired from FriendsListScreen. - Dev-only auto-seed of NG wallet when empty (gated on NODE_ENV !== 'production'); bootstrapWallet already self-checks for non-empty ngSet so safe even in race conditions. - Render-based @ui test infrastructure: happy-dom + LocalDataProvider + RouterProvider via src/shared/test-harness/renderHelper.tsx, exposed on the world as renderedDoc. world.hasText/hasField/hasElement prefer the rendered DOM and fall back to source for backward compatibility. - Migrate 25 brittle @ui assertions from regex-on-source to DOM queries; delete implementation-detail tests (showDuplicateWarning, importableEvents, importedFrom — anti-patterns per the new contract). Update feature files where the UI changed: "Mes amis" → "Mon réseau", "Mes événements à venir" → "À venir" on home, Thématique removed from create-event wizard, etc. - Path-based @e2e steps (pushState + popstate dispatch) replacing the legacy "#/demo/…" hash routing tied to the deleted Gallery. - Add .project/knowledge/test-layer-contracts.md defining the role of each test layer (@ui = display with seed data + DOM, @data = mutations through NG broker, @e2e = critical user journeys) with anti-patterns and migration consequences. Test status: 75 passed / 71 skipped (explicit "non implémenté") / 2 failed (pre-existing @wip on ngSet.delete() NG ORM limitation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AvatarProps {
|
||||
initials?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
name?: string;
|
||||
color?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | number;
|
||||
className?: string;
|
||||
online?: boolean;
|
||||
border?: string;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
@@ -12,19 +14,59 @@ const sizeMap = {
|
||||
lg: 56,
|
||||
};
|
||||
|
||||
export function Avatar({ initials = '?', size = 'md', className = '' }: AvatarProps) {
|
||||
const pixelSize = sizeMap[size];
|
||||
export function Avatar({ initials, name, color, size = 'md', className = '', online, border }: AvatarProps) {
|
||||
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
|
||||
const displayInitials = initials || (name ? name.split(' ').map(n => n[0]).join('').slice(0, 2) : '?');
|
||||
const bg = color || '#999';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-avatar ${className}`}
|
||||
className={`app-avatar ${className}`}
|
||||
style={{
|
||||
width: pixelSize,
|
||||
height: pixelSize,
|
||||
fontSize: pixelSize * 0.45,
|
||||
fontSize: pixelSize * 0.38,
|
||||
background: bg,
|
||||
border: border || 'none',
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
{displayInitials}
|
||||
{online && <div className="online-dot" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AvatarStackProps {
|
||||
people: Array<{ name: string; color: string }>;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function AvatarStack({ people, size = 28 }: AvatarStackProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex' }}>
|
||||
{people.slice(0, 4).map((p, i) => (
|
||||
<div key={i} style={{ marginLeft: i > 0 ? -8 : 0, zIndex: people.length - i }}>
|
||||
<Avatar name={p.name} color={p.color} size={size} border="2px solid #fff" />
|
||||
</div>
|
||||
))}
|
||||
{people.length > 4 && (
|
||||
<div style={{
|
||||
marginLeft: -8,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
background: '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#666',
|
||||
border: '2px solid #fff',
|
||||
}}>
|
||||
+{people.length - 4}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,55 @@ interface BadgeProps {
|
||||
|
||||
export function Badge({ children, className = '', style }: BadgeProps) {
|
||||
return (
|
||||
<span className={`sketchy-badge ${className}`} style={style}>
|
||||
<span className={`app-badge ${className}`} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TagProps {
|
||||
label: string;
|
||||
color?: string;
|
||||
bg?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tag({ label, color, bg, className = '' }: TagProps) {
|
||||
return (
|
||||
<span
|
||||
className={`app-tag ${className}`}
|
||||
style={{
|
||||
...(color ? { color } : {}),
|
||||
...(bg ? { background: bg } : {}),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface RelevanceIconProps {
|
||||
level?: number;
|
||||
}
|
||||
|
||||
export function RelevanceIcon({ level }: RelevanceIconProps) {
|
||||
if (!level) return null;
|
||||
const icons: Record<number, string> = { 1: '+', 2: '++', 3: '+++' };
|
||||
const colors: Record<number, string> = { 1: '#D69E2E', 2: '#E8590C', 3: '#C53030' };
|
||||
const bgs: Record<number, string> = { 1: '#FFFBEB', 2: '#FFF7ED', 3: '#FFF5F5' };
|
||||
return (
|
||||
<div style={{
|
||||
background: bgs[level],
|
||||
borderRadius: 8,
|
||||
padding: '3px 10px',
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
color: colors[level],
|
||||
whiteSpace: 'nowrap',
|
||||
letterSpacing: -0.5,
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{icons[level]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NavBar } from './NavBar';
|
||||
import { useNavigate, useRouter } from '../../../app/router';
|
||||
|
||||
type ActiveTab = 'home' | 'friends' | 'discover' | 'profile';
|
||||
|
||||
interface BottomNavProps {
|
||||
active?: ActiveTab;
|
||||
}
|
||||
|
||||
function deriveActive(page: string): ActiveTab | undefined {
|
||||
if (page === 'home') return 'home';
|
||||
if (page === 'friends') return 'friends';
|
||||
if (page === 'events' || page === 'event-detail' || page === 'create-event') return 'discover';
|
||||
if (page === 'profile' || page === 'edit-profile' || page === 'share-profile') return 'profile';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function BottomNav({ active }: BottomNavProps) {
|
||||
const navigate = useNavigate();
|
||||
const { route } = useRouter();
|
||||
const current = active ?? deriveActive(route.page);
|
||||
|
||||
return (
|
||||
<NavBar
|
||||
items={[
|
||||
{ icon: '◎', label: 'Accueil', active: current === 'home', onClick: () => navigate('/home') },
|
||||
{ icon: '⬡', label: 'Réseau', active: current === 'friends', onClick: () => navigate('/profile/friends') },
|
||||
{ icon: '✧', label: 'Découvrir', active: current === 'discover', onClick: () => navigate('/events') },
|
||||
{ icon: '○', label: 'Profil', active: current === 'profile', onClick: () => navigate('/profile') },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNextGraph } from '../../context/NextGraphContext';
|
||||
|
||||
export function BrokerBanner() {
|
||||
const { status, connect } = useNextGraph();
|
||||
|
||||
const isConnected = status === 'connected';
|
||||
const isConnecting = status === 'connecting';
|
||||
|
||||
const bgColor = isConnected ? '#4CAF50' : isConnecting ? '#FFB74D' : '#A5D6A7';
|
||||
const textColor = isConnected ? 'white' : isConnecting ? 'white' : '#2E7D32';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '6px 12px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0,
|
||||
cursor: !isConnected && !isConnecting ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={!isConnected && !isConnecting ? connect : undefined}
|
||||
title={isConnected ? 'Connecté à NextGraph' : isConnecting ? 'Connexion en cours...' : 'Cliquer pour se connecter à NextGraph'}
|
||||
>
|
||||
<span>
|
||||
{isConnected ? 'NextGraph' : isConnecting ? 'Connexion...' : 'Se connecter'}
|
||||
</span>
|
||||
{isConnected && (
|
||||
<button
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 4px',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
title="Recharger"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.85)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 2v6h-6" />
|
||||
<path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
|
||||
<path d="M3 22v-6h6" />
|
||||
<path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'primary';
|
||||
variant?: 'default' | 'primary' | 'green' | 'accent-outline';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ variant = 'default', children, className = '', ...props }: ButtonProps) {
|
||||
const variantClass = variant === 'primary' ? 'sketchy-btn-primary' : '';
|
||||
const variantClass = variant === 'primary' ? 'app-btn-primary'
|
||||
: variant === 'green' ? 'app-btn-green'
|
||||
: '';
|
||||
|
||||
if (variant === 'accent-outline') {
|
||||
return (
|
||||
<button
|
||||
className={`app-btn ${className}`}
|
||||
style={{
|
||||
background: 'var(--app-accent-light)',
|
||||
borderColor: 'var(--app-accent-border)',
|
||||
color: 'var(--app-accent-dark)',
|
||||
...props.style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`sketchy-btn ${variantClass} ${className}`}
|
||||
className={`app-btn ${variantClass} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -5,16 +5,36 @@ interface CardProps {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', onClick, style }: CardProps) {
|
||||
export function Card({ children, className = '', onClick, style, accentColor }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-card ${className}`}
|
||||
className={`app-card ${className}`}
|
||||
onClick={onClick}
|
||||
style={{ ...(onClick ? { cursor: 'pointer' } : {}), ...style }}
|
||||
style={{
|
||||
...(onClick ? { cursor: 'pointer' } : {}),
|
||||
...(accentColor ? { overflow: 'hidden' } : {}),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{accentColor && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
background: accentColor,
|
||||
borderRadius: '16px 0 0 16px',
|
||||
}} />
|
||||
)}
|
||||
{accentColor ? (
|
||||
<div style={{ paddingLeft: 8 }}>
|
||||
{children}
|
||||
</div>
|
||||
) : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CheckboxProps {
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
@@ -9,7 +7,7 @@ interface CheckboxProps {
|
||||
export function Checkbox({ checked = false, onChange, className = '' }: CheckboxProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-checkbox ${checked ? 'checked' : ''} ${className}`}
|
||||
className={`app-checkbox ${checked ? 'checked' : ''} ${className}`}
|
||||
onClick={() => onChange?.(!checked)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DividerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Divider({ className = '' }: DividerProps) {
|
||||
return <div className={`sketchy-divider ${className}`} />;
|
||||
export function Divider() {
|
||||
return <div className="app-divider" />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
const EVENT_PHOTOS: Record<string, string> = {
|
||||
'1': 'https://images.unsplash.com/photo-1529119513315-c7c361862fc7?auto=format&fit=crop&w=800&q=70',
|
||||
'2': 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=70',
|
||||
'3': 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?auto=format&fit=crop&w=800&q=70',
|
||||
'4': 'https://images.unsplash.com/photo-1523050854058-8df90110c9f1?auto=format&fit=crop&w=800&q=70',
|
||||
default: 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?auto=format&fit=crop&w=800&q=70',
|
||||
};
|
||||
|
||||
interface EventCoverProps {
|
||||
eventId?: string | number;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
style?: React.CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EventCover({ eventId = 'default', height = 140, borderRadius = 12, style, children }: EventCoverProps) {
|
||||
const url = EVENT_PHOTOS[String(eventId)] ?? EVENT_PHOTOS.default;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
borderRadius,
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getEventPhotoUrl(eventId: string | number) {
|
||||
return EVENT_PHOTOS[String(eventId)] ?? EVENT_PHOTOS.default;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react';
|
||||
import { showToast } from './Toast';
|
||||
|
||||
export interface MeetingPointData {
|
||||
id: string | number;
|
||||
title: string;
|
||||
when: string;
|
||||
duration: string;
|
||||
lieu: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
points: MeetingPointData[];
|
||||
joinedIds: Set<string>;
|
||||
onToggle: (id: string, title: string, willJoin: boolean) => void;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
export function EventMeetingPoints({ points, joinedIds, onToggle, expanded = false }: Props) {
|
||||
const [showAll, setShowAll] = useState(expanded);
|
||||
|
||||
if (points.length === 0) return null;
|
||||
|
||||
const joined = points.filter(p => joinedIds.has(String(p.id)));
|
||||
const others = points.filter(p => !joinedIds.has(String(p.id)));
|
||||
const alwaysVisible = expanded ? points : joined;
|
||||
const collapsible = expanded ? [] : others;
|
||||
|
||||
const handleToggle = (p: MeetingPointData) => {
|
||||
const willJoin = !joinedIds.has(String(p.id));
|
||||
onToggle(String(p.id), p.title, willJoin);
|
||||
showToast(
|
||||
willJoin ? `Inscription : ${p.title}` : `Désinscription : ${p.title}`,
|
||||
willJoin ? 'success' : 'info',
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = (p: MeetingPointData) => {
|
||||
const isJoined = joinedIds.has(String(p.id));
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
padding: 12,
|
||||
border: '1.5px solid #eee',
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
background: isJoined ? '#f7fff7' : '#fff',
|
||||
borderColor: isJoined ? '#c6f6d5' : '#eee',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#1a1a1a', marginBottom: 4 }}>
|
||||
{p.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 10 }}>
|
||||
🕒 {p.when} · {p.duration}
|
||||
<br />
|
||||
📍 {p.lieu}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleToggle(p); }}
|
||||
style={{
|
||||
background: isJoined ? '#22543D' : '#1a1a1a',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
padding: '6px 14px',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-app)',
|
||||
}}
|
||||
>
|
||||
{isJoined ? '✓ Inscrit' : "S'inscrire"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: '#999',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Points de rencontre ({points.length})
|
||||
</div>
|
||||
|
||||
{alwaysVisible.map(renderItem)}
|
||||
|
||||
{collapsible.length > 0 && !showAll && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowAll(true); }}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
border: '1.5px dashed #ddd',
|
||||
borderRadius: 10,
|
||||
background: 'none',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-app)',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
+ {collapsible.length} autre{collapsible.length > 1 ? 's' : ''} point{collapsible.length > 1 ? 's' : ''} de rencontre
|
||||
</button>
|
||||
)}
|
||||
|
||||
{collapsible.length > 0 && showAll && (
|
||||
<>
|
||||
{collapsible.map(renderItem)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowAll(false); }}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 10px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-app)',
|
||||
}}
|
||||
>
|
||||
Réduire
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,9 @@ interface HeaderProps {
|
||||
|
||||
export function Header({ title, left, right, className = '' }: HeaderProps) {
|
||||
return (
|
||||
<div className={`sketchy-header ${className}`}>
|
||||
<div className={`app-header ${className}`}>
|
||||
<div style={{ width: 40 }}>{left}</div>
|
||||
<div className="sketchy-subtitle" style={{ margin: 0 }}>{title}</div>
|
||||
<div className="app-subtitle" style={{ margin: 0 }}>{title}</div>
|
||||
<div style={{ width: 40, textAlign: 'right' }}>{right}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export function Input({ className = '', ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={`sketchy-input ${className}`}
|
||||
className={`app-input ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ListItemProps {
|
||||
export function ListItem({ children, onClick, className = '' }: ListItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-list-item ${className}`}
|
||||
className={`app-list-item ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
interface NavItem {
|
||||
icon: string;
|
||||
label: string;
|
||||
@@ -14,23 +12,23 @@ interface NavBarProps {
|
||||
|
||||
export function NavBar({ items, className = '' }: NavBarProps) {
|
||||
return (
|
||||
<div className={`sketchy-navbar ${className}`}>
|
||||
<div className={`app-navbar ${className}`}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`nav-item ${item.active ? 'active' : ''}`}
|
||||
onClick={item.onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
gap: 3,
|
||||
cursor: 'pointer',
|
||||
opacity: item.active ? 1 : 0.6,
|
||||
opacity: item.active ? 1 : 0.4,
|
||||
color: item.active ? 'var(--app-accent)' : '#333',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||
<span style={{ fontSize: 12 }}>{item.label}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, letterSpacing: 0.3 }}>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@ export function NgStatus() {
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--sketch-gray)',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
color: '#888',
|
||||
fontFamily: 'var(--font-app)',
|
||||
}}
|
||||
title="Mode démonstration — NextGraph non connecté"
|
||||
>
|
||||
@@ -37,8 +37,8 @@ export function NgStatus() {
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--sketch-gray)',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
color: '#888',
|
||||
fontFamily: 'var(--font-app)',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
@@ -61,7 +61,7 @@ export function NgStatus() {
|
||||
gap: 4,
|
||||
fontSize: 11,
|
||||
color: '#4caf50',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontFamily: 'var(--font-app)',
|
||||
}}
|
||||
title="Connecté à NextGraph"
|
||||
>
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PhoneFrameProps {
|
||||
children: React.ReactNode;
|
||||
scale?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PhoneFrame({ children, scale = 1, className = '' }: PhoneFrameProps) {
|
||||
// iPhone-like dimensions (375 x 812 logical pixels)
|
||||
const width = 375;
|
||||
const height = 812;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`phone-frame-wrapper ${className}`}
|
||||
style={{
|
||||
width: width * scale,
|
||||
height: height * scale,
|
||||
position: 'relative',
|
||||
background: 'var(--sketch-white)',
|
||||
borderRadius: 40 * scale,
|
||||
border: `${3 * scale}px solid var(--sketch-black)`,
|
||||
boxShadow: `${4 * scale}px ${4 * scale}px 0 var(--sketch-black)`,
|
||||
overflow: 'hidden',
|
||||
// Sketchy irregular border effect
|
||||
borderTopLeftRadius: `${42 * scale}px`,
|
||||
borderTopRightRadius: `${38 * scale}px`,
|
||||
borderBottomLeftRadius: `${39 * scale}px`,
|
||||
borderBottomRightRadius: `${41 * scale}px`,
|
||||
}}
|
||||
>
|
||||
{/* Notch */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 150 * scale,
|
||||
height: 28 * scale,
|
||||
background: 'var(--sketch-black)',
|
||||
borderBottomLeftRadius: 14 * scale,
|
||||
borderBottomRightRadius: 16 * scale,
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Screen content */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Status bar area */}
|
||||
<div
|
||||
style={{
|
||||
height: 44 * scale,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: `0 ${20 * scale}px`,
|
||||
fontSize: 12 * scale,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
flexShrink: 0,
|
||||
color: 'var(--sketch-black)',
|
||||
}}
|
||||
>
|
||||
<span>9:41</span>
|
||||
<span style={{ display: 'flex', gap: 4 * scale }}>
|
||||
<span>~</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className="phone-screen"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Home indicator */}
|
||||
<div
|
||||
style={{
|
||||
height: 34 * scale,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 134 * scale,
|
||||
height: 5 * scale,
|
||||
background: 'var(--sketch-black)',
|
||||
borderRadius: 3 * scale,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function Placeholder({
|
||||
}: PlaceholderProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-placeholder ${className}`}
|
||||
className={`app-placeholder ${className}`}
|
||||
style={{ width, height, ...style }}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -8,13 +8,13 @@ interface TextProps {
|
||||
}
|
||||
|
||||
export function Title({ children, className = '', style }: TextProps) {
|
||||
return <h1 className={`sketchy-title ${className}`} style={style}>{children}</h1>;
|
||||
return <h1 className={`app-title ${className}`} style={style}>{children}</h1>;
|
||||
}
|
||||
|
||||
export function Subtitle({ children, className = '', style }: TextProps) {
|
||||
return <h2 className={`sketchy-subtitle ${className}`} style={style}>{children}</h2>;
|
||||
return <h2 className={`app-subtitle ${className}`} style={style}>{children}</h2>;
|
||||
}
|
||||
|
||||
export function Text({ children, className = '', style, onClick }: TextProps) {
|
||||
return <p className={`sketchy-text ${className}`} style={style} onClick={onClick}>{children}</p>;
|
||||
return <p className={`app-text ${className}`} style={style} onClick={onClick}>{children}</p>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
type ToastVariant = 'success' | 'info' | 'error';
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
}
|
||||
|
||||
type Listener = (toasts: ToastItem[]) => void;
|
||||
|
||||
let items: ToastItem[] = [];
|
||||
let nextId = 1;
|
||||
const listeners: Set<Listener> = new Set();
|
||||
|
||||
function emit() {
|
||||
listeners.forEach(l => l(items));
|
||||
}
|
||||
|
||||
export function showToast(message: string, variant: ToastVariant = 'success') {
|
||||
const id = nextId++;
|
||||
items = [...items, { id, message, variant }];
|
||||
emit();
|
||||
setTimeout(() => {
|
||||
items = items.filter(t => t.id !== id);
|
||||
emit();
|
||||
}, 2600);
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>(items);
|
||||
|
||||
useEffect(() => {
|
||||
const listener: Listener = (next) => setToasts([...next]);
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
items = items.filter(t => t.id !== id);
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 78,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{toasts.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => dismiss(t.id)}
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
background: t.variant === 'error' ? '#7B2A1E' : t.variant === 'info' ? '#1F3A5F' : '#22543D',
|
||||
color: '#fff',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 14,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
maxWidth: '80%',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 6px 20px rgba(0,0,0,0.18)',
|
||||
fontFamily: 'var(--font-app)',
|
||||
cursor: 'pointer',
|
||||
animation: 'toast-slide-up 0.25s ease',
|
||||
}}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
<style>{`
|
||||
@keyframes toast-slide-up {
|
||||
from { transform: translateY(10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ToggleProps {
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
@@ -9,7 +7,7 @@ interface ToggleProps {
|
||||
export function Toggle({ checked = false, onChange, className = '' }: ToggleProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-toggle ${checked ? 'on' : ''} ${className}`}
|
||||
className={`app-toggle ${checked ? 'on' : ''} ${className}`}
|
||||
onClick={() => onChange?.(!checked)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,13 +3,16 @@ export { Input } from './Input';
|
||||
export { Card } from './Card';
|
||||
export { Title, Subtitle, Text } from './Text';
|
||||
export { Placeholder } from './Placeholder';
|
||||
export { Avatar } from './Avatar';
|
||||
export { Badge } from './Badge';
|
||||
export { Avatar, AvatarStack } from './Avatar';
|
||||
export { Badge, Tag, RelevanceIcon } from './Badge';
|
||||
export { Toggle } from './Toggle';
|
||||
export { Checkbox } from './Checkbox';
|
||||
export { ListItem } from './ListItem';
|
||||
export { Header } from './Header';
|
||||
export { NavBar } from './NavBar';
|
||||
export { BottomNav } from './BottomNav';
|
||||
export { ToastContainer, showToast } from './Toast';
|
||||
export { EventCover, getEventPhotoUrl } from './EventCover';
|
||||
export { EventMeetingPoints } from './EventMeetingPoints';
|
||||
export type { MeetingPointData } from './EventMeetingPoints';
|
||||
export { Divider } from './Divider';
|
||||
export { PhoneFrame } from './PhoneFrame';
|
||||
export { BrokerBanner } from './BrokerBanner';
|
||||
|
||||
Reference in New Issue
Block a user