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 { setSession } from './app/store/slices/contextSlice';
import { generateSessionToken, loading, whithAnimations } from "@customerjourney/cj-core"
import 'animate.css';
import '@customerjourney/cj-core/src/pageloader.css';
import { App } from './App';
/**
* Set Loading element before app run
*/
loading({color:"is-dark", direction:"is-right-to-left"});
let isRehydrated = false;
function startApp() {
// If you haven't rehydrated, we're leaving.
if (!isRehydrated) {
console.warn('Attention! Rehydration is not complete. Waiting...');
return;
}
console.log('✅ Complete rehydration. Data is ready.');
const currentState = store.getState();
const session = currentState?.context?.session;
if(!session){
const newSession = generateSessionToken(32);
store.dispatch(setSession(newSession));
}
if(currentState?.context?.theme){
document.documentElement.setAttribute('data-theme', currentState.context.theme);
}
App.run();
}
const unsubscribe = persistor.subscribe(() => {
const persistorState = persistor.getState();
if (persistorState.bootstrapped && !isRehydrated) {
isRehydrated = true;
unsubscribe(); // Stop listening to avoid unnecessary future executions
startApp(); // Launch the main application!
whithAnimations(); //Enable animations
}
});
App.js
import { Router } from "@customerjourney/cj-router";
import { home, bye } from "./app/pages";
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 the Redux store with slices and persistence.
*/
const persistConfig = {
key: 'root',
storage
};
/**
* Combine the slices into a root reducer.
*/
const rootReducer = combineReducers({
context: contextSlice,
home: homeSlice,
bye: byeSlice
});
/**
* Create a persisted reducer using the root reducer and persistence configuration.
*/
const persistedReducer = persistReducer(persistConfig, rootReducer);
/**
* Configure the Redux store with the persisted reducer.
*/
const store = configureStore({
reducer: persistedReducer
});
/**
* Create a persistor to manage the persistence of the store.
*/
const persistor = persistStore(store);
export { store, persistor };
contextSlice.js
import { createSlice } from '@reduxjs/toolkit';
/**
* Context slice to manage application context such as language, theme, and session token.
*/
const contextSlice = createSlice({
name: 'context',
initialState:{
lang:'es',
theme:'light'
},
reducers:{
setLanguaje:(state, action) => {
state.lang = action.payload;
},
setTheme:(state, action) => {
state.theme = action.payload;
},
setSession:(state, action) => {
state.session = action.payload;
}
}
});
export const { setLanguaje, setTheme, setSession } = 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 scroll stoping.
*/
const homeSlice = createSlice({
name: 'home',
initialState:{
stage:'awaiting',
scrollStopping:{
page:{
req:{},
name:'',
session:'',
start:0,
end:0,
time:0,
leavingApp:0,
views:0
},
}
},
reducers:{
setStage:(state, action) => {
state.stage = action.payload;
},
setScrollStopping:(state, action) => {
state.scrollStopping = action.payload;
},
setSectionTracking:(state, action) => {
let section = Object.keys(action.payload)[0];
state.scrollStopping.sections[section] = action.payload[section];
},
setEscapeAttempt:(state, action) => {
state.scrollStopping.page.leavingapp = action.payload;
},
setPageQuit:(state, action) => {
state.scrollStopping.page = action.payload;
}
}
});
export const { setStage, setScrollStopping, setSectionTracking, setEscapeAttempt, setPageQuit } = 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, setScrollStopping } from "../store/slices/homeSlice";
import { setLanguaje, setTheme } from "../store/slices/contextSlice"
import { store } from "../store/store";
import { homeUpdater } from "./updaters/homeUpdater";
/**
* home.json data describe the content of the page, design and animations
* @type {object}
*/
import data from "../data/home.json";
/**
* Declare callback funtion for home page
* @param {object} req
* @param {object} router
*/
export function home(req, router){
/**
* Template for the page
*/
let template =`
<page-header id="header"></page-header>
<hero-banner id="attention"></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>
`;
/**
* current state of the app
* @type {object}
*/
let currentState = store.getState();
/**
* dispath start stage
*/
store.dispatch(setStage('start'));
/**
* Add context to the data
*/
data.context = currentState.context;
/**
* Page object created with the data and the template
*/
page = new AppPage(data, template);
/**
* Initialize scrollStopping tracking object
*/
let track = page.scrollStopping;
if (!currentState.home.scrollStopping){
track.page.views = 0;
}else{
track.page.views = currentState.home.scrollStopping.page.views + 1;
}
track.page.req=req;
track.name=data.props.title.en;
track.session=currentState.context.session;
store.dispatch(setScrollStopping(track));
/**
* event handlers for the page
*/
const pageEvents = {
handleEvent: (e) => {
switch(e.type){
/* User change language or theme */
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 "attention-button":
store.dispatch(setStage('attention/click'));
break;
}
break;
case 'cta-click':
store.dispatch(setStage(`action/click-${e.detail.source}`));
break;
/* User interaction with the page: User view a section */
case 'viewedelement':
switch (e.detail.source){
case 'attention':
store.dispatch(setStage('attention/viewed'));
break;
case 'interest':
store.dispatch(setStage('interest/viewed'));
break;
case 'desire':
store.dispatch(setStage('desire/viewed'));
break;
case 'action':
store.dispatch(setStage('action/viewed'));
break;
case 'conversion':
store.dispatch(setStage('conversion/viewed'));
break;
}
break;
/* User interaction with the page: User leave a section */
case 'unviewedelement':
switch (e.detail.source){
case 'attention':
store.dispatch(setStage('attention/unviewed'));
break;
case 'interest':
store.dispatch(setStage('interest/unviewed'));
break;
case 'desire':
store.dispatch(setStage('desire/unviewed'));
break;
case 'action':
store.dispatch(setStage('action/unviewed'));
break;
}
break;
/* User is leaving the app */
case 'leavingapp':
store.dispatch(setStage('escape'));
break;
/* User has left the app */
case 'leavedapp':
store.dispatch(setStage('quit'));
break;
}
}
}
/**
* Handle state changes in the store
*/
function handleChange(){
let previousState = currentState;
currentState = store.getState();
if (previousState !== currentState) {
homeUpdater(previousState, currentState);
}
}
/**
* set event handlers for the page
*/
page.setEvents(pageEvents);
/**
* Suscribe to the store to listen for state changes
*/
store.subscribe(handleChange);
}
homeUpdater.js
import { store } from "../../store/store";
import { setSectionTracking, setEscapeAttempt, setPageQuit } from "../../store/slices/homeSlice";
/**
* Manage changes in the home page state
* @param {object} previousState
* @param {object} currentState
*/
export function homeUpdater(previousState, currentState){
/**
* Page instance
* @type {object}
*/
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 (previousState.context.lang!=currentState.context.lang||previousState.context.theme!=currentState.context.theme){
page.data.context = currentState.context;
page.loadData();
}else if(previousState.home.stage!=currentState.home.stage){
let track = currentState.home.scrollStopping;
let payload = {};
console.log(`Home stage changed to ${currentState.home.stage}`);
switch (true){
case currentState.home.stage === 'attention/click':
document.getElementById("action").scrollIntoView({ behavior: "smooth"});
break;
case currentState.home.stage.startsWith('action/click-'):
payload = page.setPageQuit(track.page);
store.dispatch(setPageQuit(payload));
window.location.href = `/#thanks?product=${currentState.home.stage.match(/click-([\w-]+)$/)?.[1]}`
break;
case currentState.home.stage === 'atenttion/viewed':
payload = page.setSectionViewed('attention',track.sections.attention);
store.dispatch(setSectionTracking(payload));
break;
case currentState.home.stage === 'interest/viewed':
payload = page.setSectionViewed('interest',track.sections.interest);
store.dispatch(setSectionTracking(payload));
break;
case currentState.home.stage === 'desire/viewed':
payload = page.setSectionViewed('desire',track.sections.desire);
store.dispatch(setSectionTracking(payload));
break;
case currentState.home.stage === 'action/viewed':
payload = page.setSectionViewed('action',track.sections.action);
store.dispatch(setSectionTracking(payload));
break;
case currentState.home.stage === 'attention/unviewed':
payloasd = page.setSectionUnviewed('attention',track.sections.attention);
store.dispatch(setSectionTracking(payload));
break;
case currentState.home.stage === 'interest/unviewed':
payload = page.setSectionUnviewed('interest',track.sections.interest);
store.dispatch(setSectionTracking(payload));
break;
case currentState.home.stage === 'desire/unviewed':
payload = page.setSectionUnviewed('desire',track.sections.desire);
store.dispatch(setSectionTracking(payload));
break;
case currentState.home.stage === 'action/unviewed':
payload = page.setSectionUnviewed('action',track.sections.action);
store.dispatch(setSectionTracking(payload));
break;
case currentState.home.stage === 'escape':
let leavingApp = track.page.leavingapp + 1;
store.dispatch(setEscapeAttempt(leavingApp));
if (leavingApp===1){
document.getElementById("message").setAttribute("active", "")
}
break;
case currentState.home.stage === 'quit':
payload = page.setPageQuit(track.page);
store.dispatch(setPageQuit(payload));
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