Project structure
Layout of a CJjs App with hyper-personalization and state management
App structure
The structure is very simple:
- index.js: We define the basic configuration.
- app.js: We define the router.
- app/pages/: We define each of the pages.
- app/pages/updaters/: We define the behavior of the pages when they change state.
- app/store/: Defines the Redux store.
- app/srore/slices/: Defines the state slices.
- app/scss/: We configure Bulma and custom classes.
- app/data/: Optional folder. This is where the page.json files that define the content, style, and animations are stored if they are saved locally and not generated by AI.
- .env/conf.json: Configuration where variables for Google Analytics, Facebook or LinkedIn pixels, and Odoo APIKey are stored, for example.
┣ 📂src
┃ ┣ 📂.env
┃ ┃ ┗ 📜conf.json
┃ ┣ 📂app
┃ ┃ ┣ 📂data
┃ ┃ ┃ ┣ 📜bye.json
┃ ┃ ┃ ┗ 📜home.json
┃ ┃ ┣ 📂pages
┃ ┃ ┃ ┣ 📂updaters
┃ ┃ ┃ ┃ ┣ 📜byeUpdater.js
┃ ┃ ┃ ┃ ┗ 📜homeUpdater.js
┃ ┃ ┃ ┣ 📜bye.js
┃ ┃ ┃ ┣ 📜home.js
┃ ┃ ┃ ┗ 📜index.js
┃ ┃ ┣ 📂scss
┃ ┃ ┃ ┗ 📜assets.scss
┃ ┃ ┗ 📂store
┃ ┃ ┃ ┣ 📂slices
┃ ┃ ┃ ┃ ┣ 📜byeSlice.js
┃ ┃ ┃ ┃ ┣ 📜contextSlice.js
┃ ┃ ┃ ┃ ┣ 📜homeSlice.js
┃ ┃ ┃ ┃ ┗ 📜index.js
┃ ┃ ┃ ┗ 📜store.js
┃ ┣ 📜App.js
┃ ┗ 📜index.js
index.js
import config from './.env/conf.json';
import { store, persistor } from './app/store/store';
import { loading, whithAnimations } from "@customerjourney/cj-core"
import 'animate.css';
import '@customerjourney/cj-core/src/pageloader.css';
import { App } from './App';
/**
* Set loader element and its properties: Color and direction
*/
loading({color:"is-dark", direction:"is-right-to-left"});
/**
* Set theme from the store if exists
*/
let currentValue = store.getState();
let theme = currentValue?.context?.theme;
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
}
/**
* rehydrate state from local storage
*/
persistor.subscribe(()=>{
const rehydratedState = store.getState();
})
/**
* Run the app
*/
App.run();
/**
* Init animations
*/
whithAnimations();
App.js
import { Router } from "@customerjourney/cj-router";
import { home, bye } from "./app/pages";
/**
* Main application router
*/
export const App = new Router({ hashSensitive:true});
App.on('/', home);
App.on('/#thanks', bye).setName("bye");
store.js
import { configureStore, combineReducers } from "@reduxjs/toolkit";
import { persistStore, persistReducer} from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import contextSlice from "./slices/contextSlice";
import homeSlice from "./slices/homeSlice";
import byeSlice from "./slices/byeSlice";
/**
* Configure Redux store with persistence
*/
const persistConfig = {
key: 'root',
storage
};
/**
* Combine all slices into a root reducer
*/
const rootReducer = combineReducers({
context: contextSlice,
home: homeSlice,
bye: byeSlice
});
/**
* Create a persisted reducer
*/
const persistedReducer = persistReducer(persistConfig, rootReducer);
/**
* Configure the Redux store with the persisted reducer
*/
const store = configureStore({
reducer: persistedReducer
});
/**
* Create a persistor to manage persistence
*/
const persistor = persistStore(store);
export { store, persistor };
contextSlice.js
import { createSlice } from '@reduxjs/toolkit';
import { generateSessionToken } from '@customerjourney/cj-core';
/**
* Context slice to manage global settings like language, theme, and session token.
*/
const contextSlice = createSlice({
name: 'context',
initialState:{
lang:'es',
theme:'light',
sessionToken:generateSessionToken(32)
},
reducers:{
setLanguaje:(state, action) => {
state.lang = action.payload;
},
setTheme:(state, action) => {
state.theme = action.payload;
document.documentElement.setAttribute('data-theme', action.payload);
}
}
});
export const { setLanguaje, setTheme } = contextSlice.actions;
export default contextSlice.reducer;
homeSlice.js
import { createSlice } from '@reduxjs/toolkit';
/**
* Home slice to manage the state of the home component, including stage and breadcrumb.
*/
const homeSlice = createSlice({
name: 'home',
initialState:{
stage:'awaiting',
breadcrumb:[]
},
reducers:{
setStage:(state, action) => {
state.stage = action.payload;
},
setBreadcrumb:(state, action) => {
state.breadcrumb = action.payload;
}
}
});
export const { setStage, setBreadcrumb } = homeSlice.actions;
export default homeSlice.reducer;
home.js
import { AppPage, PageHeader, PageFooter } from "@customerjourney/cj-core";
import { HeroBanner, LevelCentered, MediaList, CardsList, ModalBox } from "@customerjourney/cj-components";
import { setStage, setBreadcrumb } from "../store/slices/homeSlice";
import { setLanguaje, setTheme } from "../store/slices/contextSlice"
import { store } from "../store/store";
import { homeUpdater } from "./updaters/homeUpdater";
/**
* Import design, content and animation for the home page
*/
import data from "../data/home.json";
/**
* Declare home function
* This function will be called by the router when the user navigates to the home page.
* @param {*} req : request object with path params and query params
* @param {*} router : router object
*/
export function home(req, router){
/**
* Date time when the user enters the page
*/
let go = Date.now();
/**
* Counter describes the customer's behavior on the page
*/
let counter = {go:go, time:0, atention:0, interest:0, desire:0, action:0, conversion:0, leavingapp:0, leavedapp:0 }
/**
* Page template
*/
let template =`
<page-header id="header"></page-header>
<hero-banner id="atention"></hero-banner>
<cards-list id="interest"></cards-list>
<media-list id="desire"></media-list>
<cards-list id="action"></cards-list>
<page-footer id="footer"></page-footer>
<modal-box id="message"></modal-box>
`;
/**
* The state is recovered from the store
*/
let currentValue = store.getState();
store.dispatch(setStage('start'));
data.context = currentValue;
/**
* Page creation
*/
page = new AppPage(data, template);
/**
* Eventes handler for the page
*/
const pageEvents = {
handleEvent: (e) => {
switch(e.type){
case 'user:select-lang':
store.dispatch(setLanguaje(e.detail));
break;
case 'user:select-theme':
store.dispatch(setTheme(e.detail));
break;
case 'app-click':
switch (e.detail.source){
case "appoinment-button":
counter.leavingapp++;
store.dispatch(setStage('action/open'));
break;
case "landing-button":
store.dispatch(setStage('landing/click'));
break;
}
break;
case 'viewedelement':
switch (e.detail.source){
case 'landing':
if (counter.landing===0) {
store.dispatch(setStage('landing/viewed'));
counter.landing++;
}
break;
case 'attention':
if (counter.atention===0) {
store.dispatch(setStage('attention/viewed'));
counter.atention++;
}
break;
case 'interest':
if (counter.interest===0) {
store.dispatch(setStage('interest/viewed'));
counter.interest++;
}
break;
case 'desire':
if (counter.desire===0) {
store.dispatch(setStage('desire/viewed'));
counter.desire++;
}
break;
case 'action':
if (counter.action===0) {
store.dispatch(setStage('action/viewed'));
counter.action++;
}
break;
case 'conversion':
if (counter.conversion===0) {
store.dispatch(setStage('conversion/viewed'));
counter.conversion++;
}
break;
}
break;
case 'unviewedelement':
switch (e.detail.source){
case 'landing':
if (counter.landing>0) {
store.dispatch(setStage('landing/unviewed'));
}
break;
case 'attention':
if (counter.atention>0) {
store.dispatch(setStage('attention/unviewed'));
}
break;
case 'interest':
if (counter.interest>0) {
store.dispatch(setStage('interest/unviewed'));
}
break;
case 'desire':
if (counter.desire>0) {
store.dispatch(setStage('desire/unviewed'));
}
break;
case 'action':
if (counter.action>0) {
store.dispatch(setStage('action/unviewed'));
}
break;
}
break;
case 'leavingapp':
if (counter.leavingapp===0)
{
store.dispatch(setStage('escape'));
document.getElementById("message").setAttribute("active", "")
counter.leavingapp++;
};
break;
case 'leavedapp':
counter.leavedapp++;
counter.time = Math.round((Date.now() - go) / 1000);
store.dispatch(setBreadcrumb(counter));
break;
}}
}
/**
* Function to handle state changes in the store
* It compares the previous state with the current state and calls the homeUpdater function if there are changes.
*/
function handleChange(){
let previousValue = currentValue;
currentValue = store.getState();
if (previousValue !== currentValue) {
homeUpdater(previousValue, currentValue);
}
console.log(counter)
}
/**
* Set page events handler
*/
page.setEvents(pageEvents);
/**
* Suscribe to store changes
* The handleChange function will be called whenever the state in the store changes.
*/
store.subscribe(handleChange);
}
homeUpdater.js
export function homeUpdater(previousValue, currentValue){
/**
* Home updater to handle changes in context and home state.
*/
let page = document.querySelector('app-page');
/**
* If there are changes in language or theme, update the context and reload data.
* If there are changes in the home stage, update the appoinment component accordingly.
*/
if (previousValue.context.lang!=currentValue.context.lang||previousValue.context.theme!=currentValue.context.theme){
page.data.context = currentValue.context;
page.loadData();
}else if(previousValue.home.stage!=currentValue.home.stage){
let appoinment = page.querySelector('#appoinment');
switch (currentValue.home.stage){
case 'landing/click':
document.getElementById("action").scrollIntoView({ behavior: "smooth"});
break;
case 'action/open':
appoinment.setAttribute('stage', 'open');
break;
case 'action/close':
appoinment.setAttribute('stage', 'awaiting');
break;
case 'action/appoinment':
appoinment.setAttribute('stage', 'appoinment');
break;
}
}
}
assets.scss
@use "bulma/sass" with (
$family-primary: '"Play", sans-serif'
);
@import url("https://fonts.googleapis.com/css2?family=Play:wght@400;700&display=swap");
.has-text-shadow {
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
index.html
<!DOCTYPE html>
<html lang="es" >
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="generator" content="CustumerJourneyJS"/>
<link rel="stylesheet" href="/assets.css">
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div id="app"></div>
<script src="/index.js"></script>
</body>
</html>
Project structure
📦cj-demo
┣ 📂public
┃ ┣ 📂images
┃ ┃ ┣ 🖼️logo.png
┃ ┃ ┣ 🖼️hero.png
┃ ┣ 📜assets.css
┃ ┣ 📜assets.css.map
┃ ┣ 📜index.css
┃ ┣ 📜index.html
┃ ┣ 📜index.js
┃ ┗ 📜robots.txt
┣ 📂src
┃ ┣ 📂.env
┃ ┃ ┗ 📜conf.json
┃ ┣ 📂app
┃ ┃ ┣ 📂data
┃ ┃ ┃ ┣ 📜bye.json
┃ ┃ ┃ ┗ 📜home.json
┃ ┃ ┣ 📂pages
┃ ┃ ┃ ┣ 📂updaters
┃ ┃ ┃ ┃ ┣ 📜byeUpdater.js
┃ ┃ ┃ ┃ ┗ 📜homeUpdater.js
┃ ┃ ┃ ┣ 📜bye.js
┃ ┃ ┃ ┣ 📜home.js
┃ ┃ ┃ ┗ 📜index.js
┃ ┃ ┣ 📂scss
┃ ┃ ┃ ┗ 📜assets.scss
┃ ┃ ┗ 📂store
┃ ┃ ┃ ┣ 📂slices
┃ ┃ ┃ ┃ ┣ 📜byeSlice.js
┃ ┃ ┃ ┃ ┣ 📜contextSlice.js
┃ ┃ ┃ ┃ ┣ 📜homeSlice.js
┃ ┃ ┃ ┃ ┗ 📜index.js
┃ ┃ ┃ ┗ 📜store.js
┃ ┣ 📜App.js
┃ ┗ 📜index.js
┣ 📜.gitignore
┣ 📜LICENSE
┣ 📜README.md
┣ 📜install.sh
┣ 📜package-lock.json
┗ 📜package.json