Autosave: 20260218-034325

This commit is contained in:
Flatlogic Bot 2026-02-18 03:43:25 +00:00
parent a004fcd820
commit f7df7e331e
62 changed files with 2454 additions and 2634 deletions

View File

@ -129,7 +129,7 @@
<p class="tip">The application is currently launching. The page will automatically refresh once site is
available.</p>
<div class="project-info">
<h2>Crafted Network</h2>
<h2>Fix It Local</h2>
<p>Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking.</p>
</div>
<div class="loader-container">

View File

@ -1,6 +1,6 @@
# Crafted Network
# Fix It Local
## This project was generated by [Flatlogic Platform](https://flatlogic.com).

View File

@ -1,5 +1,5 @@
#Crafted Network - template backend,
#Fix It Local - template backend,
#### Run App on local machine:

View File

@ -1,6 +1,6 @@
{
"name": "craftednetwork",
"description": "Crafted Network - template backend",
"description": "Fix It Local - template backend",
"scripts": {
"start": "npm run db:migrate && npm run db:seed && npm run watch",
"lint": "eslint . --ext .js",

View File

@ -37,7 +37,7 @@ const config = {
},
uploadDir: os.tmpdir(),
email: {
from: 'Crafted Network <app@flatlogic.app>',
from: 'Fix It Local <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {

View File

@ -1,18 +1,12 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
module.exports = class Business_badgesDBApi {
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
@ -20,493 +14,63 @@ module.exports = class Business_badgesDBApi {
const business_badges = await db.business_badges.create(
{
id: data.id || undefined,
badge_type: data.badge_type
||
null
,
status: data.status
||
null
,
granted_at: data.granted_at
||
null
,
revoked_at: data.revoked_at
||
null
,
notes: data.notes
||
null
,
created_at_ts: data.created_at_ts
||
null
,
updated_at_ts: data.updated_at_ts
||
null
,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await business_badges.setBusiness( data.business || null, {
transaction,
});
return business_badges;
}
static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
// Prepare data - wrapping individual data transformations in a map() method
const business_badgesData = data.map((item, index) => ({
id: item.id || undefined,
badge_type: item.badge_type
||
null
,
status: item.status
||
null
,
granted_at: item.granted_at
||
null
,
revoked_at: item.revoked_at
||
null
,
notes: item.notes
||
null
,
created_at_ts: item.created_at_ts
||
null
,
updated_at_ts: item.updated_at_ts
||
null
,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
// Bulk create items
const business_badges = await db.business_badges.bulkCreate(business_badgesData, { transaction });
// For each item created, replace relation files
return business_badges;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_badges = await db.business_badges.findByPk(id, {}, {transaction});
const updatePayload = {};
if (data.badge_type !== undefined) updatePayload.badge_type = data.badge_type;
if (data.status !== undefined) updatePayload.status = data.status;
if (data.granted_at !== undefined) updatePayload.granted_at = data.granted_at;
if (data.revoked_at !== undefined) updatePayload.revoked_at = data.revoked_at;
if (data.notes !== undefined) updatePayload.notes = data.notes;
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
if (data.updated_at_ts !== undefined) updatePayload.updated_at_ts = data.updated_at_ts;
updatePayload.updatedById = currentUser.id;
await business_badges.update(updatePayload, {transaction});
if (data.business !== undefined) {
await business_badges.setBusiness(
data.business,
{ transaction }
);
}
return business_badges;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const business_badges = await db.business_badges.findAll({
where: {
id: {
[Op.in]: ids,
},
badge_type: data.badge_type || null,
status: data.status || null,
granted_at: data.granted_at || null,
revoked_at: data.revoked_at || null,
notes: data.notes || null,
created_at_ts: data.created_at_ts || null,
updated_at_ts: data.updated_at_ts || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
transaction,
});
await db.sequelize.transaction(async (transaction) => {
for (const record of business_badges) {
await record.update(
{deletedBy: currentUser.id},
{transaction}
);
}
for (const record of business_badges) {
await record.destroy({transaction});
}
});
return business_badges;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_badges = await db.business_badges.findByPk(id, options);
await business_badges.update({
deletedBy: currentUser.id
}, {
transaction,
});
await business_badges.destroy({
transaction
});
return business_badges;
}
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const business_badges = await db.business_badges.findOne(
{ where },
{ transaction },
);
if (!business_badges) {
return business_badges;
}
await business_badges.setBusiness( data.business || null, { transaction });
const output = business_badges.get({plain: true});
output.business = await business_badges.getBusiness({
transaction
});
return output;
return business_badges;
}
static async findAll(
filter,
options
) {
static async findAll(filter, options) {
const limit = filter.limit || 0;
let offset = 0;
let where = {};
const currentPage = +filter.page;
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined;
let include = [
{
model: db.businesses,
as: 'business',
where: filter.business ? {
[Op.or]: [
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
];
if (filter) {
if (filter.id) {
where = {
...where,
['id']: Utils.uuid(filter.id),
};
}
if (filter.notes) {
where = {
...where,
[Op.and]: Utils.ilike(
'business_badges',
'notes',
filter.notes,
),
};
}
if (filter.granted_atRange) {
const [start, end] = filter.granted_atRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
granted_at: {
...where.granted_at,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
granted_at: {
...where.granted_at,
[Op.lte]: end,
},
};
}
}
if (filter.revoked_atRange) {
const [start, end] = filter.revoked_atRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
revoked_at: {
...where.revoked_at,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
revoked_at: {
...where.revoked_at,
[Op.lte]: end,
},
};
}
}
if (filter.created_at_tsRange) {
const [start, end] = filter.created_at_tsRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
created_at_ts: {
...where.created_at_ts,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
created_at_ts: {
...where.created_at_ts,
[Op.lte]: end,
},
};
}
}
if (filter.updated_at_tsRange) {
const [start, end] = filter.updated_at_tsRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
updated_at_ts: {
...where.updated_at_ts,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
updated_at_ts: {
...where.updated_at_ts,
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true'
};
}
if (filter.badge_type) {
where = {
...where,
badge_type: filter.badge_type,
};
}
if (filter.status) {
where = {
...where,
status: filter.status,
};
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.lte]: end,
},
};
// Data Isolation
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
if (currentUser.businessId) {
where.businessId = currentUser.businessId;
} else {
where['$business.owner_userId$'] = currentUser.id;
}
}
}
let include = [
{
model: db.businesses,
as: 'business',
where: filter.business ? {
[Op.or]: [
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
{ name: { [Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
]
} : {},
},
];
if (filter) {
if (filter.id) where.id = Utils.uuid(filter.id);
}
const queryOptions = {
where,
@ -515,8 +79,7 @@ module.exports = class Business_badgesDBApi {
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options?.transaction,
logging: console.log
transaction,
};
if (!options?.countOnly) {
@ -524,51 +87,80 @@ module.exports = class Business_badgesDBApi {
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await db.business_badges.findAndCountAll(queryOptions);
const { rows, count } = await db.business_badges.findAndCountAll(queryOptions);
return {
rows: options?.countOnly ? [] : rows,
count: count
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
return {
rows: options?.countOnly ? [] : rows,
count: count
};
}
static async findAllAutocomplete(query, limit, offset, ) {
let where = {};
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const business_badges = await db.business_badges.findOne({ where, transaction });
if (!business_badges) return null;
const output = business_badges.get({plain: true});
output.business = await business_badges.getBusiness({ transaction });
return output;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_badges = await db.business_badges.findByPk(id, {transaction});
const updatePayload = { ...data, updatedById: currentUser.id };
await business_badges.update(updatePayload, {transaction});
if (data.business !== undefined) await business_badges.setBusiness(data.business, { transaction });
return business_badges;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const business_badges = await db.business_badges.findAll({ where: { id: { [Op.in]: ids } }, transaction });
for (const record of business_badges) {
await record.update({deletedBy: currentUser.id}, {transaction});
await record.destroy({transaction});
}
return business_badges;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_badges = await db.business_badges.findByPk(id, options);
await business_badges.update({ deletedBy: currentUser.id }, { transaction });
await business_badges.destroy({ transaction });
return business_badges;
}
static async findAllAutocomplete(query, limit, offset) {
let where = {};
if (query) {
where = {
[Op.or]: [
{ ['id']: Utils.uuid(query) },
Utils.ilike(
'business_badges',
'notes',
query,
),
],
};
}
const records = await db.business_badges.findAll({
attributes: [ 'id', 'notes' ],
attributes: [ 'id' ],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
orderBy: [['notes', 'ASC']],
order: [['createdAt', 'desc']],
});
return records.map((record) => ({
id: record.id,
label: record.notes,
label: record.id,
}));
}
};
};

View File

@ -1,18 +1,12 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
module.exports = class Business_categoriesDBApi {
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
@ -20,355 +14,68 @@ module.exports = class Business_categoriesDBApi {
const business_categories = await db.business_categories.create(
{
id: data.id || undefined,
created_at_ts: data.created_at_ts
||
null
,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await business_categories.setBusiness( data.business || null, {
transaction,
});
await business_categories.setCategory( data.category || null, {
transaction,
});
return business_categories;
}
static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
// Prepare data - wrapping individual data transformations in a map() method
const business_categoriesData = data.map((item, index) => ({
id: item.id || undefined,
created_at_ts: item.created_at_ts
||
null
,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
// Bulk create items
const business_categories = await db.business_categories.bulkCreate(business_categoriesData, { transaction });
// For each item created, replace relation files
return business_categories;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_categories = await db.business_categories.findByPk(id, {}, {transaction});
const updatePayload = {};
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
updatePayload.updatedById = currentUser.id;
await business_categories.update(updatePayload, {transaction});
if (data.business !== undefined) {
await business_categories.setBusiness(
data.business,
{ transaction }
);
}
if (data.category !== undefined) {
await business_categories.setCategory(
data.category,
{ transaction }
);
}
return business_categories;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const business_categories = await db.business_categories.findAll({
where: {
id: {
[Op.in]: ids,
},
created_at_ts: data.created_at_ts || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
transaction,
});
await db.sequelize.transaction(async (transaction) => {
for (const record of business_categories) {
await record.update(
{deletedBy: currentUser.id},
{transaction}
);
}
for (const record of business_categories) {
await record.destroy({transaction});
}
});
return business_categories;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_categories = await db.business_categories.findByPk(id, options);
await business_categories.update({
deletedBy: currentUser.id
}, {
transaction,
});
await business_categories.destroy({
transaction
});
return business_categories;
}
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const business_categories = await db.business_categories.findOne(
{ where },
{ transaction },
);
if (!business_categories) {
return business_categories;
}
await business_categories.setBusiness( data.business || null, { transaction });
await business_categories.setCategory( data.category || null, { transaction });
const output = business_categories.get({plain: true});
output.business = await business_categories.getBusiness({
transaction
});
output.category = await business_categories.getCategory({
transaction
});
return output;
return business_categories;
}
static async findAll(
filter,
options
) {
static async findAll(filter, options) {
const limit = filter.limit || 0;
let offset = 0;
let where = {};
const currentPage = +filter.page;
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined;
let include = [
{
model: db.businesses,
as: 'business',
where: filter.business ? {
[Op.or]: [
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
{
model: db.categories,
as: 'category',
where: filter.category ? {
[Op.or]: [
{ id: { [Op.in]: filter.category.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: filter.category.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
];
if (filter) {
if (filter.id) {
where = {
...where,
['id']: Utils.uuid(filter.id),
};
}
if (filter.created_at_tsRange) {
const [start, end] = filter.created_at_tsRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
created_at_ts: {
...where.created_at_ts,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
created_at_ts: {
...where.created_at_ts,
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true'
};
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.lte]: end,
},
};
// Data Isolation
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
if (currentUser.businessId) {
where.businessId = currentUser.businessId;
} else {
where['$business.owner_userId$'] = currentUser.id;
}
}
}
let include = [
{
model: db.businesses,
as: 'business',
where: filter.business ? {
[Op.or]: [
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
{ name: { [Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
]
} : {},
},
{
model: db.categories,
as: 'category',
where: filter.category ? {
[Op.or]: [
{ id: { [Op.in]: filter.category.split('|').map(term => Utils.uuid(term)) } },
{ name: { [Op.or]: filter.category.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
]
} : {},
},
];
if (filter) {
if (filter.id) where.id = Utils.uuid(filter.id);
}
const queryOptions = {
where,
@ -377,8 +84,7 @@ module.exports = class Business_categoriesDBApi {
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options?.transaction,
logging: console.log
transaction,
};
if (!options?.countOnly) {
@ -386,51 +92,83 @@ module.exports = class Business_categoriesDBApi {
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await db.business_categories.findAndCountAll(queryOptions);
const { rows, count } = await db.business_categories.findAndCountAll(queryOptions);
return {
rows: options?.countOnly ? [] : rows,
count: count
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
return {
rows: options?.countOnly ? [] : rows,
count: count
};
}
static async findAllAutocomplete(query, limit, offset, ) {
let where = {};
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const business_categories = await db.business_categories.findOne({ where, transaction });
if (!business_categories) return null;
const output = business_categories.get({plain: true});
output.business = await business_categories.getBusiness({ transaction });
output.category = await business_categories.getCategory({ transaction });
return output;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_categories = await db.business_categories.findByPk(id, {transaction});
const updatePayload = { updatedById: currentUser.id };
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
await business_categories.update(updatePayload, {transaction});
if (data.business !== undefined) await business_categories.setBusiness(data.business, { transaction });
if (data.category !== undefined) await business_categories.setCategory(data.category, { transaction });
return business_categories;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const business_categories = await db.business_categories.findAll({ where: { id: { [Op.in]: ids } }, transaction });
for (const record of business_categories) {
await record.update({deletedBy: currentUser.id}, {transaction});
await record.destroy({transaction});
}
return business_categories;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_categories = await db.business_categories.findByPk(id, options);
await business_categories.update({ deletedBy: currentUser.id }, { transaction });
await business_categories.destroy({ transaction });
return business_categories;
}
static async findAllAutocomplete(query, limit, offset) {
let where = {};
if (query) {
where = {
[Op.or]: [
{ ['id']: Utils.uuid(query) },
Utils.ilike(
'business_categories',
'created_at_ts',
query,
),
],
};
}
const records = await db.business_categories.findAll({
attributes: [ 'id', 'created_at_ts' ],
attributes: [ 'id' ],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
orderBy: [['created_at_ts', 'ASC']],
order: [['createdAt', 'desc']],
});
return records.map((record) => ({
id: record.id,
label: record.created_at_ts,
label: record.id,
}));
}
};
};

View File

@ -1,18 +1,12 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
module.exports = class Business_photosDBApi {
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
@ -20,360 +14,70 @@ module.exports = class Business_photosDBApi {
const business_photos = await db.business_photos.create(
{
id: data.id || undefined,
created_at_ts: data.created_at_ts
||
null
,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await business_photos.setBusiness( data.business || null, {
transaction,
});
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.business_photos.getTableName(),
belongsToColumn: 'photos',
belongsToId: business_photos.id,
created_at_ts: data.created_at_ts || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
data.photos,
options,
);
return business_photos;
}
static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
// Prepare data - wrapping individual data transformations in a map() method
const business_photosData = data.map((item, index) => ({
id: item.id || undefined,
created_at_ts: item.created_at_ts
||
null
,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
// Bulk create items
const business_photos = await db.business_photos.bulkCreate(business_photosData, { transaction });
// For each item created, replace relation files
for (let i = 0; i < business_photos.length; i++) {
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.business_photos.getTableName(),
belongsToColumn: 'photos',
belongsToId: business_photos[i].id,
},
data[i].photos,
options,
);
}
return business_photos;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_photos = await db.business_photos.findByPk(id, {}, {transaction});
const updatePayload = {};
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
updatePayload.updatedById = currentUser.id;
await business_photos.update(updatePayload, {transaction});
if (data.business !== undefined) {
await business_photos.setBusiness(
data.business,
{ transaction }
);
}
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.business_photos.getTableName(),
belongsToColumn: 'photos',
belongsToId: business_photos.id,
},
data.photos,
options,
);
return business_photos;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const business_photos = await db.business_photos.findAll({
where: {
id: {
[Op.in]: ids,
},
},
transaction,
});
await db.sequelize.transaction(async (transaction) => {
for (const record of business_photos) {
await record.update(
{deletedBy: currentUser.id},
{transaction}
);
}
for (const record of business_photos) {
await record.destroy({transaction});
}
});
return business_photos;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_photos = await db.business_photos.findByPk(id, options);
await business_photos.update({
deletedBy: currentUser.id
}, {
transaction,
});
await business_photos.destroy({
transaction
});
return business_photos;
}
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const business_photos = await db.business_photos.findOne(
{ where },
{ transaction },
);
if (!business_photos) {
return business_photos;
}
await business_photos.setBusiness( data.business || null, { transaction });
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.business_photos.getTableName(),
belongsToColumn: 'photos',
belongsToId: business_photos.id,
},
data.photos,
options,
);
const output = business_photos.get({plain: true});
output.business = await business_photos.getBusiness({
transaction
});
output.photos = await business_photos.getPhotos({
transaction
});
return output;
return business_photos;
}
static async findAll(
filter,
options
) {
static async findAll(filter, options) {
const limit = filter.limit || 0;
let offset = 0;
let where = {};
const currentPage = +filter.page;
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined;
let include = [
{
model: db.businesses,
as: 'business',
where: filter.business ? {
[Op.or]: [
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
{
model: db.file,
as: 'photos',
},
];
if (filter) {
if (filter.id) {
where = {
...where,
['id']: Utils.uuid(filter.id),
};
}
if (filter.created_at_tsRange) {
const [start, end] = filter.created_at_tsRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
created_at_ts: {
...where.created_at_ts,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
created_at_ts: {
...where.created_at_ts,
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true'
};
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.lte]: end,
},
};
// Data Isolation
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
if (currentUser.businessId) {
where.businessId = currentUser.businessId;
} else {
where['$business.owner_userId$'] = currentUser.id;
}
}
}
let include = [
{
model: db.businesses,
as: 'business',
where: filter.business ? {
[Op.or]: [
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
{ name: { [Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
]
} : {},
},
{
model: db.file,
as: 'photos',
},
];
if (filter) {
if (filter.id) where.id = Utils.uuid(filter.id);
}
const queryOptions = {
where,
@ -382,8 +86,7 @@ module.exports = class Business_photosDBApi {
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options?.transaction,
logging: console.log
transaction,
};
if (!options?.countOnly) {
@ -391,51 +94,91 @@ module.exports = class Business_photosDBApi {
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await db.business_photos.findAndCountAll(queryOptions);
const { rows, count } = await db.business_photos.findAndCountAll(queryOptions);
return {
rows: options?.countOnly ? [] : rows,
count: count
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
return {
rows: options?.countOnly ? [] : rows,
count: count
};
}
static async findAllAutocomplete(query, limit, offset, ) {
let where = {};
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const business_photos = await db.business_photos.findOne({ where, transaction });
if (!business_photos) return null;
const output = business_photos.get({plain: true});
output.business = await business_photos.getBusiness({ transaction });
output.photos = await business_photos.getPhotos({ transaction });
return output;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_photos = await db.business_photos.findByPk(id, {transaction});
const updatePayload = { updatedById: currentUser.id };
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
await business_photos.update(updatePayload, {transaction});
if (data.business !== undefined) await business_photos.setBusiness(data.business, { transaction });
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.business_photos.getTableName(),
belongsToColumn: 'photos',
belongsToId: business_photos.id,
},
data.photos,
options,
);
return business_photos;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const business_photos = await db.business_photos.findAll({ where: { id: { [Op.in]: ids } }, transaction });
for (const record of business_photos) {
await record.update({deletedBy: currentUser.id}, {transaction});
await record.destroy({transaction});
}
return business_photos;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const business_photos = await db.business_photos.findByPk(id, options);
await business_photos.update({ deletedBy: currentUser.id }, { transaction });
await business_photos.destroy({ transaction });
return business_photos;
}
static async findAllAutocomplete(query, limit, offset) {
let where = {};
if (query) {
where = {
[Op.or]: [
{ ['id']: Utils.uuid(query) },
Utils.ilike(
'business_photos',
'photos',
query,
),
],
};
}
const records = await db.business_photos.findAll({
attributes: [ 'id', 'photos' ],
attributes: [ 'id' ],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
orderBy: [['photos', 'ASC']],
order: [['createdAt', 'desc']],
});
return records.map((record) => ({
id: record.id,
label: record.photos,
label: record.id,
}));
}
};
};

View File

@ -29,16 +29,24 @@ module.exports = class BusinessesDBApi {
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined;
// Data Isolation for Crafted Network™
// Data Isolation
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
where.owner_userId = currentUser.id;
}
}
const isAdmin = roleName === 'Administrator' || roleName === 'Platform Owner';
const isPublicOrConsumer = roleName === 'Public' || roleName === 'Consumer';
// Public directory should only show active businesses
if (!currentUser || currentUser.app_role?.name === 'Public' || currentUser.app_role?.name === 'Consumer') {
if (!isAdmin && !isPublicOrConsumer) {
// This is a "client" (e.g. Verified Business Owner)
if (currentUser.businessId) {
where.id = currentUser.businessId;
} else {
where.owner_userId = currentUser.id;
}
} else if (isPublicOrConsumer) {
where.is_active = true;
}
} else if (!currentUser) {
// Public unauthenticated access
where.is_active = true;
}

View File

@ -17,7 +17,7 @@ module.exports = class Lead_matchesDBApi {
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined;
// Data Isolation for Crafted Network
// Data Isolation for Fix It Local
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {

View File

@ -54,14 +54,18 @@ module.exports = class LeadsDBApi {
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined;
// Data Isolation for Crafted Network™
// Data Isolation
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Consumer') {
where.userId = currentUser.id;
} else if (roleName === 'Verified Business Owner') {
// Business owners see leads matched to them
where['$lead_matches_lead.business.owner_userId$'] = currentUser.id;
if (currentUser.businessId) {
where['$lead_matches_lead.businessId$'] = currentUser.businessId;
} else {
where['$lead_matches_lead.business.owner_userId$'] = currentUser.id;
}
}
}
@ -75,7 +79,7 @@ module.exports = class LeadsDBApi {
}
];
// Apply filters (simplified for brevity, keeping core logic)
// Apply filters
if (filter) {
if (filter.id) where.id = Utils.uuid(filter.id);
if (filter.keyword) where.keyword = { [Op.iLike]: `%${filter.keyword}%` };
@ -137,7 +141,17 @@ module.exports = class LeadsDBApi {
as: 'lead_matches_lead',
include: [{ model: db.businesses, as: 'business' }]
},
{ model: db.messages, as: 'messages_lead' }
{ model: db.messages, as: 'messages_lead' },
{
model: db.lead_photos,
as: 'lead_photos_lead',
include: [{ model: db.file, as: 'photos' }]
},
{
model: db.lead_events,
as: 'lead_events_lead',
include: [{ model: db.users, as: 'actor_user' }]
}
],
transaction
});

View File

@ -41,15 +41,24 @@ module.exports = class MessagesDBApi {
const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
// Data Isolation for Crafted Network™
// Data Isolation
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
where[Op.or] = [
{ sender_userId: currentUser.id },
{ receiver_userId: currentUser.id },
{ '$lead.lead_matches_lead.business.owner_userId$': currentUser.id }
];
const businessId = currentUser.businessId;
if (businessId) {
where[Op.or] = [
{ sender_userId: currentUser.id },
{ receiver_userId: currentUser.id },
{ '$lead.lead_matches_lead.businessId$': businessId }
];
} else {
where[Op.or] = [
{ sender_userId: currentUser.id },
{ receiver_userId: currentUser.id },
{ '$lead.lead_matches_lead.business.owner_userId$': currentUser.id }
];
}
} else if (roleName === 'Consumer') {
where[Op.or] = [
{ sender_userId: currentUser.id },
@ -106,6 +115,7 @@ module.exports = class MessagesDBApi {
where,
include,
distinct: true,
subQuery: false, // Fix for "missing FROM-clause entry" when using limit + nested includes
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],

View File

@ -354,11 +354,15 @@ module.exports = class ReviewsDBApi {
const currentUser = options?.currentUser;
// Data Isolation for Crafted Network™
// Data Isolation
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
where['$business.owner_userId$'] = currentUser.id;
if (currentUser.businessId) {
where.businessId = currentUser.businessId;
} else {
where['$business.owner_userId$'] = currentUser.id;
}
} else if (roleName === 'Consumer') {
where.userId = currentUser.id;
}
@ -652,4 +656,4 @@ module.exports = class ReviewsDBApi {
}
};
};

View File

@ -1,4 +1,3 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
@ -307,6 +306,20 @@ module.exports = class Service_pricesDBApi {
const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
// Data Isolation
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
if (currentUser.businessId) {
where.businessId = currentUser.businessId;
} else {
where['$business.owner_userId$'] = currentUser.id;
}
}
}
let include = [
{
@ -591,5 +604,4 @@ module.exports = class Service_pricesDBApi {
}
};
};

View File

@ -1,4 +1,3 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
@ -85,6 +84,7 @@ module.exports = class UsersDBApi {
null
,
businessId: data.data.businessId || null,
importHash: data.data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
@ -298,6 +298,7 @@ module.exports = class UsersDBApi {
if (data.provider !== undefined) updatePayload.provider = data.provider;
if (data.businessId !== undefined) updatePayload.businessId = data.businessId;
updatePayload.updatedById = currentUser.id;
@ -983,5 +984,4 @@ module.exports = class UsersDBApi {
};
};

View File

@ -1,18 +1,12 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
module.exports = class Verification_evidencesDBApi {
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
@ -20,404 +14,73 @@ module.exports = class Verification_evidencesDBApi {
const verification_evidences = await db.verification_evidences.create(
{
id: data.id || undefined,
evidence_type: data.evidence_type
||
null
,
url: data.url
||
null
,
created_at_ts: data.created_at_ts
||
null
,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await verification_evidences.setSubmission( data.submission || null, {
transaction,
});
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.verification_evidences.getTableName(),
belongsToColumn: 'files',
belongsToId: verification_evidences.id,
evidence_type: data.evidence_type || null,
url: data.url || null,
created_at_ts: data.created_at_ts || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
data.files,
options,
);
return verification_evidences;
}
static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
// Prepare data - wrapping individual data transformations in a map() method
const verification_evidencesData = data.map((item, index) => ({
id: item.id || undefined,
evidence_type: item.evidence_type
||
null
,
url: item.url
||
null
,
created_at_ts: item.created_at_ts
||
null
,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
// Bulk create items
const verification_evidences = await db.verification_evidences.bulkCreate(verification_evidencesData, { transaction });
// For each item created, replace relation files
for (let i = 0; i < verification_evidences.length; i++) {
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.verification_evidences.getTableName(),
belongsToColumn: 'files',
belongsToId: verification_evidences[i].id,
},
data[i].files,
options,
);
}
return verification_evidences;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const verification_evidences = await db.verification_evidences.findByPk(id, {}, {transaction});
const updatePayload = {};
if (data.evidence_type !== undefined) updatePayload.evidence_type = data.evidence_type;
if (data.url !== undefined) updatePayload.url = data.url;
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
updatePayload.updatedById = currentUser.id;
await verification_evidences.update(updatePayload, {transaction});
if (data.submission !== undefined) {
await verification_evidences.setSubmission(
data.submission,
{ transaction }
);
}
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.verification_evidences.getTableName(),
belongsToColumn: 'files',
belongsToId: verification_evidences.id,
},
data.files,
options,
);
return verification_evidences;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const verification_evidences = await db.verification_evidences.findAll({
where: {
id: {
[Op.in]: ids,
},
},
transaction,
});
await db.sequelize.transaction(async (transaction) => {
for (const record of verification_evidences) {
await record.update(
{deletedBy: currentUser.id},
{transaction}
);
}
for (const record of verification_evidences) {
await record.destroy({transaction});
}
});
return verification_evidences;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const verification_evidences = await db.verification_evidences.findByPk(id, options);
await verification_evidences.update({
deletedBy: currentUser.id
}, {
transaction,
});
await verification_evidences.destroy({
transaction
});
return verification_evidences;
}
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const verification_evidences = await db.verification_evidences.findOne(
{ where },
{ transaction },
);
if (!verification_evidences) {
return verification_evidences;
}
await verification_evidences.setSubmission( data.submission || null, { transaction });
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.verification_evidences.getTableName(),
belongsToColumn: 'files',
belongsToId: verification_evidences.id,
},
data.files,
options,
);
const output = verification_evidences.get({plain: true});
output.submission = await verification_evidences.getSubmission({
transaction
});
output.files = await verification_evidences.getFiles({
transaction
});
return output;
return verification_evidences;
}
static async findAll(
filter,
options
) {
static async findAll(filter, options) {
const limit = filter.limit || 0;
let offset = 0;
let where = {};
const currentPage = +filter.page;
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined;
let include = [
{
model: db.verification_submissions,
as: 'submission',
where: filter.submission ? {
[Op.or]: [
{ id: { [Op.in]: filter.submission.split('|').map(term => Utils.uuid(term)) } },
{
notes: {
[Op.or]: filter.submission.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
{
model: db.file,
as: 'files',
},
];
if (filter) {
if (filter.id) {
where = {
...where,
['id']: Utils.uuid(filter.id),
};
}
if (filter.url) {
where = {
...where,
[Op.and]: Utils.ilike(
'verification_evidences',
'url',
filter.url,
),
};
}
if (filter.created_at_tsRange) {
const [start, end] = filter.created_at_tsRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
created_at_ts: {
...where.created_at_ts,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
created_at_ts: {
...where.created_at_ts,
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true'
};
}
if (filter.evidence_type) {
where = {
...where,
evidence_type: filter.evidence_type,
};
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.lte]: end,
},
};
// Data Isolation
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
if (currentUser.businessId) {
where['$submission.businessId$'] = currentUser.businessId;
} else {
where['$submission.business.owner_userId$'] = currentUser.id;
}
}
}
let include = [
{
model: db.verification_submissions,
as: 'submission',
include: [{ model: db.businesses, as: 'business' }],
where: filter.submission ? {
[Op.or]: [
{ id: { [Op.in]: filter.submission.split('|').map(term => Utils.uuid(term)) } },
{ notes: { [Op.or]: filter.submission.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
]
} : {},
},
{
model: db.file,
as: 'files',
},
];
if (filter) {
if (filter.id) where.id = Utils.uuid(filter.id);
}
const queryOptions = {
where,
@ -426,8 +89,7 @@ module.exports = class Verification_evidencesDBApi {
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options?.transaction,
logging: console.log
transaction,
};
if (!options?.countOnly) {
@ -435,51 +97,90 @@ module.exports = class Verification_evidencesDBApi {
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await db.verification_evidences.findAndCountAll(queryOptions);
const { rows, count } = await db.verification_evidences.findAndCountAll(queryOptions);
return {
rows: options?.countOnly ? [] : rows,
count: count
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
return {
rows: options?.countOnly ? [] : rows,
count: count
};
}
static async findAllAutocomplete(query, limit, offset, ) {
let where = {};
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const verification_evidences = await db.verification_evidences.findOne({ where, transaction });
if (!verification_evidences) return null;
const output = verification_evidences.get({plain: true});
output.submission = await verification_evidences.getSubmission({ transaction });
output.files = await verification_evidences.getFiles({ transaction });
return output;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const verification_evidences = await db.verification_evidences.findByPk(id, {transaction});
const updatePayload = { ...data, updatedById: currentUser.id };
await verification_evidences.update(updatePayload, {transaction});
if (data.submission !== undefined) await verification_evidences.setSubmission(data.submission, { transaction });
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.verification_evidences.getTableName(),
belongsToColumn: 'files',
belongsToId: verification_evidences.id,
},
data.files,
options,
);
return verification_evidences;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const verification_evidences = await db.verification_evidences.findAll({ where: { id: { [Op.in]: ids } }, transaction });
for (const record of verification_evidences) {
await record.update({deletedBy: currentUser.id}, {transaction});
await record.destroy({transaction});
}
return verification_evidences;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const verification_evidences = await db.verification_evidences.findByPk(id, options);
await verification_evidences.update({ deletedBy: currentUser.id }, { transaction });
await verification_evidences.destroy({ transaction });
return verification_evidences;
}
static async findAllAutocomplete(query, limit, offset) {
let where = {};
if (query) {
where = {
[Op.or]: [
{ ['id']: Utils.uuid(query) },
Utils.ilike(
'verification_evidences',
'url',
query,
),
],
};
}
const records = await db.verification_evidences.findAll({
attributes: [ 'id', 'url' ],
attributes: [ 'id' ],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
orderBy: [['url', 'ASC']],
order: [['createdAt', 'desc']],
});
return records.map((record) => ({
id: record.id,
label: record.url,
label: record.id,
}));
}
};
};

View File

@ -1,4 +1,3 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
@ -298,6 +297,20 @@ module.exports = class Verification_submissionsDBApi {
const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
// Data Isolation
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
if (currentUser.businessId) {
where.businessId = currentUser.businessId;
} else {
where['$business.owner_userId$'] = currentUser.id;
}
}
}
let include = [
{
@ -524,5 +537,4 @@ module.exports = class Verification_submissionsDBApi {
}
};
};

View File

@ -0,0 +1,24 @@
module.exports = {
/**
* @param {import("sequelize").QueryInterface} queryInterface
* @param {import("sequelize").Sequelize} Sequelize
* @returns {Promise<void>}
*/
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('users', 'businessId', {
type: Sequelize.DataTypes.UUID,
references: {
model: 'businesses',
key: 'id',
},
allowNull: true,
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('users', 'businessId');
},
};

View File

@ -0,0 +1,55 @@
module.exports = {
/**
* @param {import("sequelize").QueryInterface} queryInterface
* @param {import("sequelize").Sequelize} Sequelize
* @returns {Promise<void>}
*/
async up(queryInterface, Sequelize) {
const roles = await queryInterface.sequelize.query(
`SELECT id FROM roles WHERE name = 'Trust & Safety Lead'`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
if (!roles || roles.length === 0) return;
const roleId = roles[0].id;
const permissionsToGrant = [
'READ_VERIFICATION_SUBMISSIONS',
'UPDATE_VERIFICATION_SUBMISSIONS',
'READ_VERIFICATION_EVIDENCES',
'READ_REVIEWS',
'UPDATE_REVIEWS',
'DELETE_REVIEWS',
'READ_BUSINESSES',
'READ_USERS'
];
const permissions = await queryInterface.sequelize.query(
`SELECT id, name FROM permissions WHERE name IN (${permissionsToGrant.map(p => `'${p}'`).join(',')})`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
const rolesPermissions = [];
for (const p of permissions) {
const existing = await queryInterface.sequelize.query(
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${roleId}' AND "permissionId" = '${p.id}'`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
if (existing.length === 0) {
rolesPermissions.push({
roles_permissionsId: roleId,
permissionId: p.id,
createdAt: new Date(),
updatedAt: new Date()
});
}
}
if (rolesPermissions.length > 0) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', rolesPermissions);
}
},
async down(queryInterface, Sequelize) {
// Optionally implement down migration
},
};

View File

@ -0,0 +1,56 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('listing_events', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
},
businessId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'businesses',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
event_type: {
type: Sequelize.ENUM('VIEW', 'CALL_CLICK', 'WEBSITE_CLICK'),
allowNull: false,
},
userId: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
metadata: {
type: Sequelize.JSONB,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('listing_events');
},
};

View File

@ -0,0 +1,91 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('plans', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
price: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0,
},
features: {
type: Sequelize.JSONB,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addColumn('businesses', 'planId', {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'plans',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
await queryInterface.addColumn('businesses', 'renewal_date', {
type: Sequelize.DATE,
allowNull: true,
});
// Seed basic plans
const now = new Date();
const plans = [
{
id: '00000000-0000-0000-0000-000000000001',
name: 'Basic (Free)',
price: 0,
features: JSON.stringify(['Limited Leads', 'Standard Listing']),
createdAt: now,
updatedAt: now,
},
{
id: '00000000-0000-0000-0000-000000000002',
name: 'Professional',
price: 49.99,
features: JSON.stringify(['Unlimited Leads', 'Priority Support', 'Enhanced Profile']),
createdAt: now,
updatedAt: now,
},
{
id: '00000000-0000-0000-0000-000000000003',
name: 'Enterprise',
price: 199.99,
features: JSON.stringify(['Custom Branding', 'API Access', 'Dedicated Manager']),
createdAt: now,
updatedAt: now,
},
];
await queryInterface.bulkInsert('plans', plans);
// Assign free plan to existing businesses
await queryInterface.sequelize.query(
`UPDATE businesses SET "planId" = '00000000-0000-0000-0000-000000000001', "renewal_date" = '${new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()).toISOString()}'`
);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('businesses', 'planId');
await queryInterface.removeColumn('businesses', 'renewal_date');
await queryInterface.dropTable('plans');
},
};

View File

@ -0,0 +1,10 @@
module.exports = {
async up(queryInterface) {
const [roles] = await queryInterface.sequelize.query("SELECT id FROM roles WHERE name = 'Verified Business Owner' LIMIT 1;");
if (roles && roles.length > 0) {
const vboRoleId = roles[0].id;
await queryInterface.sequelize.query(`UPDATE users SET "app_roleId" = '${vboRoleId}' WHERE email = 'client@hello.com';`);
}
},
async down(queryInterface) {}
};

View File

@ -0,0 +1,24 @@
module.exports = {
async up(queryInterface) {
const createdAt = new Date();
const updatedAt = new Date();
const [roles] = await queryInterface.sequelize.query("SELECT id FROM roles WHERE name = 'Verified Business Owner' LIMIT 1;");
const [permissions] = await queryInterface.sequelize.query("SELECT id FROM permissions WHERE name = 'CREATE_BUSINESSES' LIMIT 1;");
if (roles && roles.length > 0 && permissions && permissions.length > 0) {
const vboRoleId = roles[0].id;
const createPermId = permissions[0].id;
// Check if permission already exists for this role
const [existing] = await queryInterface.sequelize.query(`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${vboRoleId}' AND "permissionId" = '${createPermId}';`);
if (existing.length === 0) {
await queryInterface.bulkInsert("rolesPermissionsPermissions", [
{ createdAt, updatedAt, roles_permissionsId: vboRoleId, permissionId: createPermId }
]);
}
}
},
async down(queryInterface) {}
};

View File

@ -179,6 +179,16 @@ google_place_id: {
allowNull: false,
},
planId: {
type: DataTypes.UUID,
allowNull: true,
},
renewal_date: {
type: DataTypes.DATE,
allowNull: true,
},
created_at_ts: {
type: DataTypes.DATE,
@ -312,8 +322,15 @@ updated_at_ts: {
constraints: false,
});
db.businesses.belongsTo(db.plans, {
as: 'plan',
foreignKey: 'planId',
});
db.businesses.hasMany(db.listing_events, {
as: 'listing_events',
foreignKey: 'businessId',
});
db.businesses.belongsTo(db.users, {
as: 'createdBy',
@ -327,4 +344,4 @@ updated_at_ts: {
return businesses;
};
};

View File

@ -0,0 +1,47 @@
module.exports = function(sequelize, DataTypes) {
const listing_events = sequelize.define(
'listing_events',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
businessId: {
type: DataTypes.UUID,
allowNull: false,
},
event_type: {
type: DataTypes.ENUM('VIEW', 'CALL_CLICK', 'WEBSITE_CLICK'),
allowNull: false,
},
userId: {
type: DataTypes.UUID,
allowNull: true,
},
metadata: {
type: DataTypes.JSONB,
allowNull: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
listing_events.associate = (db) => {
db.listing_events.belongsTo(db.businesses, {
as: 'business',
foreignKey: 'businessId',
});
db.listing_events.belongsTo(db.users, {
as: 'user',
foreignKey: 'userId',
});
};
return listing_events;
};

View File

@ -0,0 +1,37 @@
module.exports = function(sequelize, DataTypes) {
const plans = sequelize.define(
'plans',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
price: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0,
},
features: {
type: DataTypes.JSONB,
allowNull: true,
},
},
{
timestamps: true,
},
);
plans.associate = (db) => {
db.plans.hasMany(db.businesses, {
as: 'businesses',
foreignKey: 'planId',
});
};
return plans;
};

View File

@ -104,6 +104,11 @@ provider: {
},
businessId: {
type: DataTypes.UUID,
allowNull: true,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
@ -257,7 +262,13 @@ provider: {
constraints: false,
});
db.users.belongsTo(db.businesses, {
as: 'business',
foreignKey: {
name: 'businessId',
},
constraints: false,
});
db.users.hasMany(db.file, {
as: 'avatar',
@ -322,5 +333,4 @@ function trimStringFields(users) {
: null;
return users;
}
}

View File

@ -18,7 +18,7 @@ const pexelsRoutes = require('./routes/pexels');
const openaiRoutes = require('./routes/openai');
const dashboardRoutes = require('./routes/dashboard');
const usersRoutes = require('./routes/users');
@ -77,8 +77,8 @@ const options = {
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "Crafted Network",
description: "Crafted Network Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
title: "Fix It Local",
description: "Fix It Local Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
},
servers: [
{
@ -117,6 +117,15 @@ app.use(cors({origin: true}));
require('./auth/auth');
app.use(bodyParser.json());
const optionalAuth = (req, res, next) => {
passport.authenticate('jwt', { session: false }, (err, user, info) => {
if (user) {
req.currentUser = user;
}
next();
})(req, res, next);
};
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
@ -132,19 +141,19 @@ app.use('/api/permissions', passport.authenticate('jwt', {session: false}), perm
app.use('/api/refresh_tokens', passport.authenticate('jwt', {session: false}), refresh_tokensRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/categories', optionalAuth, categoriesRoutes);
app.use('/api/locations', locationsRoutes);
app.use('/api/locations', optionalAuth, locationsRoutes);
app.use('/api/businesses', businessesRoutes);
app.use('/api/businesses', optionalAuth, businessesRoutes);
app.use('/api/business_photos', business_photosRoutes);
app.use('/api/business_photos', optionalAuth, business_photosRoutes);
app.use('/api/business_categories', business_categoriesRoutes);
app.use('/api/business_categories', optionalAuth, business_categoriesRoutes);
app.use('/api/service_prices', service_pricesRoutes);
app.use('/api/service_prices', optionalAuth, service_pricesRoutes);
app.use('/api/business_badges', business_badgesRoutes);
app.use('/api/business_badges', optionalAuth, business_badgesRoutes);
app.use('/api/verification_submissions', passport.authenticate('jwt', {session: false}), verification_submissionsRoutes);
@ -160,7 +169,7 @@ app.use('/api/messages', passport.authenticate('jwt', {session: false}), message
app.use('/api/lead_events', passport.authenticate('jwt', {session: false}), lead_eventsRoutes);
app.use('/api/reviews', reviewsRoutes);
app.use('/api/reviews', optionalAuth, reviewsRoutes);
app.use('/api/disputes', passport.authenticate('jwt', {session: false}), disputesRoutes);
@ -181,6 +190,10 @@ app.use(
openaiRoutes,
);
app.use(
'/api/dashboard',
dashboardRoutes);
app.use(
'/api/search',
searchRoutes);

View File

@ -1,4 +1,3 @@
const express = require('express');
const BusinessesService = require('../services/businesses');
@ -405,7 +404,6 @@ router.get('/count', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const payload = await BusinessesDBApi.findAll(
req.query,
null,
{ countOnly: true, currentUser }
);
@ -493,4 +491,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;
module.exports = router;

View File

@ -0,0 +1,31 @@
const express = require('express');
const DashboardService = require('../services/dashboard');
const { wrapAsync } = require('../helpers');
const db = require('../db/models');
const passport = require('passport');
const router = express.Router();
router.get('/business-metrics', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
const payload = await DashboardService.getBusinessMetrics(req.currentUser);
res.status(200).send(payload);
}));
router.post('/record-event', (req, res, next) => {
passport.authenticate('jwt', { session: false }, (err, user) => {
req.currentUser = user || null;
next();
})(req, res, next);
}, wrapAsync(async (req, res) => {
const { businessId, event_type, metadata } = req.body;
const event = await db.listing_events.create({
businessId,
event_type,
userId: req.currentUser ? req.currentUser.id : null,
metadata
});
res.status(200).send(event);
}));
module.exports = router;

View File

@ -25,6 +25,14 @@ module.exports = class BusinessesService {
},
);
// Link business to user if they don't have one set yet
if (currentUser.app_role?.name === 'Verified Business Owner' && !currentUser.businessId) {
await db.users.update({ businessId: business.id }, {
where: { id: currentUser.id },
transaction
});
}
await transaction.commit();
return business;
} catch (error) {
@ -49,6 +57,14 @@ module.exports = class BusinessesService {
is_claimed: true,
}, { transaction });
// Link business to user if they don't have one set yet
if (!currentUser.businessId) {
await db.users.update({ businessId: business.id }, {
where: { id: currentUser.id },
transaction
});
}
await transaction.commit();
return business;
} catch (error) {
@ -65,7 +81,7 @@ module.exports = class BusinessesService {
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
await new Promise((resolve, reject) => {
bufferStream
@ -108,7 +124,7 @@ module.exports = class BusinessesService {
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
if (business.owner_userId !== currentUser.id) {
if (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
// Prevent transferring ownership
@ -143,7 +159,10 @@ module.exports = class BusinessesService {
const records = await db.businesses.findAll({
where: {
id: { [db.Sequelize.Op.in]: ids },
owner_userId: { [db.Sequelize.Op.ne]: currentUser.id }
[db.Sequelize.Op.and]: [
{ owner_userId: { [db.Sequelize.Op.ne]: currentUser.id } },
{ id: { [db.Sequelize.Op.ne]: currentUser.businessId || null } }
]
},
transaction
});
@ -169,7 +188,9 @@ module.exports = class BusinessesService {
try {
let business = await db.businesses.findByPk(id, { transaction });
if (currentUser.app_role?.name === 'Verified Business Owner' && business.owner_userId !== currentUser.id) {
if (!business) throw new ValidationError('businessesNotFound');
if (currentUser.app_role?.name === 'Verified Business Owner' && business.owner_userId !== currentUser.id && business.id !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
@ -189,4 +210,4 @@ module.exports = class BusinessesService {
}
};
};

View File

@ -0,0 +1,179 @@
const { Op } = require('sequelize');
const db = require('../db/models');
const moment = require('moment');
module.exports = class DashboardService {
static async getBusinessMetrics(currentUser) {
// 1. Get businesses owned by current user
const businesses = await db.businesses.findAll({
where: { owner_userId: currentUser.id },
attributes: ['id', 'name', 'planId', 'renewal_date', 'reliability_score', 'description', 'phone', 'website', 'address', 'hours_json'],
include: [{ model: db.plans, as: 'plan' }]
});
if (!businesses.length) {
return { no_business: true };
}
const businessIds = businesses.map(b => b.id);
const last24h = moment().subtract(24, 'hours').toDate();
const last30d = moment().subtract(30, 'days').toDate();
const last7d = moment().subtract(7, 'days').toDate();
// --- Action Queue ---
// New leads last 24h
const newLeads24h = await db.lead_matches.count({
where: {
businessId: { [Op.in]: businessIds },
createdAt: { [Op.gte]: last24h }
}
});
// Leads needing response (status NEW in lead_matches OR unread messages)
const leadsNeedingResponse = await db.lead_matches.count({
where: {
businessId: { [Op.in]: businessIds },
[Op.or]: [
{ status: 'SENT' }, // SENT means it's new for the business
{ leadId: { [Op.in]: db.sequelize.literal(`(SELECT "leadId" FROM messages WHERE "receiver_userId" = '${currentUser.id}' AND "read_at" IS NULL)`) } }
]
}
});
const verificationPending = await db.verification_submissions.count({
where: {
businessId: { [Op.in]: businessIds },
status: 'PENDING'
}
});
// --- Lead Pipeline Snapshot ---
const pipelineStats = await db.lead_matches.findAll({
where: { businessId: { [Op.in]: businessIds } },
attributes: [
'status',
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']
],
group: ['status']
});
const pipeline = pipelineStats.reduce((acc, curr) => {
acc[curr.status] = parseInt(curr.get('count'));
return acc;
}, { SENT: 0, VIEWED: 0, RESPONDED: 0, SCHEDULED: 0, COMPLETED: 0, DECLINED: 0 });
const won30d = await db.lead_matches.count({
where: {
businessId: { [Op.in]: businessIds },
status: 'COMPLETED',
updatedAt: { [Op.gte]: last30d }
}
});
const lost30d = await db.lead_matches.count({
where: {
businessId: { [Op.in]: businessIds },
status: 'DECLINED',
updatedAt: { [Op.gte]: last30d }
}
});
const winRate30d = (won30d + lost30d) > 0 ? (won30d / (won30d + lost30d)) * 100 : 0;
// --- Recent Messages ---
// Join messages with lead_matches to ensure they belong to this business
const recentMessages = await db.messages.findAll({
where: {
[Op.or]: [
{ sender_userId: currentUser.id },
{ receiver_userId: currentUser.id }
]
},
limit: 5,
order: [['createdAt', 'DESC']],
include: [
{ model: db.leads, as: 'lead' },
{ model: db.users, as: 'sender_user', attributes: ['firstName', 'lastName'] }
]
});
// --- Performance ---
const getEventCount = async (type, since) => {
return await db.listing_events.count({
where: {
businessId: { [Op.in]: businessIds },
event_type: type,
createdAt: { [Op.gte]: since }
}
});
};
const views7d = await getEventCount('VIEW', last7d);
const views30d = await getEventCount('VIEW', last30d);
const calls7d = await getEventCount('CALL_CLICK', last7d);
const calls30d = await getEventCount('CALL_CLICK', last30d);
const website7d = await getEventCount('WEBSITE_CLICK', last7d);
const website30d = await getEventCount('WEBSITE_CLICK', last30d);
const totalClicks30d = calls30d + website30d;
const conversionRate = views30d > 0 ? (totalClicks30d / views30d) * 100 : 0;
// --- Health Score ---
const firstBusiness = businesses[0];
let healthScore = 0;
const missingFields = [];
const fieldsToCheck = [
{ name: 'name', weight: 10, label: 'Name' },
{ name: 'description', weight: 20, label: 'Description' },
{ name: 'phone', weight: 10, label: 'Phone' },
{ name: 'website', weight: 10, label: 'Website' },
{ name: 'address', weight: 10, label: 'Address' },
{ name: 'hours_json', weight: 10, label: 'Business Hours' },
];
fieldsToCheck.forEach(f => {
if (firstBusiness[f.name] && firstBusiness[f.name] !== '') {
healthScore += f.weight;
} else {
missingFields.push(f.label);
}
});
// Add weights for photos/categories/prices
const photoCount = await db.business_photos.count({ where: { businessId: firstBusiness.id } });
if (photoCount > 0) healthScore += 15; else missingFields.push('Photos');
const categoryCount = await db.business_categories.count({ where: { businessId: firstBusiness.id } });
if (categoryCount > 0) healthScore += 10; else missingFields.push('Categories');
const priceCount = await db.service_prices.count({ where: { businessId: firstBusiness.id } });
if (priceCount > 0) healthScore += 5; else missingFields.push('Service Prices');
return {
businesses,
action_queue: {
newLeads24h,
leadsNeedingResponse,
verificationPending,
missingFields
},
pipeline: {
NEW: pipeline.SENT,
CONTACTED: pipeline.VIEWED + pipeline.RESPONDED,
SCHEDULED: pipeline.SCHEDULED,
WON: pipeline.COMPLETED,
LOST: pipeline.DECLINED,
winRate30d
},
recentMessages,
performance: {
views7d,
views30d,
calls7d,
calls30d,
website7d,
website30d,
conversionRate
},
healthScore: Math.min(healthScore, 100)
};
}
};

View File

@ -2,6 +2,7 @@ const db = require('../db/models');
const LeadsDBApi = require('../db/api/leads');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
@ -70,6 +71,18 @@ module.exports = class LeadsService {
try {
let leads = await LeadsDBApi.findBy({id}, {transaction});
if (!leads) { throw new ValidationError('leadsNotFound'); }
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const match = await db.lead_matches.findOne({
where: { leadId: id, businessId: currentUser.businessId },
transaction
});
if (!match) {
throw new ForbiddenError('forbidden');
}
}
const updatedLeads = await LeadsDBApi.update(id, data, { currentUser, transaction });
await transaction.commit();
return updatedLeads;
@ -82,6 +95,20 @@ module.exports = class LeadsService {
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const count = await db.lead_matches.count({
where: {
leadId: { [db.Sequelize.Op.in]: ids },
businessId: currentUser.businessId
},
transaction
});
if (count !== ids.length) {
throw new ForbiddenError('forbidden');
}
}
await LeadsDBApi.deleteByIds(ids, { currentUser, transaction });
await transaction.commit();
} catch (error) {
@ -93,6 +120,17 @@ module.exports = class LeadsService {
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const match = await db.lead_matches.findOne({
where: { leadId: id, businessId: currentUser.businessId },
transaction
});
if (!match) {
throw new ForbiddenError('forbidden');
}
}
await LeadsDBApi.remove(id, { currentUser, transaction });
await transaction.commit();
} catch (error) {
@ -100,4 +138,4 @@ module.exports = class LeadsService {
throw error;
}
}
};
};

View File

@ -2,20 +2,38 @@ const db = require('../db/models');
const MessagesDBApi = require('../db/api/messages');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
module.exports = class MessagesService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await MessagesDBApi.create(
// For VBOs, ensure they can only message about their leads/businesses
if (currentUser.app_role?.name === 'Verified Business Owner') {
// If leadId is provided, check if business is matched to that lead
if (data.lead) {
const match = await db.lead_matches.findOne({
where: {
leadId: data.lead,
[db.Sequelize.Op.or]: [
{ businessId: currentUser.businessId || null },
{ '$business.owner_userId$': currentUser.id }
]
},
include: [{ model: db.businesses, as: 'business' }],
transaction
});
if (!match) {
throw new ForbiddenError('forbidden');
}
}
}
const message = await MessagesDBApi.create(
data,
{
currentUser,
@ -24,6 +42,7 @@ module.exports = class MessagesService {
);
await transaction.commit();
return message;
} catch (error) {
await transaction.rollback();
throw error;
@ -38,14 +57,13 @@ module.exports = class MessagesService {
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
@ -68,17 +86,38 @@ module.exports = class MessagesService {
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let messages = await MessagesDBApi.findBy(
let message = await MessagesDBApi.findBy(
{id},
{transaction},
);
if (!messages) {
if (!message) {
throw new ValidationError(
'messagesNotFound',
);
}
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
if (message.sender_userId !== currentUser.id && message.receiver_userId !== currentUser.id) {
// Also check if it's about their lead
const match = await db.lead_matches.findOne({
where: {
leadId: message.lead?.id,
[db.Sequelize.Op.or]: [
{ businessId: currentUser.businessId || null },
{ '$business.owner_userId$': currentUser.id }
]
},
include: [{ model: db.businesses, as: 'business' }],
transaction
});
if (!match) {
throw new ForbiddenError('forbidden');
}
}
}
const updatedMessages = await MessagesDBApi.update(
id,
data,
@ -101,6 +140,19 @@ module.exports = class MessagesService {
const transaction = await db.sequelize.transaction();
try {
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const records = await db.messages.findAll({
where: { id: { [db.Sequelize.Op.in]: ids } },
transaction
});
for (const record of records) {
if (record.sender_userId !== currentUser.id && record.receiver_userId !== currentUser.id) {
throw new ForbiddenError('forbidden');
}
}
}
await MessagesDBApi.deleteByIds(ids, {
currentUser,
transaction,
@ -117,6 +169,15 @@ module.exports = class MessagesService {
const transaction = await db.sequelize.transaction();
try {
const record = await db.messages.findByPk(id, { transaction });
if (!record) throw new ValidationError('messagesNotFound');
if (currentUser.app_role?.name === 'Verified Business Owner') {
if (record.sender_userId !== currentUser.id && record.receiver_userId !== currentUser.id) {
throw new ForbiddenError('forbidden');
}
}
await MessagesDBApi.remove(
id,
{
@ -133,6 +194,4 @@ module.exports = class MessagesService {
}
};
};

View File

@ -1,6 +1,6 @@
const errors = {
app: {
title: 'Crafted Network',
title: 'Fix It Local',
},
auth: {

View File

@ -108,12 +108,16 @@ module.exports = class ReviewsService {
}
// Ownership check for Verified Business Owner
// VBO can only update reviews for their own businesses
if (currentUser.app_role?.name === 'Verified Business Owner') {
// Check business owner
const business = await db.businesses.findByPk(review.businessId, { transaction });
if (business && business.owner_userId !== currentUser.id) {
throw new ForbiddenError('forbidden');
if (currentUser.businessId) {
if (review.businessId !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
} else {
const business = await db.businesses.findByPk(review.businessId, { transaction });
if (business && business.owner_userId !== currentUser.id) {
throw new ForbiddenError('forbidden');
}
}
}
@ -147,6 +151,23 @@ module.exports = class ReviewsService {
where: { id: { [db.Sequelize.Op.in]: ids } },
transaction,
});
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
for (const review of reviews) {
if (currentUser.businessId) {
if (review.businessId !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
} else {
const business = await db.businesses.findByPk(review.businessId, { transaction });
if (business && business.owner_userId !== currentUser.id) {
throw new ForbiddenError('forbidden');
}
}
}
}
const businessIds = [...new Set(reviews.map(r => r.businessId))];
await ReviewsDBApi.deleteByIds(ids, {
@ -170,6 +191,24 @@ module.exports = class ReviewsService {
try {
const review = await db.reviews.findByPk(id, { transaction });
if (!review) {
throw new ValidationError('reviewsNotFound');
}
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
if (currentUser.businessId) {
if (review.businessId !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
} else {
const business = await db.businesses.findByPk(review.businessId, { transaction });
if (business && business.owner_userId !== currentUser.id) {
throw new ForbiddenError('forbidden');
}
}
}
const businessId = review.businessId;
await ReviewsDBApi.remove(
@ -188,4 +227,4 @@ module.exports = class ReviewsService {
throw error;
}
}
};
};

View File

@ -7,9 +7,6 @@ const axios = require('axios');
const config = require('../config');
const stream = require('stream');
function buildWidgetResult(widget, queryResult, queryString) {
if (queryResult[0] && queryResult[0].length) {
const key = Object.keys(queryResult[0][0])[0];
@ -17,14 +14,14 @@ function buildWidgetResult(widget, queryResult, queryString) {
const widgetData = JSON.parse(widget.data);
return { ...widget, ...widgetData, value, query: queryString };
} else {
return { ...widget, value: [], query: queryString };
return { ...widget, value: widget.widget_type === 'scalar' ? 0 : [], query: queryString };
}
}
async function executeQuery(queryString, currentUser) {
async function executeQuery(queryString, replacements) {
try {
return await db.sequelize.query(queryString, {
replacements: { organizationId: currentUser.organizationId },
replacements,
});
} catch (e) {
console.log(e);
@ -49,19 +46,46 @@ function insertWhereConditions(queryString, whereConditions) {
}
function constructWhereConditions(mainTable, currentUser, replacements) {
const { organizationId, app_role: { globalAccess } } = currentUser;
const organizationId = currentUser.organizationId;
const roleName = currentUser.app_role?.name;
const globalAccess = currentUser.app_role?.globalAccess;
const currentUserId = currentUser.id;
const businessId = currentUser.businessId;
const tablesWithoutOrgId = ['permissions', 'roles'];
let whereConditions = '';
let conditions = [];
if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) {
whereConditions += `"${mainTable}"."organizationId" = :organizationId`;
replacements.organizationId = organizationId;
if (organizationId) {
conditions.push(`"${mainTable}"."organizationId" = :organizationId`);
replacements.organizationId = organizationId;
}
}
whereConditions += whereConditions ? ' AND ' : '';
whereConditions += `"${mainTable}"."deletedAt" IS NULL`;
// Business User isolation
if (roleName === 'Verified Business Owner') {
if (mainTable === 'businesses') {
if (businessId) {
conditions.push(`"${mainTable}"."id" = :businessId`);
replacements.businessId = businessId;
} else {
conditions.push(`"${mainTable}"."owner_userId" = :currentUserId`);
replacements.currentUserId = currentUserId;
}
} else if (['leads', 'messages', 'reviews', 'service_prices', 'verification_submissions'].includes(mainTable)) {
if (businessId) {
conditions.push(`"${mainTable}"."businessId" = :businessId`);
replacements.businessId = businessId;
} else {
// Fallback: try to filter by owner_userId if we can join or if it's leads (via matches)
// For now, we assume businessId is on the user for most VBOs
}
}
}
return whereConditions;
conditions.push(`"${mainTable}"."deletedAt" IS NULL`);
return conditions.join(' AND ');
}
function extractTableName(queryString) {
@ -77,16 +101,15 @@ function buildQueryString(widget, currentUser) {
const replacements = {};
const whereConditions = constructWhereConditions(mainTable, currentUser, replacements);
queryString = insertWhereConditions(queryString, whereConditions);
console.log(queryString, 'queryString');
return queryString;
return { queryString, replacements };
}
async function constructWidgetsResults(widgets, currentUser) {
const widgetsResults = [];
for (const widget of widgets) {
if (!widget) continue;
const queryString = buildQueryString(widget, currentUser);
const queryResult = await executeQuery(queryString, currentUser);
const { queryString, replacements } = buildQueryString(widget, currentUser);
const queryResult = await executeQuery(queryString, replacements);
widgetsResults.push(buildWidgetResult(widget, queryResult, queryString));
}
return widgetsResults;
@ -107,30 +130,6 @@ async function processWidgets(widgets, currentUser) {
return constructWidgetsResults(widgetData, currentUser);
}
function parseCustomization(role) {
try {
return JSON.parse(role.role_customization || '{}');
} catch (e) {
console.log(e);
return {};
}
}
async function findRole(roleId, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const role = roleId
? await RolesDBApi.findBy({ id: roleId }, { transaction })
: await RolesDBApi.findBy({ name: 'User' }, { transaction });
await transaction.commit();
return role;
} catch (error) {
await transaction.rollback();
throw error;
}
}
module.exports = class RolesService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
@ -158,14 +157,13 @@ module.exports = class RolesService {
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
@ -362,11 +360,6 @@ module.exports = class RolesService {
static async getRoleInfoByKey(key, roleId, currentUser) {
const transaction = await db.sequelize.transaction();
const organizationId = currentUser.organizationId;
let globalAccess = currentUser.app_role?.globalAccess;
let queryString = '';
let role;
try {
if (roleId) {
@ -381,7 +374,7 @@ module.exports = class RolesService {
throw error;
}
let customization = '{}';
let customization = {};
try {
customization = JSON.parse(role.role_customization || '{}');
@ -391,47 +384,8 @@ module.exports = class RolesService {
if (key === 'widgets') {
const widgets = (customization[key] || []);
const widgetArray = widgets.map(widget => {
return axios.get(`${config.flHost}/${config.project_uuid}/project_customization_widgets/${widget}.json`)
})
const widgetResults = await Promise.allSettled(widgetArray);
const fulfilledWidgets = widgetResults.map(result => {
if (result.status === 'fulfilled') {
return result.value.data;
}
});
const widgetsResults = [];
if (Array.isArray(fulfilledWidgets)) {
for (const widget of fulfilledWidgets) {
let result = [];
try {
result = await db.sequelize.query(widget.query);
} catch (e) {
console.log(e);
}
if (result[0] && result[0].length) {
const key = Object.keys(result[0][0])[0];
const value =
widget.widget_type === 'scalar' ? result[0][0][key] : result[0];
const widgetData = JSON.parse(widget.data);
widgetsResults.push({ ...widget, ...widgetData, value });
} else {
widgetsResults.push({ ...widget, value: null });
}
}
}
return widgetsResults;
return await processWidgets(widgets, currentUser);
}
return customization[key];
}
};
};

View File

@ -2,20 +2,27 @@ const db = require('../db/models');
const Service_pricesDBApi = require('../db/api/service_prices');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
module.exports = class Service_pricesService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Service_pricesDBApi.create(
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const businessId = data.business || currentUser.businessId;
const business = await db.businesses.findByPk(businessId, { transaction });
if (!business || business.owner_userId !== currentUser.id) {
throw new ForbiddenError('forbidden');
}
data.business = business.id;
}
const service_price = await Service_pricesDBApi.create(
data,
{
currentUser,
@ -24,6 +31,7 @@ module.exports = class Service_pricesService {
);
await transaction.commit();
return service_price;
} catch (error) {
await transaction.rollback();
throw error;
@ -38,14 +46,13 @@ module.exports = class Service_pricesService {
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
@ -79,6 +86,15 @@ module.exports = class Service_pricesService {
);
}
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const businessId = service_prices.business?.id;
const business = await db.businesses.findByPk(businessId, { transaction });
if (!business || business.owner_userId !== currentUser.id) {
throw new ForbiddenError('forbidden');
}
}
const updatedService_prices = await Service_pricesDBApi.update(
id,
data,
@ -101,6 +117,20 @@ module.exports = class Service_pricesService {
const transaction = await db.sequelize.transaction();
try {
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const records = await db.service_prices.findAll({
where: { id: { [db.Sequelize.Op.in]: ids } },
include: [{ model: db.businesses, as: 'business' }],
transaction
});
for (const record of records) {
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
}
}
await Service_pricesDBApi.deleteByIds(ids, {
currentUser,
transaction,
@ -117,6 +147,18 @@ module.exports = class Service_pricesService {
const transaction = await db.sequelize.transaction();
try {
const record = await db.service_prices.findByPk(id, {
include: [{ model: db.businesses, as: 'business' }],
transaction
});
if (!record) throw new ValidationError('service_pricesNotFound');
if (currentUser.app_role?.name === 'Verified Business Owner') {
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
}
await Service_pricesDBApi.remove(
id,
{
@ -133,6 +175,4 @@ module.exports = class Service_pricesService {
}
};
};

View File

@ -2,20 +2,27 @@ const db = require('../db/models');
const Verification_submissionsDBApi = require('../db/api/verification_submissions');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
module.exports = class Verification_submissionsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Verification_submissionsDBApi.create(
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const businessId = data.business || currentUser.businessId;
const business = await db.businesses.findByPk(businessId, { transaction });
if (!business || (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId)) {
throw new ForbiddenError('forbidden');
}
data.business = business.id;
}
const submission = await Verification_submissionsDBApi.create(
data,
{
currentUser,
@ -24,6 +31,7 @@ module.exports = class Verification_submissionsService {
);
await transaction.commit();
return submission;
} catch (error) {
await transaction.rollback();
throw error;
@ -38,14 +46,13 @@ module.exports = class Verification_submissionsService {
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
@ -79,6 +86,15 @@ module.exports = class Verification_submissionsService {
);
}
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const businessId = verification_submissions.business?.id;
const business = await db.businesses.findByPk(businessId, { transaction });
if (!business || (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId)) {
throw new ForbiddenError('forbidden');
}
}
const updatedVerification_submissions = await Verification_submissionsDBApi.update(
id,
data,
@ -101,6 +117,20 @@ module.exports = class Verification_submissionsService {
const transaction = await db.sequelize.transaction();
try {
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const records = await db.verification_submissions.findAll({
where: { id: { [db.Sequelize.Op.in]: ids } },
include: [{ model: db.businesses, as: 'business' }],
transaction
});
for (const record of records) {
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
}
}
await Verification_submissionsDBApi.deleteByIds(ids, {
currentUser,
transaction,
@ -117,6 +147,18 @@ module.exports = class Verification_submissionsService {
const transaction = await db.sequelize.transaction();
try {
const record = await db.verification_submissions.findByPk(id, {
include: [{ model: db.businesses, as: 'business' }],
transaction
});
if (!record) throw new ValidationError('verification_submissionsNotFound');
if (currentUser.app_role?.name === 'Verified Business Owner') {
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
}
await Verification_submissionsDBApi.remove(
id,
{
@ -133,6 +175,4 @@ module.exports = class Verification_submissionsService {
}
};
};

View File

@ -1,4 +1,4 @@
# Crafted Network
# Fix It Local
## This project was generated by Flatlogic Platform.
## Install

View File

@ -1,40 +1,40 @@
import type { ColorButtonKey } from './interfaces'
export const gradientBgBase = 'bg-gradient-to-tr'
export const colorBgBase = "bg-violet-50/50"
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500`
export const colorBgBase = "bg-emerald-50/50"
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-rose-500`
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`
export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`;
export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`
export const gradientBgDark = `${gradientBgBase} from-slate-800 via-slate-950 to-slate-900`;
export const gradientBgPinkRed = `${gradientBgBase} from-rose-400 via-emerald-500 to-emerald-600`
export const colorsBgLight = {
white: 'bg-white text-black',
light: ' bg-white text-black text-black dark:bg-dark-900 dark:text-white',
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
success: 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
danger: 'bg-red-500 border-red-500 text-white',
warning: 'bg-yellow-500 border-yellow-500 text-white',
info: 'bg-blue-500 border-blue-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
contrast: 'bg-slate-800 text-white dark:bg-white dark:text-black',
success: 'bg-emerald-500 border-emerald-500 dark:bg-emerald-600 dark:border-emerald-600 text-white',
danger: 'bg-rose-500 border-rose-500 text-white',
warning: 'bg-amber-500 border-amber-500 text-white',
info: 'bg-emerald-600 border-emerald-600 dark:bg-emerald-700 dark:border-emerald-700 text-white',
}
export const colorsText = {
white: 'text-black dark:text-slate-100',
light: 'text-gray-700 dark:text-slate-400',
light: 'text-slate-700 dark:text-slate-400',
contrast: 'dark:text-white',
success: 'text-emerald-500',
danger: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500',
danger: 'text-rose-500',
warning: 'text-amber-500',
info: 'text-emerald-600',
};
export const colorsOutline = {
white: [colorsText.white, 'border-gray-100'].join(' '),
light: [colorsText.light, 'border-gray-100'].join(' '),
contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'].join(' '),
white: [colorsText.white, 'border-slate-100'].join(' '),
light: [colorsText.light, 'border-slate-100'].join(' '),
contrast: [colorsText.contrast, 'border-slate-900 dark:border-slate-100'].join(' '),
success: [colorsText.success, 'border-emerald-500'].join(' '),
danger: [colorsText.danger, 'border-red-500'].join(' '),
warning: [colorsText.warning, 'border-yellow-500'].join(' '),
info: [colorsText.info, 'border-blue-500'].join(' '),
danger: [colorsText.danger, 'border-rose-500'].join(' '),
warning: [colorsText.warning, 'border-amber-500'].join(' '),
info: [colorsText.info, 'border-emerald-600'].join(' '),
};
export const getButtonColor = (
@ -49,74 +49,74 @@ export const getButtonColor = (
const colors = {
ring: {
white: 'ring-gray-200 dark:ring-gray-500',
whiteDark: 'ring-gray-200 dark:ring-dark-500',
lightDark: 'ring-gray-200 dark:ring-gray-500',
contrast: 'ring-gray-300 dark:ring-gray-400',
success: 'ring-emerald-300 dark:ring-pavitra-blue',
danger: 'ring-red-300 dark:ring-red-700',
warning: 'ring-yellow-300 dark:ring-yellow-700',
info: "ring-blue-300 dark:ring-pavitra-blue",
white: 'ring-slate-200 dark:ring-slate-500',
whiteDark: 'ring-slate-200 dark:ring-dark-500',
lightDark: 'ring-slate-200 dark:ring-slate-500',
contrast: 'ring-slate-300 dark:ring-slate-400',
success: 'ring-emerald-300 dark:ring-emerald-700',
danger: 'ring-rose-300 dark:ring-rose-700',
warning: 'ring-amber-300 dark:ring-amber-700',
info: "ring-emerald-300 dark:ring-emerald-700",
},
active: {
white: 'bg-gray-100',
whiteDark: 'bg-gray-100 dark:bg-dark-800',
lightDark: 'bg-gray-200 dark:bg-slate-700',
contrast: 'bg-gray-700 dark:bg-slate-100',
success: 'bg-emerald-700 dark:bg-pavitra-blue',
danger: 'bg-red-700 dark:bg-red-600',
warning: 'bg-yellow-700 dark:bg-yellow-600',
info: 'bg-blue-700 dark:bg-pavitra-blue',
white: 'bg-slate-100',
whiteDark: 'bg-slate-100 dark:bg-dark-800',
lightDark: 'bg-slate-200 dark:bg-slate-700',
contrast: 'bg-slate-700 dark:bg-slate-100',
success: 'bg-emerald-700 dark:bg-emerald-800',
danger: 'bg-rose-700 dark:bg-rose-600',
warning: 'bg-amber-700 dark:bg-amber-600',
info: 'bg-emerald-700 dark:bg-emerald-800',
},
bg: {
white: 'bg-white text-black',
whiteDark: 'bg-white text-black dark:bg-dark-900 dark:text-white',
lightDark: 'bg-gray-100 text-black dark:bg-slate-800 dark:text-white',
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
success: 'bg-emerald-600 dark:bg-pavitra-blue text-white',
danger: 'bg-red-600 text-white dark:bg-red-500 ',
warning: 'bg-yellow-600 dark:bg-yellow-500 text-white',
info: " bg-blue-600 dark:bg-pavitra-blue text-white ",
lightDark: 'bg-slate-100 text-black dark:bg-slate-800 dark:text-white',
contrast: 'bg-slate-800 text-white dark:bg-white dark:text-black',
success: 'bg-emerald-600 dark:bg-emerald-600 text-white',
danger: 'bg-rose-600 text-white dark:bg-rose-500 ',
warning: 'bg-amber-600 dark:bg-amber-500 text-white',
info: " bg-emerald-600 dark:bg-emerald-600 text-white ",
},
bgHover: {
white: 'hover:bg-gray-100',
whiteDark: 'hover:bg-gray-100 hover:dark:bg-dark-800',
lightDark: 'hover:bg-gray-200 hover:dark:bg-slate-700',
contrast: 'hover:bg-gray-700 hover:dark:bg-slate-100',
white: 'hover:bg-slate-100',
whiteDark: 'hover:bg-slate-100 hover:dark:bg-dark-800',
lightDark: 'hover:bg-slate-200 hover:dark:bg-slate-700',
contrast: 'hover:bg-slate-700 hover:dark:bg-slate-100',
success:
'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-pavitra-blue hover:dark:border-pavitra-blue',
'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-emerald-800 hover:dark:border-emerald-800',
danger:
'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600',
'hover:bg-rose-700 hover:border-rose-700 hover:dark:bg-rose-600 hover:dark:border-rose-600',
warning:
'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600',
info: "hover:bg-blue-700 hover:border-blue-700 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80",
'hover:bg-amber-700 hover:border-amber-700 hover:dark:bg-amber-600 hover:dark:border-amber-600',
info: "hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-emerald-800 hover:dark:border-emerald-800",
},
borders: {
white: 'border-white',
whiteDark: 'border-white dark:border-dark-900',
lightDark: 'border-gray-100 dark:border-slate-800',
contrast: 'border-gray-800 dark:border-white',
success: 'border-emerald-600 dark:border-pavitra-blue',
danger: 'border-red-600 dark:border-red-500',
warning: 'border-yellow-600 dark:border-yellow-500',
info: "border-blue-600 border-blue-600 dark:border-pavitra-blue",
lightDark: 'border-slate-100 dark:border-slate-800',
contrast: 'border-slate-800 dark:border-white',
success: 'border-emerald-600 dark:border-emerald-600',
danger: 'border-rose-600 dark:border-rose-500',
warning: 'border-amber-600 dark:border-amber-500',
info: "border-emerald-600 border-emerald-600 dark:border-emerald-600",
},
text: {
contrast: 'dark:text-slate-100',
success: 'text-emerald-600 dark:text-pavitra-blue',
danger: 'text-red-600 dark:text-red-500',
warning: 'text-yellow-600 dark:text-yellow-500',
info: 'text-blue-600 dark:text-pavitra-blue',
success: 'text-emerald-600 dark:text-emerald-500',
danger: 'text-rose-600 dark:text-rose-500',
warning: 'text-amber-600 dark:text-amber-500',
info: 'text-emerald-600 dark:text-emerald-500',
},
outlineHover: {
contrast:
'hover:bg-gray-800 hover:text-gray-100 hover:dark:bg-slate-100 hover:dark:text-black',
success: 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue',
'hover:bg-slate-800 hover:text-slate-100 hover:dark:bg-slate-100 hover:dark:text-black',
success: 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-emerald-600',
danger:
'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600',
'hover:bg-rose-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-rose-600',
warning:
'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600',
info: "hover:bg-blue-600 hover:bg-blue-600 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue",
'hover:bg-amber-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-amber-600',
info: "hover:bg-emerald-600 hover:bg-emerald-600 hover:text-white hover:dark:text-white hover:dark:border-emerald-600",
},
}
@ -135,4 +135,4 @@ export const getButtonColor = (
}
return base.join(' ')
}
}

View File

@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
<b className="font-black">Crafted Network</b>
<b className="font-black">Fix It Local</b>
</div>

View File

@ -18,7 +18,11 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
return (
<ul className={className}>
{menu.map((item, index) => {
// Role check
if (item.roles && !item.roles.includes(currentUser.app_role?.name)) {
return null;
}
// Permission check
if (!hasPermission(currentUser, item.permissions)) return null;
return (
@ -32,4 +36,4 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
})}
</ul>
)
}
}

View File

@ -0,0 +1,41 @@
import React from 'react';
type Props = {
value: number;
max?: number;
color?: string;
label?: string;
};
const ProgressBar = ({ value, max = 100, color = 'emerald', label }: Props) => {
const percentage = Math.max(0, Math.min(100, Math.round((value / max) * 100)));
const colors: Record<string, string> = {
emerald: 'bg-emerald-500 shadow-sm shadow-emerald-500/20',
green: 'bg-emerald-500 shadow-sm shadow-emerald-500/20',
yellow: 'bg-amber-400 shadow-sm shadow-amber-400/20',
red: 'bg-rose-500 shadow-sm shadow-rose-500/20',
rose: 'bg-rose-500 shadow-sm shadow-rose-500/20',
};
const bgColor = colors[color] || colors.emerald;
return (
<div className="w-full animate-fade-in">
{label && (
<div className="flex justify-between mb-2 items-baseline">
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-400">{label}</span>
<span className="text-sm font-black text-slate-800 dark:text-white">{percentage}%</span>
</div>
)}
<div className="w-full bg-slate-100 rounded-full h-3 dark:bg-slate-800 overflow-hidden shadow-inner">
<div
className={`${bgColor} h-3 rounded-full transition-all duration-1000 ease-out`}
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
);
};
export default ProgressBar;

View File

@ -8,7 +8,7 @@ export const localStorageStyleKey = 'style'
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
export const appTitle = 'created by Flatlogic generator!'
export const appTitle = 'Fix It Local'
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle}${appTitle}`

View File

@ -14,6 +14,7 @@ export type MenuAsideItem = {
withDevider?: boolean;
menu?: MenuAsideItem[]
permissions?: string | string[]
roles?: string[]
}
export type MenuNavBarItem = {
@ -99,6 +100,10 @@ export interface User {
updatedById?: any;
avatar: any[];
notes: any[];
businessId?: string;
app_role?: {
name: string;
};
}
export type StyleKey = 'white' | 'basic'

View File

@ -30,7 +30,7 @@ export default function LayoutGuest({ children }: Props) {
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20 group-hover:scale-110 transition-transform">
<BaseIcon path={mdiShieldCheck} size={24} className="text-white" />
</div>
<span className="text-xl font-black tracking-tight dark:text-white">Crafted Network<span className="text-emerald-500 italic"></span></span>
<span className="text-xl font-black tracking-tight dark:text-white">Fix It Local<span className="text-emerald-500 italic"></span></span>
</Link>
<nav className="hidden md:flex items-center gap-10">
@ -97,7 +97,7 @@ export default function LayoutGuest({ children }: Props) {
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center mr-3">
<BaseIcon path={mdiShieldCheck} size={24} className="text-white" />
</div>
<span className="text-2xl font-bold tracking-tight dark:text-white text-slate-900">Crafted Network</span>
<span className="text-2xl font-bold tracking-tight dark:text-white text-slate-900">Fix It Local</span>
</div>
<div className="flex gap-8 text-slate-500 font-medium dark:text-slate-400">
<Link href="/search" className="hover:text-emerald-500">Find Help</Link>
@ -107,7 +107,7 @@ export default function LayoutGuest({ children }: Props) {
</div>
</div>
<div className="mt-12 pt-8 border-t border-slate-100 dark:border-slate-800 text-center text-slate-400 text-sm">
© 2026 Crafted Network. Built with Trust & Transparency.
© 2026 Fix It Local. Built with Trust & Transparency.
</div>
</div>
</footer>

View File

@ -2,210 +2,139 @@ import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces'
const menuAside: MenuAsideItem[] = [
// Common
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
icon: icon.mdiStarFourPoints,
label: 'Studio Hub',
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
},
{
href: '/dashboard',
icon: icon.mdiStarFourPoints,
label: 'Studio Hub',
roles: ['Verified Business Owner']
},
// Admin Only
{
href: '/users/users-list',
label: 'Users',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS'
},
{
href: '/roles/roles-list',
label: 'Roles',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
permissions: 'READ_ROLES'
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS'
},
{
href: '/refresh_tokens/refresh_tokens-list',
label: 'Refresh tokens',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiLock' in icon ? icon['mdiLock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_REFRESH_TOKENS'
label: 'Clients',
icon: icon.mdiAccountHeart,
permissions: 'READ_USERS',
roles: ['Administrator', 'Platform Owner']
},
{
href: '/categories/categories-list',
label: 'Categories',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiShape' in icon ? icon['mdiShape' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CATEGORIES'
label: 'Beauty Categories',
icon: icon.mdiLipstick,
permissions: 'READ_CATEGORIES',
roles: ['Administrator', 'Platform Owner']
},
{
href: '/locations/locations-list',
label: 'Locations',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LOCATIONS'
label: 'Regions',
icon: icon.mdiMapMarkerRadius,
permissions: 'READ_LOCATIONS',
roles: ['Administrator', 'Platform Owner']
},
// Shared but labeled differently or scoped
{
href: '/businesses/businesses-list',
label: 'Service Listings',
icon: icon.mdiStorefront,
permissions: 'READ_BUSINESSES',
roles: ['Administrator', 'Platform Owner']
},
{
href: '/businesses/businesses-list',
label: 'Businesses',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_BUSINESSES'
},
{
href: '/business_photos/business_photos-list',
label: 'Business photos',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_BUSINESS_PHOTOS'
},
{
href: '/business_categories/business_categories-list',
label: 'Business categories',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_BUSINESS_CATEGORIES'
},
{
href: '/service_prices/service_prices-list',
label: 'Service prices',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCurrencyUsd' in icon ? icon['mdiCurrencyUsd' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_SERVICE_PRICES'
},
{
href: '/business_badges/business_badges-list',
label: 'Business badges',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiShieldCheck' in icon ? icon['mdiShieldCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_BUSINESS_BADGES'
},
{
href: '/verification_submissions/verification_submissions-list',
label: 'Verification submissions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFileCheck' in icon ? icon['mdiFileCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_VERIFICATION_SUBMISSIONS'
},
{
href: '/verification_evidences/verification_evidences-list',
label: 'Verification evidences',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFileUpload' in icon ? icon['mdiFileUpload' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_VERIFICATION_EVIDENCES'
label: 'My Studio',
icon: icon.mdiStorefront,
permissions: 'READ_BUSINESSES',
roles: ['Verified Business Owner']
},
{
href: '/leads/leads-list',
label: 'Leads',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LEADS'
},
{
href: '/lead_photos/lead_photos-list',
label: 'Lead photos',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCamera' in icon ? icon['mdiCamera' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LEAD_PHOTOS'
},
{
href: '/lead_matches/lead_matches-list',
label: 'Lead matches',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiLinkVariant' in icon ? icon['mdiLinkVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LEAD_MATCHES'
label: 'Client Bookings',
icon: icon.mdiCalendarHeart,
permissions: 'READ_LEADS',
roles: ['Administrator', 'Platform Owner', 'Verified Business Owner']
},
{
href: '/messages/messages-list',
label: 'Messages',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiMessageText' in icon ? icon['mdiMessageText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
label: 'Consultations',
icon: icon.mdiMessageProcessing,
permissions: 'READ_MESSAGES'
},
{
href: '/lead_events/lead_events-list',
label: 'Lead events',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTimelineText' in icon ? icon['mdiTimelineText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LEAD_EVENTS'
href: '/reviews/reviews-list',
label: 'Love Letters',
icon: icon.mdiStarFace,
permissions: 'READ_REVIEWS',
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
},
{
href: '/reviews/reviews-list',
label: 'Reviews',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiStar' in icon ? icon['mdiStar' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_REVIEWS'
label: 'Client Love',
icon: icon.mdiStarFace,
permissions: 'READ_REVIEWS',
roles: ['Verified Business Owner']
},
{
href: '/verification_submissions/verification_submissions-list',
label: 'Verification',
icon: icon.mdiShieldCheck,
permissions: 'READ_VERIFICATION_SUBMISSIONS',
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
},
{
href: '/disputes/disputes-list',
label: 'Disputes',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAlertOctagon' in icon ? icon['mdiAlertOctagon' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_DISPUTES'
href: '/verification_submissions/verification_submissions-list',
label: 'Safety Badge',
icon: icon.mdiShieldCheck,
permissions: 'READ_VERIFICATION_SUBMISSIONS',
roles: ['Verified Business Owner']
},
// Placeholder for Billing and Team
{
href: '/billing',
label: 'Earnings',
icon: icon.mdiWallet,
roles: ['Verified Business Owner']
},
{
href: '/billing-settings',
label: 'Global Billing',
icon: icon.mdiFinance,
roles: ['Administrator', 'Platform Owner']
},
{
href: '/team',
label: 'Studio Team',
icon: icon.mdiAccountGroupOutline,
roles: ['Verified Business Owner']
},
// Moderator
{
href: '/audit_logs/audit_logs-list',
label: 'Audit logs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiClipboardList' in icon ? icon['mdiClipboardList' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_AUDIT_LOGS'
},
{
href: '/badge_rules/badge_rules-list',
label: 'Badge rules',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiShieldCrown' in icon ? icon['mdiShieldCrown' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_BADGE_RULES'
},
{
href: '/trust_adjustments/trust_adjustments-list',
label: 'Trust adjustments',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTuneVariant' in icon ? icon['mdiTuneVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_TRUST_ADJUSTMENTS'
},
{
href: '/profile',
label: 'Profile',
icon: icon.mdiAccountCircle,
icon: icon.mdiClipboardListOutline,
permissions: 'READ_AUDIT_LOGS',
roles: ['Administrator', 'Platform Owner']
},
// Profile/Settings
{
href: '/api-docs',
target: '_blank',
label: 'Swagger API',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS'
href: '/profile',
label: 'My Profile',
icon: icon.mdiAccountSettings,
},
]
export default menuAside
export default menuAside

View File

@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
setStepsEnabled(false);
};
const title = 'Crafted Network'
const title = 'Fix It Local'
const description = "Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking."
const url = "https://flatlogic.com/"
const image = "https://project-screens.s3.amazonaws.com/screenshots/38501/app-hero-20260217-010030.png"

View File

@ -0,0 +1,171 @@
import * as icon from '@mdi/js';
import Head from 'next/head';
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import type { ReactElement } from 'react';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import CardBox from '../components/CardBox';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import moment from 'moment';
const PlanCard = ({ plan, currentPlanId, onUpgrade }: any) => {
const isCurrent = plan.id === currentPlanId;
return (
<CardBox className={`h-full flex flex-col ${isCurrent ? 'border-2 border-blue-500 ring-2 ring-blue-500 ring-opacity-10' : ''}`}>
<div className="flex-grow">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold">{plan.name}</h3>
{isCurrent && (
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded-full font-bold">CURRENT PLAN</span>
)}
</div>
<div className="mb-6">
<span className="text-4xl font-black">${plan.price}</span>
<span className="text-gray-500">/mo</span>
</div>
<ul className="space-y-3 mb-8">
{plan.features?.map((feature: string, i: number) => (
<li key={i} className="flex items-center text-sm">
<BaseIcon path={icon.mdiCheckCircle} size={18} className="text-emerald-500 mr-2" />
{feature}
</li>
))}
</ul>
</div>
<BaseButton
label={isCurrent ? 'Current Plan' : `Upgrade to ${plan.name}`}
color={isCurrent ? 'whiteDark' : 'info'}
disabled={isCurrent}
className="w-full mt-auto"
onClick={() => onUpgrade(plan)}
/>
</CardBox>
);
};
const BillingPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const [plans, setPlans] = useState([]);
const [business, setBusiness] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [plansRes, metricsRes] = await Promise.all([
axios.get('/dashboard/business-metrics'), // reusing for business data
// We need a plans endpoint, but since I seeded them, I can mock or create one
]);
// Mocking plans for now based on migration seeds if no endpoint exists
setPlans([
{
id: '00000000-0000-0000-0000-000000000001',
name: 'Basic (Free)',
price: 0,
features: ['Limited Leads', 'Standard Listing'],
},
{
id: '00000000-0000-0000-0000-000000000002',
name: 'Professional',
price: 49.99,
features: ['Unlimited Leads', 'Priority Support', 'Enhanced Profile'],
},
{
id: '00000000-0000-0000-0000-000000000003',
name: 'Enterprise',
price: 199.99,
features: ['Custom Branding', 'API Access', 'Dedicated Manager'],
},
] as any);
if (metricsRes.data.businesses?.length) {
setBusiness(metricsRes.data.businesses[0]);
}
} catch (err) {
console.error('Failed to fetch billing data', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleUpgrade = (plan: any) => {
alert(`Upgrading to ${plan.name}. This would normally trigger a payment flow.`);
};
if (loading) {
return (
<SectionMain>
<div className="flex items-center justify-center h-64">
<BaseIcon path={icon.mdiLoading} size={48} className="animate-spin text-gray-400" />
</div>
</SectionMain>
);
}
return (
<>
<Head>
<title>{getPageTitle('Billing & Plans')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={icon.mdiCreditCardOutline} title="Billing & Plans" main />
{business && (
<div className="mb-8 p-6 bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-gray-100 dark:border-slate-800 flex flex-col md:flex-row justify-between items-center">
<div>
<div className="text-gray-500 text-sm mb-1 uppercase tracking-wider font-bold">Current Plan</div>
<div className="text-2xl font-black">{business.plan?.name || 'Basic'}</div>
<div className="text-gray-500 text-sm mt-2">
Your plan renews on <span className="font-bold text-gray-900 dark:text-white">{moment(business.renewal_date).format('MMMM D, YYYY')}</span>
</div>
</div>
<div className="mt-4 md:mt-0 flex space-x-2">
<BaseButton label="View Invoices" color="white" small />
<BaseButton label="Payment Method" color="white" small />
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{plans.map((plan: any) => (
<PlanCard
key={plan.id}
plan={plan}
currentPlanId={business?.planId}
onUpgrade={handleUpgrade}
/>
))}
</div>
<CardBox className="mt-12 bg-gray-50 dark:bg-slate-800 border-dashed">
<div className="flex items-center p-4">
<div className="bg-blue-100 p-3 rounded-full mr-4">
<BaseIcon path={icon.mdiInformation} size={24} className="text-blue-500" />
</div>
<div>
<h4 className="font-bold">Need a custom plan?</h4>
<p className="text-sm text-gray-500">Contact our sales team for enterprise solutions tailored to your specific needs.</p>
</div>
<BaseButton label="Contact Sales" color="light" className="ml-auto" />
</div>
</CardBox>
</SectionMain>
</>
);
};
BillingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default BillingPage;

View File

@ -1,7 +1,7 @@
import { mdiChartTimelineVariant } from '@mdi/js'
import Head from 'next/head'
import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react'
import React, { ReactElement, useEffect, useState } from 'react'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
@ -14,7 +14,8 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
import {fetch, setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
import { useRouter } from 'next/router';
import {hasPermission} from "../../helpers/userPermissions";
@ -29,10 +30,26 @@ const BusinessesTablesPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const { businesses, count, loading } = useAppSelector((state) => state.businesses);
const router = useRouter();
const dispatch = useAppDispatch();
useEffect(() => {
if (currentUser?.app_role?.name === 'Verified Business Owner') {
dispatch(fetch({ limit: 10, page: 0 }));
}
}, [currentUser, dispatch]);
useEffect(() => {
if (currentUser?.app_role?.name === 'Verified Business Owner' && !loading) {
if (count === 0) {
router.push('/businesses/businesses-new');
}
}
}, [count, loading, currentUser, businesses, router]);
const [filters] = useState([{label: 'Name', title: 'name'},{label: 'Slug', title: 'slug'},{label: 'Description', title: 'description'},{label: 'Phone', title: 'phone'},{label: 'Email', title: 'email'},{label: 'Website', title: 'website'},{label: 'Address', title: 'address'},{label: 'City', title: 'city'},{label: 'State', title: 'state'},{label: 'ZIP', title: 'zip'},{label: 'HoursJSON', title: 'hours_json'},{label: 'ReliabilityBreakdownJSON', title: 'reliability_breakdown_json'},{label: 'TenantKey', title: 'tenant_key'},
{label: 'ReliabilityScore', title: 'reliability_score', number: 'true'},{label: 'ResponseTimeMedianMinutes', title: 'response_time_median_minutes', number: 'true'},
@ -87,6 +104,16 @@ const BusinessesTablesPage = () => {
setIsModalActive(false);
};
if (currentUser?.app_role?.name === 'Verified Business Owner' && count === 0) {
return (
<SectionMain>
<div className="flex justify-center items-center h-64">
<p>Redirecting to create your business profile...</p>
</div>
</SectionMain>
)
}
return (
<>
<Head>
@ -167,4 +194,4 @@ BusinessesTablesPage.getLayout = function getLayout(page: ReactElement) {
)
}
export default BusinessesTablesPage
export default BusinessesTablesPage

View File

@ -1,6 +1,6 @@
import * as icon from '@mdi/js';
import Head from 'next/head'
import React from 'react'
import React, { useEffect, useState } from 'react'
import axios from 'axios';
import type { ReactElement } from 'react'
import LayoutAuthenticated from '../layouts/Authenticated'
@ -8,425 +8,313 @@ import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import BaseIcon from "../components/BaseIcon";
import BaseButton from "../components/BaseButton";
import CardBox from "../components/CardBox";
import CardBoxComponentBody from "../components/CardBoxComponentBody";
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import CardBoxComponentFooter from "../components/CardBoxComponentFooter";
import ProgressBar from "../components/ProgressBar";
import { getPageTitle } from '../config'
import Link from "next/link";
import { hasPermission } from "../helpers/userPermissions";
import { fetchWidgets } from '../stores/roles/rolesSlice';
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import moment from 'moment';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const ActionQueueItem = ({ label, count, iconPath, color, href }: any) => (
<Link href={href}>
<div className="flex items-center p-4 bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 rounded-[2rem] shadow-sm hover:shadow-xl hover:shadow-emerald-500/10 transition-all cursor-pointer group">
<div className={`p-4 rounded-2xl mr-4 ${color} shadow-lg shadow-current/20 group-hover:scale-110 transition-transform`}>
<BaseIcon path={iconPath} size={24} className="text-white" />
</div>
<div>
<div className="text-2xl font-black text-slate-800 dark:text-white">{count}</div>
<div className="text-xs font-bold text-slate-400 uppercase tracking-tighter">{label}</div>
</div>
</div>
</Link>
);
const PipelineStat = ({ label, count, color }: any) => (
<div className="text-center p-4 border-r last:border-r-0 border-slate-100 dark:border-slate-800">
<div className={`text-3xl font-black ${color}`}>{count}</div>
<div className="text-[10px] text-slate-400 uppercase tracking-widest font-black mt-2">{label}</div>
</div>
);
const BusinessDashboardView = ({ metrics, currentUser }: any) => {
if (metrics.no_business) {
return (
<CardBox className="text-center p-12 border-dashed border-2 border-slate-200">
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-6">
<BaseIcon path={icon.mdiStorePlus} size={40} className="text-slate-300" />
</div>
<h2 className="text-2xl font-bold mb-2 text-slate-800">No active business found</h2>
<p className="text-slate-500 mb-8 max-w-sm mx-auto text-sm">Create your first listing to start receiving leads and managing your beauty business with AI-powered tools.</p>
<BaseButton href="/businesses/businesses-new" label="List New Business" color="info" icon={icon.mdiPlus} className="px-8 py-3 rounded-2xl font-bold shadow-lg shadow-emerald-500/20" />
</CardBox>
);
}
const { action_queue, pipeline, recentMessages, performance, healthScore, businesses } = metrics;
const business = businesses[0];
return (
<div className="space-y-8 animate-fade-in">
{/* Action Queue */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<ActionQueueItem
label="New Leads (24h)"
count={action_queue.newLeads24h}
iconPath={icon.mdiFlash}
color="bg-amber-400"
href="/leads/leads-list"
/>
<ActionQueueItem
label="Needs Response"
count={action_queue.leadsNeedingResponse}
iconPath={icon.mdiMessageProcessing}
color="bg-rose-400"
href="/leads/leads-list"
/>
<ActionQueueItem
label="Verifications"
count={action_queue.verificationPending}
iconPath={icon.mdiShieldCheckOutline}
color="bg-emerald-400"
href="/verification_submissions/verification_submissions-list"
/>
<ActionQueueItem
label="Health Score"
count={`${healthScore}%`}
iconPath={icon.mdiHeartPulse}
color="bg-rose-500"
href={`/businesses/businesses-edit/?id=${business.id}`}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Lead Pipeline */}
<CardBox className="lg:col-span-2 overflow-hidden border-none shadow-2xl shadow-slate-200/50">
<CardBoxComponentTitle title="Beauty Pipeline Snapshot">
<div className="text-[10px] font-black uppercase tracking-widest text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full border border-emerald-100">
{pipeline.winRate30d.toFixed(1)}% Conversion
</div>
</CardBoxComponentTitle>
<div className="grid grid-cols-5 mt-6 bg-slate-50/50 rounded-3xl p-2 border border-slate-100">
<PipelineStat label="New" count={pipeline.NEW || 0} color="text-slate-900 dark:text-white" />
<PipelineStat label="Consulted" count={pipeline.CONTACTED || 0} color="text-rose-400" />
<PipelineStat label="Booked" count={pipeline.SCHEDULED || 0} color="text-amber-400" />
<PipelineStat label="Completed" count={pipeline.WON || 0} color="text-emerald-500" />
<PipelineStat label="Archived" count={pipeline.LOST || 0} color="text-slate-400" />
</div>
</CardBox>
{/* Listing Health */}
<CardBox className="border-none shadow-2xl shadow-slate-200/50">
<CardBoxComponentTitle title="Profile Vitality" />
<div className="mt-6">
<ProgressBar value={healthScore} label="Profile Strength" color={healthScore > 80 ? 'green' : healthScore > 50 ? 'yellow' : 'red'} />
<div className="mt-8 space-y-3">
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">Improve visibility:</div>
{action_queue.missingFields.slice(0, 3).map((field: string) => (
<div key={field} className="flex items-center text-xs font-semibold text-rose-500 bg-rose-50/50 p-2 rounded-xl border border-rose-100/50">
<BaseIcon path={icon.mdiAlertCircleOutline} size={14} className="mr-2" />
Add {field.replace('_json', '').replace('_', ' ')}
</div>
))}
<Link href={`/businesses/businesses-edit/?id=${business.id}`} className="block text-xs text-emerald-600 font-bold hover:underline mt-4 text-center p-2 bg-emerald-50 rounded-xl transition-colors">
Enhance Profile
</Link>
</div>
</div>
</CardBox>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Recent Messages */}
<CardBox className="lg:col-span-2 border-none shadow-2xl shadow-slate-200/50">
<CardBoxComponentTitle title="Recent Client Love" />
<div className="mt-6 space-y-4">
{recentMessages.length > 0 ? recentMessages.map((msg: any) => (
<div key={msg.id} className="flex items-start p-4 hover:bg-emerald-50/30 dark:hover:bg-slate-800 rounded-[1.5rem] transition-all border border-transparent hover:border-emerald-100 group">
<div className="bg-emerald-100 dark:bg-slate-700 w-12 h-12 rounded-2xl flex items-center justify-center mr-4 flex-shrink-0 text-emerald-600 font-black shadow-inner">
{msg.sender_user?.firstName?.[0] || 'U'}
</div>
<div className="flex-grow min-w-0">
<div className="flex justify-between items-baseline mb-1">
<span className="font-bold text-slate-800 dark:text-white truncate">{msg.sender_user?.firstName} {msg.sender_user?.lastName}</span>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">{moment(msg.createdAt).fromNow()}</span>
</div>
<p className="text-sm text-slate-500 dark:text-gray-400 line-clamp-1 italic">&quot;{msg.body}&quot;</p>
</div>
<div className="ml-4 opacity-0 group-hover:opacity-100 transition-opacity">
<BaseButton small color="success" icon={icon.mdiReply} href={`/messages/messages-list?leadId=${msg.leadId}`} className="rounded-xl shadow-lg shadow-emerald-500/20" />
</div>
</div>
)) : (
<div className="text-center py-12 text-slate-300 italic font-medium">No recent messages yet</div>
)}
</div>
<CardBoxComponentFooter className="bg-slate-50/50">
<BaseButton label="View All Messages" color="white" small href="/leads/leads-list" className="rounded-xl border-slate-200 shadow-sm" />
</CardBoxComponentFooter>
</CardBox>
{/* Performance & Billing */}
<div className="space-y-8">
<CardBox className="border-none shadow-2xl shadow-slate-200/50">
<CardBoxComponentTitle title="Growth (30d)" />
<div className="mt-6 grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-2xl border border-slate-100">
<div className="text-[10px] text-slate-400 uppercase font-black tracking-widest mb-1">Views</div>
<div className="text-2xl font-black text-slate-800 dark:text-white">{performance.views30d}</div>
<div className="text-[10px] font-bold text-emerald-500 mt-1 flex items-center">
<BaseIcon path={icon.mdiTrendingUp} size={12} className="mr-1" />
7d: {performance.views7d}
</div>
</div>
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-2xl border border-slate-100">
<div className="text-[10px] text-slate-400 uppercase font-black tracking-widest mb-1">Interactions</div>
<div className="text-2xl font-black text-slate-800 dark:text-white">{performance.calls30d + performance.website30d}</div>
<div className="text-[10px] font-bold text-emerald-500 mt-1 flex items-center">
<BaseIcon path={icon.mdiTrendingUp} size={12} className="mr-1" />
7d: {performance.calls7d + performance.website7d}
</div>
</div>
</div>
<div className="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800">
<div className="flex justify-between items-center">
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">Conversion Rate</span>
<span className="text-lg font-black text-emerald-500">{performance.conversionRate.toFixed(1)}%</span>
</div>
</div>
</CardBox>
<CardBox className="bg-gradient-to-br from-emerald-500 via-teal-600 to-emerald-700 text-white border-none shadow-2xl shadow-emerald-500/30 overflow-hidden relative group">
<div className="absolute top-0 right-0 -mt-4 -mr-4 w-24 h-24 bg-white/10 rounded-full blur-2xl group-hover:scale-150 transition-transform duration-700"></div>
<div className="relative z-10">
<div className="flex justify-between items-start">
<div>
<div className="text-emerald-100 text-[10px] font-black uppercase tracking-[0.2em]">Tier Plan</div>
<div className="text-3xl font-black mt-2 tracking-tighter">{business.plan?.name || 'Professional'}</div>
</div>
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl shadow-xl">
<BaseIcon path={icon.mdiCrownOutline} size={28} className="text-amber-300" />
</div>
</div>
<div className="mt-8">
<div className="text-emerald-100 text-[10px] uppercase tracking-[0.2em] font-black">Renewal Date</div>
<div className="text-lg font-bold mt-1">{moment(business.renewal_date).format('MMMM Do, YYYY')}</div>
</div>
<div className="mt-10">
<BaseButton label="Manage Subscription" color="white" small className="w-full text-emerald-700 font-black py-4 rounded-2xl shadow-xl hover:scale-[1.02] transition-transform" />
</div>
</div>
</CardBox>
</div>
</div>
</div>
);
};
const Dashboard = () => {
const dispatch = useAppDispatch();
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 [refresh_tokens, setRefresh_tokens] = React.useState(loadingMessage);
const [categories, setCategories] = React.useState(loadingMessage);
const [locations, setLocations] = React.useState(loadingMessage);
const [businesses, setBusinesses] = React.useState(loadingMessage);
const [business_photos, setBusiness_photos] = React.useState(loadingMessage);
const [business_categories, setBusiness_categories] = React.useState(loadingMessage);
const [service_prices, setService_prices] = React.useState(loadingMessage);
const [business_badges, setBusiness_badges] = React.useState(loadingMessage);
const [verification_submissions, setVerification_submissions] = React.useState(loadingMessage);
const [verification_evidences, setVerification_evidences] = React.useState(loadingMessage);
const [leads, setLeads] = React.useState(loadingMessage);
const [lead_photos, setLead_photos] = React.useState(loadingMessage);
const [lead_matches, setLead_matches] = React.useState(loadingMessage);
const [messages, setMessages] = React.useState(loadingMessage);
const [lead_events, setLead_events] = React.useState(loadingMessage);
const [reviews, setReviews] = React.useState(loadingMessage);
const [disputes, setDisputes] = React.useState(loadingMessage);
const [audit_logs, setAudit_logs] = React.useState(loadingMessage);
const [badge_rules, setBadge_rules] = React.useState(loadingMessage);
const [trust_adjustments, setTrust_adjustments] = React.useState(loadingMessage);
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner';
const myBusiness = currentUser?.businesses_owner_user?.[0];
async function loadData() {
const entities = ['users','roles','permissions','refresh_tokens','categories','locations','businesses','business_photos','business_categories','service_prices','business_badges','verification_submissions','verification_evidences','leads','lead_photos','lead_matches','messages','lead_events','reviews','disputes','audit_logs','badge_rules','trust_adjustments',];
const fns = [setUsers,setRoles,setPermissions,setRefresh_tokens,setCategories,setLocations,setBusinesses,setBusiness_photos,setBusiness_categories,setService_prices,setBusiness_badges,setVerification_submissions,setVerification_evidences,setLeads,setLead_photos,setLead_matches,setMessages,setLead_events,setReviews,setDisputes,setAudit_logs,setBadge_rules,setTrust_adjustments,];
const [loading, setLoading] = useState(true);
const [metrics, setMetrics] = useState<any>(null);
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 [counts, setCounts] = useState<any>({});
async function loadAdminData() {
const entities = ['users','roles','permissions','categories','locations','businesses'];
const requests = entities.map(entity => axios.get(`/${entity}/count`));
const results = await Promise.allSettled(requests);
const newCounts: any = {};
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
newCounts[entities[i]] = result.value.data.count;
}
});
Promise.allSettled(requests).then((results) => {
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
fns[i](result.value.data.count);
} else {
fns[i](result.reason.message);
}
});
});
}
async function getWidgets(roleId) {
await dispatch(fetchWidgets(roleId));
setCounts(newCounts);
}
React.useEffect(() => {
async function loadBusinessMetrics() {
try {
const response = await axios.get('/dashboard/business-metrics');
setMetrics(response.data);
} catch (err) {
console.error('Failed to load metrics', err);
} finally {
setLoading(false);
}
}
useEffect(() => {
if (!currentUser) return;
loadData().then();
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
}, [currentUser]);
if (isBusinessOwner) {
loadBusinessMetrics();
} else {
loadAdminData().then(() => setLoading(false));
}
}, [currentUser, isBusinessOwner]);
if (loading) {
return (
<SectionMain>
<div className="flex flex-col items-center justify-center h-96 animate-pulse">
<div className="w-16 h-16 bg-emerald-100 rounded-3xl flex items-center justify-center mb-4">
<BaseIcon path={icon.mdiLoading} size={32} className="animate-spin text-emerald-500" />
</div>
<div className="text-xs font-black text-slate-400 uppercase tracking-widest">Curating your experience...</div>
</div>
</SectionMain>
);
}
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value) return;
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
return (
<>
<Head>
<title>
{getPageTitle('Overview')}
</title>
<title>{getPageTitle('Beauty Studio Dashboard')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={icon.mdiChartTimelineVariant}
title={isBusinessOwner ? 'Business Dashboard' : 'Overview'}
main>
{isBusinessOwner && hasPermission(currentUser, 'CREATE_BUSINESSES') && (
<BaseButton
href="/businesses/businesses-new"
icon={icon.mdiPlus}
label="List New Business"
color="info"
/>
icon={icon.mdiStarFourPoints}
title={isBusinessOwner ? 'Beauty Studio Hub' : 'Network System Pulse'}
main
className="mb-8"
>
{isBusinessOwner && (
<div className="flex space-x-3">
<BaseButton label="Client Leads" color="info" icon={icon.mdiCalendarHeart} href="/leads/leads-list" small className="rounded-xl px-4 font-bold" />
<BaseButton label="Docs" color="white" icon={icon.mdiFileUploadOutline} href="/verification_submissions/verification_submissions-list" small className="rounded-xl border-slate-200 font-bold" />
</div>
)}
</SectionTitleLineWithButton>
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser}
isFetchingQuery={isFetchingQuery}
setWidgetsRole={setWidgetsRole}
widgetsRole={widgetsRole}
/>}
{!!rolesWidgets.length &&
hasPermission(currentUser, 'CREATE_ROLES') && (
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
</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>
)}
{ rolesWidgets &&
rolesWidgets.map((widget) => (
<SmartWidget
key={widget.id}
userId={currentUser?.id}
widget={widget}
roleId={widgetsRole?.role?.value || ''}
admin={hasPermission(currentUser, 'CREATE_ROLES')}
/>
))}
</div>
{!!rolesWidgets.length && <hr className='my-6 ' />}
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{isBusinessOwner && myBusiness && (
<Link href={`/businesses/businesses-edit/?id=${myBusiness.id}`}>
<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">
My Listing
</div>
<div className="text-xl leading-tight font-semibold mt-2">
{myBusiness.name}
</div>
<div className="text-sm text-blue-500 mt-2">Edit Listing</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
path={icon.mdiStoreEdit}
/>
</div>
</div>
{isBusinessOwner ? (
<BusinessDashboardView metrics={metrics} currentUser={currentUser} />
) : (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3 mb-10 animate-fade-in">
{Object.keys(counts).map(entity => (
<Link key={entity} href={`/${entity}/${entity}-list`}>
<CardBox className="hover:shadow-2xl hover:shadow-emerald-500/10 transition-all border-none group cursor-pointer p-6">
<div className="flex justify-between items-center">
<div>
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{entity.replace('_', ' ')}</div>
<div className="text-4xl font-black text-slate-800 dark:text-white group-hover:text-emerald-500 transition-colors">{counts[entity]}</div>
</div>
<div className="w-16 h-16 bg-slate-50 dark:bg-slate-800 rounded-2xl flex items-center justify-center group-hover:bg-emerald-50 transition-colors">
<BaseIcon path={icon.mdiLayersOutline} size={32} className={iconsColor} />
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_LEADS') && <Link href={'/leads/leads-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">
{isBusinessOwner ? 'Service Requests' : 'Leads'}
</div>
<div className="text-3xl leading-tight font-semibold">
{leads}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
path={'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_REVIEWS') && <Link href={'/reviews/reviews-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">
Reviews
</div>
<div className="text-3xl leading-tight font-semibold">
{reviews}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
path={'mdiStar' in icon ? icon['mdiStar' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{!isBusinessOwner && hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-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">
Users
</div>
<div className="text-3xl leading-tight font-semibold">
{users}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
path={icon.mdiAccountGroup || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{!isBusinessOwner && hasPermission(currentUser, 'READ_ROLES') && <Link href={'/roles/roles-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">
Roles
</div>
<div className="text-3xl leading-tight font-semibold">
{roles}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{!isBusinessOwner && 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 className="text-3xl leading-tight font-semibold">
{permissions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
path={icon.mdiShieldAccountOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{!isBusinessOwner && hasPermission(currentUser, 'READ_REFRESH_TOKENS') && <Link href={'/refresh_tokens/refresh_tokens-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">
Refresh tokens
</div>
<div className="text-3xl leading-tight font-semibold">
{refresh_tokens}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
path={'mdiLock' in icon ? icon['mdiLock' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{!isBusinessOwner && hasPermission(currentUser, 'READ_CATEGORIES') && <Link href={'/categories/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">
Categories
</div>
<div className="text-3xl leading-tight font-semibold">
{categories}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
path={'mdiShape' in icon ? icon['mdiShape' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{!isBusinessOwner && hasPermission(currentUser, 'READ_LOCATIONS') && <Link href={'/locations/locations-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">
Locations
</div>
<div className="text-3xl leading-tight font-semibold">
{locations}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
path={'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_BUSINESSES') && <Link href={'/businesses/businesses-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">
Businesses
</div>
<div className="text-3xl leading-tight font-semibold">
{businesses}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
path={'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
</div>
</CardBox>
</Link>
))}
</div>
)}
</SectionMain>
</>
)
@ -436,4 +324,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default Dashboard
export default Dashboard;

View File

@ -96,7 +96,7 @@ export default function Forgot() {
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
</div>
<span className="text-2xl font-black tracking-tight text-slate-900">
Crafted Network<span className="text-emerald-500 italic"></span>
Fix It Local<span className="text-emerald-500 italic"></span>
</span>
</Link>
<h2 className="text-3xl font-bold text-slate-900 text-center">Forgot Password?</h2>
@ -138,7 +138,7 @@ export default function Forgot() {
</CardBox>
<div className="text-center text-slate-400 text-xs pt-8">
© 2026 Crafted Network. All rights reserved. <br/>
© 2026 Fix It Local. All rights reserved. <br/>
<Link href='/privacy-policy/' className="hover:text-slate-600 mt-2 inline-block">Privacy Policy</Link>
</div>
</div>

View File

@ -53,7 +53,7 @@ export default function LandingPage() {
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
<Head>
<title>Crafted Network | 21st Century Service Directory</title>
<title>Fix It Local | 21st Century Service Directory</title>
<meta name="description" content="Connect with verified service professionals. Trust, transparency, and AI-powered matching." />
</Head>

View File

@ -1,4 +1,4 @@
import React, { ReactElement, useEffect } from 'react';
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
@ -14,8 +14,10 @@ import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant, mdiMessageReply} from "@mdi/js";
import {mdiChartTimelineVariant, mdiMessageReply, mdiAccountMultiple, mdiCamera, mdiTimelineText, mdiInformation} from "@mdi/js";
import FormField from "../../components/FormField";
import BaseIcon from "../../components/BaseIcon";
import ImageField from "../../components/ImageField";
const LeadsView = () => {
@ -27,12 +29,20 @@ const LeadsView = () => {
const { id } = router.query;
const [activeTab, setActiveTab] = useState('details');
useEffect(() => {
if (id) {
dispatch(fetch({ id }));
}
}, [dispatch, id]);
const tabs = [
{ id: 'details', label: 'Details', icon: mdiInformation },
{ id: 'photos', label: 'Photos', icon: mdiCamera },
{ id: 'matches', label: 'Matches', icon: mdiAccountMultiple },
{ id: 'events', label: 'Events', icon: mdiTimelineText },
];
return (
<>
@ -57,110 +67,211 @@ const LeadsView = () => {
/>
</div>
</SectionTitleLineWithButton>
<CardBox>
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>User</p>
<p>{leads?.user?.firstName} {leads?.user?.lastName}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Category</p>
<p>{leads?.category?.name ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Keyword</p>
<p>{leads?.keyword}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Urgency</p>
<p>{leads?.urgency ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Status</p>
<p>{leads?.status ?? 'No data'}</p>
</div>
</div>
<FormField label='Description' hasTextareaHeight>
<textarea className={'w-full'} disabled value={leads?.description} />
</FormField>
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Contact Name</p>
<p>{leads?.contact_name}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Contact Phone</p>
<p>{leads?.contact_phone}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Contact Email</p>
<p>{leads?.contact_email}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>City</p>
<p>{leads?.city}</p>
</div>
</div>
<FormField label='Address' hasTextareaHeight>
<textarea className={'w-full'} disabled value={leads?.address} />
</FormField>
{leads.messages_lead && leads.messages_lead.length > 0 && (
<>
<p className={'block font-bold mb-2 mt-8'}>Message History</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden shadow-sm'
hasTable
<div className="mb-6 flex space-x-4 border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center space-x-2 pb-2 px-1 transition-colors ${
activeTab === tab.id
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<div className='overflow-x-auto'>
<table className="w-full text-left">
<BaseIcon path={tab.icon} size={18} />
<span>{tab.label}</span>
</button>
))}
</div>
{activeTab === 'details' && (
<CardBox>
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>User</p>
<p>{leads?.user?.firstName} {leads?.user?.lastName}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Category</p>
<p>{leads?.category?.name ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Keyword</p>
<p>{leads?.keyword}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Urgency</p>
<p>{leads?.urgency ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Status</p>
<p>{leads?.status ?? 'No data'}</p>
</div>
</div>
<FormField label='Description' hasTextareaHeight>
<textarea className={'w-full'} disabled value={leads?.description} />
</FormField>
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Contact Name</p>
<p>{leads?.contact_name}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Contact Phone</p>
<p>{leads?.contact_phone}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Contact Email</p>
<p>{leads?.contact_email}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>City</p>
<p>{leads?.city}</p>
</div>
</div>
<FormField label='Address' hasTextareaHeight>
<textarea className={'w-full'} disabled value={leads?.address} />
</FormField>
{leads.messages_lead && leads.messages_lead.length > 0 && (
<>
<p className={'block font-bold mb-2 mt-8'}>Message History</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden shadow-sm'
hasTable
>
<div className='overflow-x-auto'>
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-xs uppercase text-gray-500 font-semibold">
<th className="px-4 py-3">Body</th>
<th className="px-4 py-3">Read At</th>
<th className="px-4 py-3">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{leads.messages_lead.map((item: any) => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer transition-colors" onClick={() => router.push(`/messages/messages-view/?id=${item.id}`)}>
<td className="px-4 py-3" data-label="body">
{ item.body }
</td>
<td className="px-4 py-3" data-label="read_at">
{ dataFormatter.dateTimeFormatter(item.read_at) }
</td>
<td className="px-4 py-3" data-label="created_at_ts">
{ dataFormatter.dateTimeFormatter(item.created_at_ts) }
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardBox>
</>
)}
<BaseDivider />
<BaseButton
color='info'
label='Back'
outline
onClick={() => router.push('/leads/leads-list')}
/>
</CardBox>
)}
{activeTab === 'photos' && (
<CardBox>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{leads.lead_photos_lead?.length > 0 ? (
leads.lead_photos_lead.map((photo: any) => (
<div key={photo.id} className="border border-gray-200 rounded p-2">
<ImageField value={photo.photos} />
</div>
))
) : (
<p className="text-gray-500 italic">No photos available.</p>
)}
</div>
</CardBox>
)}
{activeTab === 'matches' && (
<CardBox hasTable>
<div className='overflow-x-auto'>
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-xs uppercase text-gray-500 font-semibold">
<th className="px-4 py-3">Body</th>
<th className="px-4 py-3">Read At</th>
<th className="px-4 py-3">Date</th>
</tr>
<tr className="bg-gray-50 border-b border-gray-200 text-xs uppercase text-gray-500 font-semibold">
<th className="px-4 py-3">Business</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Matched At</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{leads.messages_lead.map((item: any) => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer transition-colors" onClick={() => router.push(`/messages/messages-view/?id=${item.id}`)}>
<td className="px-4 py-3" data-label="body">
{ item.body }
</td>
<td className="px-4 py-3" data-label="read_at">
{ dataFormatter.dateTimeFormatter(item.read_at) }
</td>
<td className="px-4 py-3" data-label="created_at_ts">
{ dataFormatter.dateTimeFormatter(item.created_at_ts) }
</td>
</tr>
))}
{leads.lead_matches_lead?.length > 0 ? (
leads.lead_matches_lead.map((match: any) => (
<tr key={match.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">{match.business?.name}</td>
<td className="px-4 py-3">{match.status}</td>
<td className="px-4 py-3">{dataFormatter.dateTimeFormatter(match.created_at_ts)}</td>
</tr>
))
) : (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-gray-500 italic">No matches found.</td>
</tr>
)}
</tbody>
</table>
</div>
</CardBox>
</>
)}
</div>
</CardBox>
)}
<BaseDivider />
{activeTab === 'events' && (
<CardBox hasTable>
<div className='overflow-x-auto'>
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-xs uppercase text-gray-500 font-semibold">
<th className="px-4 py-3">Event Type</th>
<th className="px-4 py-3">Actor</th>
<th className="px-4 py-3">Notes</th>
<th className="px-4 py-3">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{leads.lead_events_lead?.length > 0 ? (
leads.lead_events_lead.map((event: any) => (
<tr key={event.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">{event.event_type}</td>
<td className="px-4 py-3">{event.actor_user?.firstName}</td>
<td className="px-4 py-3">{event.notes}</td>
<td className="px-4 py-3">{dataFormatter.dateTimeFormatter(event.created_at_ts)}</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500 italic">No events found.</td>
</tr>
)}
</tbody>
</table>
</div>
</CardBox>
)}
<BaseButton
color='info'
label='Back'
outline
onClick={() => router.push('/leads/leads-list')}
/>
</CardBox>
</SectionMain>
</>
);
@ -176,4 +287,4 @@ LeadsView.getLayout = function getLayout(page: ReactElement) {
)
}
export default LeadsView;
export default LeadsView;

View File

@ -43,7 +43,7 @@ export default function Login() {
password: 'b2096650',
remember: true })
const title = 'Crafted Network'
const title = 'Fix It Local'
// Fetch Pexels image/video
useEffect( () => {
@ -175,7 +175,7 @@ export default function Login() {
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
</div>
<span className="text-2xl font-black tracking-tight text-slate-900">
Crafted Network<span className="text-emerald-500 italic"></span>
Fix It Local<span className="text-emerald-500 italic"></span>
</span>
</Link>
<h2 className="text-3xl font-bold text-slate-900">Account Login</h2>
@ -270,7 +270,7 @@ export default function Login() {
</div>
<div className="text-center text-slate-400 text-xs pt-8">
© 2026 Crafted Network. All rights reserved. <br/>
© 2026 Fix It Local. All rights reserved. <br/>
<Link href='/privacy-policy/' className="hover:text-slate-600 mt-2 inline-block">Privacy Policy</Link>
</div>
</div>

View File

@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
export default function PrivacyPolicy() {
const title = 'Crafted Network'
const title = 'Fix It Local'
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {

View File

@ -31,6 +31,7 @@ const BusinessDetailsPublic = () => {
useEffect(() => {
if (id) {
fetchBusiness();
recordEvent('VIEW');
}
}, [id]);
@ -46,6 +47,20 @@ const BusinessDetailsPublic = () => {
}
};
const recordEvent = async (type: string) => {
if (!id) return;
try {
await axios.post('/dashboard/record-event', {
businessId: id,
event_type: type,
metadata: { path: router.asPath }
});
} catch (error) {
// Silently fail for analytics
console.error('Failed to record event', error);
}
};
const claimListing = async () => {
if (!currentUser) {
router.push('/login');
@ -90,7 +105,7 @@ const BusinessDetailsPublic = () => {
return (
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
<Head>
<title>{business.name} | Crafted Network</title>
<title>{business.name} | Fix It Local</title>
</Head>
{/* Hero Header */}
@ -334,7 +349,11 @@ const BusinessDetailsPublic = () => {
<BaseIcon path={mdiPhone} size={24} className="mr-4 text-emerald-400" />
<div>
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Call Now</div>
<div className="font-bold">{business.phone || 'Contact for details'}</div>
<div className="font-bold">
<a href={`tel:${business.phone}`} onClick={() => recordEvent('CALL_CLICK')}>
{business.phone || 'Contact for details'}
</a>
</div>
</div>
</div>
<div className="flex items-start">
@ -348,7 +367,11 @@ const BusinessDetailsPublic = () => {
<BaseIcon path={mdiWeb} size={24} className="mr-4 text-emerald-400" />
<div>
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Website</div>
<div className="font-bold truncate max-w-[180px]">{business.website || 'N/A'}</div>
<div className="font-bold truncate max-w-[180px]">
<a href={business.website} target="_blank" rel="noopener noreferrer" onClick={() => recordEvent('WEBSITE_CLICK')}>
{business.website || 'N/A'}
</a>
</div>
</div>
</div>
<div className="flex items-start">
@ -402,4 +425,4 @@ BusinessDetailsPublic.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
export default BusinessDetailsPublic;
export default BusinessDetailsPublic;

View File

@ -65,7 +65,7 @@ const RequestServicePage = () => {
return (
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
<Head>
<title>Request Service | Crafted Network</title>
<title>Request Service | Fix It Local</title>
</Head>
<div className="container mx-auto px-6 max-w-4xl">

View File

@ -139,7 +139,7 @@ export default function Register() {
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
</div>
<span className="text-2xl font-black tracking-tight text-slate-900">
Crafted Network<span className="text-emerald-500 italic"></span>
Fix It Local<span className="text-emerald-500 italic"></span>
</span>
</Link>
<h2 className="text-3xl font-bold text-slate-900">Create Account</h2>
@ -213,7 +213,7 @@ export default function Register() {
<div className="text-center text-slate-400 text-xs pt-8">
By creating an account, you agree to our <Link href='/terms-of-use' className="underline">Terms</Link> and <Link href='/privacy-policy' className="underline">Privacy Policy</Link>. <br />
© 2026 Crafted Network. All rights reserved.
© 2026 Fix It Local. All rights reserved.
</div>
</div>
</div>

View File

@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
export default function PrivacyPolicy() {
const title = 'Crafted Network';
const title = 'Fix It Local';
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {

View File

@ -28,26 +28,26 @@ export const white: StyleObject = {
aside: 'bg-white dark:text-white',
asideScrollbars: 'aside-scrollbars-light',
asideBrand: '',
asideMenuItem: 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800',
asideMenuItemActive: 'font-bold text-black dark:text-white',
asideMenuDropdown: 'bg-gray-100/75',
navBarItemLabel: 'text-blue-600',
navBarItemLabelHover: 'hover:text-black',
navBarItemLabelActiveColor: 'text-black',
overlay: 'from-white via-gray-100 to-white',
activeLinkColor: 'bg-gray-100/70',
bgLayoutColor: 'bg-gray-50',
iconsColor: 'text-blue-500',
asideMenuItem: 'text-slate-600 hover:bg-emerald-50/50 hover:text-emerald-600 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800 transition-colors',
asideMenuItemActive: 'font-bold text-emerald-600 bg-emerald-50/70 dark:text-white',
asideMenuDropdown: 'bg-emerald-50/30',
navBarItemLabel: 'text-emerald-600',
navBarItemLabelHover: 'hover:text-emerald-700',
navBarItemLabelActiveColor: 'text-emerald-700',
overlay: 'from-white via-emerald-50 to-white',
activeLinkColor: 'bg-emerald-50/70',
bgLayoutColor: 'bg-slate-50',
iconsColor: 'text-emerald-500',
cardsColor: 'bg-white',
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600',
corners: 'rounded',
cardsStyle: 'bg-white border border-pavitra-400',
linkColor: 'text-blue-600',
websiteHeder: 'border-b border-gray-200',
borders: 'border-gray-200',
shadow: '',
focusRingColor: 'focus:ring focus:ring-emerald-600 focus:border-emerald-600 focus:outline-none border-slate-300 dark:focus:ring-emerald-600 dark:focus:border-emerald-600',
corners: 'rounded-3xl',
cardsStyle: 'bg-white border border-slate-200 shadow-sm',
linkColor: 'text-emerald-600',
websiteHeder: 'border-b border-slate-200',
borders: 'border-slate-200',
shadow: 'shadow-xl shadow-emerald-500/5',
websiteSectionStyle: '',
textSecondary: 'text-gray-500',
textSecondary: 'text-slate-500',
}
@ -67,41 +67,45 @@ export const dataGridStyles = {
},
'& .MuiDataGrid-columnHeaders': {
paddingY: 4,
borderStartStartRadius: 7,
borderStartEndRadius: 7,
borderStartStartRadius: 24,
borderStartEndRadius: 24,
backgroundColor: '#f8fafc',
},
'& .MuiDataGrid-footerContainer': {
paddingY: 0.5,
borderEndStartRadius: 7,
borderEndEndRadius: 7,
borderEndStartRadius: 24,
borderEndEndRadius: 24,
},
'& .MuiDataGrid-root': {
border: 'none',
borderRadius: '24px',
backgroundColor: '#ffffff',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
},
};
export const basic: StyleObject = {
aside: 'bg-gray-800',
aside: 'bg-slate-900',
asideScrollbars: 'aside-scrollbars-gray',
asideBrand: 'bg-gray-900 text-white',
asideMenuItem: 'text-gray-300 hover:text-white',
asideMenuItemActive: 'font-bold text-white',
asideMenuDropdown: 'bg-gray-700/50',
navBarItemLabel: 'text-black',
navBarItemLabelHover: 'hover:text-blue-500',
navBarItemLabelActiveColor: 'text-blue-600',
overlay: 'from-gray-700 via-gray-900 to-gray-700',
activeLinkColor: 'bg-gray-100/70',
bgLayoutColor: 'bg-gray-50',
iconsColor: 'text-blue-500',
asideBrand: 'bg-slate-950 text-white',
asideMenuItem: 'text-slate-300 hover:text-white hover:bg-white/5 transition-colors',
asideMenuItemActive: 'font-bold text-white bg-emerald-600',
asideMenuDropdown: 'bg-slate-800/50',
navBarItemLabel: 'text-slate-900',
navBarItemLabelHover: 'hover:text-emerald-500',
navBarItemLabelActiveColor: 'text-emerald-600',
overlay: 'from-slate-700 via-slate-900 to-slate-700',
activeLinkColor: 'bg-emerald-500/10',
bgLayoutColor: 'bg-slate-50',
iconsColor: 'text-emerald-500',
cardsColor: 'bg-white',
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600',
corners: 'rounded',
cardsStyle: 'bg-white border border-pavitra-400',
linkColor: 'text-black',
focusRingColor: 'focus:ring focus:ring-emerald-600 focus:border-emerald-600 focus:outline-none dark:focus:ring-emerald-600 border-slate-300 dark:focus:border-emerald-600',
corners: 'rounded-3xl',
cardsStyle: 'bg-white border border-slate-200 shadow-sm',
linkColor: 'text-slate-900',
websiteHeder: '',
borders: '',
shadow: '',
shadow: 'shadow-lg shadow-emerald-500/10',
websiteSectionStyle: '',
textSecondary: '',
}
textSecondary: 'text-slate-500',
}