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:
+118
-86
@@ -1,127 +1,159 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// ============================================================================
|
||||
// Route types
|
||||
// ============================================================================
|
||||
|
||||
type Route =
|
||||
| { page: 'gallery' }
|
||||
| { page: 'demo'; screenId: string }
|
||||
| { page: 'specs'; featureId?: string; storyId?: string };
|
||||
| { page: 'welcome' }
|
||||
| { page: 'login' }
|
||||
| { page: 'home' }
|
||||
| { page: 'events' }
|
||||
| { page: 'create-event' }
|
||||
| { page: 'event-detail'; eventId: string }
|
||||
| { page: 'update-event'; eventId: string }
|
||||
| { page: 'invite'; eventId: string }
|
||||
| { page: 'participants'; eventId: string }
|
||||
| { page: 'meeting-points'; eventId: string }
|
||||
| { page: 'profile' }
|
||||
| { page: 'edit-profile' }
|
||||
| { page: 'friends' }
|
||||
| { page: 'share-profile' }
|
||||
| { page: 'connect' }
|
||||
| { page: 'user-profile'; userId: string }
|
||||
| { page: 'settings' };
|
||||
|
||||
export type { Route };
|
||||
|
||||
export interface RouteParams {
|
||||
eventId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path parsing & generation
|
||||
// ============================================================================
|
||||
|
||||
function parsePath(pathname: string): Route {
|
||||
const path = pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
if (path === '/' || path === '') return { page: 'welcome' };
|
||||
if (path === '/login') return { page: 'login' };
|
||||
if (path === '/home') return { page: 'home' };
|
||||
if (path === '/events') return { page: 'events' };
|
||||
if (path === '/events/new') return { page: 'create-event' };
|
||||
if (path === '/settings') return { page: 'settings' };
|
||||
if (path === '/profile') return { page: 'profile' };
|
||||
if (path === '/profile/edit') return { page: 'edit-profile' };
|
||||
if (path === '/profile/friends') return { page: 'friends' };
|
||||
if (path === '/profile/share') return { page: 'share-profile' };
|
||||
if (path === '/profile/connect') return { page: 'connect' };
|
||||
|
||||
// /events/:id/...
|
||||
const eventMatch = path.match(/^\/events\/([^/]+)(?:\/(.+))?$/);
|
||||
if (eventMatch) {
|
||||
const eventId = eventMatch[1]!;
|
||||
const sub = eventMatch[2];
|
||||
if (!sub) return { page: 'event-detail', eventId };
|
||||
if (sub === 'edit') return { page: 'update-event', eventId };
|
||||
if (sub === 'invite') return { page: 'invite', eventId };
|
||||
if (sub === 'participants') return { page: 'participants', eventId };
|
||||
if (sub === 'meeting-points') return { page: 'meeting-points', eventId };
|
||||
}
|
||||
|
||||
// /users/:id
|
||||
const userMatch = path.match(/^\/users\/([^/]+)$/);
|
||||
if (userMatch) {
|
||||
return { page: 'user-profile', userId: userMatch[1]! };
|
||||
}
|
||||
|
||||
return { page: 'welcome' };
|
||||
}
|
||||
|
||||
export function routeToPath(route: Route): string {
|
||||
switch (route.page) {
|
||||
case 'welcome': return '/';
|
||||
case 'login': return '/login';
|
||||
case 'home': return '/home';
|
||||
case 'events': return '/events';
|
||||
case 'create-event': return '/events/new';
|
||||
case 'event-detail': return `/events/${route.eventId}`;
|
||||
case 'update-event': return `/events/${route.eventId}/edit`;
|
||||
case 'invite': return `/events/${route.eventId}/invite`;
|
||||
case 'participants': return `/events/${route.eventId}/participants`;
|
||||
case 'meeting-points': return `/events/${route.eventId}/meeting-points`;
|
||||
case 'profile': return '/profile';
|
||||
case 'edit-profile': return '/profile/edit';
|
||||
case 'friends': return '/profile/friends';
|
||||
case 'share-profile': return '/profile/share';
|
||||
case 'connect': return '/profile/connect';
|
||||
case 'user-profile': return `/users/${route.userId}`;
|
||||
case 'settings': return '/settings';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Router context
|
||||
// ============================================================================
|
||||
|
||||
interface RouterContextValue {
|
||||
route: Route;
|
||||
navigate: (route: Route) => void;
|
||||
navigate: (path: string) => void;
|
||||
goBack: () => void;
|
||||
params: RouteParams;
|
||||
}
|
||||
|
||||
const RouterContext = createContext<RouterContextValue | null>(null);
|
||||
|
||||
function parseHash(hash: string): Route {
|
||||
const path = hash.replace(/^#\/?/, '') || '/';
|
||||
|
||||
if (path === '/' || path === '') {
|
||||
return { page: 'gallery' };
|
||||
}
|
||||
|
||||
// Redirect /stories to /specs (backward compatibility)
|
||||
if (path === 'stories') {
|
||||
return { page: 'specs' };
|
||||
}
|
||||
|
||||
// Redirect /stories/{id} to /specs with storyId (backward compatibility)
|
||||
if (path.startsWith('stories/')) {
|
||||
const storyId = path.replace('stories/', '');
|
||||
if (storyId) {
|
||||
return { page: 'specs', storyId };
|
||||
}
|
||||
}
|
||||
|
||||
if (path.startsWith('demo/')) {
|
||||
const screenId = path.replace('demo/', '');
|
||||
if (screenId) {
|
||||
return { page: 'demo', screenId };
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'specs') {
|
||||
return { page: 'specs' };
|
||||
}
|
||||
|
||||
if (path.startsWith('specs/')) {
|
||||
const featureId = path.replace('specs/', '');
|
||||
if (featureId) {
|
||||
return { page: 'specs', featureId };
|
||||
}
|
||||
}
|
||||
|
||||
return { page: 'gallery' };
|
||||
}
|
||||
|
||||
function routeToHash(route: Route): string {
|
||||
switch (route.page) {
|
||||
case 'gallery':
|
||||
return '#/';
|
||||
case 'demo':
|
||||
return `#/demo/${route.screenId}`;
|
||||
case 'specs':
|
||||
if (route.featureId) return `#/specs/${route.featureId}`;
|
||||
if (route.storyId) return `#/specs/${route.storyId}`;
|
||||
return '#/specs';
|
||||
}
|
||||
}
|
||||
|
||||
export function RouterProvider({ children }: { children: React.ReactNode }) {
|
||||
const [route, setRoute] = useState<Route>(() => parseHash(window.location.hash));
|
||||
const [route, setRoute] = useState<Route>(() => parsePath(window.location.pathname));
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
setRoute(parseHash(window.location.hash));
|
||||
const handlePopState = () => {
|
||||
setRoute(parsePath(window.location.pathname));
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback((newRoute: Route) => {
|
||||
window.location.hash = routeToHash(newRoute);
|
||||
const navigate = useCallback((path: string) => {
|
||||
window.history.pushState(null, '', path);
|
||||
setRoute(parsePath(path));
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
window.history.back();
|
||||
}, []);
|
||||
|
||||
const params: RouteParams = {};
|
||||
if ('eventId' in route) params.eventId = route.eventId;
|
||||
if ('userId' in route) params.userId = route.userId;
|
||||
|
||||
return (
|
||||
<RouterContext.Provider value={{ route, navigate, goBack }}>
|
||||
<RouterContext.Provider value={{ route, navigate, goBack, params }}>
|
||||
{children}
|
||||
</RouterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
export function useRouter() {
|
||||
const context = useContext(RouterContext);
|
||||
if (!context) {
|
||||
throw new Error('useRouter must be used within a RouterProvider');
|
||||
}
|
||||
if (!context) throw new Error('useRouter must be used within a RouterProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useNavigate() {
|
||||
const { navigate } = useRouter();
|
||||
return navigate;
|
||||
return useRouter().navigate;
|
||||
}
|
||||
|
||||
export function useGoBack() {
|
||||
const { goBack } = useRouter();
|
||||
return goBack;
|
||||
return useRouter().goBack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL for a specific story (now redirects to specs)
|
||||
*/
|
||||
export function getStoryUrl(storyId: string): string {
|
||||
return `#/specs/${storyId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL for a specific feature spec
|
||||
*/
|
||||
export function getSpecUrl(featureId: string): string {
|
||||
return `#/specs/${featureId}`;
|
||||
export function useParams(): RouteParams {
|
||||
return useRouter().params;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user