NextGraph integration (WIP), broker banner, and feature-based architecture
- Add NextGraph data layer with @ng-org/orm, SHEX shapes (Event, UserProfile, Participation), session management, and FestipodDataContext with dual-mode operation (connected via NextGraph or local seed data) - Add BrokerBanner and NgStatus components showing connection status - Refactor to feature-based architecture: organize code by business domain (event, user, home, auth, workshop, meeting, notification) instead of technical layer. Modules only import from shared/, never from each other - Collocate BDD features and step definitions with their modules: event-specific steps in event/steps/, user steps in user/steps/, shared generic steps remain in shared/steps/ - Set up multi-layer BDD structure (frontend/backend/e2e steps per module) - Add project documentation (AGENTS.md, .project/knowledge/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AvatarProps {
|
||||
initials?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: 32,
|
||||
md: 40,
|
||||
lg: 56,
|
||||
};
|
||||
|
||||
export function Avatar({ initials = '?', size = 'md', className = '' }: AvatarProps) {
|
||||
const pixelSize = sizeMap[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-avatar ${className}`}
|
||||
style={{
|
||||
width: pixelSize,
|
||||
height: pixelSize,
|
||||
fontSize: pixelSize * 0.45,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Badge({ children, className = '', style }: BadgeProps) {
|
||||
return (
|
||||
<span className={`sketchy-badge ${className}`} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'primary';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ variant = 'default', children, className = '', ...props }: ButtonProps) {
|
||||
const variantClass = variant === 'primary' ? 'sketchy-btn-primary' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`sketchy-btn ${variantClass} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', onClick, style }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-card ${className}`}
|
||||
onClick={onClick}
|
||||
style={{ ...(onClick ? { cursor: 'pointer' } : {}), ...style }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CheckboxProps {
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Checkbox({ checked = false, onChange, className = '' }: CheckboxProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-checkbox ${checked ? 'checked' : ''} ${className}`}
|
||||
onClick={() => onChange?.(!checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DividerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Divider({ className = '' }: DividerProps) {
|
||||
return <div className={`sketchy-divider ${className}`} />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
title?: string;
|
||||
left?: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Header({ title, left, right, className = '' }: HeaderProps) {
|
||||
return (
|
||||
<div className={`sketchy-header ${className}`}>
|
||||
<div style={{ width: 40 }}>{left}</div>
|
||||
<div className="sketchy-subtitle" style={{ margin: 0 }}>{title}</div>
|
||||
<div style={{ width: 40, textAlign: 'right' }}>{right}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export function Input({ className = '', ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={`sketchy-input ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ListItemProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ListItem({ children, onClick, className = '' }: ListItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-list-item ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
interface NavItem {
|
||||
icon: string;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface NavBarProps {
|
||||
items: NavItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NavBar({ items, className = '' }: NavBarProps) {
|
||||
return (
|
||||
<div className={`sketchy-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,
|
||||
cursor: 'pointer',
|
||||
opacity: item.active ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||
<span style={{ fontSize: 12 }}>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { useNextGraph } from '../../context/NextGraphContext';
|
||||
|
||||
export function NgStatus() {
|
||||
const { status } = useNextGraph();
|
||||
|
||||
if (status === 'disconnected' || status === 'error') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--sketch-gray)',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}
|
||||
title="Mode démonstration — NextGraph non connecté"
|
||||
>
|
||||
<span style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: '#ccc',
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
démo
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'connecting') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--sketch-gray)',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: '#ff9800',
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
connexion...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 11,
|
||||
color: '#4caf50',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}
|
||||
title="Connecté à NextGraph"
|
||||
>
|
||||
<span style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: '#4caf50',
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
NextGraph
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PlaceholderProps {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Placeholder({
|
||||
width = '100%',
|
||||
height = 100,
|
||||
label = 'Image',
|
||||
className = '',
|
||||
style
|
||||
}: PlaceholderProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-placeholder ${className}`}
|
||||
style={{ width, height, ...style }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TextProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Title({ children, className = '', style }: TextProps) {
|
||||
return <h1 className={`sketchy-title ${className}`} style={style}>{children}</h1>;
|
||||
}
|
||||
|
||||
export function Subtitle({ children, className = '', style }: TextProps) {
|
||||
return <h2 className={`sketchy-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>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ToggleProps {
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Toggle({ checked = false, onChange, className = '' }: ToggleProps) {
|
||||
return (
|
||||
<div
|
||||
className={`sketchy-toggle ${checked ? 'on' : ''} ${className}`}
|
||||
onClick={() => onChange?.(!checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { Button } from './Button';
|
||||
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 { Toggle } from './Toggle';
|
||||
export { Checkbox } from './Checkbox';
|
||||
export { ListItem } from './ListItem';
|
||||
export { Header } from './Header';
|
||||
export { NavBar } from './NavBar';
|
||||
export { Divider } from './Divider';
|
||||
export { PhoneFrame } from './PhoneFrame';
|
||||
export { BrokerBanner } from './BrokerBanner';
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />;
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,473 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
|
||||
import type {
|
||||
FpEventData,
|
||||
FpUserData,
|
||||
FpParticipationData,
|
||||
FpMeetingPointData,
|
||||
FpFriendshipData,
|
||||
} from '../data/types';
|
||||
import {
|
||||
CURRENT_USER_ID,
|
||||
seedEvents,
|
||||
seedUsers,
|
||||
seedParticipations,
|
||||
seedMeetingPoints,
|
||||
seedFriendships,
|
||||
} from '../data/seedData';
|
||||
import { useNextGraph } from './NextGraphContext';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
import { useShapeWithDefaults } from '../hooks/useShapeWithDefaults';
|
||||
import { bootstrapWallet } from '../utils/ngBootstrap';
|
||||
import {
|
||||
FpEventShapeType,
|
||||
FpUserProfileShapeType,
|
||||
FpParticipationShapeType,
|
||||
} from '../shapes/orm/festipodShapes.shapeTypes';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
|
||||
// ============================================================================
|
||||
// Context interface
|
||||
// ============================================================================
|
||||
|
||||
interface FestipodDataContextValue {
|
||||
currentUserId: string;
|
||||
currentUser: FpUserData | undefined;
|
||||
|
||||
events: FpEventData[];
|
||||
users: FpUserData[];
|
||||
participations: FpParticipationData[];
|
||||
meetingPoints: FpMeetingPointData[];
|
||||
friendships: FpFriendshipData[];
|
||||
|
||||
getEvent(id: string): FpEventData | undefined;
|
||||
getUser(id: string): FpUserData | undefined;
|
||||
getEventParticipants(eventId: string): FpUserData[];
|
||||
getUserEvents(userId: string): FpEventData[];
|
||||
isParticipating(eventId: string, userId?: string): boolean;
|
||||
getFriends(userId?: string): FpUserData[];
|
||||
getEventMeetingPoints(eventId: string): FpMeetingPointData[];
|
||||
|
||||
selectedEventId: string;
|
||||
setSelectedEventId(id: string): void;
|
||||
selectedEvent: FpEventData | undefined;
|
||||
selectedUserId: string;
|
||||
setSelectedUserId(id: string): void;
|
||||
selectedUser: FpUserData | undefined;
|
||||
|
||||
createEvent(event: Omit<FpEventData, 'id'>): FpEventData;
|
||||
updateEvent(id: string, updates: Partial<FpEventData>): void;
|
||||
joinEvent(eventId: string, userId?: string): void;
|
||||
leaveEvent(eventId: string, userId?: string): void;
|
||||
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
|
||||
addFriend(friendId: string): void;
|
||||
updateProfile(updates: Partial<FpUserData>): void;
|
||||
}
|
||||
|
||||
const FestipodDataContext = createContext<FestipodDataContextValue | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
let idCounter = 100;
|
||||
function nextId(prefix: string): string {
|
||||
return `${prefix}-${++idCounter}`;
|
||||
}
|
||||
|
||||
function findNg<T extends { "@id": string }>(set: Set<T>, predicate: (item: T) => boolean): T | undefined {
|
||||
for (const item of set) {
|
||||
if (predicate(item)) return item;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// NG shape → app type mappers
|
||||
const mapEvent = (e: FpEvent): FpEventData => ({
|
||||
id: e["@id"],
|
||||
title: e.title,
|
||||
description: e.description || '',
|
||||
date: e.date,
|
||||
location: e.location,
|
||||
distance: e.distance,
|
||||
participantCount: e.participantCount,
|
||||
coverImage: e.coverImage,
|
||||
hostName: e.hostName,
|
||||
hostInitials: e.hostInitials,
|
||||
});
|
||||
|
||||
const mapUser = (u: FpUserProfile): FpUserData => ({
|
||||
id: u["@id"],
|
||||
name: u.name,
|
||||
initials: u.initials,
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
isPublic: u.isPublic,
|
||||
});
|
||||
|
||||
const mapParticipation = (p: FpParticipation): FpParticipationData => ({
|
||||
id: p["@id"],
|
||||
eventId: p.event,
|
||||
userId: p.user,
|
||||
isConfirmed: p.isConfirmed,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Shared queries builder — same logic for both local and NG modes
|
||||
// ============================================================================
|
||||
|
||||
function buildQueries(
|
||||
events: FpEventData[],
|
||||
users: FpUserData[],
|
||||
participations: FpParticipationData[],
|
||||
meetingPoints: FpMeetingPointData[],
|
||||
friendships: FpFriendshipData[],
|
||||
currentUserId: string,
|
||||
) {
|
||||
const getEvent = (id: string) => events.find(e => e.id === id);
|
||||
const getUser = (id: string) => users.find(u => u.id === id);
|
||||
|
||||
const getEventParticipants = (eventId: string) => {
|
||||
const partUserIds = participations.filter(p => p.eventId === eventId).map(p => p.userId);
|
||||
return users.filter(u => partUserIds.includes(u.id));
|
||||
};
|
||||
|
||||
const getUserEvents = (userId: string) => {
|
||||
const partEventIds = participations.filter(p => p.userId === userId).map(p => p.eventId);
|
||||
return events.filter(e => partEventIds.includes(e.id));
|
||||
};
|
||||
|
||||
const isParticipating = (eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
return participations.some(p => p.eventId === eventId && p.userId === uid);
|
||||
};
|
||||
|
||||
const getFriends = (userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
const friendIds = friendships
|
||||
.filter(f => f.userId === uid || f.friendId === uid)
|
||||
.map(f => f.userId === uid ? f.friendId : f.userId);
|
||||
return users.filter(u => friendIds.includes(u.id));
|
||||
};
|
||||
|
||||
const getEventMeetingPoints = (eventId: string) => {
|
||||
return meetingPoints.filter(mp => mp.eventId === eventId);
|
||||
};
|
||||
|
||||
return { getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Local (disconnected) provider — uses seed data directly, read-only
|
||||
// ============================================================================
|
||||
|
||||
function useLocalData(): FestipodDataContextValue {
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>('event-1');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
|
||||
const events = seedEvents;
|
||||
const users = seedUsers;
|
||||
const participations = seedParticipations;
|
||||
const meetingPoints = seedMeetingPoints;
|
||||
const friendships = seedFriendships;
|
||||
|
||||
const currentUserId = CURRENT_USER_ID;
|
||||
const currentUser = users.find(u => u.id === currentUserId);
|
||||
const selectedEvent = events.find(e => e.id === selectedEventId);
|
||||
const selectedUser = users.find(u => u.id === selectedUserId);
|
||||
|
||||
const queries = buildQueries(events, users, participations, meetingPoints, friendships, currentUserId);
|
||||
|
||||
console.log('[FestipodData] Render — local | events:', events.length,
|
||||
'| selectedEvent:', selectedEvent?.title ?? '(none)');
|
||||
|
||||
// Local mode: mutations are no-ops (static defaults)
|
||||
const createEvent = useCallback((event: Omit<FpEventData, 'id'>): FpEventData => {
|
||||
console.log('[FestipodData] createEvent (local, no-op):', event.title);
|
||||
return { ...event, id: nextId('event') };
|
||||
}, []);
|
||||
const updateEvent = useCallback((_id: string, _updates: Partial<FpEventData>) => {
|
||||
console.log('[FestipodData] updateEvent (local, no-op)');
|
||||
}, []);
|
||||
const joinEvent = useCallback((_eventId: string) => {
|
||||
console.log('[FestipodData] joinEvent (local, no-op)');
|
||||
}, []);
|
||||
const leaveEvent = useCallback((_eventId: string) => {
|
||||
console.log('[FestipodData] leaveEvent (local, no-op)');
|
||||
}, []);
|
||||
const addMeetingPoint = useCallback((_mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
console.log('[FestipodData] addMeetingPoint (local, no-op)');
|
||||
}, []);
|
||||
const addFriend = useCallback((_friendId: string) => {
|
||||
console.log('[FestipodData] addFriend (local, no-op)');
|
||||
}, []);
|
||||
const updateProfile = useCallback((_updates: Partial<FpUserData>) => {
|
||||
console.log('[FestipodData] updateProfile (local, no-op)');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentUserId, currentUser,
|
||||
events, users, participations, meetingPoints, friendships,
|
||||
selectedEventId, setSelectedEventId, selectedEvent,
|
||||
selectedUserId, setSelectedUserId, selectedUser,
|
||||
...queries,
|
||||
createEvent, updateEvent, joinEvent, leaveEvent,
|
||||
addMeetingPoint, addFriend, updateProfile,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NextGraph-connected provider — uses useShapeWithDefaults + bootstrap
|
||||
// ============================================================================
|
||||
|
||||
function useNgData(): FestipodDataContextValue {
|
||||
const [shapesReady, setShapesReady] = useState(false);
|
||||
|
||||
// useShapeWithDefaults calls useShape internally (only safe when NG is connected)
|
||||
const eventsShape = useShapeWithDefaults(FpEventShapeType, seedEvents, mapEvent, shapesReady);
|
||||
const usersShape = useShapeWithDefaults(FpUserProfileShapeType, seedUsers, mapUser, shapesReady);
|
||||
const participationsShape = useShapeWithDefaults(FpParticipationShapeType, seedParticipations, mapParticipation, shapesReady);
|
||||
|
||||
const events = eventsShape.items;
|
||||
const users = usersShape.items;
|
||||
const participations = participationsShape.items;
|
||||
|
||||
// Not in SHEX shapes yet
|
||||
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>(seedMeetingPoints);
|
||||
const [friendships, setFriendships] = useState<FpFriendshipData[]>(seedFriendships);
|
||||
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>('');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
|
||||
// --- Bootstrap: detect when NG data is available, seed if first time ---
|
||||
const bootstrapDone = useRef(false);
|
||||
const bootstrapInProgress = useRef(false);
|
||||
|
||||
// Reactive detection: when ngSet gets populated, NG data has arrived (returning user)
|
||||
// Skip if bootstrap is currently seeding (to avoid race condition)
|
||||
useEffect(() => {
|
||||
if (shapesReady || bootstrapDone.current || bootstrapInProgress.current) return;
|
||||
const evSize = eventsShape.ngSet.size;
|
||||
const uSize = usersShape.ngSet.size;
|
||||
const pSize = participationsShape.ngSet.size;
|
||||
console.log('[FestipodData] Checking ngSet sizes — events:', evSize, 'users:', uSize, 'participations:', pSize);
|
||||
if (evSize > 0 && uSize > 0) {
|
||||
console.log('[FestipodData] NG data fully loaded — returning user, marking ready');
|
||||
bootstrapDone.current = true;
|
||||
setShapesReady(true);
|
||||
// Auto-select first event
|
||||
const first = [...eventsShape.ngSet][0];
|
||||
if (first) {
|
||||
console.log('[FestipodData] Selecting first event:', first.title, first["@id"]);
|
||||
setSelectedEventId(first["@id"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout fallback: if ngSet stays empty, assume empty wallet → bootstrap seed data
|
||||
useEffect(() => {
|
||||
if (bootstrapDone.current) return;
|
||||
const timer = setTimeout(async () => {
|
||||
if (bootstrapDone.current) return;
|
||||
// Lock so reactive effect doesn't fire during seeding
|
||||
bootstrapInProgress.current = true;
|
||||
console.log('[FestipodData] Timeout reached — checking if seeding needed...');
|
||||
console.log('[FestipodData] ngSet sizes — events:', eventsShape.ngSet.size,
|
||||
'users:', usersShape.ngSet.size, 'participations:', participationsShape.ngSet.size);
|
||||
|
||||
// If data arrived while we waited, just mark ready
|
||||
if (eventsShape.ngSet.size > 0 && usersShape.ngSet.size > 0) {
|
||||
console.log('[FestipodData] Data arrived before timeout — marking ready');
|
||||
bootstrapDone.current = true;
|
||||
bootstrapInProgress.current = false;
|
||||
setShapesReady(true);
|
||||
const first = [...eventsShape.ngSet][0];
|
||||
if (first) setSelectedEventId(first["@id"]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[FestipodData] Wallet empty — seeding...');
|
||||
const result = await bootstrapWallet(
|
||||
eventsShape.ngSet as any,
|
||||
usersShape.ngSet as any,
|
||||
participationsShape.ngSet as any,
|
||||
);
|
||||
|
||||
bootstrapDone.current = true;
|
||||
bootstrapInProgress.current = false;
|
||||
setShapesReady(true);
|
||||
|
||||
if (result.seeded) {
|
||||
const firstIri = result.eventIdMap.get('event-1');
|
||||
if (firstIri) {
|
||||
console.log('[FestipodData] Bootstrap done, selecting first event:', firstIri);
|
||||
setSelectedEventId(firstIri);
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// --- Derived ---
|
||||
const currentUser = users.find(u => u.username === '@mariedupont') || users[0];
|
||||
const currentUserId = currentUser?.id || '';
|
||||
const selectedEvent = events.find(e => e.id === selectedEventId);
|
||||
const selectedUser = users.find(u => u.id === selectedUserId);
|
||||
|
||||
const queries = buildQueries(events, users, participations, meetingPoints, friendships, currentUserId);
|
||||
|
||||
console.log('[FestipodData] Render — NG | events:', events.length,
|
||||
'| users:', users.length, '| participations:', participations.length,
|
||||
'| selectedEvent:', selectedEvent?.title ?? '(none)');
|
||||
|
||||
// --- Mutations (NG) ---
|
||||
const createEvent = useCallback((event: Omit<FpEventData, 'id'>): FpEventData => {
|
||||
console.log('[FestipodData] createEvent (NG):', event.title);
|
||||
(async () => {
|
||||
const session = await sessionPromise;
|
||||
const graph = `did:ng:${session.private_store_id}`;
|
||||
eventsShape.ngSet.add({
|
||||
"@graph": graph, "@type": "http://festipod.org/Event", "@id": "",
|
||||
title: event.title, description: event.description, date: event.date,
|
||||
location: event.location, distance: event.distance,
|
||||
participantCount: event.participantCount || 1,
|
||||
coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials,
|
||||
} as FpEvent);
|
||||
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
|
||||
if (addedEvent && currentUserId) {
|
||||
participationsShape.ngSet.add({
|
||||
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
|
||||
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
setSelectedEventId(addedEvent["@id"]);
|
||||
}
|
||||
})();
|
||||
return { ...event, id: `ng-pending-${Date.now()}` };
|
||||
}, [eventsShape.ngSet, participationsShape.ngSet, currentUserId]);
|
||||
|
||||
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
|
||||
console.log('[FestipodData] updateEvent (NG):', id, updates);
|
||||
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === id);
|
||||
if (ngEvent) {
|
||||
if (updates.title !== undefined) ngEvent.title = updates.title;
|
||||
if (updates.description !== undefined) ngEvent.description = updates.description;
|
||||
if (updates.date !== undefined) ngEvent.date = updates.date;
|
||||
if (updates.location !== undefined) ngEvent.location = updates.location;
|
||||
if (updates.distance !== undefined) ngEvent.distance = updates.distance;
|
||||
if (updates.participantCount !== undefined) ngEvent.participantCount = updates.participantCount;
|
||||
}
|
||||
}, [eventsShape.ngSet]);
|
||||
|
||||
const joinEvent = useCallback((eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
|
||||
const existing = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
|
||||
if (existing) {
|
||||
console.log('[FestipodData] Already participating, skipping');
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const session = await sessionPromise;
|
||||
const graph = `did:ng:${session.private_store_id}`;
|
||||
participationsShape.ngSet.add({
|
||||
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
|
||||
event: eventId, user: uid, isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
|
||||
if (ngEvent) {
|
||||
ngEvent.participantCount = ngEvent.participantCount + 1;
|
||||
}
|
||||
console.log('[FestipodData] joinEvent done');
|
||||
})();
|
||||
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
|
||||
|
||||
const leaveEvent = useCallback((eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid,
|
||||
'| ngSet sizes — events:', eventsShape.ngSet.size,
|
||||
'users:', usersShape.ngSet.size,
|
||||
'participations:', participationsShape.ngSet.size,
|
||||
'| bootstrapDone:', bootstrapDone.current);
|
||||
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
|
||||
if (ngPart) {
|
||||
console.log('[FestipodData] Deleting participation:', ngPart["@id"]);
|
||||
participationsShape.ngSet.delete(ngPart);
|
||||
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
|
||||
if (ngEvent) {
|
||||
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
|
||||
}
|
||||
}
|
||||
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
|
||||
|
||||
const addMeetingPoint = useCallback((mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
|
||||
}, []);
|
||||
|
||||
const addFriend = useCallback((friendId: string) => {
|
||||
setFriendships(prev => {
|
||||
if (prev.some(f =>
|
||||
(f.userId === currentUserId && f.friendId === friendId) ||
|
||||
(f.userId === friendId && f.friendId === currentUserId)
|
||||
)) return prev;
|
||||
return [...prev, { id: `ng-fr-${Date.now()}`, userId: currentUserId, friendId }];
|
||||
});
|
||||
}, [currentUserId]);
|
||||
|
||||
const updateProfile = useCallback((updates: Partial<FpUserData>) => {
|
||||
console.log('[FestipodData] updateProfile (NG):', updates);
|
||||
const ngUser = findNg(usersShape.ngSet as any as Set<FpUserProfile>, u => u.username === '@mariedupont')
|
||||
|| [...usersShape.ngSet][0];
|
||||
if (ngUser) {
|
||||
if (updates.name !== undefined) ngUser.name = updates.name;
|
||||
if (updates.initials !== undefined) ngUser.initials = updates.initials;
|
||||
if (updates.username !== undefined) ngUser.username = updates.username;
|
||||
if (updates.role !== undefined) ngUser.role = updates.role;
|
||||
if (updates.isPublic !== undefined) ngUser.isPublic = updates.isPublic;
|
||||
}
|
||||
}, [usersShape.ngSet]);
|
||||
|
||||
return {
|
||||
currentUserId, currentUser,
|
||||
events, users, participations, meetingPoints, friendships,
|
||||
selectedEventId, setSelectedEventId, selectedEvent,
|
||||
selectedUserId, setSelectedUserId, selectedUser,
|
||||
...queries,
|
||||
createEvent, updateEvent, joinEvent, leaveEvent,
|
||||
addMeetingPoint, addFriend, updateProfile,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider — switches between local and NG
|
||||
// ============================================================================
|
||||
|
||||
function LocalDataProvider({ children }: { children: ReactNode }) {
|
||||
const data = useLocalData();
|
||||
return <FestipodDataContext.Provider value={data}>{children}</FestipodDataContext.Provider>;
|
||||
}
|
||||
|
||||
function NgDataProvider({ children }: { children: ReactNode }) {
|
||||
const data = useNgData();
|
||||
return <FestipodDataContext.Provider value={data}>{children}</FestipodDataContext.Provider>;
|
||||
}
|
||||
|
||||
export function FestipodDataProvider({ children }: { children: ReactNode }) {
|
||||
const { status } = useNextGraph();
|
||||
console.log('[FestipodData] Provider — NG status:', status);
|
||||
|
||||
if (status === 'connected') {
|
||||
return <NgDataProvider>{children}</NgDataProvider>;
|
||||
}
|
||||
return <LocalDataProvider>{children}</LocalDataProvider>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
export function useFestipodData() {
|
||||
const context = useContext(FestipodDataContext);
|
||||
if (!context) {
|
||||
throw new Error('useFestipodData must be used within a FestipodDataProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { session, sessionPromise, init as initNg, type NextGraphSession } from '../utils/ngSession';
|
||||
|
||||
type NgStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
interface NextGraphContextValue {
|
||||
status: NgStatus;
|
||||
session: NextGraphSession | undefined;
|
||||
error: string | undefined;
|
||||
connect: () => void;
|
||||
}
|
||||
|
||||
const NextGraphContext = createContext<NextGraphContextValue>({
|
||||
status: 'disconnected',
|
||||
session: undefined,
|
||||
error: undefined,
|
||||
connect: () => {},
|
||||
});
|
||||
|
||||
// Track whether initNg() has been called (module-level to survive re-renders)
|
||||
let ngInitStarted = false;
|
||||
|
||||
export function NextGraphProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<NgStatus>(session ? 'connected' : 'disconnected');
|
||||
const [ngSession, setNgSession] = useState<NextGraphSession | undefined>(session);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
// Auto-init on mount: register the initNgWeb callback so we catch the
|
||||
// auto-connect event from the NG iframe. This must happen early.
|
||||
useEffect(() => {
|
||||
if (ngInitStarted) return;
|
||||
ngInitStarted = true;
|
||||
|
||||
console.log('[NG] Auto-init: calling initNg() on mount');
|
||||
setStatus('connecting');
|
||||
initNg();
|
||||
|
||||
sessionPromise
|
||||
.then((s) => {
|
||||
console.log('[NG] Session obtained, stores:', {
|
||||
private: s.private_store_id,
|
||||
protected: s.protected_store_id,
|
||||
public: s.public_store_id,
|
||||
});
|
||||
setNgSession(s);
|
||||
setStatus('connected');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[NG] Connection failed:', err);
|
||||
setError(err?.message || 'Connexion NextGraph impossible');
|
||||
setStatus('error');
|
||||
});
|
||||
}, []);
|
||||
|
||||
// connect() is now just a fallback — initNg() already started on mount
|
||||
const connect = useCallback(() => {
|
||||
if (status === 'connecting' || status === 'connected') return;
|
||||
|
||||
console.log('[NG] connect() called, current status:', status);
|
||||
setStatus('connecting');
|
||||
setError(undefined);
|
||||
|
||||
// initNg() is idempotent (initNgWeb handles multiple calls)
|
||||
initNg();
|
||||
|
||||
sessionPromise
|
||||
.then((s) => {
|
||||
setNgSession(s);
|
||||
setStatus('connected');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[NG] Connection failed:', err);
|
||||
setError(err?.message || 'Connexion NextGraph impossible');
|
||||
setStatus('error');
|
||||
});
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<NextGraphContext.Provider value={{ status, session: ngSession, error, connect }}>
|
||||
{children}
|
||||
</NextGraphContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNextGraph() {
|
||||
return useContext(NextGraphContext);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = 'festipod-theme';
|
||||
|
||||
function getSystemTheme(): 'light' | 'dark' {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function getStoredTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'system';
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||
return stored;
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(getStoredTheme);
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(
|
||||
theme === 'system' ? getSystemTheme() : theme
|
||||
);
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
||||
setResolvedTheme(resolved);
|
||||
|
||||
const root = document.documentElement;
|
||||
if (resolved === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
setResolvedTheme(e.matches ? 'dark' : 'light');
|
||||
const root = document.documentElement;
|
||||
if (e.matches) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Centralized data architecture for Festipod mockups
|
||||
*
|
||||
* This module provides:
|
||||
* - User stories definitions
|
||||
* - Screen-to-story bidirectional links (computed automatically from feature files)
|
||||
* - Sample data for mockups
|
||||
*
|
||||
* screenIds are extracted automatically from .feature files by parse-features.ts.
|
||||
* Run `bun run features:parse` to update them.
|
||||
*/
|
||||
|
||||
import { parsedFeatures } from './features';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type StoryCategory = 'WORKSHOP' | 'EVENT' | 'USER' | 'MEETING' | 'NOTIF';
|
||||
|
||||
export interface UserStoryDefinition {
|
||||
id: string;
|
||||
priority: number;
|
||||
category: StoryCategory;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UserStory extends UserStoryDefinition {
|
||||
screenIds: string[];
|
||||
}
|
||||
|
||||
export interface ScreenStories {
|
||||
screenId: string;
|
||||
stories: UserStory[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Category metadata
|
||||
// ============================================================================
|
||||
|
||||
export const categoryLabels: Record<StoryCategory, string> = {
|
||||
WORKSHOP: 'Atelier',
|
||||
EVENT: 'Événement',
|
||||
USER: 'Utilisateur',
|
||||
MEETING: 'Point de rencontre',
|
||||
NOTIF: 'Notification',
|
||||
};
|
||||
|
||||
export const categoryColors: Record<StoryCategory, string> = {
|
||||
WORKSHOP: '#9c27b0',
|
||||
EVENT: '#2196f3',
|
||||
USER: '#4caf50',
|
||||
MEETING: '#ff9800',
|
||||
NOTIF: '#f44336',
|
||||
};
|
||||
|
||||
export const priorityLabels: Record<number, string> = {
|
||||
0: 'Impossible',
|
||||
1: 'Haute',
|
||||
2: 'Moyenne',
|
||||
3: 'Basse',
|
||||
};
|
||||
|
||||
export const priorityColors: Record<number, string> = {
|
||||
0: '#999',
|
||||
1: '#c00',
|
||||
2: '#e60',
|
||||
3: '#08a',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// User Stories Data
|
||||
// ============================================================================
|
||||
|
||||
const userStoriesData: UserStoryDefinition[] = [
|
||||
// Row 1 - WORKSHOP - Priority 3
|
||||
{
|
||||
id: 'us-1',
|
||||
priority: 3,
|
||||
category: 'WORKSHOP',
|
||||
title: 'Visualiser un événement terminé',
|
||||
description: 'En tant qu\'utilisateur, je peux visualiser un événement terminé et consulter le programme détaillé des ateliers par journée/heure afin de voir les personnes qui ont participé à chaque atelier et consulter les notes/liens (Ressources)/commentaires de cet atelier (Zone de partage Collective).',
|
||||
},
|
||||
// Row 2 - WORKSHOP - Priority 3
|
||||
{
|
||||
id: 'us-2',
|
||||
priority: 3,
|
||||
category: 'WORKSHOP',
|
||||
title: 'Visualiser un événement terminé (notes)',
|
||||
description: 'En tant qu\'utilisateur, je peux visualiser un événement terminé et consulter le programme détaillé des ateliers par journée/heure afin de ajouter d\'éventuelles prises de notes/liens (ressources) ou des commentaires associés à l\'atelier (Zone privée de l\'utilisateur et/ou Zone partage publique).',
|
||||
},
|
||||
// Row 3 - EVENT - Priority 1
|
||||
{
|
||||
id: 'us-3',
|
||||
priority: 1,
|
||||
category: 'EVENT',
|
||||
title: 'Visualiser un événement terminé',
|
||||
description: 'En tant qu\'utilisateur, je peux visualiser un événement terminé et consulter la description de l\'événement afin de voir les personnes qui ont participé à cet événement.',
|
||||
},
|
||||
// Row 4 - WORKSHOP - Priority 3
|
||||
{
|
||||
id: 'us-4',
|
||||
priority: 3,
|
||||
category: 'WORKSHOP',
|
||||
title: 'Ajouter/modifier/supprimer un commentaire à un atelier',
|
||||
description: 'En tant qu\'utilisateur, je peux consulter et ajouter/modifier/supprimer un commentaire à un atelier en sélectionnant l\'icône « ajouter un commentaire » en dessous du titre de l\'atelier afin de voir les commentaires précédents et ajouter mes commentaires, mentionner mes ressentis, faire part de mes annotations.',
|
||||
},
|
||||
// Row 5 - EVENT - Priority 3
|
||||
{
|
||||
id: 'us-5',
|
||||
priority: 3,
|
||||
category: 'EVENT',
|
||||
title: 'Ajouter/modifier/supprimer un commentaire à un événement',
|
||||
description: 'En tant qu\'utilisateur, je peux consulter et ajouter/modifier/supprimer un commentaire à un événement en sélectionnant l\'icône « ajouter un commentaire » en dessous du titre de l\'événement (Notes Privées / Personnelles) indiquant les interactions avec les individus rencontrés (Date / Heure / Lieu) afin de voir les commentaires précédents et ajouter mes commentaires, mentionner mes ressentis, faire part de mes annotations.',
|
||||
},
|
||||
// Row 6 - WORKSHOP - Priority 3
|
||||
{
|
||||
id: 'us-6',
|
||||
priority: 3,
|
||||
category: 'WORKSHOP',
|
||||
title: 'M\'inscrire/me désinscrire à un événement (atelier)',
|
||||
description: 'En tant qu\'utilisateur, je peux m\'inscrire/me désinscrire à un événement en : 1- regardant si l\'événement public existe déjà, 2- M\'enregistrant sur les différents ateliers afin de m\'inscrire à l\'atelier tout en visualisant les personnes qui sont déjà pré-inscrites.',
|
||||
},
|
||||
// Row 7 - EVENT - Priority 1
|
||||
{
|
||||
id: 'us-7',
|
||||
priority: 1,
|
||||
category: 'EVENT',
|
||||
title: 'M\'inscrire/me désinscrire à un événement',
|
||||
description: 'En tant qu\'utilisateur, je peux m\'inscrire/me désinscrire à un événement après avoir consulté la description de l\'événement, les dates et le lieu de tenue de l\'événement s\'il existe déjà dans le système, ou en le retrouvant dans une base existante (Mobilizon, etc.).',
|
||||
},
|
||||
// Row 8 - EVENT - Priority 3
|
||||
{
|
||||
id: 'us-8',
|
||||
priority: 3,
|
||||
category: 'EVENT',
|
||||
title: 'Consulter et m\'inscrire à un macro-événement',
|
||||
description: 'En tant qu\'utilisateur, je peux consulter et m\'inscrire à un événement de type « Macro-événement » en créant ou en rattachant des événements existants à ce macro-événement afin de rattacher des événements existants à une thématique particulière ou créer un événement qui est répété est plusieurs périodes dans l\'année (les résidences reconnecté) et voir une consolidation des commentaires / Liens/Ressources /participants de chaque événement rattaché.',
|
||||
},
|
||||
// Row 9 - USER - Priority 0
|
||||
{
|
||||
id: 'us-9',
|
||||
priority: 0,
|
||||
category: 'USER',
|
||||
title: 'Visualiser la photo d\'un individu',
|
||||
description: 'En tant qu\'utilisateur, je peux visualiser la photo d\'un individu (ou ajouter une photo personnelle sur une fiche existante) et consulter la liste des inscrits à un atelier afin de identifier les personnes que j\'ai rencontré dont je n\'ai pas noté leur nom.',
|
||||
},
|
||||
// Row 10 - USER - Priority 1
|
||||
{
|
||||
id: 'us-10',
|
||||
priority: 1,
|
||||
category: 'USER',
|
||||
title: 'Visualiser la fiche/le profil d\'un participant',
|
||||
description: 'En tant qu\'utilisateur, je peux sélectionner un individu dans la liste des inscrits à un événement/atelier afin de voir les événements auxquels la personne a participé et voir un formulaire de contact pour intéragir avec elle.',
|
||||
},
|
||||
// Row 11 - WORKSHOP - Priority 3
|
||||
{
|
||||
id: 'us-11',
|
||||
priority: 3,
|
||||
category: 'WORKSHOP',
|
||||
title: 'Visualiser le bilan consolidé de l\'événement',
|
||||
description: 'En tant qu\'utilisateur, je peux visualiser le bilan consolidé de l\'événement en consultant l\'ensemble des commentaires regroupés par atelier afin de obtenir une synthèse du contenu de chaque atelier et de l\'ensemble des ateliers constituant l\'événement.',
|
||||
},
|
||||
// Row 12 - USER - Priority 2
|
||||
{
|
||||
id: 'us-12',
|
||||
priority: 2,
|
||||
category: 'USER',
|
||||
title: 'Consulter la carte / tableau des événements',
|
||||
description: 'En tant qu\'utilisateur, je peux consulter la carte / tableau des événements auxquels j\'ai participé en filtrant les événements auxquels j\'ai participé par dates ou par personne afin de avoir une vue consolidée des événements auxquels j\'ai participé ainsi que le lieu ou j\'ai pu rencontrer une personne ou une vue consolidée des événements auxquels une personne a participé.',
|
||||
},
|
||||
// Row 13 - EVENT - Priority 1
|
||||
{
|
||||
id: 'us-13',
|
||||
priority: 1,
|
||||
category: 'EVENT',
|
||||
title: 'Créer/Modifier/Supprimer un événement',
|
||||
description: 'En tant qu\'utilisateur, je peux créer/modifier/supprimer un événement en choisissant les dates et les horaires de début et de fin de l\'événement, retirer une organisation (personne ou structure) et choisir un lieu. [Données obligatoires : Titre, description, image, adresse, + thématique (sera utilisé pour les notifications)] afin de créer/présenter le contenu de cet événement et le catégoriser par type/thématique.',
|
||||
},
|
||||
// Row 14 - WORKSHOP - Priority 3
|
||||
{
|
||||
id: 'us-14',
|
||||
priority: 3,
|
||||
category: 'WORKSHOP',
|
||||
title: 'Créer/Modifier/Supprimer un atelier',
|
||||
description: 'En tant qu\'utilisateur, je peux créer/modifier/supprimer un atelier en sélectionnant mon événement et en saisissant les dates et les horaires de début et de fin de l\'atelier afin de définir le programme de mon événement et ajouter description de cet atelier.',
|
||||
},
|
||||
// Row 15 - USER - Priority 1
|
||||
{
|
||||
id: 'us-15',
|
||||
priority: 1,
|
||||
category: 'USER',
|
||||
title: 'Visualiser les inscrits à un atelier/événement',
|
||||
description: 'En tant qu\'utilisateur, je peux visualiser les inscrits à un atelier/événement en sélectionnant l\'atelier/l\'événement désiré dans la liste des événements/ateliers de l\'événement afin de consulter la liste des inscrits triée par ordre alphabétique.',
|
||||
},
|
||||
// Row 16 - MEETING - Priority 1
|
||||
{
|
||||
id: 'us-16',
|
||||
priority: 1,
|
||||
category: 'MEETING',
|
||||
title: 'Indiquer un ou plusieurs points de rencontre',
|
||||
description: 'En tant qu\'utilisateur, je peux indiquer un ou plusieurs points de rencontre en précisant le lieu (café,...) ainsi que l\'heure de cette rencontre (ou le délai ex : 30min avant l\'événement) afin de croiser et faire connaissance d\'autres participants. Je peux aussi échanger avec les autres participants nos liens de contact (QR code ou lien ou bluetooth ?).',
|
||||
},
|
||||
// Row 17 - NOTIF - Priority 2
|
||||
{
|
||||
id: 'us-17',
|
||||
priority: 2,
|
||||
category: 'NOTIF',
|
||||
title: 'Informer automatiquement d\'autres utilisateurs',
|
||||
description: 'En tant qu\'utilisateur, je peux informer automatiquement d\'autres utilisateurs de ma participation à un événement en utilisant un système de notifications (e-mail,...) pour transmettre le lien de l\'événement afin d\'informer les utilisateurs qui résident à proximité de l\'événement (distance à déterminer/configurer). Ou bien informer les utilisateurs ayant manifesté un intérêt pour la thématique de l\'événement. Ou bien informer mes abonnés. Ou bien les trois à la fois.',
|
||||
},
|
||||
// Row 18 - NOTIF - Priority 2
|
||||
{
|
||||
id: 'us-18',
|
||||
priority: 2,
|
||||
category: 'NOTIF',
|
||||
title: 'Être informé lorsque de nouveaux participants s\'inscrivent',
|
||||
description: 'En tant qu\'utilisateur, je peux être informé lorsque de nouveaux participants s\'inscrivent à un événement auquel je suis inscrit en utilisant un système de notifications (e-mail,...) afin de savoir qui participe à un événement. Éventuellement être uniquement informé des participants que je connais déjà (paramétrable ex : mon réseau).',
|
||||
},
|
||||
// Row 19 - NOTIF - Priority 2
|
||||
{
|
||||
id: 'us-19',
|
||||
priority: 2,
|
||||
category: 'NOTIF',
|
||||
title: 'Recevoir un récapitulatif des prochaines rencontres',
|
||||
description: 'En tant qu\'utilisateur, je peux recevoir un récapitulatif des prochaines rencontres en réceptionnant une liste des événements auxquels je suis inscrit ou qui sont proches de chez moi afin de établir un programme des événements auxquels je participe par période (mois, trimestre, année,...).',
|
||||
},
|
||||
// Row 20 - USER - Priority 1
|
||||
{
|
||||
id: 'us-20',
|
||||
priority: 1,
|
||||
category: 'USER',
|
||||
title: 'Voir le profil des personnes faisant partie de mon réseau',
|
||||
description: 'En tant qu\'utilisateur, je peux voir le profil des personnes faisant partie de mon réseau ainsi que le profil des personnes publiques et consulter la description de l\'événement afin de savoir si j\'ai envie de participer à cet événement.',
|
||||
},
|
||||
// Row 21 - USER - Priority 2
|
||||
{
|
||||
id: 'us-21',
|
||||
priority: 2,
|
||||
category: 'USER',
|
||||
title: 'Décider que tous les utilisateurs puissent suivre mes activités',
|
||||
description: 'En tant qu\'utilisateur, je peux décider que tous les utilisateurs puissent suivre toutes mes activités en déclarant mon profil public afin de communiquer au sujet de mes déplacements et faire la publicité des événements auxquels je participe.',
|
||||
},
|
||||
// Row 22 - USER - Priority 2
|
||||
{
|
||||
id: 'us-22',
|
||||
priority: 2,
|
||||
category: 'USER',
|
||||
title: 'Parrainer un nouvel utilisateur',
|
||||
description: 'En tant qu\'utilisateur, je peux parrainer un nouvel utilisateur en lui partageant mon QR code ou lien de contact afin de savoir combien de personnes ont rejoint le réseau grâce à moi.',
|
||||
},
|
||||
// Row 23 - USER - Priority 1
|
||||
{
|
||||
id: 'us-23',
|
||||
priority: 1,
|
||||
category: 'USER',
|
||||
title: 'Me connecter avec d\'autres utilisateurs',
|
||||
description: 'En tant qu\'utilisateur, je peux me connecter avec d\'autres utilisateurs en partageant mon QR code ou mon lien de contact afin de étendre mon réseau.',
|
||||
},
|
||||
// Row 24 - USER - Priority 2
|
||||
{
|
||||
id: 'us-24',
|
||||
priority: 2,
|
||||
category: 'USER',
|
||||
title: 'Être notifié des activités de mes contacts',
|
||||
description: 'En tant qu\'utilisateur, je peux être notifié lorsqu\'un contact participe à des événements afin de obtenir une synthèse du contenu de chaque atelier et de l\'ensemble des ateliers constituant l\'événement.',
|
||||
},
|
||||
// Row 25 - USER - Priority 2
|
||||
{
|
||||
id: 'us-25',
|
||||
priority: 2,
|
||||
category: 'USER',
|
||||
title: 'Être averti des événements susceptibles de m\'intéresser',
|
||||
description: 'En tant qu\'utilisateur, je peux être notifié lorsqu\'un nouvel événement est ajouté près de chez moi et/ou avec une thématique qui m\'intéresse en configurant mes notifications.',
|
||||
},
|
||||
// Row 26 - USER - Priority 2
|
||||
{
|
||||
id: 'us-26',
|
||||
priority: 2,
|
||||
category: 'USER',
|
||||
title: 'Définir la portée d\'un événement',
|
||||
description: 'En tant qu\'utilisateur, je peux créer/présenter le contenu de cet événement et le catégoriser par type/thématique (Liste fixe à déterminer) en indiquant son rayon d\'intérêt en kilomètres afin de m\'assurer que les utilisateurs qui habitent trop loin ne reçoivent pas de notification.',
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Computed data & indexes
|
||||
// ============================================================================
|
||||
|
||||
// Build a map of feature id -> screenIds from parsed features
|
||||
const featureScreenIds = new Map<string, string[]>();
|
||||
for (const feature of parsedFeatures) {
|
||||
featureScreenIds.set(feature.id, feature.screenIds);
|
||||
}
|
||||
|
||||
// Export the stories with screenIds computed from parsed features
|
||||
export const userStories: UserStory[] = userStoriesData.map(story => ({
|
||||
...story,
|
||||
screenIds: featureScreenIds.get(story.id) || [],
|
||||
}));
|
||||
|
||||
// Build reverse index: screenId -> stories
|
||||
const storiesByScreenIndex = new Map<string, UserStory[]>();
|
||||
|
||||
userStories.forEach(story => {
|
||||
story.screenIds.forEach(screenId => {
|
||||
if (!storiesByScreenIndex.has(screenId)) {
|
||||
storiesByScreenIndex.set(screenId, []);
|
||||
}
|
||||
storiesByScreenIndex.get(screenId)!.push(story);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Query functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all stories linked to a specific screen
|
||||
*/
|
||||
export function getStoriesForScreen(screenId: string): UserStory[] {
|
||||
return storiesByScreenIndex.get(screenId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stories filtered by priority
|
||||
*/
|
||||
export function getStoriesByPriority(priority: number): UserStory[] {
|
||||
return userStories.filter(story => story.priority === priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stories filtered by category
|
||||
*/
|
||||
export function getStoriesByCategory(category: StoryCategory): UserStory[] {
|
||||
return userStories.filter(story => story.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a story by its ID
|
||||
*/
|
||||
export function getStoryById(id: string): UserStory | undefined {
|
||||
return userStories.find(story => story.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all screen IDs that have linked stories
|
||||
*/
|
||||
export function getScreenIdsWithStories(): string[] {
|
||||
return Array.from(storiesByScreenIndex.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get story count for a screen
|
||||
*/
|
||||
export function getStoryCountForScreen(screenId: string): number {
|
||||
return storiesByScreenIndex.get(screenId)?.length || 0;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sample data for mockups
|
||||
// ============================================================================
|
||||
|
||||
export const sampleUsers = [
|
||||
{ id: '1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' },
|
||||
{ id: '2', name: 'Jean Durand', initials: 'JD', username: '@jeandurand' },
|
||||
{ id: '3', name: 'Alice Martin', initials: 'AM', username: '@alice' },
|
||||
{ id: '4', name: 'Baptiste Morel', initials: 'BM', username: '@baptiste' },
|
||||
{ id: '5', name: 'Camille Dubois', initials: 'CD', username: '@camille' },
|
||||
{ id: '6', name: 'David Leroy', initials: 'DL', username: '@david' },
|
||||
];
|
||||
|
||||
export const sampleEvents = [
|
||||
{ id: '1', title: 'Résidence Reconnexion', date: '16-20 fév.', location: 'Le Revel, Rogues (30)', participants: 24 },
|
||||
{ id: '2', title: 'Atelier low-tech', date: '8 fév.', location: 'La Maison du Vélo, Lyon', participants: 12 },
|
||||
{ id: '3', title: 'Forum Ouvert Transition', date: '22 fév.', location: 'Tiers-lieu L\'Hermitage', participants: 45 },
|
||||
{ id: '4', title: 'Formation CNV', date: '1 mars', location: 'MJC Montplaisir, Lyon', participants: 16 },
|
||||
];
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Seed/fake data for Festipod mockups.
|
||||
* Used as defaults when not connected to NextGraph or when the store is empty.
|
||||
*/
|
||||
|
||||
import type { FpEventData, FpUserData, FpParticipationData, FpMeetingPointData, FpFriendshipData } from './types';
|
||||
|
||||
// Current user (the logged-in user in the mockup)
|
||||
export const CURRENT_USER_ID = 'user-1';
|
||||
|
||||
export const seedUsers: FpUserData[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
name: 'Marie Dupont',
|
||||
initials: 'MD',
|
||||
username: '@mariedupont',
|
||||
bio: 'Passionnée de transition écologique et de rencontres humaines.',
|
||||
city: 'Lyon, France',
|
||||
eventsCount: 12,
|
||||
friendsCount: 48,
|
||||
participationsCount: 156,
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
name: 'Jean Durand',
|
||||
initials: 'JD',
|
||||
username: '@jeandurand',
|
||||
eventsCount: 8,
|
||||
friendsCount: 23,
|
||||
participationsCount: 42,
|
||||
},
|
||||
{
|
||||
id: 'user-3',
|
||||
name: 'Alice Martin',
|
||||
initials: 'AM',
|
||||
username: '@alice',
|
||||
},
|
||||
{
|
||||
id: 'user-4',
|
||||
name: 'Baptiste Morel',
|
||||
initials: 'BM',
|
||||
username: '@baptiste',
|
||||
},
|
||||
{
|
||||
id: 'user-5',
|
||||
name: 'Camille Dubois',
|
||||
initials: 'CD',
|
||||
username: '@camille',
|
||||
},
|
||||
{
|
||||
id: 'user-6',
|
||||
name: 'David Leroy',
|
||||
initials: 'DL',
|
||||
username: '@david',
|
||||
},
|
||||
{
|
||||
id: 'user-7',
|
||||
name: 'Thomas Martin',
|
||||
initials: 'TM',
|
||||
username: '@thomas',
|
||||
},
|
||||
{
|
||||
id: 'user-8',
|
||||
name: 'Emma Bernard',
|
||||
initials: 'EB',
|
||||
username: '@emma',
|
||||
},
|
||||
{
|
||||
id: 'user-9',
|
||||
name: 'François Petit',
|
||||
initials: 'FP',
|
||||
username: '@francois',
|
||||
},
|
||||
{
|
||||
id: 'user-10',
|
||||
name: 'Emma Girard',
|
||||
initials: 'EG',
|
||||
username: '@emma',
|
||||
eventsCount: 7,
|
||||
},
|
||||
// Public profiles
|
||||
{
|
||||
id: 'user-pub-1',
|
||||
name: 'Léa Bernard',
|
||||
initials: 'LB',
|
||||
username: '@leabernard',
|
||||
role: 'Relayeuse',
|
||||
isPublic: true,
|
||||
eventsCount: 45,
|
||||
},
|
||||
{
|
||||
id: 'user-pub-2',
|
||||
name: 'Marc Richard',
|
||||
initials: 'MR',
|
||||
username: '@marcrichard',
|
||||
role: 'Animateur',
|
||||
isPublic: true,
|
||||
eventsCount: 67,
|
||||
},
|
||||
{
|
||||
id: 'user-pub-3',
|
||||
name: 'Sophie Fontaine',
|
||||
initials: 'SF',
|
||||
username: '@sophief',
|
||||
role: 'Créatrice',
|
||||
isPublic: true,
|
||||
eventsCount: 23,
|
||||
},
|
||||
{
|
||||
id: 'user-pub-4',
|
||||
name: 'Pierre Gagnon',
|
||||
initials: 'PG',
|
||||
username: '@pierreg',
|
||||
role: 'Relayeur',
|
||||
isPublic: true,
|
||||
eventsCount: 89,
|
||||
},
|
||||
];
|
||||
|
||||
export const seedEvents: FpEventData[] = [
|
||||
{
|
||||
id: 'event-1',
|
||||
title: 'Résidence Reconnexion',
|
||||
date: 'Lun. 16 - Ven. 20 fév.',
|
||||
startDate: '2026-02-16',
|
||||
endDate: '2026-02-20',
|
||||
startTime: '09:00',
|
||||
endTime: '18:00',
|
||||
location: 'Le Revel, Rogues (30)',
|
||||
distance: 142,
|
||||
participantCount: 24,
|
||||
description: 'Une semaine collaborative pour se rencontrer, co-créer et faire avancer le projet de Réseau Social Universel. Au programme : sessions plénières en intelligence collective, ateliers en forum ouvert, et randonnée au Cirque de Navacelles. Hébergement sur place au Revel, écolieu à Rogues dans le Gard.',
|
||||
hostName: 'Reconnexion',
|
||||
hostInitials: 'RC',
|
||||
themes: ['Social'],
|
||||
},
|
||||
{
|
||||
id: 'event-2',
|
||||
title: 'Atelier low-tech',
|
||||
date: 'Sam. 8 fév. · 14h00',
|
||||
startDate: '2026-02-08',
|
||||
startTime: '14:00',
|
||||
endTime: '17:00',
|
||||
location: 'La Maison du Vélo, Lyon',
|
||||
distance: 3,
|
||||
participantCount: 12,
|
||||
description: 'Un atelier pratique pour découvrir les low-tech et apprendre à fabriquer des objets du quotidien.',
|
||||
hostName: 'La Maison du Vélo',
|
||||
hostInitials: 'MV',
|
||||
themes: ['Tech', 'Nature'],
|
||||
},
|
||||
{
|
||||
id: 'event-3',
|
||||
title: 'Forum Ouvert Transition',
|
||||
date: 'Sam. 22 fév. · 9h00',
|
||||
startDate: '2026-02-22',
|
||||
startTime: '09:00',
|
||||
endTime: '18:00',
|
||||
location: "Tiers-lieu L'Hermitage",
|
||||
distance: 89,
|
||||
participantCount: 45,
|
||||
description: "Un forum ouvert sur la transition écologique et sociale, dans le tiers-lieu L'Hermitage.",
|
||||
hostName: "L'Hermitage",
|
||||
hostInitials: 'LH',
|
||||
themes: ['Social', 'Nature'],
|
||||
},
|
||||
{
|
||||
id: 'event-4',
|
||||
title: 'Formation CNV',
|
||||
date: 'Sam. 1 mars · 9h30',
|
||||
startDate: '2026-03-01',
|
||||
startTime: '09:30',
|
||||
endTime: '17:00',
|
||||
location: 'MJC Montplaisir, Lyon',
|
||||
distance: 5,
|
||||
participantCount: 16,
|
||||
description: 'Initiation à la Communication Non Violente. Venez découvrir les bases de la CNV pour améliorer vos relations.',
|
||||
hostName: 'MJC Montplaisir',
|
||||
hostInitials: 'MJ',
|
||||
themes: ['Social'],
|
||||
},
|
||||
{
|
||||
id: 'event-5',
|
||||
title: 'Rencontre des Colibris',
|
||||
date: 'Mer. 12 fév. · 19h00',
|
||||
startDate: '2026-02-12',
|
||||
startTime: '19:00',
|
||||
endTime: '21:00',
|
||||
location: "La Maison de l'Environnement",
|
||||
distance: 7,
|
||||
participantCount: 30,
|
||||
description: "Rencontre mensuelle du groupe local des Colibris pour échanger sur les projets en cours.",
|
||||
hostName: 'Les Colibris',
|
||||
hostInitials: 'LC',
|
||||
themes: ['Social', 'Nature'],
|
||||
},
|
||||
];
|
||||
|
||||
// Participations: which users are in which events
|
||||
export const seedParticipations: FpParticipationData[] = [
|
||||
// Marie (current user) participates in events 1, 2, 3
|
||||
{ id: 'part-1', eventId: 'event-1', userId: 'user-1', isConfirmed: true },
|
||||
{ id: 'part-2', eventId: 'event-2', userId: 'user-1', isConfirmed: true },
|
||||
{ id: 'part-3', eventId: 'event-3', userId: 'user-1', isConfirmed: true },
|
||||
// Jean participates in events 1
|
||||
{ id: 'part-4', eventId: 'event-1', userId: 'user-2', isConfirmed: true },
|
||||
// Thomas participates in event 1
|
||||
{ id: 'part-5', eventId: 'event-1', userId: 'user-7', isConfirmed: true },
|
||||
];
|
||||
|
||||
export const seedMeetingPoints: FpMeetingPointData[] = [
|
||||
{
|
||||
id: 'mp-1',
|
||||
eventId: 'event-1',
|
||||
location: 'Café de la Place',
|
||||
time: '30 min avant',
|
||||
hostName: 'Marie',
|
||||
hostInitials: 'MD',
|
||||
},
|
||||
{
|
||||
id: 'mp-2',
|
||||
eventId: 'event-1',
|
||||
location: 'Station de métro Bellecour',
|
||||
time: '15h30',
|
||||
hostName: 'Jean',
|
||||
hostInitials: 'JD',
|
||||
},
|
||||
];
|
||||
|
||||
export const seedFriendships: FpFriendshipData[] = [
|
||||
{ id: 'fr-1', userId: 'user-1', friendId: 'user-2' },
|
||||
{ id: 'fr-2', userId: 'user-1', friendId: 'user-3' },
|
||||
{ id: 'fr-3', userId: 'user-1', friendId: 'user-4' },
|
||||
{ id: 'fr-4', userId: 'user-1', friendId: 'user-5' },
|
||||
{ id: 'fr-5', userId: 'user-1', friendId: 'user-6' },
|
||||
{ id: 'fr-6', userId: 'user-1', friendId: 'user-10' },
|
||||
];
|
||||
@@ -0,0 +1,388 @@
|
||||
// Auto-generated by scripts/extract-step-definitions.ts
|
||||
// Do not edit manually - run "bun run steps:extract" to regenerate
|
||||
|
||||
export interface StepDefinitionInfo {
|
||||
pattern: string;
|
||||
keyword: 'Given' | 'When' | 'Then';
|
||||
file: string;
|
||||
sourceCode: string;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
{
|
||||
"pattern": "je clique sur un événement",
|
||||
"keyword": "When",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "When('je clique sur un événement', async function (this: FestipodWorld) {\n this.navigateTo('#/demo/event-detail');\n});",
|
||||
"lineNumber": 5
|
||||
},
|
||||
{
|
||||
"pattern": "je visualise l'événement {string}",
|
||||
"keyword": "Given",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Given('je visualise l\\'événement {string}', async function (this: FestipodWorld, eventName: string) {\n this.navigateTo('#/demo/event-detail');\n expect(this.currentScreen, 'Event detail screen should be loaded').to.not.be.null;\n this.attach(`Viewing event: ${eventName}`, 'text/plain');\n});",
|
||||
"lineNumber": 9
|
||||
},
|
||||
{
|
||||
"pattern": "je peux annuler et revenir à l'écran précédent",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('je peux annuler et revenir à l\\'écran précédent', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n const found = /onClick\\s*=\\s*\\{\\s*\\(\\)\\s*=>\\s*navigate\\s*\\(['\"]home['\"]\\)\\s*\\}[^>]*>✕</.test(source);\n expect(found, 'Create event screen should have ✕ button with navigate(\"home\")').to.be.true;\n});",
|
||||
"lineNumber": 15
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir la liste des participants",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('je peux voir la liste des participants', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n const hasAvatars = /<Avatar/.test(source);\n const hasParticipantsSection = /Participants\\s*\\(\\d+\\)/.test(source);\n expect(hasAvatars, 'Event detail should have Avatar components for participants').to.be.true;\n expect(hasParticipantsSection, 'Event detail should have \"Participants (N)\" section').to.be.true;\n});",
|
||||
"lineNumber": 22
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir les détails de l'événement",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('je peux voir les détails de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n expect(/<Title[^>]*>[^<]+<\\/Title>/.test(source), 'Event detail should have a Title').to.be.true;\n expect(/📅/.test(source), 'Event detail should have date emoji 📅').to.be.true;\n expect(/🕓/.test(source), 'Event detail should have time emoji 🕓').to.be.true;\n expect(/📍/.test(source), 'Event detail should have location emoji 📍').to.be.true;\n expect(/À propos/.test(source), 'Event detail should have \"À propos\" section').to.be.true;\n});",
|
||||
"lineNumber": 31
|
||||
},
|
||||
{
|
||||
"pattern": "l'écran affiche les informations de l'événement",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran affiche les informations de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n expect(/<Title[^>]*>[^<]+<\\/Title>/.test(source), 'Event detail should have a Title').to.be.true;\n expect(/📅/.test(source), 'Event detail should have date emoji 📅').to.be.true;\n expect(/🕓/.test(source), 'Event detail should have time emoji 🕓').to.be.true;\n expect(/📍/.test(source), 'Event detail should have location emoji 📍').to.be.true;\n expect(/À propos/.test(source), 'Event detail should have \"À propos\" section').to.be.true;\n});",
|
||||
"lineNumber": 41
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir la liste des événements",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('je peux voir la liste des événements', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n if (this.currentScreenId === 'home') {\n expect(/Mes événements à venir/.test(source), 'Home screen should have \"Événements à venir\" text').to.be.true;\n } else if (this.currentScreenId === 'events') {\n expect(/<Card[^>]*onClick/.test(source), 'Events screen should have clickable Card components').to.be.true;\n } else {\n expect.fail(`Unexpected screen \"${this.currentScreenId}\" - events list should be on home or events screen`);\n }\n});",
|
||||
"lineNumber": 51
|
||||
},
|
||||
{
|
||||
"pattern": "les événements affichent leur lieu",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('les événements affichent leur lieu', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n const locationPattern = /📍.*<span[^>]*className=\"user-content\"[^>]*>[^<]+<\\/span>/;\n expect(locationPattern.test(source), 'Event cards should display location text after 📍 emoji').to.be.true;\n});",
|
||||
"lineNumber": 62
|
||||
},
|
||||
{
|
||||
"pattern": "je peux m'inscrire à l'événement",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('je peux m\\'inscrire à l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n const hasParticiperButton = /isJoined \\? '✓ Inscrit' : 'Participer'/.test(source);\n expect(hasParticiperButton, 'Event detail should have Participer/Inscrit toggle button').to.be.true;\n});",
|
||||
"lineNumber": 68
|
||||
},
|
||||
{
|
||||
"pattern": "je peux me désinscrire de l'événement",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('je peux me désinscrire de l\\'événement', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('event-detail');\n const source = this.getRenderedText();\n const hasInscritButton = /isJoined \\? '✓ Inscrit' : 'Participer'/.test(source);\n expect(hasInscritButton, 'Event detail should have Participer/Inscrit toggle button (click to unregister)').to.be.true;\n});",
|
||||
"lineNumber": 75
|
||||
},
|
||||
{
|
||||
"pattern": "le formulaire contient le champ obligatoire {string}",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('le formulaire contient le champ obligatoire {string}', async function (this: FestipodWorld, fieldName: string) {\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n const source = this.getRenderedText();\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`>${escapedName}\\\\s*\\\\*<`);\n expect(pattern.test(source), `Field \"${fieldName}\" should be marked as required (with *) in create-event screen`).to.be.true;\n});",
|
||||
"lineNumber": 84
|
||||
},
|
||||
{
|
||||
"pattern": "le formulaire contient les champs obligatoires suivants:",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('le formulaire contient les champs obligatoires suivants:', async function (this: FestipodWorld, dataTable) {\n expect(this.currentScreenId, 'This step is for form screens only').to.equal('create-event');\n const source = this.getRenderedText();\n const expectedFields = dataTable.raw().flat();\n expectedFields.forEach((fieldName: string) => {\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`>${escapedName}\\\\s*\\\\*<`);\n expect(pattern.test(source), `Field \"${fieldName}\" should be marked as required (with *) in create-event screen`).to.be.true;\n });",
|
||||
"lineNumber": 92
|
||||
},
|
||||
{
|
||||
"pattern": "le formulaire permet de détecter les doublons",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('le formulaire permet de détecter les doublons', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n expect(/showDuplicateWarning/.test(source), 'Form should have duplicate detection logic').to.be.true;\n expect(/Événement similaire détecté/.test(source), 'Form should have duplicate warning message').to.be.true;\n});",
|
||||
"lineNumber": 103
|
||||
},
|
||||
{
|
||||
"pattern": "le formulaire permet d'importer depuis Mobilizon ou Transiscope",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('le formulaire permet d\\'importer depuis Mobilizon ou Transiscope', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n expect(/importableEvents/.test(source), 'Form should have importable events data').to.be.true;\n expect(/Mobilizon/.test(source), 'Form should support Mobilizon import').to.be.true;\n expect(/Transiscope/.test(source), 'Form should support Transiscope import').to.be.true;\n expect(/Importer depuis une source externe/.test(source), 'Form should have import section').to.be.true;\n});",
|
||||
"lineNumber": 110
|
||||
},
|
||||
{
|
||||
"pattern": "l'import externe ne déclenche pas d'alerte doublon",
|
||||
"keyword": "Then",
|
||||
"file": "event.steps.ts",
|
||||
"sourceCode": "Then('l\\'import externe ne déclenche pas d\\'alerte doublon', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('create-event');\n const source = this.getRenderedText();\n expect(/importedFrom/.test(source), 'Form should track import source').to.be.true;\n expect(/&& !importedFrom/.test(source), 'Duplicate warning should be disabled for imports').to.be.true;\n});",
|
||||
"lineNumber": 119
|
||||
},
|
||||
{
|
||||
"pattern": "je peux configurer mes notifications",
|
||||
"keyword": "Then",
|
||||
"file": "home.steps.ts",
|
||||
"sourceCode": "Then('je peux configurer mes notifications', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('settings');\n const source = this.getRenderedText();\n expect(/>Notifications</.test(source), 'Settings should have \"Notifications\" text').to.be.true;\n expect(/<Toggle[^>]*checked=\\{notifications\\}/.test(source), 'Settings should have Toggle for notifications').to.be.true;\n});",
|
||||
"lineNumber": 5
|
||||
},
|
||||
{
|
||||
"pattern": "je clique sur un participant",
|
||||
"keyword": "When",
|
||||
"file": "user.steps.ts",
|
||||
"sourceCode": "When('je clique sur un participant', async function (this: FestipodWorld) {\n this.navigateTo('#/demo/user-profile');\n});",
|
||||
"lineNumber": 5
|
||||
},
|
||||
{
|
||||
"pattern": "je visualise le profil de {string}",
|
||||
"keyword": "Given",
|
||||
"file": "user.steps.ts",
|
||||
"sourceCode": "Given('je visualise le profil de {string}', async function (this: FestipodWorld, userName: string) {\n this.navigateTo('#/demo/user-profile');\n expect(this.currentScreen, 'User profile screen should be loaded').to.not.be.null;\n this.attach(`Viewing profile: ${userName}`, 'text/plain');\n});",
|
||||
"lineNumber": 9
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir mon profil",
|
||||
"keyword": "Then",
|
||||
"file": "user.steps.ts",
|
||||
"sourceCode": "Then('je peux voir mon profil', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('profile');\n const source = this.getRenderedText();\n expect(/<Avatar[^>]*initials=\"MD\"[^>]*size=\"lg\"/.test(source), 'Profile should have Avatar with initials=\"MD\" and size=\"lg\"').to.be.true;\n expect(/<Title[^>]*>Marie Dupont<\\/Title>/.test(source), 'Profile should have Title \"Marie Dupont\"').to.be.true;\n expect(/@mariedupont/.test(source), 'Profile should have username @mariedupont').to.be.true;\n});",
|
||||
"lineNumber": 15
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir le profil de l'utilisateur",
|
||||
"keyword": "Then",
|
||||
"file": "user.steps.ts",
|
||||
"sourceCode": "Then('je peux voir le profil de l\\'utilisateur', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n expect(/<Avatar[^>]*initials=\"JD\"[^>]*size=\"lg\"/.test(source), 'User profile should have Avatar with initials=\"JD\" and size=\"lg\"').to.be.true;\n expect(/<Title[^>]*>Jean Durand<\\/Title>/.test(source), 'User profile should have Title \"Jean Durand\"').to.be.true;\n expect(/@jeandurand/.test(source), 'User profile should have username @jeandurand').to.be.true;\n});",
|
||||
"lineNumber": 23
|
||||
},
|
||||
{
|
||||
"pattern": "l'écran affiche les informations du profil",
|
||||
"keyword": "Then",
|
||||
"file": "user.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran affiche les informations du profil', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n if (this.currentScreenId === 'profile') {\n expect(/<Avatar[^>]*initials=\"MD\"/.test(source), 'Profile should have Avatar with initials=\"MD\"').to.be.true;\n expect(/<Title[^>]*>Marie Dupont<\\/Title>/.test(source), 'Profile should have Title \"Marie Dupont\"').to.be.true;\n expect(/@mariedupont/.test(source), 'Profile should have username @mariedupont').to.be.true;\n } else if (this.currentScreenId === 'user-profile') {\n expect(/<Avatar[^>]*initials=\"JD\"/.test(source), 'User profile should have Avatar with initials=\"JD\"').to.be.true;\n expect(/<Title[^>]*>Jean Durand<\\/Title>/.test(source), 'User profile should have Title \"Jean Durand\"').to.be.true;\n expect(/@jeandurand/.test(source), 'User profile should have username @jeandurand').to.be.true;\n } else {\n expect.fail(`Unexpected screen \"${this.currentScreenId}\" for profile info check`);\n }\n});",
|
||||
"lineNumber": 31
|
||||
},
|
||||
{
|
||||
"pattern": "je peux contacter l'utilisateur",
|
||||
"keyword": "Then",
|
||||
"file": "user.steps.ts",
|
||||
"sourceCode": "Then('je peux contacter l\\'utilisateur', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n const hasContactButton = /<Button>Contacter<\\/Button>/.test(source);\n expect(hasContactButton, 'User profile should have \"Contacter\" button').to.be.true;\n});",
|
||||
"lineNumber": 46
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir les événements auxquels l'utilisateur a participé",
|
||||
"keyword": "Then",
|
||||
"file": "user.steps.ts",
|
||||
"sourceCode": "Then('je peux voir les événements auxquels l\\'utilisateur a participé', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n expect(/Événements à venir/.test(source), 'User profile should have \"Événements à venir\" section').to.be.true;\n expect(/Événements passés/.test(source), 'User profile should have \"Événements passés\" section').to.be.true;\n});",
|
||||
"lineNumber": 53
|
||||
},
|
||||
{
|
||||
"pattern": "les événements affichent leur localisation et distance",
|
||||
"keyword": "Then",
|
||||
"file": "user.steps.ts",
|
||||
"sourceCode": "Then('les événements affichent leur localisation et distance', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const source = this.getRenderedText();\n expect(/location: '[^']+'/.test(source), 'Events should have location data').to.be.true;\n expect(/distance: \\d+/.test(source), 'Events should have distance data').to.be.true;\n expect(/\\{event\\.location\\}/.test(source), 'Events should render location').to.be.true;\n expect(/\\{event\\.distance\\}/.test(source), 'Events should render distance').to.be.true;\n});",
|
||||
"lineNumber": 60
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir le QR code",
|
||||
"keyword": "Then",
|
||||
"file": "user.steps.ts",
|
||||
"sourceCode": "Then('je peux voir le QR code', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n if (this.currentScreenId === 'share-profile') {\n expect(/QR Code/.test(source), 'Share profile should have \"QR Code\" text').to.be.true;\n expect(/Scannez pour me retrouver/.test(source), 'Share profile should have \"Scannez pour me retrouver\" text').to.be.true;\n } else if (this.currentScreenId === 'meeting-points') {\n expect(/Mon QR Code/.test(source), 'Meeting points should have \"Mon QR Code\" text').to.be.true;\n expect(/Scannez pour m'ajouter/.test(source), 'Meeting points should have \"Scannez pour m\\'ajouter\" text').to.be.true;\n } else {\n expect.fail(`QR code should be on share-profile or meeting-points, not \"${this.currentScreenId}\"`);\n }\n});",
|
||||
"lineNumber": 69
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir le lien de partage",
|
||||
"keyword": "Then",
|
||||
"file": "user.steps.ts",
|
||||
"sourceCode": "Then('je peux voir le lien de partage', async function (this: FestipodWorld) {\n expect(this.currentScreenId, 'Share link should be on share-profile screen').to.equal('share-profile');\n const source = this.getRenderedText();\n expect(/Mon lien de profil/.test(source), 'Share profile should have \"Mon lien de profil\" text').to.be.true;\n expect(/festipod\\.app\\/u\\//.test(source), 'Share profile should have profile link URL').to.be.true;\n});",
|
||||
"lineNumber": 82
|
||||
},
|
||||
{
|
||||
"pattern": "l'écran {string} est affiché",
|
||||
"keyword": "Given",
|
||||
"file": "form.steps.ts",
|
||||
"sourceCode": "Given('l\\'écran {string} est affiché', async function (this: FestipodWorld, screenName: string) {\n const screenId = screenName.toLowerCase().replace(/ /g, '-');\n this.navigateTo(`#/demo/${screenId}`);\n});",
|
||||
"lineNumber": 5
|
||||
},
|
||||
{
|
||||
"pattern": "le formulaire de création est vide",
|
||||
"keyword": "Given",
|
||||
"file": "form.steps.ts",
|
||||
"sourceCode": "Given('le formulaire de création est vide', async function (this: FestipodWorld) {\n this.formFields.forEach((field, key) => {\n this.formFields.set(key, { ...field, value: '' });",
|
||||
"lineNumber": 10
|
||||
},
|
||||
{
|
||||
"pattern": "le champ {string} est facultatif",
|
||||
"keyword": "Then",
|
||||
"file": "form.steps.ts",
|
||||
"sourceCode": "Then('le champ {string} est facultatif', async function (this: FestipodWorld, fieldName: string) {\n const source = this.getRenderedText();\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const existsPattern = new RegExp(`>${escapedName}<`);\n const requiredPattern = new RegExp(`>${escapedName}\\\\s*\\\\*<`);\n expect(existsPattern.test(source), `Field \"${fieldName}\" should exist in screen`).to.be.true;\n expect(requiredPattern.test(source), `Field \"${fieldName}\" should NOT be marked as required`).to.be.false;\n});",
|
||||
"lineNumber": 16
|
||||
},
|
||||
{
|
||||
"pattern": "le champ {string} est présent",
|
||||
"keyword": "Then",
|
||||
"file": "form.steps.ts",
|
||||
"sourceCode": "Then('le champ {string} est présent', async function (this: FestipodWorld, fieldName: string) {\n const source = this.getRenderedText();\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`>${escapedName}[^<]*<`);\n expect(pattern.test(source), `Field \"${fieldName}\" should be present in screen`).to.be.true;\n});",
|
||||
"lineNumber": 25
|
||||
},
|
||||
{
|
||||
"pattern": "Scénario non implémenté",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('Scénario non implémenté', async function (this: FestipodWorld) {\n return 'skipped';\n});",
|
||||
"lineNumber": 7
|
||||
},
|
||||
{
|
||||
"pattern": "je suis sur la page {string}",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('je suis sur la page {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n this.navigateTo(`#/demo/${screenId}`);\n});",
|
||||
"lineNumber": 38
|
||||
},
|
||||
{
|
||||
"pattern": "je suis connecté en tant qu'utilisateur",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('je suis connecté en tant qu\\'utilisateur', async function (this: FestipodWorld) {\n this.isAuthenticated = true;\n});",
|
||||
"lineNumber": 43
|
||||
},
|
||||
{
|
||||
"pattern": "je suis connecté",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('je suis connecté', async function (this: FestipodWorld) {\n this.isAuthenticated = true;\n});",
|
||||
"lineNumber": 47
|
||||
},
|
||||
{
|
||||
"pattern": "je ne suis pas connecté",
|
||||
"keyword": "Given",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Given('je ne suis pas connecté', async function (this: FestipodWorld) {\n this.isAuthenticated = false;\n});",
|
||||
"lineNumber": 51
|
||||
},
|
||||
{
|
||||
"pattern": "je navigue vers {string}",
|
||||
"keyword": "When",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "When('je navigue vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n this.navigateTo(`#/demo/${screenId}`);\n});",
|
||||
"lineNumber": 55
|
||||
},
|
||||
{
|
||||
"pattern": "je clique sur {string}",
|
||||
"keyword": "When",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "When('je clique sur {string}', async function (this: FestipodWorld, elementName: string) {\n const source = this.getRenderedText();\n const escapedName = elementName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`onClick[^>]*>[^<]*${escapedName}`, 'i');\n expect(pattern.test(source), `Clickable element \"${elementName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 60
|
||||
},
|
||||
{
|
||||
"pattern": "je sélectionne {string}",
|
||||
"keyword": "When",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "When('je sélectionne {string}', async function (this: FestipodWorld, elementName: string) {\n const source = this.getRenderedText();\n const escapedName = elementName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`onClick[^>]*>[^<]*${escapedName}`, 'i');\n expect(pattern.test(source), `Selectable element \"${elementName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 67
|
||||
},
|
||||
{
|
||||
"pattern": "je clique sur le bouton {string}",
|
||||
"keyword": "When",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "When('je clique sur le bouton {string}', async function (this: FestipodWorld, buttonName: string) {\n const source = this.getRenderedText();\n const escapedName = buttonName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`<Button[^>]*>[^<]*${escapedName}[^<]*</Button>`, 'i');\n expect(pattern.test(source), `Button \"${buttonName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 74
|
||||
},
|
||||
{
|
||||
"pattern": "je suis redirigé vers {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('je suis redirigé vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n expect(this.currentScreenId).to.equal(screenId);\n});",
|
||||
"lineNumber": 81
|
||||
},
|
||||
{
|
||||
"pattern": "je vois l'écran {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('je vois l\\'écran {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n expect(this.currentScreenId).to.equal(screenId);\n});",
|
||||
"lineNumber": 86
|
||||
},
|
||||
{
|
||||
"pattern": "je reste sur la page {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('je reste sur la page {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n expect(this.currentScreenId).to.equal(screenId);\n});",
|
||||
"lineNumber": 91
|
||||
},
|
||||
{
|
||||
"pattern": "l'écran contient une section {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran contient une section {string}', async function (this: FestipodWorld, sectionName: string) {\n expect(this.hasText(sectionName), `Section \"${sectionName}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 96
|
||||
},
|
||||
{
|
||||
"pattern": "je peux naviguer vers {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('je peux naviguer vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n const source = this.getRenderedText();\n const pattern = new RegExp(`navigate\\\\s*\\\\(\\\\s*['\"]${screenId}['\"]\\\\s*\\\\)`);\n expect(pattern.test(source), `Navigation to \"${screenId}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 100
|
||||
},
|
||||
{
|
||||
"pattern": "la navigation affiche {string} comme actif",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('la navigation affiche {string} comme actif', async function (this: FestipodWorld, menuItem: string) {\n const source = this.getRenderedText();\n const escapedItem = menuItem.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`label:\\\\s*['\"]${escapedItem}['\"][^}]*active:\\\\s*true`, 'i');\n expect(pattern.test(source), `Menu item \"${menuItem}\" should be active in NavBar of screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 107
|
||||
},
|
||||
{
|
||||
"pattern": "l'écran contient un bouton {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran contient un bouton {string}', async function (this: FestipodWorld, buttonText: string) {\n expect(this.hasText(buttonText), `Button \"${buttonText}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 114
|
||||
},
|
||||
{
|
||||
"pattern": "l'écran contient un champ {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran contient un champ {string}', async function (this: FestipodWorld, fieldLabel: string) {\n expect(this.hasText(fieldLabel), `Field \"${fieldLabel}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 118
|
||||
},
|
||||
{
|
||||
"pattern": "l'écran contient un texte {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran contient un texte {string}', async function (this: FestipodWorld, text: string) {\n expect(this.hasText(text), `Text \"${text}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 122
|
||||
},
|
||||
{
|
||||
"pattern": "l'écran contient un avatar",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('l\\'écran contient un avatar', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n const hasAvatar = /<Avatar/.test(source);\n expect(hasAvatar, `Avatar should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
|
||||
"lineNumber": 126
|
||||
},
|
||||
{
|
||||
"pattern": "je peux voir la section {string}",
|
||||
"keyword": "Then",
|
||||
"file": "navigation.steps.ts",
|
||||
"sourceCode": "Then('je peux voir la section {string}', async function (this: FestipodWorld, sectionName: string) {\n const source = this.getRenderedText();\n const found = source.includes(sectionName);\n if (!found) {\n this.attach(`Looking for section: \"${sectionName}\"`, 'text/plain');\n this.attach(`Rendered text: ${source.substring(0, 500)}...`, 'text/plain');\n }\n expect(found, `Section \"${sectionName}\" should be visible on screen`).to.be.true;\n});",
|
||||
"lineNumber": 132
|
||||
}
|
||||
];
|
||||
|
||||
export function findStepDefinition(stepText: string): StepDefinitionInfo | null {
|
||||
for (const def of stepDefinitions) {
|
||||
// Convert Cucumber expression to regex
|
||||
// {string} -> "[^"]+"
|
||||
// {int} -> \\d+
|
||||
const regexPattern = def.pattern
|
||||
.replace(/\{string\}/g, '"[^"]+"')
|
||||
.replace(/\{int\}/g, '\\d+');
|
||||
|
||||
try {
|
||||
const regex = new RegExp(regexPattern);
|
||||
if (regex.test(stepText)) {
|
||||
return def;
|
||||
}
|
||||
} catch {
|
||||
// If pattern fails, try simple includes
|
||||
const simplified = def.pattern.replace(/\{string\}/g, '').replace(/\{int\}/g, '').trim();
|
||||
if (stepText.includes(simplified)) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
// Auto-generated by scripts/parse-test-results.ts
|
||||
// Do not edit manually - run "bun run test:results" to regenerate
|
||||
import type { FeatureTestStatus, ScenarioTestResult } from '../types/gherkin';
|
||||
|
||||
interface RawFeatureTestStatus {
|
||||
featureId: string;
|
||||
totalScenarios: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
lastRun?: string;
|
||||
scenarios?: ScenarioTestResult[];
|
||||
}
|
||||
|
||||
const rawResults: RawFeatureTestStatus[] = [
|
||||
{
|
||||
"featureId": "us-13",
|
||||
"totalScenarios": 10,
|
||||
"passed": 7,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au formulaire de relai d'événement",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les champs obligatoires du formulaire",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier la présence du bouton de relai",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Pouvoir annuler le relai d'événement",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Détecter un événement similaire déjà relayé",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Importer un événement depuis une source externe",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Pas d'alerte doublon lors d'un import externe",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Modifier un événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Supprimer un événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Retirer une organisation (personne ou structure)",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-3",
|
||||
"totalScenarios": 3,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux détails d'un événement terminé",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir la description de l'événement",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir la liste des participants",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-5",
|
||||
"totalScenarios": 5,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les commentaires existants",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Ajouter un commentaire",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Modifier un commentaire",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Supprimer un commentaire",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Enregistrer les interactions avec des individus (Date/Heure/Lieu)",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-7",
|
||||
"totalScenarios": 6,
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.878Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Consulter un événement avant inscription",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "S'inscrire à un événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Se désinscrire d'un événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Rechercher un événement existant",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données de l'écran",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Rechercher dans une base existante (Mobilizon)",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-8",
|
||||
"totalScenarios": 8,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 8,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Consulter un macro-événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir les événements rattachés",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Rattacher un événement existant",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir la consolidation des participants",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Créer un macro-événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir la consolidation des commentaires/liens/ressources",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Rattacher à une thématique particulière",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Gérer un événement répété sur plusieurs périodes",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-16",
|
||||
"totalScenarios": 4,
|
||||
"passed": 4,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux points de rencontre",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir le bouton pour proposer un point de rencontre",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Ouvrir le formulaire de proposition",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Définir l'heure de rencontre",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-17",
|
||||
"totalScenarios": 5,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Partager un événement auquel je participe",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Informer les utilisateurs à proximité",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Informer les utilisateurs par thématique",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Informer mes abonnés",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Combiner les options de notification",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-18",
|
||||
"totalScenarios": 4,
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Configurer les notifications de nouveaux participants",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Activer les notifications pour un événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Filtrer les notifications par réseau",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir les nouveaux participants sur l'accueil",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-19",
|
||||
"totalScenarios": 5,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les événements à venir sur l'accueil",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir le récapitulatif par période",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir les événements proches géographiquement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir mes inscriptions",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données de l'accueil",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-10",
|
||||
"totalScenarios": 6,
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au profil d'un participant",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir les événements du participant",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir la localisation des événements",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir le formulaire de contact",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les informations du profil",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir les détails du profil utilisateur",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-12",
|
||||
"totalScenarios": 7,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la liste des événements depuis le profil",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Accéder à la liste des événements depuis découvrir",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Filtrer par date",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Filtrer par personne",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données de l'écran événements",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données de l'écran profil",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir la vue carte des événements",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-15",
|
||||
"totalScenarios": 5,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la liste des inscrits d'un événement",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Accéder à la liste des inscrits d'un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir la liste des participants d'un événement",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir la liste des participants d'un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Cliquer sur un inscrit pour voir son profil",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-20",
|
||||
"totalScenarios": 6,
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à mon profil",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir mon réseau",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir un profil de mon réseau",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Consulter un événement depuis un profil",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données du profil",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir les profils publiques",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-21",
|
||||
"totalScenarios": 5,
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de profil",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Configurer la visibilité du profil",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Rendre le profil public",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données des paramètres",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données du profil",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-22",
|
||||
"totalScenarios": 5,
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au partage de profil",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Naviguer vers le partage de profil",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir le QR code de parrainage",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir le lien de parrainage",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir les statistiques de parrainage",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-23",
|
||||
"totalScenarios": 5,
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au partage depuis le profil",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir le QR code",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir le lien de partage",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Accéder à l'écran de partage dédié",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données du profil",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-24",
|
||||
"totalScenarios": 3,
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de notification",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Configurer les notifications de contacts",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Voir les activités de mes contacts sur l'accueil",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-25",
|
||||
"totalScenarios": 3,
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"skipped": 2,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux paramètres de notification",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Configurer le rayon de notification",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Configurer les thématiques d'intérêt",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-26",
|
||||
"totalScenarios": 4,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au formulaire de relai d'événement",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Définir le rayon d'intérêt",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Choisir une thématique",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les champs obligatoires",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-9",
|
||||
"totalScenarios": 5,
|
||||
"passed": 4,
|
||||
"failed": 0,
|
||||
"skipped": 1,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au profil pour voir la photo",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Naviguer vers le profil depuis la liste des participants",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Consulter la liste des inscrits à un atelier",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les champs de données du profil",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "Ajouter une photo personnelle sur une fiche existante",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-1",
|
||||
"totalScenarios": 5,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder aux détails d'un événement terminé",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Consulter la liste des participants d'un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Consulter les ressources d'un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Consulter le programme détaillé par journée/heure",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Accéder à la zone de partage collective",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-11",
|
||||
"totalScenarios": 3,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 3,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder au bilan consolidé",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir les commentaires regroupés par atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir la synthèse globale",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-14",
|
||||
"totalScenarios": 7,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 7,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la création d'atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les champs obligatoires pour créer un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Créer un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Modifier un atelier existant",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Supprimer un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Sélectionner mon événement parent",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Définir les horaires de fin de l'atelier",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-2",
|
||||
"totalScenarios": 5,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Accéder à la zone de notes personnelles",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Accéder à la zone de partage publique",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Ajouter une note personnelle",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Ajouter un lien/ressource",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Consulter le programme détaillé des ateliers par journée/heure",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-4",
|
||||
"totalScenarios": 5,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 5,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les commentaires existants d'un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Ajouter un commentaire à un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Modifier un commentaire existant",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Supprimer un commentaire",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Accéder à l'icône ajouter un commentaire",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"featureId": "us-6",
|
||||
"totalScenarios": 4,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 4,
|
||||
"lastRun": "2026-01-26T17:31:35.879Z",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Voir les ateliers d'un événement",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Voir les personnes pré-inscrites à un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "S'inscrire à un atelier",
|
||||
"status": "skipped"
|
||||
},
|
||||
{
|
||||
"name": "Se désinscrire d'un atelier",
|
||||
"status": "skipped"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const testResults: Map<string, FeatureTestStatus> = new Map(
|
||||
rawResults.map(r => [r.featureId, { ...r, lastRun: r.lastRun ? new Date(r.lastRun) : undefined }])
|
||||
);
|
||||
|
||||
export function getTestStatus(featureId: string): FeatureTestStatus | undefined {
|
||||
return testResults.get(featureId);
|
||||
}
|
||||
|
||||
export function getScenarioResults(featureId: string): ScenarioTestResult[] {
|
||||
return testResults.get(featureId)?.scenarios ?? [];
|
||||
}
|
||||
|
||||
export function getAllTestResults(): FeatureTestStatus[] {
|
||||
return Array.from(testResults.values());
|
||||
}
|
||||
|
||||
export function getTestSummary() {
|
||||
const results = getAllTestResults();
|
||||
const firstResult = results[0];
|
||||
return {
|
||||
totalFeatures: results.length,
|
||||
totalScenarios: results.reduce((acc, r) => acc + r.totalScenarios, 0),
|
||||
passed: results.reduce((acc, r) => acc + r.passed, 0),
|
||||
failed: results.reduce((acc, r) => acc + r.failed, 0),
|
||||
skipped: results.reduce((acc, r) => acc + r.skipped, 0),
|
||||
lastRun: firstResult?.lastRun,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* App-level data types used by screens.
|
||||
* These are plain TypeScript types (not RDF-bound).
|
||||
* The data layer maps to/from NextGraph shapes internally.
|
||||
*/
|
||||
|
||||
export interface FpEventData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
location: string;
|
||||
distance?: number;
|
||||
participantCount: number;
|
||||
coverImage?: string;
|
||||
hostName?: string;
|
||||
hostInitials?: string;
|
||||
themes?: string[];
|
||||
}
|
||||
|
||||
export interface FpUserData {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
username: string;
|
||||
role?: string;
|
||||
isPublic?: boolean;
|
||||
bio?: string;
|
||||
city?: string;
|
||||
eventsCount?: number;
|
||||
friendsCount?: number;
|
||||
participationsCount?: number;
|
||||
}
|
||||
|
||||
export interface FpParticipationData {
|
||||
id: string;
|
||||
eventId: string;
|
||||
userId: string;
|
||||
isConfirmed: boolean;
|
||||
}
|
||||
|
||||
export interface FpMeetingPointData {
|
||||
id: string;
|
||||
eventId: string;
|
||||
location: string;
|
||||
time: string;
|
||||
hostName: string;
|
||||
hostInitials: string;
|
||||
}
|
||||
|
||||
export interface FpFriendshipData {
|
||||
id: string;
|
||||
userId: string;
|
||||
friendId: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* useShapeWithDefaults — wrapper around NextGraph ORM's useShape.
|
||||
*
|
||||
* Calls useShape with scope="" (whole dataset) and maps results to app types.
|
||||
* If the NG set is empty (not yet loaded or truly empty),
|
||||
* it returns the provided defaults.
|
||||
*
|
||||
* Must only be called when NG is connected (inside NgDataProvider).
|
||||
*/
|
||||
|
||||
import { useShape } from '@ng-org/orm/react';
|
||||
import type { ShapeType, BaseType } from '@ng-org/shex-orm';
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
|
||||
export interface ShapeWithDefaults<NgT extends BaseType, AppT> {
|
||||
/** Mapped items from NG store */
|
||||
items: AppT[];
|
||||
/** Raw NG signal set for mutations */
|
||||
ngSet: DeepSignalSet<NgT>;
|
||||
}
|
||||
|
||||
export function useShapeWithDefaults<NgT extends BaseType, AppT>(
|
||||
shapeType: ShapeType<NgT>,
|
||||
defaults: AppT[],
|
||||
mapFromNg: (item: NgT) => AppT,
|
||||
shapesReady: boolean,
|
||||
): ShapeWithDefaults<NgT, AppT> {
|
||||
// scope="did:ng:i" means whole dataset
|
||||
const ngSet = useShape(shapeType, "did:ng:i") as DeepSignalSet<NgT>;
|
||||
// Before shapes are ready, always show defaults (static display)
|
||||
// After ready, show NG data even if empty (means user deleted everything)
|
||||
const usingDefaults = !shapesReady;
|
||||
const items = usingDefaults ? defaults : [...ngSet].map(mapFromNg);
|
||||
|
||||
console.log(`[useShapeWithDefaults] ${(shapeType as any).shape ?? 'unknown'}: ngSet.size=${ngSet.size}, shapesReady=${shapesReady}, using=${usingDefaults ? 'DEFAULTS' : 'NG data'}`);
|
||||
|
||||
return { items, ngSet };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { Schema } from "@ng-org/shex-orm";
|
||||
|
||||
/**
|
||||
* =============================================================================
|
||||
* festipodShapesSchema: Schema for festipodShapes
|
||||
* =============================================================================
|
||||
*/
|
||||
export const festipodShapesSchema: Schema = {
|
||||
"http://festipod.org/Event": {
|
||||
iri: "http://festipod.org/Event",
|
||||
predicates: [
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "iri",
|
||||
literals: ["http://festipod.org/Event"],
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "@type",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/title",
|
||||
readablePredicate: "title",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/description",
|
||||
readablePredicate: "description",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/date",
|
||||
readablePredicate: "date",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/location",
|
||||
readablePredicate: "location",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "number" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/distance",
|
||||
readablePredicate: "distance",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "number" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/participantCount",
|
||||
readablePredicate: "participantCount",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/coverImage",
|
||||
readablePredicate: "coverImage",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/hostName",
|
||||
readablePredicate: "hostName",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/hostInitials",
|
||||
readablePredicate: "hostInitials",
|
||||
},
|
||||
],
|
||||
},
|
||||
"http://festipod.org/UserProfile": {
|
||||
iri: "http://festipod.org/UserProfile",
|
||||
predicates: [
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "iri",
|
||||
literals: ["http://festipod.org/UserProfile"],
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "@type",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/name",
|
||||
readablePredicate: "name",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/initials",
|
||||
readablePredicate: "initials",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/username",
|
||||
readablePredicate: "username",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "string" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/role",
|
||||
readablePredicate: "role",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "boolean" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 0,
|
||||
iri: "http://festipod.org/isPublic",
|
||||
readablePredicate: "isPublic",
|
||||
},
|
||||
],
|
||||
},
|
||||
"http://festipod.org/Participation": {
|
||||
iri: "http://festipod.org/Participation",
|
||||
predicates: [
|
||||
{
|
||||
dataTypes: [
|
||||
{
|
||||
valType: "iri",
|
||||
literals: ["http://festipod.org/Participation"],
|
||||
},
|
||||
],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "@type",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "iri" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/event",
|
||||
readablePredicate: "event",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "iri" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/user",
|
||||
readablePredicate: "user",
|
||||
},
|
||||
{
|
||||
dataTypes: [{ valType: "boolean" }],
|
||||
maxCardinality: 1,
|
||||
minCardinality: 1,
|
||||
iri: "http://festipod.org/isConfirmed",
|
||||
readablePredicate: "isConfirmed",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ShapeType } from "@ng-org/shex-orm";
|
||||
import { festipodShapesSchema } from "./festipodShapes.schema";
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from "./festipodShapes.typings";
|
||||
|
||||
// ShapeTypes for festipodShapes
|
||||
export const FpEventShapeType: ShapeType<FpEvent> = {
|
||||
schema: festipodShapesSchema,
|
||||
shape: "http://festipod.org/Event",
|
||||
};
|
||||
export const FpUserProfileShapeType: ShapeType<FpUserProfile> = {
|
||||
schema: festipodShapesSchema,
|
||||
shape: "http://festipod.org/UserProfile",
|
||||
};
|
||||
export const FpParticipationShapeType: ShapeType<FpParticipation> = {
|
||||
schema: festipodShapesSchema,
|
||||
shape: "http://festipod.org/Participation",
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
export type IRI = string;
|
||||
|
||||
/**
|
||||
* =============================================================================
|
||||
* Typescript Typings for festipodShapes
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* Event Type
|
||||
*/
|
||||
export interface FpEvent {
|
||||
/**
|
||||
* The graph IRI.
|
||||
*/
|
||||
readonly "@graph": IRI;
|
||||
/**
|
||||
* The subject IRI.
|
||||
*/
|
||||
readonly "@id": IRI;
|
||||
/**
|
||||
* Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type
|
||||
*/
|
||||
"@type": "http://festipod.org/Event";
|
||||
/**
|
||||
* The title of the event
|
||||
*
|
||||
* Original IRI: http://festipod.org/title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* A description of the event
|
||||
*
|
||||
* Original IRI: http://festipod.org/description
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* The display date of the event (e.g. 'Lun. 16 - Ven. 20 fév.')
|
||||
*
|
||||
* Original IRI: http://festipod.org/date
|
||||
*/
|
||||
date: string;
|
||||
/**
|
||||
* The location of the event
|
||||
*
|
||||
* Original IRI: http://festipod.org/location
|
||||
*/
|
||||
location: string;
|
||||
/**
|
||||
* Distance in km from the user
|
||||
*
|
||||
* Original IRI: http://festipod.org/distance
|
||||
*/
|
||||
distance?: number;
|
||||
/**
|
||||
* Number of participants
|
||||
*
|
||||
* Original IRI: http://festipod.org/participantCount
|
||||
*/
|
||||
participantCount: number;
|
||||
/**
|
||||
* URL of the cover image
|
||||
*
|
||||
* Original IRI: http://festipod.org/coverImage
|
||||
*/
|
||||
coverImage?: string;
|
||||
/**
|
||||
* Name of the event host or relay
|
||||
*
|
||||
* Original IRI: http://festipod.org/hostName
|
||||
*/
|
||||
hostName?: string;
|
||||
/**
|
||||
* Initials of the event host
|
||||
*
|
||||
* Original IRI: http://festipod.org/hostInitials
|
||||
*/
|
||||
hostInitials?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserProfile Type
|
||||
*/
|
||||
export interface FpUserProfile {
|
||||
/**
|
||||
* The graph IRI.
|
||||
*/
|
||||
readonly "@graph": IRI;
|
||||
/**
|
||||
* The subject IRI.
|
||||
*/
|
||||
readonly "@id": IRI;
|
||||
/**
|
||||
* Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type
|
||||
*/
|
||||
"@type": "http://festipod.org/UserProfile";
|
||||
/**
|
||||
* Full name of the user
|
||||
*
|
||||
* Original IRI: http://festipod.org/name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Initials for avatar display
|
||||
*
|
||||
* Original IRI: http://festipod.org/initials
|
||||
*/
|
||||
initials: string;
|
||||
/**
|
||||
* Username handle (e.g. @mariedupont)
|
||||
*
|
||||
* Original IRI: http://festipod.org/username
|
||||
*/
|
||||
username: string;
|
||||
/**
|
||||
* Role or title of the user
|
||||
*
|
||||
* Original IRI: http://festipod.org/role
|
||||
*/
|
||||
role?: string;
|
||||
/**
|
||||
* Whether the profile is public
|
||||
*
|
||||
* Original IRI: http://festipod.org/isPublic
|
||||
*/
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Participation Type
|
||||
*/
|
||||
export interface FpParticipation {
|
||||
/**
|
||||
* The graph IRI.
|
||||
*/
|
||||
readonly "@graph": IRI;
|
||||
/**
|
||||
* The subject IRI.
|
||||
*/
|
||||
readonly "@id": IRI;
|
||||
/**
|
||||
* Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type
|
||||
*/
|
||||
"@type": "http://festipod.org/Participation";
|
||||
/**
|
||||
* Reference to the event
|
||||
*
|
||||
* Original IRI: http://festipod.org/event
|
||||
*/
|
||||
event: IRI;
|
||||
/**
|
||||
* Reference to the user
|
||||
*
|
||||
* Original IRI: http://festipod.org/user
|
||||
*/
|
||||
user: IRI;
|
||||
/**
|
||||
* Whether the participation is confirmed
|
||||
*
|
||||
* Original IRI: http://festipod.org/isConfirmed
|
||||
*/
|
||||
isConfirmed: boolean;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
PREFIX fp: <http://festipod.org/>
|
||||
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
|
||||
fp:Event {
|
||||
a [fp:Event] ;
|
||||
fp:title xsd:string
|
||||
// rdfs:comment "The title of the event" ;
|
||||
fp:description xsd:string ?
|
||||
// rdfs:comment "A description of the event" ;
|
||||
fp:date xsd:string
|
||||
// rdfs:comment "The display date of the event (e.g. 'Lun. 16 - Ven. 20 fév.')" ;
|
||||
fp:location xsd:string
|
||||
// rdfs:comment "The location of the event" ;
|
||||
fp:distance xsd:float ?
|
||||
// rdfs:comment "Distance in km from the user" ;
|
||||
fp:participantCount xsd:integer
|
||||
// rdfs:comment "Number of participants" ;
|
||||
fp:coverImage xsd:string ?
|
||||
// rdfs:comment "URL of the cover image" ;
|
||||
fp:hostName xsd:string ?
|
||||
// rdfs:comment "Name of the event host or relay" ;
|
||||
fp:hostInitials xsd:string ?
|
||||
// rdfs:comment "Initials of the event host" ;
|
||||
}
|
||||
|
||||
fp:UserProfile {
|
||||
a [fp:UserProfile] ;
|
||||
fp:name xsd:string
|
||||
// rdfs:comment "Full name of the user" ;
|
||||
fp:initials xsd:string
|
||||
// rdfs:comment "Initials for avatar display" ;
|
||||
fp:username xsd:string
|
||||
// rdfs:comment "Username handle (e.g. @mariedupont)" ;
|
||||
fp:role xsd:string ?
|
||||
// rdfs:comment "Role or title of the user" ;
|
||||
fp:isPublic xsd:boolean ?
|
||||
// rdfs:comment "Whether the profile is public" ;
|
||||
}
|
||||
|
||||
fp:Participation {
|
||||
a [fp:Participation] ;
|
||||
fp:event IRI
|
||||
// rdfs:comment "Reference to the event" ;
|
||||
fp:user IRI
|
||||
// rdfs:comment "Reference to the user" ;
|
||||
fp:isConfirmed xsd:boolean
|
||||
// rdfs:comment "Whether the participation is confirmed" ;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Given, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../support/world';
|
||||
|
||||
Given('l\'écran {string} est affiché', async function (this: FestipodWorld, screenName: string) {
|
||||
const screenId = screenName.toLowerCase().replace(/ /g, '-');
|
||||
this.navigateTo(`#/demo/${screenId}`);
|
||||
});
|
||||
|
||||
Given('le formulaire de création est vide', async function (this: FestipodWorld) {
|
||||
this.formFields.forEach((field, key) => {
|
||||
this.formFields.set(key, { ...field, value: '' });
|
||||
});
|
||||
});
|
||||
|
||||
Then('le champ {string} est facultatif', async function (this: FestipodWorld, fieldName: string) {
|
||||
const source = this.getRenderedText();
|
||||
const escapedName = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const existsPattern = new RegExp(`>${escapedName}<`);
|
||||
const requiredPattern = new RegExp(`>${escapedName}\\s*\\*<`);
|
||||
expect(existsPattern.test(source), `Field "${fieldName}" should exist in screen`).to.be.true;
|
||||
expect(requiredPattern.test(source), `Field "${fieldName}" should NOT be marked as required`).to.be.false;
|
||||
});
|
||||
|
||||
Then('le champ {string} est présent', async function (this: FestipodWorld, fieldName: string) {
|
||||
const source = this.getRenderedText();
|
||||
const escapedName = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`>${escapedName}[^<]*<`);
|
||||
expect(pattern.test(source), `Field "${fieldName}" should be present in screen`).to.be.true;
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../support/world';
|
||||
|
||||
// Placeholder step for scenarios that are not yet implemented
|
||||
// This step indicates the feature is planned but not built yet
|
||||
Given('Scénario non implémenté', async function (this: FestipodWorld) {
|
||||
return 'skipped';
|
||||
});
|
||||
|
||||
const screenNameMap: Record<string, string> = {
|
||||
'accueil': 'home',
|
||||
'liste des événements': 'events',
|
||||
'découvrir': 'events',
|
||||
'détail événement': 'event-detail',
|
||||
'détail de l\'événement': 'event-detail',
|
||||
'relayer un événement': 'create-event',
|
||||
'relai d\'événement': 'create-event',
|
||||
'inviter des amis': 'invite',
|
||||
'invitation': 'invite',
|
||||
'mon profil': 'profile',
|
||||
'profil': 'profile',
|
||||
'profil utilisateur': 'user-profile',
|
||||
'profil d\'un utilisateur': 'user-profile',
|
||||
'connexion': 'login',
|
||||
'paramètres': 'settings',
|
||||
'réglages': 'settings',
|
||||
'points de rencontre': 'meeting-points',
|
||||
'partage de profil': 'share-profile',
|
||||
'partage profil': 'share-profile',
|
||||
};
|
||||
|
||||
function resolveScreenId(pageName: string): string {
|
||||
const normalized = pageName.toLowerCase().trim();
|
||||
return screenNameMap[normalized] || normalized.replace(/ /g, '-');
|
||||
}
|
||||
|
||||
Given('je suis sur la page {string}', async function (this: FestipodWorld, pageName: string) {
|
||||
const screenId = resolveScreenId(pageName);
|
||||
this.navigateTo(`#/demo/${screenId}`);
|
||||
});
|
||||
|
||||
Given('je suis connecté en tant qu\'utilisateur', async function (this: FestipodWorld) {
|
||||
this.isAuthenticated = true;
|
||||
});
|
||||
|
||||
Given('je suis connecté', async function (this: FestipodWorld) {
|
||||
this.isAuthenticated = true;
|
||||
});
|
||||
|
||||
Given('je ne suis pas connecté', async function (this: FestipodWorld) {
|
||||
this.isAuthenticated = false;
|
||||
});
|
||||
|
||||
When('je navigue vers {string}', async function (this: FestipodWorld, pageName: string) {
|
||||
const screenId = resolveScreenId(pageName);
|
||||
this.navigateTo(`#/demo/${screenId}`);
|
||||
});
|
||||
|
||||
When('je clique sur {string}', async function (this: FestipodWorld, elementName: string) {
|
||||
const source = this.getRenderedText();
|
||||
const escapedName = elementName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`onClick[^>]*>[^<]*${escapedName}`, 'i');
|
||||
expect(pattern.test(source), `Clickable element "${elementName}" should exist in screen "${this.currentScreenId}"`).to.be.true;
|
||||
});
|
||||
|
||||
When('je sélectionne {string}', async function (this: FestipodWorld, elementName: string) {
|
||||
const source = this.getRenderedText();
|
||||
const escapedName = elementName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`onClick[^>]*>[^<]*${escapedName}`, 'i');
|
||||
expect(pattern.test(source), `Selectable element "${elementName}" should exist in screen "${this.currentScreenId}"`).to.be.true;
|
||||
});
|
||||
|
||||
When('je clique sur le bouton {string}', async function (this: FestipodWorld, buttonName: string) {
|
||||
const source = this.getRenderedText();
|
||||
const escapedName = buttonName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`<Button[^>]*>[^<]*${escapedName}[^<]*</Button>`, 'i');
|
||||
expect(pattern.test(source), `Button "${buttonName}" should exist in screen "${this.currentScreenId}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('je suis redirigé vers {string}', async function (this: FestipodWorld, pageName: string) {
|
||||
const screenId = resolveScreenId(pageName);
|
||||
expect(this.currentScreenId).to.equal(screenId);
|
||||
});
|
||||
|
||||
Then('je vois l\'écran {string}', async function (this: FestipodWorld, pageName: string) {
|
||||
const screenId = resolveScreenId(pageName);
|
||||
expect(this.currentScreenId).to.equal(screenId);
|
||||
});
|
||||
|
||||
Then('je reste sur la page {string}', async function (this: FestipodWorld, pageName: string) {
|
||||
const screenId = resolveScreenId(pageName);
|
||||
expect(this.currentScreenId).to.equal(screenId);
|
||||
});
|
||||
|
||||
Then('l\'écran contient une section {string}', async function (this: FestipodWorld, sectionName: string) {
|
||||
expect(this.hasText(sectionName), `Section "${sectionName}" should be present in screen "${this.currentScreenId}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('je peux naviguer vers {string}', async function (this: FestipodWorld, pageName: string) {
|
||||
const screenId = resolveScreenId(pageName);
|
||||
const source = this.getRenderedText();
|
||||
const pattern = new RegExp(`navigate\\s*\\(\\s*['"]${screenId}['"]\\s*\\)`);
|
||||
expect(pattern.test(source), `Navigation to "${screenId}" should exist in screen "${this.currentScreenId}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('la navigation affiche {string} comme actif', async function (this: FestipodWorld, menuItem: string) {
|
||||
const source = this.getRenderedText();
|
||||
const escapedItem = menuItem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`label:\\s*['"]${escapedItem}['"][^}]*active:\\s*true`, 'i');
|
||||
expect(pattern.test(source), `Menu item "${menuItem}" should be active in NavBar of screen "${this.currentScreenId}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'écran contient un bouton {string}', async function (this: FestipodWorld, buttonText: string) {
|
||||
expect(this.hasText(buttonText), `Button "${buttonText}" should be present in screen "${this.currentScreenId}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'écran contient un champ {string}', async function (this: FestipodWorld, fieldLabel: string) {
|
||||
expect(this.hasText(fieldLabel), `Field "${fieldLabel}" should be present in screen "${this.currentScreenId}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'écran contient un texte {string}', async function (this: FestipodWorld, text: string) {
|
||||
expect(this.hasText(text), `Text "${text}" should be present in screen "${this.currentScreenId}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'écran contient un avatar', async function (this: FestipodWorld) {
|
||||
const source = this.getRenderedText();
|
||||
const hasAvatar = /<Avatar/.test(source);
|
||||
expect(hasAvatar, `Avatar should be present in screen "${this.currentScreenId}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('je peux voir la section {string}', async function (this: FestipodWorld, sectionName: string) {
|
||||
const source = this.getRenderedText();
|
||||
const found = source.includes(sectionName);
|
||||
if (!found) {
|
||||
this.attach(`Looking for section: "${sectionName}"`, 'text/plain');
|
||||
this.attach(`Rendered text: ${source.substring(0, 500)}...`, 'text/plain');
|
||||
}
|
||||
expect(found, `Section "${sectionName}" should be visible on screen`).to.be.true;
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Before, After, BeforeAll, AfterAll, Status } from '@cucumber/cucumber';
|
||||
import type { FestipodWorld } from './world';
|
||||
|
||||
BeforeAll(async function () {
|
||||
console.log('Starting Festipod BDD tests...');
|
||||
});
|
||||
|
||||
Before(async function (this: FestipodWorld, scenario) {
|
||||
this.currentRoute = '#/';
|
||||
this.currentScreenId = null;
|
||||
this.formFields.clear();
|
||||
this.navigationHistory = [];
|
||||
this.isAuthenticated = false;
|
||||
this.screenSourceContent = '';
|
||||
this.currentScreen = null;
|
||||
|
||||
// Skipped scenarios use the "* Scénario non implémenté" placeholder step
|
||||
// which returns 'skipped' - no special handling needed in the hook
|
||||
});
|
||||
|
||||
After(async function (this: FestipodWorld, scenario) {
|
||||
if (scenario.result?.status === Status.FAILED) {
|
||||
this.attach(`Current route: ${this.currentRoute}`, 'text/plain');
|
||||
this.attach(`Current screen: ${this.currentScreenId}`, 'text/plain');
|
||||
this.attach(`Navigation history: ${JSON.stringify(this.navigationHistory)}`, 'text/plain');
|
||||
this.attach(`Form fields: ${JSON.stringify(Array.from(this.formFields.entries()))}`, 'text/plain');
|
||||
if (this.screenSourceContent) {
|
||||
// Show first 500 chars of source to help debug
|
||||
this.attach(`Screen source (first 500 chars): ${this.screenSourceContent.substring(0, 500)}...`, 'text/plain');
|
||||
}
|
||||
}
|
||||
// Clean up
|
||||
this.cleanup();
|
||||
});
|
||||
|
||||
AfterAll(async function () {
|
||||
console.log('Festipod BDD tests completed.');
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { World, setWorldConstructor, type IWorldOptions } from '@cucumber/cucumber';
|
||||
import { getScreen, type Screen } from '../../screens/index';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface FestipodWorld extends World {
|
||||
currentRoute: string;
|
||||
currentScreenId: string | null;
|
||||
formFields: Map<string, { required: boolean; value: string }>;
|
||||
navigationHistory: string[];
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// Screen analysis
|
||||
currentScreen: Screen | null;
|
||||
screenSourceContent: string;
|
||||
|
||||
navigateTo(route: string): void;
|
||||
getFormField(name: string): { required: boolean; value: string } | undefined;
|
||||
getCurrentScreenFields(): string[];
|
||||
setScreenFields(screenId: string): void;
|
||||
|
||||
// Methods for screen content analysis
|
||||
loadScreenSource(screenId: string): void;
|
||||
getRenderedText(): string;
|
||||
hasText(text: string): boolean;
|
||||
hasField(fieldName: string): boolean;
|
||||
hasElement(selector: string): boolean;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
// Map screen IDs to their source file paths (relative to project root)
|
||||
const screenFileMap: Record<string, string> = {
|
||||
'home': 'src/modules/home/screens/HomeScreen.tsx',
|
||||
'login': 'src/modules/auth/screens/LoginScreen.tsx',
|
||||
'profile': 'src/modules/user/screens/ProfileScreen.tsx',
|
||||
'update-profile': 'src/modules/user/screens/UpdateProfileScreen.tsx',
|
||||
'user-profile': 'src/modules/user/screens/UserProfileScreen.tsx',
|
||||
'settings': 'src/modules/home/screens/SettingsScreen.tsx',
|
||||
'events': 'src/modules/event/screens/EventsScreen.tsx',
|
||||
'event-detail': 'src/modules/event/screens/EventDetailScreen.tsx',
|
||||
'create-event': 'src/modules/event/screens/CreateEventScreen.tsx',
|
||||
'update-event': 'src/modules/event/screens/UpdateEventScreen.tsx',
|
||||
'invite': 'src/modules/event/screens/InviteScreen.tsx',
|
||||
'participants-list': 'src/modules/event/screens/ParticipantsListScreen.tsx',
|
||||
'meeting-points': 'src/modules/event/screens/MeetingPointsScreen.tsx',
|
||||
'friends-list': 'src/modules/user/screens/FriendsListScreen.tsx',
|
||||
'share-profile': 'src/modules/user/screens/ShareProfileScreen.tsx',
|
||||
};
|
||||
|
||||
// Screen-specific field detectors - each screen has its own precise detectors
|
||||
// tailored to its actual implementation. This avoids generic matching.
|
||||
export const screenFieldDetectors: Record<string, Record<string, (source: string) => boolean>> = {
|
||||
'event-detail': {
|
||||
// EventDetailScreen.tsx line 29: <Title>Barbecue d'été</Title>
|
||||
'Titre': (s) => /<Title[^>]*>[^<]+<\/Title>/.test(s),
|
||||
// EventDetailScreen.tsx line 33: 📅 Samedi 25 janvier 2025
|
||||
'Date': (s) => /📅[^<]*(?:janvier|février|mars|avril|mai|juin|juillet|août|septembre|octobre|novembre|décembre)[^<]*\d{4}/i.test(s),
|
||||
// EventDetailScreen.tsx line 36: 🕓 16h00 - 21h00
|
||||
'Heure': (s) => /🕓[^<]*\d{1,2}h\d{2}/.test(s),
|
||||
// EventDetailScreen.tsx line 39: 📍 Parc Central, Pelouse Ouest
|
||||
'Lieu': (s) => /📍[^<]*[A-ZÀ-Ý][a-zà-ÿ]+/.test(s),
|
||||
// EventDetailScreen.tsx lines 77-81: À propos section with description
|
||||
'Description': (s) => {
|
||||
const match = s.match(/À propos[\s\S]*?<Text[^>]*>([\s\S]*?)<\/Text>/);
|
||||
return match !== null && match[1] !== undefined && match[1].trim().length > 50;
|
||||
},
|
||||
// EventDetailScreen.tsx lines 8-13: attendees with { name: 'Marie' } rendered via {a.name}
|
||||
'Nom': (s) => /name:\s*['"][^'"]+['"]/.test(s) && /\{[^}]*\.name\}/.test(s),
|
||||
'Nom du participant': (s) => /name:\s*['"][^'"]+['"]/.test(s) && /\{[^}]*\.name\}/.test(s),
|
||||
// EventDetailScreen.tsx: <Avatar> components for participants
|
||||
'Photo': (s) => /<Avatar/.test(s),
|
||||
// NOT IMPLEMENTED: no comment UI in EventDetailScreen
|
||||
'Commentaire': (s) => /<textarea/i.test(s) || /commentaire/i.test(s),
|
||||
},
|
||||
|
||||
'user-profile': {
|
||||
// UserProfileScreen.tsx line 24: <Title>Jean Durand</Title>
|
||||
'Nom': (s) => /<Title[^>]*>[A-ZÀ-Ý][a-zà-ÿ]+\s+[A-ZÀ-Ý][a-zà-ÿ]+<\/Title>/.test(s),
|
||||
// UserProfileScreen.tsx line 25: @jeandurand
|
||||
'Pseudo': (s) => /@[a-zA-Z0-9_]+/.test(s),
|
||||
// UserProfileScreen.tsx line 23: <Avatar initials="JD" size="lg" />
|
||||
'Photo': (s) => /<Avatar/.test(s),
|
||||
'Photo de profil': (s) => /<Avatar/.test(s),
|
||||
},
|
||||
|
||||
'profile': {
|
||||
// ProfileScreen.tsx: similar to user-profile
|
||||
'Nom': (s) => /<Title[^>]*>[A-ZÀ-Ý][a-zà-ÿ]+\s+[A-ZÀ-Ý][a-zà-ÿ]+<\/Title>/.test(s),
|
||||
'Pseudo': (s) => /@[a-zA-Z0-9_]+/.test(s),
|
||||
'Photo': (s) => /<Avatar/.test(s),
|
||||
'Photo de profil': (s) => /<Avatar/.test(s),
|
||||
},
|
||||
};
|
||||
|
||||
// Expected content that should be present in each screen
|
||||
// This maps to what the BDD specs verify - based on actual screen content
|
||||
export const screenExpectedContent: Record<string, string[]> = {
|
||||
'create-event': [
|
||||
'Nom de l\'événement',
|
||||
'Date',
|
||||
'Heure de début',
|
||||
'Lieu',
|
||||
'Thématique',
|
||||
'Créer l\'événement',
|
||||
],
|
||||
'profile': [
|
||||
'Mon profil',
|
||||
'Modifier le profil',
|
||||
'Partager',
|
||||
'Événement',
|
||||
],
|
||||
'user-profile': [
|
||||
'Profil',
|
||||
],
|
||||
'settings': [
|
||||
'Paramètres',
|
||||
'Notifications',
|
||||
'Confidentialité',
|
||||
'Localisation',
|
||||
],
|
||||
'login': [
|
||||
'Email',
|
||||
'Mot de passe',
|
||||
'Se connecter',
|
||||
],
|
||||
'event-detail': [
|
||||
'Participants',
|
||||
'À propos',
|
||||
'Participer',
|
||||
'Inviter',
|
||||
],
|
||||
'events': [
|
||||
'Découvrir',
|
||||
'Rechercher',
|
||||
],
|
||||
'home': [
|
||||
'Mes événements à venir',
|
||||
'Créer un événement',
|
||||
],
|
||||
'invite': [
|
||||
'Inviter',
|
||||
'Rechercher',
|
||||
],
|
||||
'meeting-points': [
|
||||
'Point de rencontre',
|
||||
],
|
||||
'share-profile': [
|
||||
'Partager',
|
||||
'QR',
|
||||
],
|
||||
'friends-list': [
|
||||
'Mon réseau',
|
||||
],
|
||||
'participants-list': [
|
||||
'Participants',
|
||||
],
|
||||
};
|
||||
|
||||
// Required fields that forms should have (for form verification)
|
||||
export const screenRequiredFields: Record<string, string[]> = {
|
||||
'create-event': [
|
||||
'Nom de l\'événement',
|
||||
'Date',
|
||||
'Heure de début',
|
||||
'Lieu',
|
||||
'Thématique',
|
||||
],
|
||||
'profile': [
|
||||
'Photo de profil',
|
||||
'Nom',
|
||||
'Pseudo',
|
||||
],
|
||||
'user-profile': [
|
||||
'Photo de profil',
|
||||
'Nom',
|
||||
'Pseudo',
|
||||
],
|
||||
'settings': [
|
||||
'Notifications',
|
||||
'Confidentialité',
|
||||
'Rayon de notification',
|
||||
],
|
||||
'login': [
|
||||
'Email',
|
||||
'Mot de passe',
|
||||
],
|
||||
'event-detail': [
|
||||
'Titre',
|
||||
'Date',
|
||||
'Lieu',
|
||||
'Description',
|
||||
'Liste des participants',
|
||||
],
|
||||
'events': [
|
||||
'Liste des événements',
|
||||
'Filtre par date',
|
||||
],
|
||||
'home': [
|
||||
'Mes événements à venir',
|
||||
'Navigation',
|
||||
],
|
||||
'invite': [
|
||||
'Liste des contacts',
|
||||
'Recherche',
|
||||
],
|
||||
'meeting-points': [
|
||||
'Lieu de rencontre',
|
||||
'Heure',
|
||||
],
|
||||
'share-profile': [
|
||||
'QR Code',
|
||||
'Lien de partage',
|
||||
],
|
||||
};
|
||||
|
||||
class CustomWorld extends World implements FestipodWorld {
|
||||
currentRoute: string = '#/';
|
||||
currentScreenId: string | null = null;
|
||||
formFields: Map<string, { required: boolean; value: string }> = new Map();
|
||||
navigationHistory: string[] = [];
|
||||
isAuthenticated: boolean = false;
|
||||
|
||||
// Screen analysis
|
||||
currentScreen: Screen | null = null;
|
||||
screenSourceContent: string = '';
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
navigateTo(route: string): void {
|
||||
this.navigationHistory.push(route);
|
||||
this.currentRoute = route;
|
||||
|
||||
if (route.startsWith('#/demo/')) {
|
||||
this.currentScreenId = route.replace('#/demo/', '');
|
||||
this.setScreenFields(this.currentScreenId);
|
||||
// Load the screen source for content verification
|
||||
this.loadScreenSource(this.currentScreenId);
|
||||
} else if (route === '#/specs' || route.startsWith('#/specs/')) {
|
||||
this.currentScreenId = null;
|
||||
} else if (route === '#/stories' || route.startsWith('#/stories/')) {
|
||||
this.currentScreenId = null;
|
||||
} else {
|
||||
this.currentScreenId = null;
|
||||
}
|
||||
}
|
||||
|
||||
getFormField(name: string) {
|
||||
return this.formFields.get(name);
|
||||
}
|
||||
|
||||
getCurrentScreenFields(): string[] {
|
||||
return Array.from(this.formFields.keys());
|
||||
}
|
||||
|
||||
setScreenFields(screenId: string): void {
|
||||
this.formFields.clear();
|
||||
const fields = screenRequiredFields[screenId] || [];
|
||||
fields.forEach(field => {
|
||||
this.formFields.set(field, { required: true, value: '' });
|
||||
});
|
||||
}
|
||||
|
||||
loadScreenSource(screenId: string): void {
|
||||
// Get the screen component
|
||||
const screen = getScreen(screenId);
|
||||
if (!screen) {
|
||||
this.screenSourceContent = '';
|
||||
this.currentScreen = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentScreen = screen;
|
||||
|
||||
// Read the source file to analyze its content
|
||||
const fileName = screenFileMap[screenId];
|
||||
if (fileName) {
|
||||
const filePath = path.join(process.cwd(), fileName);
|
||||
try {
|
||||
this.screenSourceContent = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
this.screenSourceContent = '';
|
||||
}
|
||||
} else {
|
||||
this.screenSourceContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
getRenderedText(): string {
|
||||
// Return the source content which contains all the text that will be rendered
|
||||
return this.screenSourceContent;
|
||||
}
|
||||
|
||||
hasText(text: string): boolean {
|
||||
// Check if the text appears in the screen source
|
||||
// This verifies the component contains the expected text
|
||||
return this.screenSourceContent.includes(text);
|
||||
}
|
||||
|
||||
hasField(fieldName: string): boolean {
|
||||
// Use screen-specific field detector if available
|
||||
if (this.currentScreenId) {
|
||||
const screenDetectors = screenFieldDetectors[this.currentScreenId];
|
||||
if (screenDetectors && screenDetectors[fieldName]) {
|
||||
return screenDetectors[fieldName](this.screenSourceContent);
|
||||
}
|
||||
}
|
||||
// Fall back to literal text search
|
||||
return this.screenSourceContent.includes(fieldName);
|
||||
}
|
||||
|
||||
hasElement(selector: string): boolean {
|
||||
// Check for common patterns in JSX
|
||||
if (!this.screenSourceContent) return false;
|
||||
|
||||
// Check for element types like textarea, input, button
|
||||
if (selector === 'textarea') {
|
||||
return this.screenSourceContent.includes('<textarea') ||
|
||||
this.screenSourceContent.includes('textarea');
|
||||
}
|
||||
if (selector === 'input') {
|
||||
return this.screenSourceContent.includes('<Input') ||
|
||||
this.screenSourceContent.includes('<input');
|
||||
}
|
||||
if (selector === 'button') {
|
||||
return this.screenSourceContent.includes('<Button') ||
|
||||
this.screenSourceContent.includes('<button');
|
||||
}
|
||||
|
||||
return this.screenSourceContent.includes(selector);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.screenSourceContent = '';
|
||||
this.currentScreen = null;
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(CustomWorld);
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface ParsedStep {
|
||||
keyword: string;
|
||||
text: string;
|
||||
dataTable?: string[][];
|
||||
}
|
||||
|
||||
export interface ParsedScenario {
|
||||
name: string;
|
||||
tags: string[];
|
||||
steps: ParsedStep[];
|
||||
}
|
||||
|
||||
export interface ParsedFeature {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
priority: number;
|
||||
background?: ParsedStep[];
|
||||
scenarios: ParsedScenario[];
|
||||
filePath: string;
|
||||
rawContent: string;
|
||||
screenIds: string[];
|
||||
}
|
||||
|
||||
export interface ScenarioTestResult {
|
||||
name: string;
|
||||
status: 'passed' | 'failed' | 'skipped' | 'unknown';
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface FeatureTestStatus {
|
||||
featureId: string;
|
||||
totalScenarios: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
lastRun?: Date;
|
||||
scenarios?: ScenarioTestResult[];
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Bootstrap: seeds default data into the NextGraph wallet on first use.
|
||||
*
|
||||
* Called once after NG connection + shapes ready. If the wallet already
|
||||
* has events/users, it's a returning user — skip seeding.
|
||||
*/
|
||||
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
import { sessionPromise } from './ngSession';
|
||||
import {
|
||||
seedEvents,
|
||||
seedUsers,
|
||||
seedParticipations,
|
||||
} from '../data/seedData';
|
||||
|
||||
export interface BootstrapResult {
|
||||
seeded: boolean;
|
||||
userIdMap: Map<string, string>;
|
||||
eventIdMap: Map<string, string>;
|
||||
}
|
||||
|
||||
export async function bootstrapWallet(
|
||||
ngEvents: DeepSignalSet<FpEvent>,
|
||||
ngUsers: DeepSignalSet<FpUserProfile>,
|
||||
ngParticipations: DeepSignalSet<FpParticipation>,
|
||||
): Promise<BootstrapResult> {
|
||||
const session = await sessionPromise;
|
||||
const graph = `did:ng:${session.private_store_id}`;
|
||||
|
||||
// Already has data → returning user, nothing to seed
|
||||
if (ngEvents.size > 0 || ngUsers.size > 0) {
|
||||
console.log('[Bootstrap] Wallet already has data — events:', ngEvents.size,
|
||||
'users:', ngUsers.size, 'participations:', ngParticipations.size);
|
||||
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map() };
|
||||
}
|
||||
|
||||
console.log('[Bootstrap] First time for this wallet — seeding default data...');
|
||||
|
||||
// Seed users
|
||||
const userIdMap = new Map<string, string>();
|
||||
for (const u of seedUsers) {
|
||||
ngUsers.add({
|
||||
"@graph": graph,
|
||||
"@type": "http://festipod.org/UserProfile",
|
||||
"@id": "",
|
||||
name: u.name,
|
||||
initials: u.initials,
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
isPublic: u.isPublic,
|
||||
} as FpUserProfile);
|
||||
const added = [...ngUsers].find(nu => nu.username === u.username);
|
||||
if (added) userIdMap.set(u.id, added["@id"]);
|
||||
}
|
||||
console.log('[Bootstrap] Seeded', userIdMap.size, 'users');
|
||||
|
||||
// Seed events
|
||||
const eventIdMap = new Map<string, string>();
|
||||
for (const e of seedEvents) {
|
||||
ngEvents.add({
|
||||
"@graph": graph,
|
||||
"@type": "http://festipod.org/Event",
|
||||
"@id": "",
|
||||
title: e.title,
|
||||
description: e.description,
|
||||
date: e.date,
|
||||
location: e.location,
|
||||
distance: e.distance,
|
||||
participantCount: e.participantCount,
|
||||
coverImage: e.coverImage,
|
||||
hostName: e.hostName,
|
||||
hostInitials: e.hostInitials,
|
||||
} as FpEvent);
|
||||
const added = [...ngEvents].find(ne => ne.title === e.title);
|
||||
if (added) eventIdMap.set(e.id, added["@id"]);
|
||||
}
|
||||
console.log('[Bootstrap] Seeded', eventIdMap.size, 'events');
|
||||
|
||||
// Seed participations with mapped IDs
|
||||
let partCount = 0;
|
||||
for (const p of seedParticipations) {
|
||||
const eventIri = eventIdMap.get(p.eventId) || p.eventId;
|
||||
const userIri = userIdMap.get(p.userId) || p.userId;
|
||||
ngParticipations.add({
|
||||
"@graph": graph,
|
||||
"@type": "http://festipod.org/Participation",
|
||||
"@id": "",
|
||||
event: eventIri,
|
||||
user: userIri,
|
||||
isConfirmed: p.isConfirmed,
|
||||
} as FpParticipation);
|
||||
partCount++;
|
||||
}
|
||||
console.log('[Bootstrap] Seeded', partCount, 'participations');
|
||||
|
||||
return { seeded: true, userIdMap, eventIdMap };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ng, init as initNgWeb } from "@ng-org/web";
|
||||
import type { NG } from "@ng-org/web";
|
||||
import { initNg as initNgSignals } from "@ng-org/orm";
|
||||
|
||||
export let session: NextGraphSession | undefined;
|
||||
|
||||
let resolveSessionPromise: (
|
||||
value: NextGraphSession | PromiseLike<NextGraphSession>
|
||||
) => void;
|
||||
let rejectSessionPromise: (reason?: any) => void;
|
||||
|
||||
export let sessionPromise: Promise<NextGraphSession> = new Promise(
|
||||
(resolve, reject) => {
|
||||
resolveSessionPromise = resolve;
|
||||
rejectSessionPromise = reject;
|
||||
}
|
||||
);
|
||||
|
||||
let initCalled = false;
|
||||
|
||||
export async function init() {
|
||||
if (initCalled) return;
|
||||
initCalled = true;
|
||||
console.log('[NG session] init() called');
|
||||
await initNgWeb(
|
||||
async (event: any) => {
|
||||
console.log('[NG session] initNgWeb callback received, event type:', event?.type);
|
||||
session = event.session;
|
||||
|
||||
session!.ng ??= ng;
|
||||
console.log('[NG session] Session established, private_store_id:', session!.private_store_id);
|
||||
resolveSessionPromise(session!);
|
||||
|
||||
initNgSignals(ng, session!);
|
||||
console.log('[NG session] ORM signals initialized');
|
||||
},
|
||||
true,
|
||||
[]
|
||||
).catch((error) => {
|
||||
console.error('[NG session] init error:', error);
|
||||
rejectSessionPromise(error);
|
||||
});
|
||||
}
|
||||
|
||||
export interface NextGraphSession {
|
||||
ng: typeof NG;
|
||||
session_id: string;
|
||||
protected_store_id: string;
|
||||
private_store_id: string;
|
||||
public_store_id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user