Chapter 2: Frontend

1. React routes configuration

// components/app/Router/index.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { PublicRoutes, PrivateRoutes } from './Guards';

import { LoginPage } from '../../pages/public/LoginPage';
import { TasksPage } from '../../pages/private/TasksPage';
import { TaskDetailPage } from '../../pages/private/TaskDetailPage';
import { TaskFormPage } from '../../pages/private/TaskFormPage';
import { Error404Page } from '../../pages/errors/Error404Page';

export const Router = () => {
    return (
        <BrowserRouter>
            <Routes>
                <Route element={<PublicRoutes />}>
                    <Route path="/login" element={<LoginPage />} />
                </Route>

                <Route element={<PrivateRoutes />}>
                    <Route path="/" element={<TasksPage />} />
                    <Route path="/tasks/:id" element={<TaskDetailPage />} />
                    <Route path="/tasks/:id/edit" element={<TaskFormPage />} />
                    <Route path="/tasks/new" element={<TaskFormPage />} />
                </Route>

                <Route path="*" element={<Error404Page />} />
            </Routes>
        </BrowserRouter>
    );
};

2. List page

// components/pages/private/TasksPage/index.jsx
import { useEffect } from 'react';
import {
    Page,
    Block,
    List,
    ListItem,
    Button,
    Spinner,
    Tag
} from '@cap-rel/smartcommon';
import { useApi, useStates, useNavigation } from '@cap-rel/smartcommon';
import { FiPlus, FiCheck, FiClock } from 'react-icons/fi';

export const TasksPage = () => {
    const api = useApi();
    const nav = useNavigation();

    const st = useStates({
        initialStates: {
            tasks: [],
            loading: true,
            error: null,
            filter: 'all'  // all, todo, done
        }
    });

    useEffect(() => {
        loadTasks();
    }, [st.get('filter')]);

    const loadTasks = async () => {
        st.set('loading', true);

        try {
            const params = {};
            if (st.get('filter') === 'todo') params.status = 1;
            if (st.get('filter') === 'done') params.status = 2;

            const data = await api.private.get('tasks', {
                searchParams: params
            }).json();

            st.set('tasks', data.tasks);
        } catch (err) {
            st.set('error', err.message);
        } finally {
            st.set('loading', false);
        }
    };

    const toggleStatus = async (task) => {
        const newStatus = task.status === 'done' ? 1 : 2;

        try {
            await api.private.put(`tasks/${task.id}`, {
                json: { status: newStatus }
            });
            loadTasks();
        } catch (err) {
            alert('Error: ' + err.message);
        }
    };

    const getStatusColor = (status) => {
        switch (status) {
            case 'done': return 'green';
            case 'todo': return 'blue';
            default: return 'gray';
        }
    };

    if (st.get('loading') && st.get('tasks').length === 0) {
        return <Page title="Tasks"><Spinner /></Page>;
    }

    return (
        <Page title="My tasks" onRefresh={loadTasks}>
            {/* Filters */}
            <Block>
                <div className="flex gap-2">
                    {['all', 'todo', 'done'].map(f => (
                        <Button
                            key={f}
                            variant={st.get('filter') === f ? 'primary' : 'outline'}
                            size="sm"
                            onClick={() => st.set('filter', f)}
                        >
                            {f === 'all' ? 'All' : f === 'todo' ? 'To do' : 'Done'}
                        </Button>
                    ))}
                </div>
            </Block>

            {/* List */}
            <Block>
                {st.get('tasks').length === 0 ? (
                    <p className="text-center text-gray-500 py-8">
                        No task
                    </p>
                ) : (
                    <List>
                        {st.get('tasks').map(task => (
                            <ListItem
                                key={task.id}
                                title={task.label}
                                subtitle={task.date_start || 'No date'}
                                onClick={() => nav.navigate(`/tasks/${task.id}`)}
                                icon={task.status === 'done' ? FiCheck : FiClock}
                                chevron
                                actions={
                                    <Tag color={getStatusColor(task.status)}>
                                        {task.status}
                                    </Tag>
                                }
                            />
                        ))}
                    </List>
                )}
            </Block>

            {/* Add button */}
            <div className="fixed bottom-20 right-4">
                <Button
                    variant="primary"
                    onClick={() => nav.navigate('/tasks/new')}
                    className="rounded-full w-14 h-14"
                >
                    <FiPlus size={24} />
                </Button>
            </div>
        </Page>
    );
};

3. Detail page

// components/pages/private/TaskDetailPage/index.jsx
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import {
    Page,
    Block,
    Button,
    Spinner,
    Tag,
    Popup
} from '@cap-rel/smartcommon';
import { useApi, useStates, useNavigation } from '@cap-rel/smartcommon';
import { FiEdit, FiTrash2, FiCheck } from 'react-icons/fi';

export const TaskDetailPage = () => {
    const { id } = useParams();
    const api = useApi();
    const nav = useNavigation();

    const st = useStates({
        initialStates: {
            task: null,
            loading: true,
            error: null,
            showDeletePopup: false
        }
    });

    useEffect(() => {
        loadTask();
    }, [id]);

    const loadTask = async () => {
        try {
            const data = await api.private.get(`tasks/${id}`).json();
            st.set('task', data);
        } catch (err) {
            st.set('error', err.message);
        } finally {
            st.set('loading', false);
        }
    };

    const toggleComplete = async () => {
        const task = st.get('task');
        const newStatus = task.status === 'done' ? 1 : 2;

        try {
            await api.private.put(`tasks/${id}`, {
                json: { status: newStatus }
            });
            loadTask();
        } catch (err) {
            alert('Error: ' + err.message);
        }
    };

    const handleDelete = async () => {
        try {
            await api.del(`tasks/${id}`);
            nav.navigate('/');
        } catch (err) {
            alert('Error: ' + err.message);
        }
    };

    if (st.get('loading')) {
        return <Page><Spinner /></Page>;
    }

    if (st.get('error')) {
        return (
            <Page>
                <Block>Error: {st.get('error')}</Block>
            </Page>
        );
    }

    const task = st.get('task');

    return (
        <Page title={task.label}>
            {/* Status */}
            <Block>
                <div className="flex justify-between items-center">
                    <Tag color={task.status === 'done' ? 'green' : 'blue'}>
                        {task.status === 'done' ? 'Done' : 'To do'}
                    </Tag>
                    <span className="text-gray-500">Ref: {task.ref}</span>
                </div>
            </Block>

            {/* Description */}
            {task.description && (
                <Block title="Description">
                    <p>{task.description}</p>
                </Block>
            )}

            {/* Dates */}
            <Block title="Dates">
                <div className="space-y-2">
                    {task.date_start && (
                        <p>Start: {task.date_start}</p>
                    )}
                    {task.date_end && (
                        <p>End: {task.date_end}</p>
                    )}
                </div>
            </Block>

            {/* Actions */}
            <Block>
                <div className="flex gap-2">
                    <Button
                        variant={task.status === 'done' ? 'outline' : 'primary'}
                        onClick={toggleComplete}
                    >
                        <FiCheck className="mr-2" />
                        {task.status === 'done' ? 'Reopen' : 'Complete'}
                    </Button>

                    <Button
                        variant="outline"
                        onClick={() => nav.navigate(`/tasks/${id}/edit`)}
                    >
                        <FiEdit className="mr-2" />
                        Edit
                    </Button>

                    <Button
                        variant="danger"
                        onClick={() => st.set('showDeletePopup', true)}
                    >
                        <FiTrash2 />
                    </Button>
                </div>
            </Block>

            {/* Delete popup */}
            <Popup
                isOpen={st.get('showDeletePopup')}
                onClose={() => st.set('showDeletePopup', false)}
                title="Confirm deletion"
            >
                <p>Delete this task?</p>
                <div className="flex gap-2 mt-4">
                    <Button onClick={() => st.set('showDeletePopup', false)}>
                        Cancel
                    </Button>
                    <Button variant="danger" onClick={handleDelete}>
                        Delete
                    </Button>
                </div>
            </Popup>
        </Page>
    );
};

4. Form

// components/pages/private/TaskFormPage/index.jsx
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import {
    Page,
    Block,
    Form,
    Input,
    Textarea,
    Select,
    Calendar,
    Button,
    Spinner
} from '@cap-rel/smartcommon';
import { useApi, useStates, useNavigation, useForm } from '@cap-rel/smartcommon';

export const TaskFormPage = () => {
    const { id } = useParams();
    const isEdit = !!id;

    const api = useApi();
    const nav = useNavigation();

    const st = useStates({
        initialStates: {
            loading: isEdit,
            submitting: false,
            defaultValues: null
        }
    });

    useEffect(() => {
        if (isEdit) {
            loadTask();
        }
    }, [id]);

    const loadTask = async () => {
        try {
            const data = await api.private.get(`tasks/${id}`).json();
            st.set('defaultValues', {
                label: data.label,
                description: data.description || '',
                priority: data.priority || 0,
                date_start: data.date_start || '',
                date_end: data.date_end || ''
            });
        } catch (err) {
            alert('Error: ' + err.message);
            nav.navigate('/');
        } finally {
            st.set('loading', false);
        }
    };

    const handleSubmit = async (values) => {
        st.set('submitting', true);

        try {
            if (isEdit) {
                await api.private.put(`tasks/${id}`, { json: values });
            } else {
                await api.private.post('tasks', { json: values });
            }
            nav.navigate('/');
        } catch (err) {
            alert('Error: ' + err.message);
        } finally {
            st.set('submitting', false);
        }
    };

    if (st.get('loading')) {
        return <Page><Spinner /></Page>;
    }

    return (
        <Page title={isEdit ? 'Edit task' : 'New task'}>
            <Block>
                <Form onSubmit={handleSubmit} defaultValues={st.get('defaultValues')}>
                    <Input
                        name="label"
                        label="Title"
                        required
                    />

                    <Textarea
                        name="description"
                        label="Description"
                        rows={4}
                    />

                    <Select
                        name="priority"
                        label="Priority"
                        options={[
                            { value: 0, label: 'Low' },
                            { value: 1, label: 'Normal' },
                            { value: 2, label: 'High' },
                            { value: 3, label: 'Urgent' }
                        ]}
                    />

                    <Calendar
                        name="date_start"
                        label="Start date"
                    />

                    <Calendar
                        name="date_end"
                        label="End date"
                    />

                    <div className="flex gap-2 mt-4">
                        <Button
                            type="button"
                            variant="outline"
                            onClick={() => nav.navigate(-1)}
                        >
                            Cancel
                        </Button>
                        <Button
                            type="submit"
                            loading={st.get('submitting')}
                        >
                            {isEdit ? 'Save' : 'Create'}
                        </Button>
                    </div>
                </Form>
            </Block>
        </Page>
    );
};

5. Global configuration

// src/appConfig.js
export const config = {
    debug: import.meta.env.DEV,

    api: {
        prefixUrl: import.meta.env.VITE_API_URL,
        timeout: 30000,
        paths: {
            login: "login",
            logout: "logout",
            refresh: "refresh"
        }
    },

    storage: {
        local: ["session"]
    },

    globalState: {
        reducers: {
            session: null
        }
    },

    pages: {
        "*": "fade"
    }
};

Previous Chapter | Back to Module | Next Chapter: Deployment ->