Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
f7560ce595 Auto commit: 2026-02-10T21:42:28.394Z 2026-02-10 21:42:28 +00:00
14 changed files with 1111 additions and 979 deletions

View File

@ -1,4 +1,3 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto'); const crypto = require('crypto');
@ -32,6 +31,11 @@ module.exports = class UsersDBApi {
|| ||
null null
, ,
national_id: data.data.national_id || null,
address: data.data.address || null,
birth_day: data.data.birth_day || null,
birth_year: data.data.birth_year || null,
account_type: data.data.account_type || null,
phoneNumber: data.data.phoneNumber phoneNumber: data.data.phoneNumber
|| ||
@ -109,7 +113,12 @@ module.exports = class UsersDBApi {
}); });
} }
await users.setAccount_tier(data.data.account_tier || null, { transaction });
await users.setProvince(data.data.province || null, { transaction });
await users.setDistrict(data.data.district || null, { transaction });
await users.setSector(data.data.sector || null, { transaction });
await users.setCell(data.data.cell || null, { transaction });
await users.setVillage(data.data.village || null, { transaction });
await users.setCustom_permissions(data.data.custom_permissions || [], { await users.setCustom_permissions(data.data.custom_permissions || [], {
@ -262,43 +271,27 @@ module.exports = class UsersDBApi {
const updatePayload = {}; const updatePayload = {};
if (data.firstName !== undefined) updatePayload.firstName = data.firstName; if (data.firstName !== undefined) updatePayload.firstName = data.firstName;
if (data.lastName !== undefined) updatePayload.lastName = data.lastName; if (data.lastName !== undefined) updatePayload.lastName = data.lastName;
if (data.national_id !== undefined) updatePayload.national_id = data.national_id;
if (data.address !== undefined) updatePayload.address = data.address;
if (data.birth_day !== undefined) updatePayload.birth_day = data.birth_day;
if (data.birth_year !== undefined) updatePayload.birth_year = data.birth_year;
if (data.account_type !== undefined) updatePayload.account_type = data.account_type;
if (data.phoneNumber !== undefined) updatePayload.phoneNumber = data.phoneNumber; if (data.phoneNumber !== undefined) updatePayload.phoneNumber = data.phoneNumber;
if (data.email !== undefined) updatePayload.email = data.email; if (data.email !== undefined) updatePayload.email = data.email;
if (data.disabled !== undefined) updatePayload.disabled = data.disabled; if (data.disabled !== undefined) updatePayload.disabled = data.disabled;
if (data.password !== undefined) updatePayload.password = data.password; if (data.password !== undefined) updatePayload.password = data.password;
if (data.emailVerified !== undefined) updatePayload.emailVerified = data.emailVerified; if (data.emailVerified !== undefined) updatePayload.emailVerified = data.emailVerified;
else updatePayload.emailVerified = true; else updatePayload.emailVerified = true;
if (data.emailVerificationToken !== undefined) updatePayload.emailVerificationToken = data.emailVerificationToken; if (data.emailVerificationToken !== undefined) updatePayload.emailVerificationToken = data.emailVerificationToken;
if (data.emailVerificationTokenExpiresAt !== undefined) updatePayload.emailVerificationTokenExpiresAt = data.emailVerificationTokenExpiresAt; if (data.emailVerificationTokenExpiresAt !== undefined) updatePayload.emailVerificationTokenExpiresAt = data.emailVerificationTokenExpiresAt;
if (data.passwordResetToken !== undefined) updatePayload.passwordResetToken = data.passwordResetToken; if (data.passwordResetToken !== undefined) updatePayload.passwordResetToken = data.passwordResetToken;
if (data.passwordResetTokenExpiresAt !== undefined) updatePayload.passwordResetTokenExpiresAt = data.passwordResetTokenExpiresAt; if (data.passwordResetTokenExpiresAt !== undefined) updatePayload.passwordResetTokenExpiresAt = data.passwordResetTokenExpiresAt;
if (data.provider !== undefined) updatePayload.provider = data.provider; if (data.provider !== undefined) updatePayload.provider = data.provider;
updatePayload.updatedById = currentUser.id; updatePayload.updatedById = currentUser.id;
await users.update(updatePayload, {transaction}); await users.update(updatePayload, {transaction});
@ -314,6 +307,13 @@ module.exports = class UsersDBApi {
); );
} }
if (data.account_tier !== undefined) await users.setAccount_tier(data.account_tier, { transaction });
if (data.province !== undefined) await users.setProvince(data.province, { transaction });
if (data.district !== undefined) await users.setDistrict(data.district, { transaction });
if (data.sector !== undefined) await users.setSector(data.sector, { transaction });
if (data.cell !== undefined) await users.setCell(data.cell, { transaction });
if (data.village !== undefined) await users.setVillage(data.village, { transaction });
@ -363,7 +363,7 @@ module.exports = class UsersDBApi {
}); });
return users; return posts;
} }
static async remove(id, options) { static async remove(id, options) {
@ -463,6 +463,13 @@ module.exports = class UsersDBApi {
transaction transaction
}); });
output.account_tier = await users.getAccount_tier({ transaction });
output.province = await users.getProvince({ transaction });
output.district = await users.getDistrict({ transaction });
output.sector = await users.getSector({ transaction });
output.cell = await users.getCell({ transaction });
output.village = await users.getVillage({ transaction });
if (output.app_role) { if (output.app_role) {
output.app_role_permissions = await output.app_role.getPermissions({ output.app_role_permissions = await output.app_role.getPermissions({
transaction, transaction,
@ -974,4 +981,3 @@ module.exports = class UsersDBApi {
}; };

View File

@ -0,0 +1,82 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.addColumn(
'users',
'national_id',
{
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
{ transaction }
);
await queryInterface.addColumn(
'users',
'address',
{
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
{ transaction }
);
await queryInterface.addColumn(
'users',
'birth_day',
{
type: Sequelize.DataTypes.INTEGER,
allowNull: true,
},
{ transaction }
);
await queryInterface.addColumn(
'users',
'birth_year',
{
type: Sequelize.DataTypes.INTEGER,
allowNull: true,
},
{ transaction }
);
await queryInterface.addColumn(
'users',
'account_type',
{
type: Sequelize.DataTypes.ENUM('Worker', 'Employer', 'Business', 'Government', 'NGO'),
allowNull: true,
},
{ transaction }
);
await queryInterface.addColumn(
'users',
'wallet_balance_rwf',
{
type: Sequelize.DataTypes.DECIMAL,
defaultValue: 0,
allowNull: false,
},
{ transaction }
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.removeColumn('users', 'national_id', { transaction });
await queryInterface.removeColumn('users', 'address', { transaction });
await queryInterface.removeColumn('users', 'birth_day', { transaction });
await queryInterface.removeColumn('users', 'birth_year', { transaction });
await queryInterface.removeColumn('users', 'account_type', { transaction });
await queryInterface.removeColumn('users', 'wallet_balance_rwf', { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View File

@ -28,6 +28,27 @@ lastName: {
}, },
national_id: {
type: DataTypes.TEXT,
},
address: {
type: DataTypes.TEXT,
},
birth_day: {
type: DataTypes.INTEGER,
},
birth_year: {
type: DataTypes.INTEGER,
},
account_type: {
type: DataTypes.ENUM,
values: ['Worker', 'Employer', 'Business', 'Government', 'NGO'],
},
phoneNumber: { phoneNumber: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
@ -104,6 +125,12 @@ provider: {
}, },
wallet_balance_rwf: {
type: DataTypes.DECIMAL,
allowNull: false,
defaultValue: 0,
},
importHash: { importHash: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),
allowNull: true, allowNull: true,
@ -239,6 +266,53 @@ provider: {
constraints: false, constraints: false,
}); });
db.users.belongsTo(db.account_tiers, {
as: 'account_tier',
foreignKey: {
name: 'account_tierId',
},
constraints: false,
});
db.users.belongsTo(db.provinces, {
as: 'province',
foreignKey: {
name: 'provinceId',
},
constraints: false,
});
db.users.belongsTo(db.districts, {
as: 'district',
foreignKey: {
name: 'districtId',
},
constraints: false,
});
db.users.belongsTo(db.sectors, {
as: 'sector',
foreignKey: {
name: 'sectorId',
},
constraints: false,
});
db.users.belongsTo(db.cells, {
as: 'cell',
foreignKey: {
name: 'cellId',
},
constraints: false,
});
db.users.belongsTo(db.villages, {
as: 'village',
foreignKey: {
name: 'villageId',
},
constraints: false,
});
db.users.hasMany(db.file, { db.users.hasMany(db.file, {
@ -305,4 +379,3 @@ function trimStringFields(users) {
return users; return users;
} }

View File

@ -1,4 +1,3 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const app = express(); const app = express();
@ -63,6 +62,7 @@ const help_center_articlesRoutes = require('./routes/help_center_articles');
const for_you_rankingsRoutes = require('./routes/for_you_rankings'); const for_you_rankingsRoutes = require('./routes/for_you_rankings');
const walletRoutes = require('./routes/wallet');
const getBaseUrl = (url) => { const getBaseUrl = (url) => {
if (!url) return ''; if (!url) return '';
@ -118,6 +118,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes); app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes); app.use('/api/pexels', pexelsRoutes);
app.use('/api/landing', require('./routes/landing'));
app.enable('trust proxy'); app.enable('trust proxy');
@ -163,6 +164,8 @@ app.use('/api/help_center_articles', passport.authenticate('jwt', {session: fals
app.use('/api/for_you_rankings', passport.authenticate('jwt', {session: false}), for_you_rankingsRoutes); app.use('/api/for_you_rankings', passport.authenticate('jwt', {session: false}), for_you_rankingsRoutes);
app.use('/api/wallet', walletRoutes);
app.use( app.use(
'/api/openai', '/api/openai',
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),

View File

@ -0,0 +1,21 @@
const express = require('express');
const router = express.Router();
const PostsDBApi = require('../db/api/posts');
const AccountTiersDBApi = require('../db/api/account_tiers');
const wrapAsync = require('../helpers').wrapAsync;
router.get('/data', wrapAsync(async (req, res) => {
// Fetch latest posts for guest users
const posts = await PostsDBApi.findAll({ limit: 6, page: 0 }, { currentUser: null });
// Fetch account tiers
const tiers = await AccountTiersDBApi.findAll({ limit: 10, page: 0 }, { currentUser: null });
res.status(200).send({
posts: posts.rows,
tiers: tiers.rows
});
}));
module.exports = router;

View File

@ -0,0 +1,66 @@
const express = require('express');
const router = express.Router();
const passport = require('passport');
const db = require('../db/models');
const { wrapAsync } = require('../helpers');
router.post(
'/deposit',
passport.authenticate('jwt', { session: false }),
wrapAsync(async (req, res) => {
const { amount } = req.body;
const userId = req.user.id;
if (!amount || isNaN(amount) || amount <= 0) {
return res.status(400).send({ message: 'Invalid amount' });
}
const transaction = await db.sequelize.transaction();
try {
const user = await db.users.findByPk(userId, { transaction });
const balanceBefore = parseFloat(user.wallet_balance_rwf || 0);
const depositAmount = parseFloat(amount);
const balanceAfter = balanceBefore + depositAmount;
await user.update({ wallet_balance_rwf: balanceAfter }, { transaction });
await db.wallet_transactions.create({
userId,
type: 'deposit',
status: 'completed',
amount_rwf: depositAmount,
balance_before_rwf: balanceBefore,
balance_after_rwf: balanceAfter,
reference_code: `DEP-${Date.now()}`,
reason: 'Manual simulation deposit',
processed_at: new Date(),
createdById: userId,
updatedById: userId,
}, { transaction });
await transaction.commit();
res.status(200).send({
message: 'Deposit successful',
balance: balanceAfter
});
} catch (error) {
await transaction.rollback();
console.error('Deposit error:', error);
res.status(500).send({ message: 'Internal server error' });
}
})
);
router.get(
'/balance',
passport.authenticate('jwt', { session: false }),
wrapAsync(async (req, res) => {
const user = await db.users.findByPk(req.user.id);
res.status(200).send({ balance: user.wallet_balance_rwf });
})
);
module.exports = router;

View File

@ -1,11 +1,12 @@
import React, { ReactNode, useState, useEffect } from 'react' import React, { ReactNode, useState, useEffect } from 'react'
import { mdiClose, mdiDotsVertical } from '@mdi/js' import { mdiClose, mdiDotsVertical, mdiWallet } from '@mdi/js'
import { containerMaxW } from '../config' import { containerMaxW } from '../config'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'
import NavBarItemPlain from './NavBarItemPlain' import NavBarItemPlain from './NavBarItemPlain'
import NavBarMenuList from './NavBarMenuList' import NavBarMenuList from './NavBarMenuList'
import { MenuNavBarItem } from '../interfaces' import { MenuNavBarItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'; import { useAppSelector } from '../stores/hooks';
import Link from 'next/link';
type Props = { type Props = {
menu: MenuNavBarItem[] menu: MenuNavBarItem[]
@ -17,6 +18,7 @@ export default function NavBar({ menu, className = '', children }: Props) {
const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false) const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false)
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor); const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const { currentUser } = useAppSelector((state) => state.auth);
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
@ -39,6 +41,16 @@ export default function NavBar({ menu, className = '', children }: Props) {
> >
<div className={`flex lg:items-stretch ${containerMaxW} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}> <div className={`flex lg:items-stretch ${containerMaxW} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}>
<div className="flex flex-1 items-stretch h-14">{children}</div> <div className="flex flex-1 items-stretch h-14">{children}</div>
{currentUser && (
<div className="flex items-center px-4">
<Link href="/wallet_transactions/wallet_transactions-list" className="flex items-center gap-2 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-100 px-3 py-1 rounded-full text-sm font-bold border border-blue-200 dark:border-blue-800 transition-transform hover:scale-105">
<BaseIcon path={mdiWallet} size={18} />
<span>0 RWF</span>
</Link>
</div>
)}
<div className="flex-none items-stretch flex h-14 lg:hidden"> <div className="flex-none items-stretch flex h-14 lg:hidden">
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}> <NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" /> <BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" />

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, {useEffect, useRef, useState} from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -0,0 +1,48 @@
import React from 'react';
import { mdiAccount, mdiImageOutline } from '@mdi/js';
import BaseIcon from './BaseIcon';
type Props = {
user: any;
className?: string;
size?: number;
};
export default function UserAvatarWithBadge({ user, className = '', size = 48 }: Props) {
const avatarUrl = user?.avatar && user.avatar[0] ? user.avatar[0].publicUrl : null;
const tierName = user?.account_tier?.name?.toLowerCase() || '';
const getBadgeColor = (name: string) => {
if (name.includes('plutonium')) return 'bg-purple-600';
if (name.includes('platinum')) return 'bg-blue-600';
if (name.includes('gold')) return 'bg-yellow-500';
if (name.includes('silver')) return 'bg-slate-400';
if (name.includes('bronze')) return 'bg-orange-500';
return null;
};
const badgeColor = getBadgeColor(tierName);
return (
<div className={`relative inline-block ${className}`} style={{ width: size, height: size }}>
{avatarUrl ? (
<img
src={avatarUrl}
alt={user?.firstName || 'User'}
className="rounded-full w-full h-full object-cover border-2 border-white shadow-sm"
/>
) : (
<div className="bg-gray-200 rounded-full w-full h-full flex items-center justify-center border-2 border-white shadow-sm">
<BaseIcon path={mdiAccount} size={size * 0.6} className="text-gray-400" />
</div>
)}
{badgeColor && (
<div className={`absolute -bottom-1 -right-1 ${badgeColor} text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full border-2 border-white shadow-sm uppercase tracking-tighter`}>
{user.account_tier.name.substring(0, 1)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { mdiWallet, mdiPlusCircle } from '@mdi/js';
import BaseIcon from './BaseIcon';
import CardBox from './CardBox';
import BaseButton from './BaseButton';
import CardBoxModal from './CardBoxModal';
import FormField from './FormField';
const WalletCard = () => {
const [balance, setBalance] = useState<number>(0);
const [isModalOpen, setIsModalOpen] = useState(false);
const [depositAmount, setDepositAmount] = useState<string>('1000');
const [isLoading, setIsLoading] = useState(false);
const fetchBalance = async () => {
try {
const response = await axios.get('/wallet/balance');
setBalance(Number(response.data.balance) || 0);
} catch (error) {
console.error('Failed to fetch balance:', error);
}
};
useEffect(() => {
fetchBalance();
}, []);
const handleDeposit = async () => {
setIsLoading(true);
try {
await axios.post('/wallet/deposit', { amount: Number(depositAmount) });
await fetchBalance();
setIsModalOpen(false);
} catch (error) {
console.error('Deposit failed:', error);
alert('Deposit failed. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<>
<CardBoxModal
title="Simulate Deposit"
buttonColor="info"
buttonLabel="Deposit Now"
isActive={isModalOpen}
onConfirm={handleDeposit}
onCancel={() => setIsModalOpen(false)}
isLoading={isLoading}
>
<p className="mb-4 text-gray-600">
This is a <b>Wallet Simulation</b>. No real money will be charged.
Enter the amount in RWF you want to add to your MENYAHAFI account.
</p>
<FormField label="Amount (RWF)" help="Min 100 RWF">
<input
type="number"
value={depositAmount}
onChange={(e) => setDepositAmount(e.target.value)}
className="px-3 py-2 max-w-full focus:ring focus:outline-none border-gray-700 rounded w-full bg-white"
/>
</FormField>
</CardBoxModal>
<div className="bg-white p-6 rounded-2xl shadow-xl border-b-4 border-blue-500">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-100 rounded-lg text-blue-600">
<BaseIcon path={mdiWallet} size={32} />
</div>
<div>
<p className="text-sm text-gray-500 font-bold uppercase tracking-wider">Your Balance</p>
<p className="text-2xl font-black text-gray-900">
{balance.toLocaleString()} RWF
</p>
</div>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors flex items-center gap-1 group"
title="Add Funds"
>
<BaseIcon path={mdiPlusCircle} size={24} />
<span className="text-sm font-bold hidden group-hover:inline">Deposit</span>
</button>
</div>
</div>
</>
);
};
export default WalletCard;

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -11,748 +11,195 @@ import { getPageTitle } from '../config'
import Link from "next/link"; import Link from "next/link";
import { hasPermission } from "../helpers/userPermissions"; import { hasPermission } from "../helpers/userPermissions";
import { fetchWidgets } from '../stores/roles/rolesSlice';
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import CardBox from '../components/CardBox';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import UserAvatarWithBadge from '../components/UserAvatarWithBadge';
const Dashboard = () => { const Dashboard = () => {
const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor); const iconsColor = useAppSelector((state) => state.style.iconsColor);
const corners = useAppSelector((state) => state.style.corners);
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
const loadingMessage = 'Loading...';
const [users, setUsers] = React.useState(loadingMessage);
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
const [provinces, setProvinces] = React.useState(loadingMessage);
const [districts, setDistricts] = React.useState(loadingMessage);
const [sectors, setSectors] = React.useState(loadingMessage);
const [cells, setCells] = React.useState(loadingMessage);
const [villages, setVillages] = React.useState(loadingMessage);
const [account_tiers, setAccount_tiers] = React.useState(loadingMessage);
const [subscriptions, setSubscriptions] = React.useState(loadingMessage);
const [wallet_transactions, setWallet_transactions] = React.useState(loadingMessage);
const [content_categories, setContent_categories] = React.useState(loadingMessage);
const [posts, setPosts] = React.useState(loadingMessage);
const [paid_actions, setPaid_actions] = React.useState(loadingMessage);
const [engagement_events, setEngagement_events] = React.useState(loadingMessage);
const [fee_rules, setFee_rules] = React.useState(loadingMessage);
const [rule_engine_events, setRule_engine_events] = React.useState(loadingMessage);
const [notifications, setNotifications] = React.useState(loadingMessage);
const [admin_settings, setAdmin_settings] = React.useState(loadingMessage);
const [help_center_articles, setHelp_center_articles] = React.useState(loadingMessage);
const [for_you_rankings, setFor_you_rankings] = React.useState(loadingMessage);
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
});
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
async function loadData() {
const entities = ['users','roles','permissions','provinces','districts','sectors','cells','villages','account_tiers','subscriptions','wallet_transactions','content_categories','posts','paid_actions','engagement_events','fee_rules','rule_engine_events','notifications','admin_settings','help_center_articles','for_you_rankings',];
const fns = [setUsers,setRoles,setPermissions,setProvinces,setDistricts,setSectors,setCells,setVillages,setAccount_tiers,setSubscriptions,setWallet_transactions,setContent_categories,setPosts,setPaid_actions,setEngagement_events,setFee_rules,setRule_engine_events,setNotifications,setAdmin_settings,setHelp_center_articles,setFor_you_rankings,];
const requests = entities.map((entity, index) => {
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
return axios.get(`/${entity.toLowerCase()}/count`);
} else {
fns[index](null);
return Promise.resolve({data: {count: null}});
}
const [stats, setStats] = React.useState({
posts: 0,
wallet_balance: 0,
paid_actions: 0,
notifications: 0
}); });
Promise.allSettled(requests).then((results) => { async function loadStats() {
results.forEach((result, i) => { try {
if (result.status === 'fulfilled') { const [postsCount, actionsCount, notifsCount] = await Promise.all([
fns[i](result.value.data.count); axios.get('/posts/count'),
} else { axios.get('/paid_actions/count'),
fns[i](result.reason.message); axios.get('/notifications/count')
]);
setStats({
posts: postsCount.data.count,
wallet_balance: 0, // Placeholder as wallet logic is simulated
paid_actions: actionsCount.data.count,
notifications: notifsCount.data.count
});
} catch (e) {
console.error(e);
} }
});
});
} }
async function getWidgets(roleId) {
await dispatch(fetchWidgets(roleId));
}
React.useEffect(() => { React.useEffect(() => {
if (!currentUser) return; if (currentUser) loadStats();
loadData().then();
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
}, [currentUser]); }, [currentUser]);
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value) return;
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
return ( return (
<> <>
<Head> <Head>
<title> <title>{getPageTitle('Menyahafi Dashboard')}</title>
{getPageTitle('Overview')}
</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton <SectionTitleLineWithButton
icon={icon.mdiChartTimelineVariant} icon={icon.mdiViewDashboard}
title='Overview' title='Menyahafi Dashboard'
main> main
{''} >
<BaseButton
href="/posts/posts-new"
label="New Post"
color="info"
icon={icon.mdiPlus}
/>
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator {/* User Status Card */}
currentUser={currentUser} <CardBox className="mb-6 border-l-8 border-blue-600">
isFetchingQuery={isFetchingQuery} <div className="flex flex-col md:flex-row items-center gap-6">
setWidgetsRole={setWidgetsRole} <UserAvatarWithBadge user={currentUser} size={80} />
widgetsRole={widgetsRole} <div className="flex-grow text-center md:text-left">
/>} <h2 className="text-2xl font-bold">{currentUser?.firstName}, Welcome back!</h2>
{!!rolesWidgets.length && <p className="text-gray-500">
hasPermission(currentUser, 'CREATE_ROLES') && ( Current Tier: <span className="font-bold text-blue-600">{currentUser?.account_tier?.name || 'Starter'}</span>
<p className=' text-gray-500 dark:text-gray-400 mb-4'> Location: <span className="font-bold">{currentUser?.village?.name || 'Kigali'}</span>
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
</p> </p>
)}
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
{(isFetchingQuery || loading) && (
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
<BaseIcon
className={`${iconsColor} animate-spin mr-5`}
w='w-16'
h='h-16'
size={48}
path={icon.mdiLoading}
/>{' '}
Loading widgets...
</div> </div>
)} <div className="bg-blue-50 p-4 rounded-2xl text-center min-w-[150px]">
<p className="text-xs text-blue-600 font-bold uppercase tracking-wider">Wallet Balance</p>
<p className="text-2xl font-black text-blue-900">{stats.wallet_balance} RWF</p>
<Link href="/wallet_transactions/wallet_transactions-list" className="text-xs text-blue-600 font-bold hover:underline mt-1 block">
Deposit Funds
</Link>
</div>
</div>
</CardBox>
{ rolesWidgets && <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
rolesWidgets.map((widget) => ( <div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<SmartWidget <div className="flex justify-between items-start">
key={widget.id} <div>
userId={currentUser?.id} <p className="text-sm text-gray-500 font-bold uppercase">My Posts</p>
widget={widget} <p className="text-3xl font-black">{stats.posts}</p>
roleId={widgetsRole?.role?.value || ''} </div>
admin={hasPermission(currentUser, 'CREATE_ROLES')} <BaseIcon path={icon.mdiPostOutline} size={32} className="text-blue-500" />
</div>
</div>
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="text-sm text-gray-500 font-bold uppercase">Paid Actions</p>
<p className="text-3xl font-black">{stats.paid_actions}</p>
</div>
<BaseIcon path={icon.mdiCashCheck} size={32} className="text-green-500" />
</div>
</div>
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="text-sm text-gray-500 font-bold uppercase">Alerts</p>
<p className="text-3xl font-black">{stats.notifications}</p>
</div>
<BaseIcon path={icon.mdiBellAlertOutline} size={32} className="text-red-500" />
</div>
</div>
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<div className="flex justify-between items-start">
<div>
<p className="text-sm text-gray-500 font-bold uppercase">Trust Score</p>
<p className="text-3xl font-black">98%</p>
</div>
<BaseIcon path={icon.mdiShieldCheckOutline} size={32} className="text-purple-500" />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CardBox title="Quick Actions" icon={icon.mdiFlash}>
<div className="grid grid-cols-2 gap-4">
<BaseButton
href="/posts/posts-new"
label="Post a Job"
color="info"
outline
className="w-full"
icon={icon.mdiBriefcasePlus}
/> />
))} <BaseButton
</div> href="/posts/posts-new"
label="Post an Ad"
{!!rolesWidgets.length && <hr className='my-6 ' />} color="success"
outline
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'> className="w-full"
icon={icon.mdiBullhornVariant}
/>
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}> <BaseButton
<div href="/account_tiers/account_tiers-list"
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`} label="Upgrade Tier"
> color="warning"
<div className="flex justify-between align-center"> outline
<div> className="w-full"
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400"> icon={icon.mdiShieldCrown}
Users />
</div> <BaseButton
<div className="text-3xl leading-tight font-semibold"> href="/help_center_articles/help_center_articles-list"
{users} label="Help Center"
</div> color="contrast"
</div> outline
<div> className="w-full"
<BaseIcon icon={icon.mdiHelpCircle}
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiAccountGroup || icon.mdiTable}
/> />
</div> </div>
</div> </CardBox>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ROLES') && <Link href={'/roles/roles-list'}> <CardBox title="System Status" icon={icon.mdiRobot}>
<div <div className="space-y-4">
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`} <div className="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
> <div className="flex items-center gap-3">
<div className="flex justify-between align-center"> <div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
<div> <span className="font-bold text-sm">AI Rules Engine</span>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Roles
</div> </div>
<div className="text-3xl leading-tight font-semibold"> <span className="text-xs text-gray-500 font-bold">ACTIVE</span>
{roles}
</div> </div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
<span className="font-bold text-sm">Wallet Enforcement</span>
</div> </div>
<div> <span className="text-xs text-gray-500 font-bold">AUTO-SHIELD ON</span>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
/>
</div> </div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
<span className="font-bold text-sm">For You Algorithm</span>
</div> </div>
<span className="text-xs text-gray-500 font-bold">V1.2 RUNNING</span>
</div> </div>
</Link>}
{hasPermission(currentUser, 'READ_PERMISSIONS') && <Link href={'/permissions/permissions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Permissions
</div> </div>
<div className="text-3xl leading-tight font-semibold"> </CardBox>
{permissions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PROVINCES') && <Link href={'/provinces/provinces-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Provinces
</div>
<div className="text-3xl leading-tight font-semibold">
{provinces}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiMap' in icon ? icon['mdiMap' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_DISTRICTS') && <Link href={'/districts/districts-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Districts
</div>
<div className="text-3xl leading-tight font-semibold">
{districts}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_SECTORS') && <Link href={'/sectors/sectors-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Sectors
</div>
<div className="text-3xl leading-tight font-semibold">
{sectors}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiMapMarkerRadius' in icon ? icon['mdiMapMarkerRadius' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_CELLS') && <Link href={'/cells/cells-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Cells
</div>
<div className="text-3xl leading-tight font-semibold">
{cells}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiMapMarkerOutline' in icon ? icon['mdiMapMarkerOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_VILLAGES') && <Link href={'/villages/villages-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Villages
</div>
<div className="text-3xl leading-tight font-semibold">
{villages}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiHomeMapMarker' in icon ? icon['mdiHomeMapMarker' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ACCOUNT_TIERS') && <Link href={'/account_tiers/account_tiers-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Account tiers
</div>
<div className="text-3xl leading-tight font-semibold">
{account_tiers}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiShieldCrown' in icon ? icon['mdiShieldCrown' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_SUBSCRIPTIONS') && <Link href={'/subscriptions/subscriptions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Subscriptions
</div>
<div className="text-3xl leading-tight font-semibold">
{subscriptions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCalendarClock' in icon ? icon['mdiCalendarClock' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_WALLET_TRANSACTIONS') && <Link href={'/wallet_transactions/wallet_transactions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Wallet transactions
</div>
<div className="text-3xl leading-tight font-semibold">
{wallet_transactions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCashSync' in icon ? icon['mdiCashSync' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_CONTENT_CATEGORIES') && <Link href={'/content_categories/content_categories-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Content categories
</div>
<div className="text-3xl leading-tight font-semibold">
{content_categories}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_POSTS') && <Link href={'/posts/posts-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Posts
</div>
<div className="text-3xl leading-tight font-semibold">
{posts}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiPostOutline' in icon ? icon['mdiPostOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PAID_ACTIONS') && <Link href={'/paid_actions/paid_actions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Paid actions
</div>
<div className="text-3xl leading-tight font-semibold">
{paid_actions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiLockCheck' in icon ? icon['mdiLockCheck' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ENGAGEMENT_EVENTS') && <Link href={'/engagement_events/engagement_events-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Engagement events
</div>
<div className="text-3xl leading-tight font-semibold">
{engagement_events}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiThumbUpOutline' in icon ? icon['mdiThumbUpOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_FEE_RULES') && <Link href={'/fee_rules/fee_rules-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Fee rules
</div>
<div className="text-3xl leading-tight font-semibold">
{fee_rules}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCalculatorVariant' in icon ? icon['mdiCalculatorVariant' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_RULE_ENGINE_EVENTS') && <Link href={'/rule_engine_events/rule_engine_events-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Rule engine events
</div>
<div className="text-3xl leading-tight font-semibold">
{rule_engine_events}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiRobotAlert' in icon ? icon['mdiRobotAlert' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_NOTIFICATIONS') && <Link href={'/notifications/notifications-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Notifications
</div>
<div className="text-3xl leading-tight font-semibold">
{notifications}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiBellOutline' in icon ? icon['mdiBellOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ADMIN_SETTINGS') && <Link href={'/admin_settings/admin_settings-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Admin settings
</div>
<div className="text-3xl leading-tight font-semibold">
{admin_settings}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCogOutline' in icon ? icon['mdiCogOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_HELP_CENTER_ARTICLES') && <Link href={'/help_center_articles/help_center_articles-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Help center articles
</div>
<div className="text-3xl leading-tight font-semibold">
{help_center_articles}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiHelpCircleOutline' in icon ? icon['mdiHelpCircleOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_FOR_YOU_RANKINGS') && <Link href={'/for_you_rankings/for_you_rankings-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
For you rankings
</div>
<div className="text-3xl leading-tight font-semibold">
{for_you_rankings}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiChartLine' in icon ? icon['mdiChartLine' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
</div> </div>
</SectionMain> </SectionMain>
</> </>
) );
} }
Dashboard.getLayout = function getLayout(page: ReactElement) { Dashboard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated> return <LayoutAuthenticated>{page}</LayoutAuthenticated>
} }
export default Dashboard export default Dashboard;

View File

@ -1,166 +1,423 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import BaseButton from '../components/BaseButton'; import axios from 'axios';
import CardBox from '../components/CardBox'; import SectionMain from '../components/SectionMain';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks'; import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; import { mdiWallet, mdiMapMarker, mdiStar, mdiBriefcase, mdiBullhorn, mdiChevronRight, mdiShieldCheck, mdiAlert } from '@mdi/js';
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; import BaseIcon from '../components/BaseIcon';
import WalletCard from '../components/WalletCard';
import FormField from '../components/FormField';
export default function MenyahafiLanding() {
const [posts, setPosts] = useState([]);
const [tiers, setTiers] = useState([]);
const [loading, setLoading] = useState(true);
const { currentUser } = useAppSelector((state) => state.auth);
export default function Starter() { // Demo Post State
const [illustrationImage, setIllustrationImage] = useState({ const [demoPostType, setDemoPostType] = useState('job_daily');
src: undefined, const [demoTitle, setDemoTitle] = useState('');
photographer: undefined, const [demoFee, setDemoFee] = useState(500);
photographer_url: undefined, const [userBalance, setUserBalance] = useState(0);
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'MENYAHAFI'
// Fetch Pexels image/video
useEffect(() => { useEffect(() => {
async function fetchData() { const fetchLandingData = async () => {
const image = await getPexelsImage(); try {
const video = await getPexelsVideo(); const response = await axios.get('/landing/data');
setIllustrationImage(image); setPosts(response.data.posts || []);
setIllustrationVideo(video); setTiers(response.data.tiers || []);
} } catch (error) {
fetchData(); console.error('Failed to fetch landing data:', error);
}, []); } finally {
setLoading(false);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
} }
}; };
fetchLandingData();
if (currentUser) {
axios.get('/wallet/balance').then(res => {
setUserBalance(Number(res.data.balance) || 0);
});
}
}, [currentUser]);
useEffect(() => {
// Simple fee logic for demo
let baseFee = 500;
if (demoPostType === 'job_monthly') baseFee = 2000;
if (demoPostType.startsWith('ad_')) baseFee = 3500;
if (demoPostType === 'nyakabyizi') baseFee = 0;
// AI detection of links (mock)
const hasLinks = /http|www|\.com|\.rw|07[8923]\d{7}/.test(demoTitle);
if (hasLinks) baseFee += 1500;
setDemoFee(baseFee);
}, [demoPostType, demoTitle]);
const defaultTiers = [
{ name: 'Starter', price: '0', color: 'bg-gray-100', text: 'text-gray-800' },
{ name: 'Bronze', price: '1,000', color: 'bg-orange-100', text: 'text-orange-800' },
{ name: 'Silver', price: '3,000', color: 'bg-slate-200', text: 'text-slate-800' },
{ name: 'Gold', price: '6,000', color: 'bg-yellow-100', text: 'text-yellow-800' },
{ name: 'Platinum', price: '10,000', color: 'bg-blue-100', text: 'text-blue-800' },
{ name: 'Plutonium', price: '15,000', color: 'bg-purple-100', text: 'text-purple-800' },
];
const displayTiers = tiers.length > 0 ? tiers.map((t: any) => ({
name: t.name,
price: t.subscription_fee_rwf,
color: t.name.toLowerCase().includes('platinum') ? 'bg-blue-100' : (t.name.toLowerCase().includes('gold') ? 'bg-yellow-100' : 'bg-gray-100'),
text: 'text-gray-800'
})) : defaultTiers;
const isBalanceInsufficient = currentUser && userBalance < demoFee;
return ( return (
<div <div className="bg-gray-50 min-h-screen font-sans">
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('MENYAHAFI - Paid Digital Marketplace Rwanda')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> {/* Hero Section */}
<div <div className="bg-gradient-to-r from-blue-700 via-green-600 to-green-700 text-white py-24 px-4 overflow-hidden relative">
className={`flex ${ <div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none">
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <div className="absolute -top-24 -left-24 w-96 h-96 bg-white rounded-full blur-3xl"></div>
} min-h-screen w-full`} <div className="absolute -bottom-24 -right-24 w-96 h-96 bg-yellow-400 rounded-full blur-3xl"></div>
</div>
<div className="max-w-6xl mx-auto text-center relative z-10">
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-md px-4 py-2 rounded-full mb-8 border border-white/20">
<BaseIcon path={mdiShieldCheck} size={20} className="text-yellow-400" />
<span className="text-sm font-bold uppercase tracking-widest">100% Automated Trusted Platform</span>
</div>
<h1 className="text-6xl md:text-8xl font-black mb-8 tracking-tighter leading-none">
MENYA<span className="text-yellow-400">HAFI</span>
</h1>
<p className="text-xl md:text-2xl mb-12 font-medium max-w-3xl mx-auto text-blue-50 leading-relaxed">
The heartbeat of Rwanda&apos;s digital economy.
Safe jobs, professional ads, and verified local engagement.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-6">
{!currentUser ? (
<>
<Link href="/register">
<button className="bg-yellow-400 hover:bg-yellow-500 text-blue-900 px-10 py-5 rounded-2xl font-black text-xl transition-all hover:scale-105 shadow-[0_10px_20px_rgba(250,210,1,0.3)]">
Join Menyahafi
</button>
</Link>
<Link href="/login">
<button className="bg-white/10 hover:bg-white/20 text-white border-2 border-white/30 backdrop-blur-sm px-10 py-5 rounded-2xl font-black text-xl transition-all shadow-xl">
Sign In
</button>
</Link>
</>
) : (
<Link href="/dashboard">
<button className="bg-white text-blue-700 px-10 py-5 rounded-2xl font-black text-xl transition-all hover:scale-105 shadow-2xl">
Go to Dashboard
</button>
</Link>
)}
</div>
</div>
</div>
<SectionMain>
{/* Stats / Wallet Promo */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 -mt-16 mb-20">
{currentUser ? (
<WalletCard />
) : (
<div className="bg-white p-8 rounded-3xl shadow-2xl border-b-8 border-blue-500 transform hover:-translate-y-1 transition-transform">
<div className="flex items-center gap-5">
<div className="p-4 bg-blue-50 rounded-2xl text-blue-600">
<BaseIcon path={mdiWallet} size={40} />
</div>
<div>
<p className="text-xs text-gray-400 font-black uppercase tracking-widest mb-1">Your Wallet</p>
<p className="text-3xl font-black text-gray-900 italic tracking-tighter">Login to View</p>
</div>
</div>
</div>
)}
<div className="bg-white p-8 rounded-3xl shadow-2xl border-b-8 border-green-500 transform hover:-translate-y-1 transition-transform">
<div className="flex items-center gap-5">
<div className="p-4 bg-green-50 rounded-2xl text-green-600">
<BaseIcon path={mdiBriefcase} size={40} />
</div>
<div>
<p className="text-xs text-gray-400 font-black uppercase tracking-widest mb-1">Jobs Posted</p>
<p className="text-3xl font-black text-gray-900 tracking-tighter">2,450+</p>
</div>
</div>
</div>
<div className="bg-white p-8 rounded-3xl shadow-2xl border-b-8 border-yellow-500 transform hover:-translate-y-1 transition-transform">
<div className="flex items-center gap-5">
<div className="p-4 bg-yellow-50 rounded-2xl text-yellow-600">
<BaseIcon path={mdiBullhorn} size={40} />
</div>
<div>
<p className="text-xs text-gray-400 font-black uppercase tracking-widest mb-1">Verified Ads</p>
<p className="text-3xl font-black text-gray-900 tracking-tighter">1,200+</p>
</div>
</div>
</div>
</div>
{/* AI Fee Engine Demo */}
<div className="bg-white rounded-[2rem] shadow-2xl border border-gray-100 overflow-hidden mb-20">
<div className="grid grid-cols-1 lg:grid-cols-2">
<div className="p-10 lg:p-16 bg-gray-50 border-r border-gray-100">
<div className="inline-flex items-center gap-2 bg-blue-100 text-blue-700 px-4 py-1 rounded-full text-xs font-black uppercase mb-6">
<BaseIcon path={mdiStar} size={14} />
Automatic Fee Engine
</div>
<h2 className="text-4xl font-black text-gray-900 mb-6 leading-tight">
Test Our AI-Powered <br/> Pricing Real-Time
</h2>
<p className="text-gray-500 mb-10 text-lg font-medium leading-relaxed">
See how our rules engine calculates fees based on category,
content detection (links/scams), and your location.
</p>
<div className="space-y-6">
<FormField label="What are you posting?">
<select
value={demoPostType}
onChange={(e) => setDemoPostType(e.target.value)}
className="w-full bg-white border-2 border-gray-200 rounded-2xl px-5 py-4 font-bold text-gray-700 focus:border-blue-500 focus:ring-0 transition-all outline-none"
> >
{contentType === 'image' && contentPosition !== 'background' <option value="job_daily">Daily Job Post</option>
? imageBlock(illustrationImage) <option value="job_monthly">Monthly Job Post</option>
: null} <option value="ad_service">Business Service Ad</option>
{contentType === 'video' && contentPosition !== 'background' <option value="ad_real_estate">Real Estate Ad</option>
? videoBlock(illustrationVideo) <option value="nyakabyizi">Nyakabyizi (Village Only)</option>
: null} </select>
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'> </FormField>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your MENYAHAFI app!"/>
<div className="space-y-3"> <FormField label="Post Content (Try adding a link or phone)">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p> <textarea
<p className='text-center text-gray-500'>For guides and documentation please check placeholder="Type something like 'Contact me at 078...'"
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p> value={demoTitle}
</div> onChange={(e) => setDemoTitle(e.target.value)}
className="w-full bg-white border-2 border-gray-200 rounded-2xl px-5 py-4 font-bold text-gray-700 focus:border-blue-500 focus:ring-0 transition-all outline-none h-32"
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/> />
</FormField>
</div>
</div>
</BaseButtons> <div className="p-10 lg:p-16 flex flex-col justify-center items-center text-center">
</CardBox> <div className="mb-8 p-6 bg-blue-50 rounded-[2.5rem] w-full max-w-sm border-2 border-blue-100 relative">
<p className="text-blue-600 font-black uppercase text-sm tracking-widest mb-2">Calculated Fee</p>
<p className="text-6xl font-black text-gray-900 tracking-tighter mb-2">
{demoFee.toLocaleString()} <span className="text-2xl">RWF</span>
</p>
{demoTitle.includes('07') || demoTitle.includes('http') ? (
<div className="bg-red-100 text-red-700 text-xs font-bold px-3 py-1 rounded-full inline-block animate-pulse">
LINK DETECTED: +1,500 RWF Fee
</div>
) : null}
</div>
<div className="w-full max-w-sm">
{isBalanceInsufficient ? (
<div className="bg-red-50 border-2 border-red-200 p-6 rounded-3xl mb-8">
<div className="flex items-center gap-2 text-red-600 mb-3 justify-center">
<BaseIcon path={mdiAlert} size={24} />
<span className="font-black text-lg italic">Locked!</span>
</div>
<p className="text-red-800 font-bold leading-tight">
&ldquo;Ntushobora gukomeza. Banza wongere amafaranga kuri konti yawe.&rdquo;
</p>
<p className="text-red-500 text-sm mt-2 font-medium">
Your balance ({userBalance} RWF) is too low.
</p>
</div>
) : (
<div className="bg-green-50 border-2 border-green-200 p-6 rounded-3xl mb-8">
<p className="text-green-800 font-bold">
{currentUser ? 'Balance Sufficient!' : 'Login to Post'}
</p>
</div>
)}
<button
disabled={isBalanceInsufficient || !currentUser}
className={`w-full py-5 rounded-2xl font-black text-xl shadow-xl transition-all flex items-center justify-center gap-3 ${
isBalanceInsufficient || !currentUser
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700 text-white hover:scale-105 active:scale-95'
}`}
>
{isBalanceInsufficient ? 'LOCKED' : (currentUser ? 'POST NOW' : 'JOIN TO POST')}
</button>
</div> </div>
</div> </div>
</SectionFullScreen> </div>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> </div>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> {/* For You Feed */}
Privacy Policy <div className="mb-24">
<div className="flex items-center justify-between mb-12">
<div>
<h2 className="text-4xl font-black text-gray-900 flex items-center gap-3 italic">
<BaseIcon path={mdiStar} size={36} className="text-yellow-500" />
For You
</h2>
<p className="text-gray-400 font-bold mt-1">Personalized Marketplace Ranking</p>
</div>
<Link href="/posts" className="bg-white hover:bg-gray-100 text-gray-900 border border-gray-200 px-6 py-3 rounded-2xl font-black flex items-center gap-2 transition-all shadow-sm">
View All <BaseIcon path={mdiChevronRight} />
</Link> </Link>
</div> </div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-10">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-3xl p-4 shadow-lg">
<div className="bg-gray-100 animate-pulse h-56 rounded-2xl mb-4"></div>
<div className="bg-gray-100 animate-pulse h-6 rounded-lg w-3/4 mb-3"></div>
<div className="bg-gray-100 animate-pulse h-4 rounded-lg w-1/2"></div>
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-10">
{posts.length > 0 ? posts.map((post: any) => (
<div key={post.id} className="bg-white rounded-[2rem] shadow-xl overflow-hidden hover:shadow-2xl transition-all border border-gray-50 group transform hover:-translate-y-2">
<div className="h-64 bg-gray-100 relative overflow-hidden">
{post.images && post.images.length > 0 ? (
<img src={post.images[0].url} alt={post.title} className="w-full h-full object-cover transition-transform group-hover:scale-110 duration-700" />
) : (
<div className="flex items-center justify-center h-full text-gray-300">
<BaseIcon path={mdiBriefcase} size={64} />
</div>
)}
<div className="absolute top-6 left-6 flex flex-col gap-2">
<span className="bg-blue-600/90 backdrop-blur-md text-white px-4 py-1.5 rounded-full text-xs font-black uppercase tracking-widest shadow-lg">
{post.post_type?.replace('_', ' ')}
</span>
</div>
{post.is_boosted && (
<div className="absolute top-6 right-6">
<span className="bg-yellow-400 text-blue-900 px-4 py-1.5 rounded-full text-xs font-black shadow-lg border-2 border-white/20">
BOOSTED
</span>
</div>
)}
</div>
<div className="p-8">
<h3 className="text-2xl font-black mb-3 text-gray-900 group-hover:text-blue-700 transition-colors line-clamp-1 italic">
{post.title}
</h3>
<div className="flex items-center text-gray-400 font-bold text-sm mb-6">
<BaseIcon path={mdiMapMarker} size={18} className="mr-2 text-green-500" />
{post.village?.name || 'Local Village'}
</div>
<div className="flex items-center justify-between pt-6 border-t border-gray-50">
<div>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mb-1">Fee To Reveal</p>
<span className="text-2xl font-black text-gray-900">
{post.required_fee_rwf ? `${post.required_fee_rwf}` : 'FREE'}
{post.required_fee_rwf ? <span className="text-xs ml-1">RWF</span> : ''}
</span>
</div>
<button className="bg-blue-50 hover:bg-blue-600 text-blue-700 hover:text-white px-5 py-3 rounded-2xl font-black transition-all shadow-sm">
View
</button>
</div>
</div>
</div>
)) : (
<div className="col-span-full py-24 text-center bg-white rounded-[3rem] border-4 border-dashed border-gray-100">
<div className="p-8 bg-gray-50 rounded-full inline-block mb-8">
<BaseIcon path={mdiBriefcase} size={64} className="text-gray-200" />
</div>
<p className="text-gray-400 font-black text-3xl italic tracking-tighter mb-4">No posts in your area yet.</p>
<p className="text-gray-400 font-bold max-w-sm mx-auto">Be the first to post a job or ad and start growing your business today!</p>
</div>
)}
</div>
)}
</div>
{/* Tiers Section */}
<div className="bg-gray-900 rounded-[3rem] p-12 md:p-20 shadow-3xl relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/20 rounded-full blur-[100px]"></div>
<div className="absolute bottom-0 left-0 w-64 h-64 bg-green-600/20 rounded-full blur-[100px]"></div>
<div className="relative z-10">
<h2 className="text-4xl md:text-5xl font-black text-white text-center mb-6 italic tracking-tighter">Choose Your Power</h2>
<p className="text-gray-400 text-center mb-16 font-bold text-xl">Unlock higher visibility and exclusive discounts.</p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
{displayTiers.map((tier) => (
<div key={tier.name} className={`bg-white/5 backdrop-blur-lg border border-white/10 p-8 rounded-3xl text-center flex flex-col justify-between hover:bg-white/10 transition-all cursor-pointer group transform hover:-translate-y-2`}>
<div>
<p className="text-white font-black text-2xl mb-2 group-hover:text-yellow-400 transition-colors">{tier.name}</p>
<div className="h-1 w-8 bg-blue-500 mx-auto mb-6 rounded-full group-hover:w-16 transition-all"></div>
</div>
<div>
<p className="text-2xl font-black text-white tracking-tighter">{tier.price} <span className="text-xs">RWF</span></p>
<p className="text-[10px] text-gray-500 font-black uppercase mt-2 tracking-widest">Per Month</p>
</div>
</div>
))}
</div>
</div>
</div>
</SectionMain>
{/* Footer */}
<footer className="bg-gray-950 text-white py-24 px-4 mt-32 border-t border-white/5">
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-16">
<div className="col-span-1 md:col-span-2">
<h3 className="text-4xl font-black mb-8 tracking-tighter">MENYA<span className="text-yellow-400">HAFI</span></h3>
<p className="text-gray-400 font-bold text-lg leading-relaxed max-w-md">
Revolutionizing trust in Rwanda&apos;s digital marketplace.
Verified actions, transparent pricing, and instant connections.
</p>
</div>
<div>
<h4 className="text-xs font-black uppercase tracking-widest text-blue-500 mb-8">Support</h4>
<ul className="space-y-6 text-gray-400 font-bold">
<li className="flex items-center gap-3">
<span className="text-white">Email:</span> ishlambosh@gmail.com
</li>
<li className="flex items-center gap-3">
<span className="text-white">WhatsApp:</span> 0793031666
</li>
<li><Link href="/help_center_articles" className="hover:text-blue-400 transition-colors">Help Center</Link></li>
</ul>
</div>
<div>
<h4 className="text-xs font-black uppercase tracking-widest text-blue-500 mb-8">Legal</h4>
<ul className="space-y-6 text-gray-400 font-bold">
<li><Link href="/privacy-policy" className="hover:text-white">Privacy Policy</Link></li>
<li><Link href="/terms-of-use" className="hover:text-white">Terms of Use</Link></li>
</ul>
</div>
</div>
<div className="max-w-6xl mx-auto border-t border-white/5 mt-20 pt-10 flex flex-col md:flex-row justify-between items-center gap-6">
<p className="text-gray-600 font-bold text-sm">
© 2026 MENYAHAFI. Empowering Rwanda&apos;s Workforce.
</p>
<div className="flex gap-8 text-gray-600 font-black text-xs uppercase tracking-widest">
<span>Made with in Kigali</span>
</div>
</div>
</footer>
</div> </div>
); );
} }
Starter.getLayout = function getLayout(page: ReactElement) { MenyahafiLanding.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };

View File

@ -1,6 +1,7 @@
import { import {
mdiChartTimelineVariant, mdiChartTimelineVariant,
mdiUpload, mdiUpload,
mdiAccountDetails,
} from '@mdi/js'; } from '@mdi/js';
import Head from 'next/head'; import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useState } from 'react';
@ -29,6 +30,7 @@ import { update, fetch } from '../stores/users/usersSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import {findMe} from "../stores/authSlice"; import {findMe} from "../stores/authSlice";
import UserAvatarWithBadge from '../components/UserAvatarWithBadge';
const EditUsers = () => { const EditUsers = () => {
const { currentUser, isFetching, token } = useAppSelector( const { currentUser, isFetching, token } = useAppSelector(
@ -45,7 +47,18 @@ const EditUsers = () => {
app_role: '', app_role: '',
disabled: false, disabled: false,
avatar: [], avatar: [],
password: '' password: '',
national_id: '',
address: '',
birth_day: '',
birth_year: '',
account_type: '',
account_tier: '',
province: '',
district: '',
sector: '',
cell: '',
village: '',
}; };
const [initialValues, setInitialValues] = useState(initVals); const [initialValues, setInitialValues] = useState(initVals);
@ -54,7 +67,15 @@ const EditUsers = () => {
const newInitialVal = { ...initVals }; const newInitialVal = { ...initVals };
Object.keys(initVals).forEach( Object.keys(initVals).forEach(
(el) => (newInitialVal[el] = currentUser[el]), (el) => {
if (currentUser[el]) {
if (typeof currentUser[el] === 'object' && currentUser[el].id) {
newInitialVal[el] = currentUser[el].id;
} else {
newInitialVal[el] = currentUser[el];
}
}
}
); );
setInitialValues(newInitialVal); setInitialValues(newInitialVal);
@ -64,7 +85,6 @@ const EditUsers = () => {
const handleSubmit = async (data) => { const handleSubmit = async (data) => {
await dispatch(update({ id: currentUser.id, data })); await dispatch(update({ id: currentUser.id, data }));
await dispatch(findMe()); await dispatch(findMe());
await router.push('/users/users-list');
notify('success', 'Profile was updated!'); notify('success', 'Profile was updated!');
}; };
@ -75,39 +95,38 @@ const EditUsers = () => {
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton <SectionTitleLineWithButton
icon={mdiChartTimelineVariant} icon={mdiAccountDetails}
title='Edit profile' title='My Profile'
main main
> >
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox> <CardBox>
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}> <div className="flex flex-col items-center mb-8">
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8"> <UserAvatarWithBadge user={currentUser} size={160} />
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Avatar" /> <h2 className="text-2xl font-bold mt-4">{currentUser?.firstName} {currentUser?.lastName}</h2>
<p className="text-gray-500 font-bold uppercase text-sm">{currentUser?.account_tier?.name || 'Starter'}</p>
</div> </div>
</div>}
<Formik <Formik
enableReinitialize enableReinitialize
initialValues={initialValues} initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)} onSubmit={(values) => handleSubmit(values)}
> >
<Form> <Form>
<FormField> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField label='Avatar'>
<Field <Field
label='Avatar'
color='info' color='info'
icon={mdiUpload} icon={mdiUpload}
path={'users/avatar'} path={'users/avatar'}
name='avatar' name='avatar'
id='avatar' id='avatar'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker} component={FormImagePicker}
></Field> ></Field>
</FormField> </FormField>
<div className="space-y-4">
<FormField label='First Name'> <FormField label='First Name'>
<Field name='firstName' placeholder='First Name' /> <Field name='firstName' placeholder='First Name' />
</FormField> </FormField>
@ -115,6 +134,49 @@ const EditUsers = () => {
<FormField label='Last Name'> <FormField label='Last Name'>
<Field name='lastName' placeholder='Last Name' /> <Field name='lastName' placeholder='Last Name' />
</FormField> </FormField>
</div>
</div>
<BaseDivider />
<h3 className="text-lg font-bold mb-4">Identification & Location</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField label='National ID (16 digits)'>
<Field name='national_id' placeholder='National ID' />
</FormField>
<FormField label='Account Type'>
<Field name='account_type' component={SelectField} options={[
{ id: 'Worker', name: 'Worker' },
{ id: 'Employer', name: 'Employer' },
{ id: 'Business', name: 'Business' },
{ id: 'Government', name: 'Government' },
{ id: 'NGO', name: 'NGO' },
]} />
</FormField>
<FormField label='Birth Day'>
<Field name='birth_day' type="number" placeholder='Day' />
</FormField>
<FormField label='Birth Year'>
<Field name='birth_year' type="number" placeholder='Year' />
</FormField>
<FormField label='Province'>
<Field name='province' component={SelectField} itemRef={'provinces'} showField={'name'} />
</FormField>
<FormField label='District'>
<Field name='district' component={SelectField} itemRef={'districts'} showField={'name'} />
</FormField>
</div>
<FormField label='Address'>
<Field name='address' placeholder='Detailed Address' />
</FormField>
<BaseDivider />
<FormField label='Phone Number'> <FormField label='Phone Number'>
<Field name='phoneNumber' placeholder='Phone Number' /> <Field name='phoneNumber' placeholder='Phone Number' />
@ -124,46 +186,8 @@ const EditUsers = () => {
<Field name='email' placeholder='E-Mail' disabled /> <Field name='email' placeholder='E-Mail' disabled />
</FormField> </FormField>
<FormField label='App Role' labelFor='app_role'>
<Field
name='app_role'
id='app_role'
component={SelectField}
options={initialValues.app_role}
itemRef={'roles'}
showField={'name'}
></Field>
</FormField>
<FormField label='Disabled' labelFor='disabled'>
<Field
name='disabled'
id='disabled'
component={SwitchField}
></Field>
</FormField>
<FormField
label="Password"
>
<Field
name="password"
placeholder="password"
/>
</FormField>
<BaseDivider />
<BaseButtons> <BaseButtons>
<BaseButton type='submit' color='info' label='Submit' /> <BaseButton type='submit' color='info' label='Update Profile' />
<BaseButton type='reset' color='info' outline label='Reset' />
<BaseButton
type='reset'
color='danger'
outline
label='Cancel'
onClick={() => router.push('/users/users-list')}
/>
</BaseButtons> </BaseButtons>
</Form> </Form>
</Formik> </Formik>