Compare commits

...

8 Commits

Author SHA1 Message Date
Flatlogic Bot
ff6518916f almost complete 2026-02-18 16:31:33 +00:00
Flatlogic Bot
111693ae6e Autosave: 20260218-134704 2026-02-18 13:47:05 +00:00
Flatlogic Bot
b03b911e99 111 2026-02-18 13:28:04 +00:00
Flatlogic Bot
f7df7e331e Autosave: 20260218-034325 2026-02-18 03:43:25 +00:00
Flatlogic Bot
a004fcd820 Autosave: 20260218-021506 2026-02-18 02:15:06 +00:00
Flatlogic Bot
15445f0bc1 Revert to version 777bc71 2026-02-17 23:52:44 +00:00
Flatlogic Bot
2304edcdf3 Autosave: 20260217-233305 2026-02-17 23:33:06 +00:00
Flatlogic Bot
777bc713b3 Autosave: 20260217-210907 2026-02-17 21:09:07 +00:00
105 changed files with 7559 additions and 16405 deletions

View File

@ -129,7 +129,7 @@
<p class="tip">The application is currently launching. The page will automatically refresh once site is <p class="tip">The application is currently launching. The page will automatically refresh once site is
available.</p> available.</p>
<div class="project-info"> <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> <p>Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking.</p>
</div> </div>
<div class="loader-container"> <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). ## This project was generated by [Flatlogic Platform](https://flatlogic.com).

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

View File

@ -12,3 +12,4 @@ EMAIL_USER=AKIAVEW7G4PQUBGM52OF
EMAIL_PASS=BLnD4hKGb6YkSz3gaQrf8fnyLi3C3/EdjOOsLEDTDPTz EMAIL_PASS=BLnD4hKGb6YkSz3gaQrf8fnyLi3C3/EdjOOsLEDTDPTz
SECRET_KEY=HUEyqESqgQ1yTwzVlO6wprC9Kf1J1xuA SECRET_KEY=HUEyqESqgQ1yTwzVlO6wprC9Kf1J1xuA
PEXELS_KEY=Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18 PEXELS_KEY=Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18
GOOGLE_PLACES_API_KEY=AIzaSyDZlhJAIi-qFiy93MIiaYciCq28bZl6Y3Y

View File

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

View File

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

View File

@ -1,6 +1,3 @@
const os = require('os'); const os = require('os');
const config = { const config = {
@ -32,6 +29,7 @@ const config = {
google: { google: {
clientId: process.env.GOOGLE_CLIENT_ID || '', clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
placesApiKey: process.env.GOOGLE_PLACES_API_KEY || 'AIzaSyDZlhJAIi-qFiy93MIiaYciCq28bZl6Y3Y',
}, },
microsoft: { microsoft: {
clientId: process.env.MS_CLIENT_ID || '', clientId: process.env.MS_CLIENT_ID || '',
@ -39,7 +37,7 @@ const config = {
}, },
uploadDir: os.tmpdir(), uploadDir: os.tmpdir(),
email: { email: {
from: 'Crafted Network <app@flatlogic.app>', from: 'Fix-It-Local <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com', host: 'email-smtp.us-east-1.amazonaws.com',
port: 587, port: 587,
auth: { auth: {
@ -69,7 +67,7 @@ const config = {
config.pexelsKey = process.env.PEXELS_KEY || ''; config.pexelsKey = process.env.PEXELS_KEY || '';
config.pexelsQuery = 'Crafted bridge over calm river'; config.pexelsQuery = 'home repair services';
config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost"; config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost";
config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`; config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`;
config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`; config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`;

View File

@ -1,18 +1,12 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto'); const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
module.exports = class Business_badgesDBApi { module.exports = class Business_badgesDBApi {
static async create(data, options) { static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
@ -20,493 +14,63 @@ module.exports = class Business_badgesDBApi {
const business_badges = await db.business_badges.create( const business_badges = await db.business_badges.create(
{ {
id: data.id || undefined, id: data.id || undefined,
badge_type: data.badge_type || null,
badge_type: data.badge_type status: data.status || null,
|| granted_at: data.granted_at || null,
null revoked_at: data.revoked_at || null,
, notes: data.notes || null,
created_at_ts: data.created_at_ts || null,
status: data.status updated_at_ts: data.updated_at_ts || null,
|| importHash: data.importHash || null,
null createdById: currentUser.id,
, updatedById: currentUser.id,
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,
},
}, },
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 }, { transaction },
); );
if (!business_badges) { await business_badges.setBusiness( data.business || null, { transaction });
return business_badges;
}
const output = business_badges.get({plain: true}); return business_badges;
output.business = await business_badges.getBusiness({
transaction
});
return output;
} }
static async findAll( static async findAll(filter, options) {
filter,
options
) {
const limit = filter.limit || 0; const limit = filter.limit || 0;
let offset = 0; let offset = 0;
let where = {}; let where = {};
const currentPage = +filter.page; const currentPage = +filter.page;
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null; const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined; // Data Isolation
if (currentUser && currentUser.app_role) {
let include = [ const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
{ if (currentUser.businessId) {
model: db.businesses, where.businessId = currentUser.businessId;
as: 'business', } else {
where['$business.owner_userId$'] = currentUser.id;
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,
},
};
} }
} }
} }
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 = { const queryOptions = {
where, where,
@ -515,8 +79,7 @@ module.exports = class Business_badgesDBApi {
order: filter.field && filter.sort order: filter.field && filter.sort
? [[filter.field, filter.sort]] ? [[filter.field, filter.sort]]
: [['createdAt', 'desc']], : [['createdAt', 'desc']],
transaction: options?.transaction, transaction,
logging: console.log
}; };
if (!options?.countOnly) { if (!options?.countOnly) {
@ -524,51 +87,80 @@ module.exports = class Business_badgesDBApi {
queryOptions.offset = offset ? Number(offset) : undefined; 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 { return {
rows: options?.countOnly ? [] : rows, rows: options?.countOnly ? [] : rows,
count: count count: count
}; };
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
} }
static async findAllAutocomplete(query, limit, offset, ) { 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 = {}; let where = {};
if (query) { if (query) {
where = { where = {
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike(
'business_badges',
'notes',
query,
),
], ],
}; };
} }
const records = await db.business_badges.findAll({ const records = await db.business_badges.findAll({
attributes: [ 'id', 'notes' ], attributes: [ 'id' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['notes', 'ASC']], order: [['createdAt', 'desc']],
}); });
return records.map((record) => ({ return records.map((record) => ({
id: record.id, id: record.id,
label: record.notes, label: record.id,
})); }));
} }
}; };

View File

@ -1,18 +1,12 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto'); const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
module.exports = class Business_categoriesDBApi { module.exports = class Business_categoriesDBApi {
static async create(data, options) { static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
@ -20,355 +14,68 @@ module.exports = class Business_categoriesDBApi {
const business_categories = await db.business_categories.create( const business_categories = await db.business_categories.create(
{ {
id: data.id || undefined, id: data.id || undefined,
created_at_ts: data.created_at_ts || null,
created_at_ts: data.created_at_ts importHash: data.importHash || null,
|| createdById: currentUser.id,
null updatedById: currentUser.id,
,
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,
},
}, },
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 }, { transaction },
); );
if (!business_categories) { await business_categories.setBusiness( data.business || null, { transaction });
return business_categories; await business_categories.setCategory( data.category || null, { transaction });
}
const output = business_categories.get({plain: true}); return business_categories;
output.business = await business_categories.getBusiness({
transaction
});
output.category = await business_categories.getCategory({
transaction
});
return output;
} }
static async findAll( static async findAll(filter, options) {
filter,
options
) {
const limit = filter.limit || 0; const limit = filter.limit || 0;
let offset = 0; let offset = 0;
let where = {}; let where = {};
const currentPage = +filter.page; const currentPage = +filter.page;
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null; const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined; // Data Isolation
if (currentUser && currentUser.app_role) {
let include = [ const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
{ if (currentUser.businessId) {
model: db.businesses, where.businessId = currentUser.businessId;
as: 'business', } else {
where['$business.owner_userId$'] = currentUser.id;
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,
},
};
} }
} }
} }
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 = { const queryOptions = {
where, where,
@ -377,8 +84,7 @@ module.exports = class Business_categoriesDBApi {
order: filter.field && filter.sort order: filter.field && filter.sort
? [[filter.field, filter.sort]] ? [[filter.field, filter.sort]]
: [['createdAt', 'desc']], : [['createdAt', 'desc']],
transaction: options?.transaction, transaction,
logging: console.log
}; };
if (!options?.countOnly) { if (!options?.countOnly) {
@ -386,51 +92,83 @@ module.exports = class Business_categoriesDBApi {
queryOptions.offset = offset ? Number(offset) : undefined; 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 { return {
rows: options?.countOnly ? [] : rows, rows: options?.countOnly ? [] : rows,
count: count count: count
}; };
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
} }
static async findAllAutocomplete(query, limit, offset, ) { 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 = {}; let where = {};
if (query) { if (query) {
where = { where = {
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike(
'business_categories',
'created_at_ts',
query,
),
], ],
}; };
} }
const records = await db.business_categories.findAll({ const records = await db.business_categories.findAll({
attributes: [ 'id', 'created_at_ts' ], attributes: [ 'id' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['created_at_ts', 'ASC']], order: [['createdAt', 'desc']],
}); });
return records.map((record) => ({ return records.map((record) => ({
id: record.id, id: record.id,
label: record.created_at_ts, label: record.id,
})); }));
} }
}; };

View File

@ -1,18 +1,12 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto'); const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
module.exports = class Business_photosDBApi { module.exports = class Business_photosDBApi {
static async create(data, options) { static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
@ -20,360 +14,70 @@ module.exports = class Business_photosDBApi {
const business_photos = await db.business_photos.create( const business_photos = await db.business_photos.create(
{ {
id: data.id || undefined, id: data.id || undefined,
created_at_ts: data.created_at_ts || null,
created_at_ts: data.created_at_ts importHash: data.importHash || null,
|| createdById: currentUser.id,
null updatedById: currentUser.id,
,
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,
}, },
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 }, { transaction },
); );
if (!business_photos) { await business_photos.setBusiness( data.business || null, { transaction });
return business_photos; await FileDBApi.replaceRelationFiles(
} {
belongsTo: db.business_photos.getTableName(),
belongsToColumn: 'photos',
belongsToId: business_photos.id,
},
data.photos,
options,
);
const output = business_photos.get({plain: true}); return business_photos;
output.business = await business_photos.getBusiness({
transaction
});
output.photos = await business_photos.getPhotos({
transaction
});
return output;
} }
static async findAll( static async findAll(filter, options) {
filter,
options
) {
const limit = filter.limit || 0; const limit = filter.limit || 0;
let offset = 0; let offset = 0;
let where = {}; let where = {};
const currentPage = +filter.page; const currentPage = +filter.page;
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null; const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined; // Data Isolation
if (currentUser && currentUser.app_role) {
let include = [ const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
{ if (currentUser.businessId) {
model: db.businesses, where.businessId = currentUser.businessId;
as: 'business', } else {
where['$business.owner_userId$'] = currentUser.id;
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,
},
};
} }
} }
} }
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 = { const queryOptions = {
where, where,
@ -382,8 +86,7 @@ module.exports = class Business_photosDBApi {
order: filter.field && filter.sort order: filter.field && filter.sort
? [[filter.field, filter.sort]] ? [[filter.field, filter.sort]]
: [['createdAt', 'desc']], : [['createdAt', 'desc']],
transaction: options?.transaction, transaction,
logging: console.log
}; };
if (!options?.countOnly) { if (!options?.countOnly) {
@ -391,51 +94,91 @@ module.exports = class Business_photosDBApi {
queryOptions.offset = offset ? Number(offset) : undefined; 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 { return {
rows: options?.countOnly ? [] : rows, rows: options?.countOnly ? [] : rows,
count: count count: count
}; };
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
} }
static async findAllAutocomplete(query, limit, offset, ) { 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 = {}; let where = {};
if (query) { if (query) {
where = { where = {
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike(
'business_photos',
'photos',
query,
),
], ],
}; };
} }
const records = await db.business_photos.findAll({ const records = await db.business_photos.findAll({
attributes: [ 'id', 'photos' ], attributes: [ 'id' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['photos', 'ASC']], order: [['createdAt', 'desc']],
}); });
return records.map((record) => ({ return records.map((record) => ({
id: record.id, id: record.id,
label: record.photos, label: record.id,
})); }));
} }
}; };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,70 @@
const db = require('../models');
module.exports = class Claim_requestsDBApi {
static async create(data, { currentUser, transaction }) {
const claim_request = await db.claim_requests.create(
{
businessId: data.businessId,
userId: data.userId,
status: data.status || 'PENDING',
createdById: currentUser?.id,
},
{ transaction },
);
return claim_request;
}
static async update(id, data, { currentUser, transaction }) {
const claim_request = await db.claim_requests.findByPk(id, { transaction });
if (!claim_request) throw new Error('Claim request not found');
await claim_request.update(
{
...data,
updatedById: currentUser?.id,
},
{ transaction },
);
return claim_request;
}
static async findBy(where, options = {}) {
return await db.claim_requests.findOne({
where,
include: [
{ model: db.businesses, as: 'business' },
{ model: db.users, as: 'user' },
],
...options,
});
}
static async findAll(query = {}, { currentUser } = {}) {
const { limit, offset, filter } = query;
const where = {};
// Support direct query params
if (query.userId) where.userId = query.userId;
if (query.status) where.status = query.status;
if (query.businessId) where.businessId = query.businessId;
if (filter) {
// Support filter object if provided
if (filter.userId) where.userId = filter.userId;
if (filter.status) where.status = filter.status;
if (filter.businessId) where.businessId = filter.businessId;
}
const { rows, count } = await db.claim_requests.findAndCountAll({
where,
include: [
{ model: db.businesses, as: 'business' },
{ model: db.users, as: 'user' },
],
limit: limit ? parseInt(limit) : undefined,
offset: offset ? parseInt(offset) : undefined,
order: [['createdAt', 'DESC']],
});
return { rows, count };
}
};

View File

@ -1,752 +1,103 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto'); const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
module.exports = class Lead_matchesDBApi { module.exports = class Lead_matchesDBApi {
static async findAll(filter, options) {
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const lead_matches = await db.lead_matches.create(
{
id: data.id || undefined,
match_score: data.match_score
||
null
,
status: data.status
||
null
,
sent_at: data.sent_at
||
null
,
viewed_at: data.viewed_at
||
null
,
responded_at: data.responded_at
||
null
,
scheduled_at: data.scheduled_at
||
null
,
completed_at: data.completed_at
||
null
,
declined_at: data.declined_at
||
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 lead_matches.setLead( data.lead || null, {
transaction,
});
await lead_matches.setBusiness( data.business || null, {
transaction,
});
return lead_matches;
}
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 lead_matchesData = data.map((item, index) => ({
id: item.id || undefined,
match_score: item.match_score
||
null
,
status: item.status
||
null
,
sent_at: item.sent_at
||
null
,
viewed_at: item.viewed_at
||
null
,
responded_at: item.responded_at
||
null
,
scheduled_at: item.scheduled_at
||
null
,
completed_at: item.completed_at
||
null
,
declined_at: item.declined_at
||
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 lead_matches = await db.lead_matches.bulkCreate(lead_matchesData, { transaction });
// For each item created, replace relation files
return lead_matches;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const lead_matches = await db.lead_matches.findByPk(id, {}, {transaction});
const updatePayload = {};
if (data.match_score !== undefined) updatePayload.match_score = data.match_score;
if (data.status !== undefined) updatePayload.status = data.status;
if (data.sent_at !== undefined) updatePayload.sent_at = data.sent_at;
if (data.viewed_at !== undefined) updatePayload.viewed_at = data.viewed_at;
if (data.responded_at !== undefined) updatePayload.responded_at = data.responded_at;
if (data.scheduled_at !== undefined) updatePayload.scheduled_at = data.scheduled_at;
if (data.completed_at !== undefined) updatePayload.completed_at = data.completed_at;
if (data.declined_at !== undefined) updatePayload.declined_at = data.declined_at;
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 lead_matches.update(updatePayload, {transaction});
if (data.lead !== undefined) {
await lead_matches.setLead(
data.lead,
{ transaction }
);
}
if (data.business !== undefined) {
await lead_matches.setBusiness(
data.business,
{ transaction }
);
}
return lead_matches;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const lead_matches = await db.lead_matches.findAll({
where: {
id: {
[Op.in]: ids,
},
},
transaction,
});
await db.sequelize.transaction(async (transaction) => {
for (const record of lead_matches) {
await record.update(
{deletedBy: currentUser.id},
{transaction}
);
}
for (const record of lead_matches) {
await record.destroy({transaction});
}
});
return lead_matches;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const lead_matches = await db.lead_matches.findByPk(id, options);
await lead_matches.update({
deletedBy: currentUser.id
}, {
transaction,
});
await lead_matches.destroy({
transaction
});
return lead_matches;
}
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const lead_matches = await db.lead_matches.findOne(
{ where },
{ transaction },
);
if (!lead_matches) {
return lead_matches;
}
const output = lead_matches.get({plain: true});
output.lead = await lead_matches.getLead({
transaction
});
output.business = await lead_matches.getBusiness({
transaction
});
return output;
}
static async findAll(
filter,
options
) {
const limit = filter.limit || 0; const limit = filter.limit || 0;
let offset = 0; let offset = 0;
let where = {}; let where = {};
const currentPage = +filter.page; const currentPage = +filter.page;
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null; const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined;
const transaction = (options && options.transaction) || undefined; // Data Isolation for Fix-It-Local™
if (currentUser && currentUser.app_role) {
let include = [ const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
{ // Business owners only see matches for THEIR businesses
model: db.leads, where['$business.owner_userId$'] = currentUser.id;
as: 'lead', } else if (roleName === 'Consumer') {
// Consumers only see matches for THEIR leads
where: filter.lead ? { where['$lead.userId$'] = currentUser.id;
[Op.or]: [
{ id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } },
{
keyword: {
[Op.or]: filter.lead.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
{
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.match_scoreRange) {
const [start, end] = filter.match_scoreRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
match_score: {
...where.match_score,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
match_score: {
...where.match_score,
[Op.lte]: end,
},
};
}
}
if (filter.sent_atRange) {
const [start, end] = filter.sent_atRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
sent_at: {
...where.sent_at,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
sent_at: {
...where.sent_at,
[Op.lte]: end,
},
};
}
}
if (filter.viewed_atRange) {
const [start, end] = filter.viewed_atRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
viewed_at: {
...where.viewed_at,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
viewed_at: {
...where.viewed_at,
[Op.lte]: end,
},
};
}
}
if (filter.responded_atRange) {
const [start, end] = filter.responded_atRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
responded_at: {
...where.responded_at,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
responded_at: {
...where.responded_at,
[Op.lte]: end,
},
};
}
}
if (filter.scheduled_atRange) {
const [start, end] = filter.scheduled_atRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
scheduled_at: {
...where.scheduled_at,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
scheduled_at: {
...where.scheduled_at,
[Op.lte]: end,
},
};
}
}
if (filter.completed_atRange) {
const [start, end] = filter.completed_atRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
completed_at: {
...where.completed_at,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
completed_at: {
...where.completed_at,
[Op.lte]: end,
},
};
}
}
if (filter.declined_atRange) {
const [start, end] = filter.declined_atRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
declined_at: {
...where.declined_at,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
declined_at: {
...where.declined_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.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,
},
};
}
} }
} }
let include = [
{ model: db.leads, as: 'lead' },
{ model: db.businesses, as: 'business' }
];
if (filter) {
if (filter.id) where.id = Utils.uuid(filter.id);
if (filter.status) where.status = filter.status;
}
const queryOptions = { const queryOptions = {
where, where,
include, include,
distinct: true, distinct: true,
order: filter.field && filter.sort limit: options?.countOnly ? undefined : (limit ? Number(limit) : undefined),
? [[filter.field, filter.sort]] offset: options?.countOnly ? undefined : (offset ? Number(offset) : undefined),
: [['createdAt', 'desc']], order: [['createdAt', 'desc']],
transaction: options?.transaction, transaction
logging: console.log
}; };
if (!options?.countOnly) { const { rows, count } = await db.lead_matches.findAndCountAll(queryOptions);
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try { return {
const { rows, count } = await db.lead_matches.findAndCountAll(queryOptions); rows: options?.countOnly ? [] : rows,
count: count
return { };
rows: options?.countOnly ? [] : rows,
count: count
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
} }
static async findAllAutocomplete(query, limit, offset, ) { static async findBy(where, options) {
let where = {}; const transaction = (options && options.transaction) || undefined;
const lead_matches = await db.lead_matches.findOne({
if (query) {
where = {
[Op.or]: [
{ ['id']: Utils.uuid(query) },
Utils.ilike(
'lead_matches',
'status',
query,
),
],
};
}
const records = await db.lead_matches.findAll({
attributes: [ 'id', 'status' ],
where, where,
limit: limit ? Number(limit) : undefined, include: [
offset: offset ? Number(offset) : undefined, { model: db.leads, as: 'lead' },
orderBy: [['status', 'ASC']], { model: db.businesses, as: 'business' }
],
transaction
}); });
return lead_matches ? lead_matches.get({plain: true}) : null;
return records.map((record) => ({
id: record.id,
label: record.status,
}));
} }
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const record = await db.lead_matches.findByPk(id, {transaction});
if (!record) return null;
const updatePayload = { ...data, updatedById: currentUser.id };
await record.update(updatePayload, {transaction});
return record;
}
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const record = await db.lead_matches.create({
...data,
createdById: currentUser.id,
updatedById: currentUser.id
}, { transaction });
return record;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const record = await db.lead_matches.findByPk(id, options);
await record.update({ deletedBy: currentUser.id }, { transaction });
await record.destroy({ transaction });
return record;
}
}; };

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,12 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto'); const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
module.exports = class MessagesDBApi { module.exports = class MessagesDBApi {
static async create(data, options) { static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
@ -20,463 +14,115 @@ module.exports = class MessagesDBApi {
const messages = await db.messages.create( const messages = await db.messages.create(
{ {
id: data.id || undefined, id: data.id || undefined,
body: data.body || null,
body: data.body read_at: data.read_at || null,
|| created_at_ts: data.created_at_ts || null,
null importHash: data.importHash || null,
, createdById: currentUser.id,
updatedById: currentUser.id,
read_at: data.read_at
||
null
,
created_at_ts: data.created_at_ts
||
null
,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await messages.setLead( data.lead || null, {
transaction,
});
await messages.setSender_user( data.sender_user || null, {
transaction,
});
await messages.setReceiver_user( data.receiver_user || null, {
transaction,
});
return messages;
}
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 messagesData = data.map((item, index) => ({
id: item.id || undefined,
body: item.body
||
null
,
read_at: item.read_at
||
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 messages = await db.messages.bulkCreate(messagesData, { transaction });
// For each item created, replace relation files
return messages;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const messages = await db.messages.findByPk(id, {}, {transaction});
const updatePayload = {};
if (data.body !== undefined) updatePayload.body = data.body;
if (data.read_at !== undefined) updatePayload.read_at = data.read_at;
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
updatePayload.updatedById = currentUser.id;
await messages.update(updatePayload, {transaction});
if (data.lead !== undefined) {
await messages.setLead(
data.lead,
{ transaction }
);
}
if (data.sender_user !== undefined) {
await messages.setSender_user(
data.sender_user,
{ transaction }
);
}
if (data.receiver_user !== undefined) {
await messages.setReceiver_user(
data.receiver_user,
{ transaction }
);
}
return messages;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const messages = await db.messages.findAll({
where: {
id: {
[Op.in]: ids,
},
}, },
transaction,
});
await db.sequelize.transaction(async (transaction) => {
for (const record of messages) {
await record.update(
{deletedBy: currentUser.id},
{transaction}
);
}
for (const record of messages) {
await record.destroy({transaction});
}
});
return messages;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const messages = await db.messages.findByPk(id, options);
await messages.update({
deletedBy: currentUser.id
}, {
transaction,
});
await messages.destroy({
transaction
});
return messages;
}
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const messages = await db.messages.findOne(
{ where },
{ transaction }, { transaction },
); );
if (!messages) { await messages.setLead( data.lead || null, { transaction });
return messages; await messages.setSender_user( data.sender_user || currentUser.id, { transaction });
} await messages.setReceiver_user( data.receiver_user || null, { transaction });
const output = messages.get({plain: true}); return messages;
output.lead = await messages.getLead({
transaction
});
output.sender_user = await messages.getSender_user({
transaction
});
output.receiver_user = await messages.getReceiver_user({
transaction
});
return output;
} }
static async findAll( static async findAll(filter, options) {
filter,
options
) {
const limit = filter.limit || 0; const limit = filter.limit || 0;
let offset = 0; let offset = 0;
let where = {}; let where = {};
const currentPage = +filter.page; const currentPage = +filter.page;
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null; const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined; // Data Isolation
if (currentUser && currentUser.app_role) {
let include = [ const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
{ const businessId = currentUser.businessId;
model: db.leads, if (businessId) {
as: 'lead', where[Op.or] = [
{ sender_userId: currentUser.id },
where: filter.lead ? { { receiver_userId: currentUser.id },
[Op.or]: [ { '$lead.lead_matches_lead.businessId$': businessId }
{ id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } }, ];
{ } else {
keyword: { where[Op.or] = [
[Op.or]: filter.lead.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) { sender_userId: currentUser.id },
} { receiver_userId: currentUser.id },
}, { '$lead.lead_matches_lead.business.owner_userId$': currentUser.id }
] ];
} : {},
},
{
model: db.users,
as: 'sender_user',
where: filter.sender_user ? {
[Op.or]: [
{ id: { [Op.in]: filter.sender_user.split('|').map(term => Utils.uuid(term)) } },
{
firstName: {
[Op.or]: filter.sender_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
{
model: db.users,
as: 'receiver_user',
where: filter.receiver_user ? {
[Op.or]: [
{ id: { [Op.in]: filter.receiver_user.split('|').map(term => Utils.uuid(term)) } },
{
firstName: {
[Op.or]: filter.receiver_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
];
if (filter) {
if (filter.id) {
where = {
...where,
['id']: Utils.uuid(filter.id),
};
}
if (filter.body) {
where = {
...where,
[Op.and]: Utils.ilike(
'messages',
'body',
filter.body,
),
};
}
if (filter.read_atRange) {
const [start, end] = filter.read_atRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
read_at: {
...where.read_at,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
read_at: {
...where.read_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.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,
},
};
} }
} else if (roleName === 'Consumer') {
where[Op.or] = [
{ sender_userId: currentUser.id },
{ receiver_userId: currentUser.id },
{ '$lead.userId$': currentUser.id }
];
} }
} }
let include = [
{
model: db.leads,
as: 'lead',
required: false,
include: [{
model: db.lead_matches,
as: 'lead_matches_lead',
include: [{ model: db.businesses, as: 'business' }]
}],
where: filter.lead ? {
[Op.or]: [
{ id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } },
{ keyword: { [Op.or]: filter.lead.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
]
} : undefined,
},
{
model: db.users,
as: 'sender_user',
required: false,
where: filter.sender_user ? {
[Op.or]: [
{ id: { [Op.in]: filter.sender_user.split('|').map(term => Utils.uuid(term)) } },
{ firstName: { [Op.or]: filter.sender_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
]
} : undefined,
},
{
model: db.users,
as: 'receiver_user',
required: false,
where: filter.receiver_user ? {
[Op.or]: [
{ id: { [Op.in]: filter.receiver_user.split('|').map(term => Utils.uuid(term)) } },
{ firstName: { [Op.or]: filter.receiver_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
]
} : undefined,
},
];
if (filter) {
if (filter.id) where.id = Utils.uuid(filter.id);
if (filter.body) where.body = { [Op.iLike]: `%${filter.body}%` };
}
const queryOptions = { const queryOptions = {
where, where,
include, include,
distinct: true, distinct: true,
subQuery: false, // Fix for "missing FROM-clause entry" when using limit + nested includes
order: filter.field && filter.sort order: filter.field && filter.sort
? [[filter.field, filter.sort]] ? [[filter.field, filter.sort]]
: [['createdAt', 'desc']], : [['createdAt', 'desc']],
transaction: options?.transaction, transaction,
logging: console.log
}; };
if (!options?.countOnly) { if (!options?.countOnly) {
@ -484,43 +130,81 @@ module.exports = class MessagesDBApi {
queryOptions.offset = offset ? Number(offset) : undefined; queryOptions.offset = offset ? Number(offset) : undefined;
} }
try { const { rows, count } = await db.messages.findAndCountAll(queryOptions);
const { rows, count } = await db.messages.findAndCountAll(queryOptions);
return { return {
rows: options?.countOnly ? [] : rows, rows: options?.countOnly ? [] : rows,
count: count count: count
}; };
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
} }
static async findAllAutocomplete(query, limit, offset, ) { static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const messages = await db.messages.findOne({ where, transaction });
if (!messages) return null;
const output = messages.get({plain: true});
output.lead = await messages.getLead({ transaction });
output.sender_user = await messages.getSender_user({ transaction });
output.receiver_user = await messages.getReceiver_user({ transaction });
return output;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const messages = await db.messages.findByPk(id, {transaction});
if (!messages) return null;
const updatePayload = { ...data, updatedById: currentUser.id };
await messages.update(updatePayload, {transaction});
if (data.lead !== undefined) await messages.setLead(data.lead, { transaction });
if (data.sender_user !== undefined) await messages.setSender_user(data.sender_user, { transaction });
if (data.receiver_user !== undefined) await messages.setReceiver_user(data.receiver_user, { transaction });
return messages;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const messages = await db.messages.findAll({ where: { id: { [Op.in]: ids } }, transaction });
for (const record of messages) {
await record.update({deletedBy: currentUser.id}, {transaction});
await record.destroy({transaction});
}
return messages;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const messages = await db.messages.findByPk(id, options);
await messages.update({ deletedBy: currentUser.id }, { transaction });
await messages.destroy({ transaction });
return messages;
}
static async findAllAutocomplete(query, limit, offset) {
let where = {}; let where = {};
if (query) { if (query) {
where = { where = {
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike( { ['body']: { [Op.iLike]: `%${query}%` } },
'messages',
'body',
query,
),
], ],
}; };
} }
const records = await db.messages.findAll({ const records = await db.messages.findAll({
attributes: [ 'id', 'body' ], attributes: ['id', 'body'],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['body', 'ASC']], order: [['body', 'ASC']],
}); });
return records.map((record) => ({ return records.map((record) => ({
@ -528,7 +212,4 @@ module.exports = class MessagesDBApi {
label: record.body, label: record.body,
})); }));
} }
}; };

View File

@ -1,4 +1,3 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto'); const crypto = require('crypto');
@ -47,6 +46,9 @@ module.exports = class ReviewsDBApi {
null null
, ,
response: data.response || null,
response_at_ts: data.response ? new Date() : null,
created_at_ts: data.created_at_ts created_at_ts: data.created_at_ts
|| ||
null null
@ -171,6 +173,11 @@ module.exports = class ReviewsDBApi {
if (data.moderation_notes !== undefined) updatePayload.moderation_notes = data.moderation_notes; if (data.moderation_notes !== undefined) updatePayload.moderation_notes = data.moderation_notes;
if (data.response !== undefined) {
updatePayload.response = data.response;
updatePayload.response_at_ts = new Date();
}
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts; if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
@ -345,12 +352,28 @@ module.exports = class ReviewsDBApi {
const transaction = (options && options.transaction) || undefined; 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;
}
} else if (roleName === 'Consumer') {
where.userId = currentUser.id;
}
}
let include = [ let include = [
{ {
model: db.businesses, model: db.businesses,
as: 'business', as: 'business',
required: false,
where: filter.business ? { where: filter.business ? {
[Op.or]: [ [Op.or]: [
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } }, { id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
@ -360,14 +383,14 @@ module.exports = class ReviewsDBApi {
} }
}, },
] ]
} : {}, } : undefined,
}, },
{ {
model: db.users, model: db.users,
as: 'user', as: 'user',
required: false,
where: filter.user ? { where: filter.user ? {
[Op.or]: [ [Op.or]: [
{ id: { [Op.in]: filter.user.split('|').map(term => Utils.uuid(term)) } }, { id: { [Op.in]: filter.user.split('|').map(term => Utils.uuid(term)) } },
@ -377,14 +400,14 @@ module.exports = class ReviewsDBApi {
} }
}, },
] ]
} : {}, } : undefined,
}, },
{ {
model: db.leads, model: db.leads,
as: 'lead', as: 'lead',
required: false,
where: filter.lead ? { where: filter.lead ? {
[Op.or]: [ [Op.or]: [
{ id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } }, { id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } },
@ -394,7 +417,7 @@ module.exports = class ReviewsDBApi {
} }
}, },
] ]
} : {}, } : undefined,
}, },
@ -575,6 +598,7 @@ module.exports = class ReviewsDBApi {
where, where,
include, include,
distinct: true, distinct: true,
subQuery: false,
order: filter.field && filter.sort order: filter.field && filter.sort
? [[filter.field, filter.sort]] ? [[filter.field, filter.sort]]
: [['createdAt', 'desc']], : [['createdAt', 'desc']],
@ -634,4 +658,3 @@ module.exports = class ReviewsDBApi {
}; };

View File

@ -1,4 +1,3 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto'); const crypto = require('crypto');
@ -307,6 +306,20 @@ module.exports = class Service_pricesDBApi {
const transaction = (options && options.transaction) || undefined; 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 = [ let include = [
{ {
@ -592,4 +605,3 @@ module.exports = class Service_pricesDBApi {
}; };

View File

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

View File

@ -1,18 +1,12 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto'); const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
module.exports = class Verification_evidencesDBApi { module.exports = class Verification_evidencesDBApi {
static async create(data, options) { static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
@ -20,404 +14,73 @@ module.exports = class Verification_evidencesDBApi {
const verification_evidences = await db.verification_evidences.create( const verification_evidences = await db.verification_evidences.create(
{ {
id: data.id || undefined, id: data.id || undefined,
evidence_type: data.evidence_type || null,
evidence_type: data.evidence_type url: data.url || null,
|| created_at_ts: data.created_at_ts || null,
null importHash: data.importHash || null,
, createdById: currentUser.id,
updatedById: currentUser.id,
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,
}, },
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 }, { transaction },
); );
if (!verification_evidences) { await verification_evidences.setSubmission( data.submission || null, { transaction });
return verification_evidences; await FileDBApi.replaceRelationFiles(
} {
belongsTo: db.verification_evidences.getTableName(),
belongsToColumn: 'files',
belongsToId: verification_evidences.id,
},
data.files,
options,
);
const output = verification_evidences.get({plain: true}); return verification_evidences;
output.submission = await verification_evidences.getSubmission({
transaction
});
output.files = await verification_evidences.getFiles({
transaction
});
return output;
} }
static async findAll( static async findAll(filter, options) {
filter,
options
) {
const limit = filter.limit || 0; const limit = filter.limit || 0;
let offset = 0; let offset = 0;
let where = {}; let where = {};
const currentPage = +filter.page; const currentPage = +filter.page;
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null; const transaction = (options && options.transaction) || undefined;
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined; // Data Isolation
if (currentUser && currentUser.app_role) {
let include = [ const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {
{ if (currentUser.businessId) {
model: db.verification_submissions, where['$submission.businessId$'] = currentUser.businessId;
as: 'submission', } else {
where['$submission.business.owner_userId$'] = currentUser.id;
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,
},
};
} }
} }
} }
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 = { const queryOptions = {
where, where,
@ -426,8 +89,7 @@ module.exports = class Verification_evidencesDBApi {
order: filter.field && filter.sort order: filter.field && filter.sort
? [[filter.field, filter.sort]] ? [[filter.field, filter.sort]]
: [['createdAt', 'desc']], : [['createdAt', 'desc']],
transaction: options?.transaction, transaction,
logging: console.log
}; };
if (!options?.countOnly) { if (!options?.countOnly) {
@ -435,51 +97,90 @@ module.exports = class Verification_evidencesDBApi {
queryOptions.offset = offset ? Number(offset) : undefined; 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 { return {
rows: options?.countOnly ? [] : rows, rows: options?.countOnly ? [] : rows,
count: count count: count
}; };
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
} }
static async findAllAutocomplete(query, limit, offset, ) { 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 = {}; let where = {};
if (query) { if (query) {
where = { where = {
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike(
'verification_evidences',
'url',
query,
),
], ],
}; };
} }
const records = await db.verification_evidences.findAll({ const records = await db.verification_evidences.findAll({
attributes: [ 'id', 'url' ], attributes: [ 'id' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['url', 'ASC']], order: [['createdAt', 'desc']],
}); });
return records.map((record) => ({ return records.map((record) => ({
id: record.id, id: record.id,
label: record.url, label: record.id,
})); }));
} }
}; };

View File

@ -1,4 +1,3 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto'); const crypto = require('crypto');
@ -298,6 +297,20 @@ module.exports = class Verification_submissionsDBApi {
const transaction = (options && options.transaction) || undefined; 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 = [ let include = [
{ {
@ -525,4 +538,3 @@ module.exports = class Verification_submissionsDBApi {
}; };

View File

@ -1,5 +1,3 @@
module.exports = { module.exports = {
production: { production: {
dialect: 'postgres', dialect: 'postgres',
@ -12,11 +10,12 @@ module.exports = {
seederStorage: 'sequelize', seederStorage: 'sequelize',
}, },
development: { development: {
username: 'postgres',
dialect: 'postgres', dialect: 'postgres',
password: '', username: process.env.DB_USER || 'postgres',
database: 'db_crafted_network', password: process.env.DB_PASS || '',
database: process.env.DB_NAME || 'db_crafted_network',
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
logging: console.log, logging: console.log,
seederStorage: 'sequelize', seederStorage: 'sequelize',
}, },

View File

@ -0,0 +1,30 @@
module.exports = {
async up(queryInterface, Sequelize) {
const [[publicRole]] = await queryInterface.sequelize.query(
"SELECT id FROM roles WHERE name = 'Public' LIMIT 1"
);
if (!publicRole) return;
const [permissions] = await queryInterface.sequelize.query(
"SELECT id FROM permissions WHERE name IN ('CREATE_SEARCH', 'CREATE_BUSINESSES')"
);
if (!permissions.length) return;
// Avoid duplicate inserts
for (const permission of permissions) {
const [[existing]] = await queryInterface.sequelize.query(
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${publicRole.id}' AND "permissionId" = '${permission.id}'`
);
if (!existing) {
await queryInterface.sequelize.query(
`INSERT INTO "rolesPermissionsPermissions" ("roles_permissionsId", "permissionId", "createdAt", "updatedAt") VALUES ('${publicRole.id}', '${permission.id}', NOW(), NOW())`
);
}
}
},
async down(queryInterface, Sequelize) {
},
};

View File

@ -0,0 +1,63 @@
module.exports = {
/**
* @param {QueryInterface} queryInterface
* @param {Sequelize} Sequelize
* @returns {Promise<void>}
*/
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.addColumn(
'businesses',
'google_place_id',
{
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
{ transaction }
);
await queryInterface.addColumn(
'businesses',
'is_claimed',
{
type: Sequelize.DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
{ transaction }
);
await queryInterface.addColumn(
'businesses',
'rating',
{
type: Sequelize.DataTypes.DECIMAL(3, 2),
defaultValue: 0,
allowNull: false,
},
{ transaction }
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
/**
* @param {QueryInterface} queryInterface
* @param {Sequelize} Sequelize
* @returns {Promise<void>}
*/
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.removeColumn('businesses', 'google_place_id', { transaction });
await queryInterface.removeColumn('businesses', 'is_claimed', { transaction });
await queryInterface.removeColumn('businesses', 'rating', { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
}
};

View File

@ -0,0 +1,30 @@
module.exports = {
async up(queryInterface, Sequelize) {
const [[publicRole]] = await queryInterface.sequelize.query(
"SELECT id FROM roles WHERE name = 'Public' LIMIT 1"
);
if (!publicRole) return;
const [permissions] = await queryInterface.sequelize.query(
"SELECT id FROM permissions WHERE name IN ('READ_BUSINESSES', 'READ_CATEGORIES', 'READ_LOCATIONS', 'READ_REVIEWS')"
);
if (!permissions.length) return;
// Avoid duplicate inserts
for (const permission of permissions) {
const [[existing]] = await queryInterface.sequelize.query(
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${publicRole.id}' AND "permissionId" = '${permission.id}'`
);
if (!existing) {
await queryInterface.sequelize.query(
`INSERT INTO "rolesPermissionsPermissions" ("roles_permissionsId", "permissionId", "createdAt", "updatedAt") VALUES ('${publicRole.id}', '${permission.id}', NOW(), NOW())`
);
}
}
},
async down(queryInterface, Sequelize) {
},
};

View File

@ -0,0 +1,20 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addColumn('reviews', 'response', {
type: Sequelize.TEXT,
allowNull: true,
});
await queryInterface.addColumn('reviews', 'response_at_ts', {
type: Sequelize.DATE,
allowNull: true,
});
},
async down (queryInterface, Sequelize) {
await queryInterface.removeColumn('reviews', 'response');
await queryInterface.removeColumn('reviews', 'response_at_ts');
}
};

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

@ -0,0 +1,72 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.createTable('claim_requests', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
businessId: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'businesses',
},
allowNull: false,
},
userId: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
allowNull: false,
},
status: {
type: Sequelize.DataTypes.ENUM('PENDING', 'APPROVED', 'REJECTED'),
defaultValue: 'PENDING',
allowNull: false,
},
rejectionReason: {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
},
createdById: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
updatedById: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
createdAt: { type: Sequelize.DataTypes.DATE },
updatedAt: { type: Sequelize.DataTypes.DATE },
deletedAt: { type: Sequelize.DataTypes.DATE },
}, { transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.dropTable('claim_requests', { transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,62 @@
const { v4: uuid } = require("uuid");
module.exports = {
async up(queryInterface) {
const createdAt = new Date();
const updatedAt = new Date();
const permissions = [
{ id: uuid(), name: 'CREATE_CLAIM_REQUESTS', createdAt, updatedAt },
{ id: uuid(), name: 'READ_CLAIM_REQUESTS', createdAt, updatedAt },
{ id: uuid(), name: 'UPDATE_CLAIM_REQUESTS', createdAt, updatedAt },
{ id: uuid(), name: 'DELETE_CLAIM_REQUESTS', createdAt, updatedAt },
];
await queryInterface.bulkInsert('permissions', permissions);
const roles = await queryInterface.sequelize.query(
`SELECT id, name FROM "roles" WHERE name IN ('Administrator', 'Platform Owner', 'Verified Business Owner')`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
const adminRole = roles.find(r => r.name === 'Administrator');
const ownerRole = roles.find(r => r.name === 'Platform Owner');
const vboRole = roles.find(r => r.name === 'Verified Business Owner');
const rolePerms = [];
permissions.forEach(p => {
if (adminRole) {
rolePerms.push({
createdAt,
updatedAt,
roles_permissionsId: adminRole.id,
permissionId: p.id,
});
}
if (ownerRole) {
rolePerms.push({
createdAt,
updatedAt,
roles_permissionsId: ownerRole.id,
permissionId: p.id,
});
}
// VBO can only create and read
if (vboRole && (p.name === 'CREATE_CLAIM_REQUESTS' || p.name === 'READ_CLAIM_REQUESTS')) {
rolePerms.push({
createdAt,
updatedAt,
roles_permissionsId: vboRole.id,
permissionId: p.id,
});
}
});
await queryInterface.bulkInsert('rolesPermissionsPermissions', rolePerms);
},
async down(queryInterface) {
// No need to implement down for this simple permission addition in a dev environment
}
};

View File

@ -162,6 +162,33 @@ tenant_key: {
}, },
google_place_id: {
type: DataTypes.TEXT,
allowNull: true,
},
is_claimed: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
rating: {
type: DataTypes.DECIMAL(3, 2),
defaultValue: 0,
allowNull: false,
},
planId: {
type: DataTypes.UUID,
allowNull: true,
},
renewal_date: {
type: DataTypes.DATE,
allowNull: true,
},
created_at_ts: { created_at_ts: {
type: DataTypes.DATE, type: DataTypes.DATE,
@ -295,8 +322,15 @@ updated_at_ts: {
constraints: false, 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, { db.businesses.belongsTo(db.users, {
as: 'createdBy', as: 'createdBy',
@ -311,5 +345,3 @@ updated_at_ts: {
return businesses; return businesses;
}; };

View File

@ -0,0 +1,58 @@
module.exports = function(sequelize, DataTypes) {
const claim_requests = sequelize.define(
'claim_requests',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
status: {
type: DataTypes.ENUM('PENDING', 'APPROVED', 'REJECTED'),
defaultValue: 'PENDING',
allowNull: false,
},
rejectionReason: {
type: DataTypes.TEXT,
allowNull: true,
},
createdAt: { type: DataTypes.DATE },
updatedAt: { type: DataTypes.DATE },
deletedAt: { type: DataTypes.DATE },
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
claim_requests.associate = (db) => {
db.claim_requests.belongsTo(db.businesses, {
as: 'business',
foreignKey: {
name: 'businessId',
},
constraints: false,
});
db.claim_requests.belongsTo(db.users, {
as: 'user',
foreignKey: {
name: 'userId',
},
constraints: false,
});
db.claim_requests.belongsTo(db.users, {
as: 'createdBy',
});
db.claim_requests.belongsTo(db.users, {
as: 'updatedBy',
});
};
return claim_requests;
};

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

@ -67,6 +67,14 @@ moderation_notes: {
}, },
response: {
type: DataTypes.TEXT,
},
response_at_ts: {
type: DataTypes.DATE,
},
created_at_ts: { created_at_ts: {
type: DataTypes.DATE, type: DataTypes.DATE,
@ -168,5 +176,3 @@ updated_at_ts: {
return reviews; return reviews;
}; };

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -299,7 +299,7 @@ const CategoriesData = [
"name": "Alan Turing", "name": "Plumbing",
@ -359,7 +359,7 @@ const CategoriesData = [
"name": "Grace Hopper", "name": "Electrical",
@ -419,7 +419,7 @@ const CategoriesData = [
"name": "Alan Turing", "name": "Plumbing",
@ -479,7 +479,7 @@ const CategoriesData = [
"name": "Alan Turing", "name": "Plumbing",
@ -798,7 +798,7 @@ const BusinessesData = [
"name": "Alan Turing", "name": "Plumbing",
@ -956,7 +956,7 @@ const BusinessesData = [
"name": "Grace Hopper", "name": "Electrical",
@ -1114,7 +1114,7 @@ const BusinessesData = [
"name": "Grace Hopper", "name": "Electrical",
@ -1272,7 +1272,7 @@ const BusinessesData = [
"name": "Alan Turing", "name": "Plumbing",

View File

@ -0,0 +1,88 @@
const { v4: uuid } = require("uuid");
module.exports = {
/**
* @param{import("sequelize").QueryInterface} queryInterface
* @return {Promise<void>}
*/
async up(queryInterface) {
const createdAt = new Date();
const updatedAt = new Date();
/** @type {Map<string, string>} */
const idMap = new Map();
/**
* @param {string} key
* @return {string}
*/
function getId(key) {
if (idMap.has(key)) {
return idMap.get(key);
}
const id = uuid();
idMap.set(key, id);
return id;
}
// Since we are updating, we should try to fetch existing IDs if possible,
// but in a seeder for this kind of platform it's often better to just recreate or use fixed IDs if they were fixed.
// However, the previous seeder used random UUIDs.
// To update permissions, I'll need to fetch the roles.
const roles = await queryInterface.sequelize.query(
`SELECT id, name FROM "roles";`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
const permissions = await queryInterface.sequelize.query(
`SELECT id, name FROM "permissions";`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
const getRoleId = (name) => roles.find(r => r.name === name)?.id;
const getPermId = (name) => permissions.find(p => p.name === name)?.id;
const vboRoleId = getRoleId("Verified Business Owner");
if (vboRoleId) {
const newPerms = [
"CREATE_BUSINESSES",
"UPDATE_REVIEWS",
"CREATE_BUSINESS_PHOTOS",
"CREATE_BUSINESS_CATEGORIES",
"CREATE_SERVICE_PRICES",
"CREATE_LEAD_MATCHES", // Maybe?
"CREATE_MESSAGES",
];
const rolePermsToInsert = [];
for (const p of newPerms) {
const permId = getPermId(p);
if (permId) {
// Check if it already exists
const existing = await queryInterface.sequelize.query(
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${vboRoleId}' AND "permissionId" = '${permId}';`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
if (existing.length === 0) {
rolePermsToInsert.push({
createdAt,
updatedAt,
roles_permissionsId: vboRoleId,
permissionId: permId
});
}
}
}
if (rolePermsToInsert.length > 0) {
await queryInterface.bulkInsert("rolesPermissionsPermissions", rolePermsToInsert);
}
}
},
async down(queryInterface) {
// No easy way to undo this without more logic
}
};

View File

@ -0,0 +1,42 @@
const { v4: uuid } = require("uuid");
module.exports = {
async up(queryInterface) {
const createdAt = new Date();
const updatedAt = new Date();
const roles = await queryInterface.sequelize.query(
`SELECT id, name FROM "roles";`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
const permissions = await queryInterface.sequelize.query(
`SELECT id, name FROM "permissions";`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
const getRoleId = (name) => roles.find(r => r.name === name)?.id;
const getPermId = (name) => permissions.find(p => p.name === name)?.id;
const vboRoleId = getRoleId("Verified Business Owner");
const createDisputesPermId = getPermId("CREATE_DISPUTES");
if (vboRoleId && createDisputesPermId) {
const existing = await queryInterface.sequelize.query(
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${vboRoleId}' AND "permissionId" = '${createDisputesPermId}';`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
if (existing.length === 0) {
await queryInterface.bulkInsert("rolesPermissionsPermissions", [{
createdAt,
updatedAt,
roles_permissionsId: vboRoleId,
permissionId: createDisputesPermId
}]);
}
}
},
async down(queryInterface) {
}
};

View File

@ -0,0 +1,41 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const createdAt = new Date();
const updatedAt = new Date();
const [roles] = await queryInterface.sequelize.query(
`SELECT id FROM roles WHERE name = 'Verified Business Owner';`
);
const [permissions] = await queryInterface.sequelize.query(
`SELECT id FROM permissions WHERE name = 'DELETE_BUSINESS_PHOTOS';`
);
if (roles.length > 0 && permissions.length > 0) {
const roleId = roles[0].id;
const permissionId = permissions[0].id;
// Check if it already exists to avoid duplicates
const [existing] = await queryInterface.sequelize.query(
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${roleId}' AND "permissionId" = '${permissionId}';`
);
if (existing.length === 0) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', [
{
createdAt,
updatedAt,
roles_permissionsId: roleId,
permissionId: permissionId,
},
]);
}
}
},
down: async (queryInterface, Sequelize) => {
// Logic to remove the permission if needed
}
};

View File

@ -0,0 +1,36 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const createdAt = new Date();
const updatedAt = new Date();
const [publicRole] = await queryInterface.sequelize.query(
"SELECT id FROM roles WHERE name = 'Public' LIMIT 1"
);
const [createLeadsPermission] = await queryInterface.sequelize.query(
"SELECT id FROM permissions WHERE name = 'CREATE_LEADS' LIMIT 1"
);
if (publicRole.length && createLeadsPermission.length) {
// Check if already exists
const [existing] = await queryInterface.sequelize.query(
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${publicRole[0].id}' AND "permissionId" = '${createLeadsPermission[0].id}'`
);
if (!existing.length) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', [{
createdAt,
updatedAt,
roles_permissionsId: publicRole[0].id,
permissionId: createLeadsPermission[0].id,
}]);
}
}
},
down: async (queryInterface, Sequelize) => {
// Logic to revert if needed
}
};

View File

@ -1,4 +1,3 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const app = express(); const app = express();
@ -19,7 +18,7 @@ const pexelsRoutes = require('./routes/pexels');
const openaiRoutes = require('./routes/openai'); const openaiRoutes = require('./routes/openai');
const dashboardRoutes = require('./routes/dashboard');
const usersRoutes = require('./routes/users'); const usersRoutes = require('./routes/users');
@ -47,6 +46,8 @@ const verification_submissionsRoutes = require('./routes/verification_submission
const verification_evidencesRoutes = require('./routes/verification_evidences'); const verification_evidencesRoutes = require('./routes/verification_evidences');
const claim_requestsRoutes = require('./routes/claim_requests');
const leadsRoutes = require('./routes/leads'); const leadsRoutes = require('./routes/leads');
const lead_photosRoutes = require('./routes/lead_photos'); const lead_photosRoutes = require('./routes/lead_photos');
@ -78,8 +79,8 @@ const options = {
openapi: "3.0.0", openapi: "3.0.0",
info: { info: {
version: "1.0.0", version: "1.0.0",
title: "Crafted Network", title: "Fix-It-Local",
description: "Crafted Network Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.", 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: [ servers: [
{ {
@ -119,6 +120,15 @@ require('./auth/auth');
app.use(bodyParser.json()); 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/auth', authRoutes);
app.use('/api/file', fileRoutes); app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes); app.use('/api/pexels', pexelsRoutes);
@ -133,25 +143,27 @@ 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/refresh_tokens', passport.authenticate('jwt', {session: false}), refresh_tokensRoutes);
app.use('/api/categories', passport.authenticate('jwt', {session: false}), categoriesRoutes); app.use('/api/categories', optionalAuth, categoriesRoutes);
app.use('/api/locations', passport.authenticate('jwt', {session: false}), locationsRoutes); app.use('/api/locations', optionalAuth, locationsRoutes);
app.use('/api/businesses', passport.authenticate('jwt', {session: false}), businessesRoutes); app.use('/api/businesses', optionalAuth, businessesRoutes);
app.use('/api/business_photos', passport.authenticate('jwt', {session: false}), business_photosRoutes); app.use('/api/business_photos', optionalAuth, business_photosRoutes);
app.use('/api/business_categories', passport.authenticate('jwt', {session: false}), business_categoriesRoutes); app.use('/api/business_categories', optionalAuth, business_categoriesRoutes);
app.use('/api/service_prices', passport.authenticate('jwt', {session: false}), service_pricesRoutes); app.use('/api/service_prices', optionalAuth, service_pricesRoutes);
app.use('/api/business_badges', passport.authenticate('jwt', {session: false}), business_badgesRoutes); app.use('/api/business_badges', optionalAuth, business_badgesRoutes);
app.use('/api/verification_submissions', passport.authenticate('jwt', {session: false}), verification_submissionsRoutes); app.use('/api/verification_submissions', passport.authenticate('jwt', {session: false}), verification_submissionsRoutes);
app.use('/api/verification_evidences', passport.authenticate('jwt', {session: false}), verification_evidencesRoutes); app.use('/api/verification_evidences', passport.authenticate('jwt', {session: false}), verification_evidencesRoutes);
app.use('/api/leads', passport.authenticate('jwt', {session: false}), leadsRoutes); app.use('/api/claim_requests', passport.authenticate('jwt', {session: false}), claim_requestsRoutes);
app.use('/api/leads', optionalAuth, leadsRoutes);
app.use('/api/lead_photos', passport.authenticate('jwt', {session: false}), lead_photosRoutes); app.use('/api/lead_photos', passport.authenticate('jwt', {session: false}), lead_photosRoutes);
@ -161,7 +173,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/lead_events', passport.authenticate('jwt', {session: false}), lead_eventsRoutes);
app.use('/api/reviews', passport.authenticate('jwt', {session: false}), reviewsRoutes); app.use('/api/reviews', optionalAuth, reviewsRoutes);
app.use('/api/disputes', passport.authenticate('jwt', {session: false}), disputesRoutes); app.use('/api/disputes', passport.authenticate('jwt', {session: false}), disputesRoutes);
@ -182,9 +194,12 @@ app.use(
openaiRoutes, openaiRoutes,
); );
app.use(
'/api/dashboard',
dashboardRoutes);
app.use( app.use(
'/api/search', '/api/search',
passport.authenticate('jwt', { session: false }),
searchRoutes); searchRoutes);
app.use( app.use(
'/api/sql', '/api/sql',

View File

@ -1,8 +1,8 @@
const express = require('express'); const express = require('express');
const BusinessesService = require('../services/businesses'); const BusinessesService = require('../services/businesses');
const BusinessesDBApi = require('../db/api/businesses'); const BusinessesDBApi = require('../db/api/businesses');
const GooglePlacesService = require('../services/googlePlaces');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
@ -131,6 +131,23 @@ router.post('/', wrapAsync(async (req, res) => {
res.status(200).send(payload); res.status(200).send(payload);
})); }));
router.post('/google-search', wrapAsync(async (req, res) => {
const { query, location } = req.body;
const results = await GooglePlacesService.searchPlaces(query, location);
res.status(200).send(results);
}));
router.post('/google-import', wrapAsync(async (req, res) => {
const { googlePlace } = req.body;
const business = await GooglePlacesService.importFromGoogle(googlePlace);
res.status(200).send(business);
}));
router.post('/:id/claim', wrapAsync(async (req, res) => {
const business = await BusinessesService.claim(req.params.id, req.currentUser);
res.status(200).send(business);
}));
/** /**
* @swagger * @swagger
* /api/budgets/bulk-import: * /api/budgets/bulk-import:
@ -387,7 +404,6 @@ router.get('/count', wrapAsync(async (req, res) => {
const currentUser = req.currentUser; const currentUser = req.currentUser;
const payload = await BusinessesDBApi.findAll( const payload = await BusinessesDBApi.findAll(
req.query, req.query,
null,
{ countOnly: true, currentUser } { countOnly: true, currentUser }
); );
@ -464,12 +480,11 @@ router.get('/autocomplete', async (req, res) => {
* description: Some server error * description: Some server error
*/ */
router.get('/:id', wrapAsync(async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => {
const payload = await BusinessesDBApi.findBy( const payload = await BusinessesService.findBy(
{ id: req.params.id }, req.params.id,
req.currentUser
); );
res.status(200).send(payload); res.status(200).send(payload);
})); }));

View File

@ -0,0 +1,29 @@
const express = require('express');
const Claim_requestsService = require('../services/claim_requests');
const wrapAsync = require('../helpers').wrapAsync;
const { checkPermissions } = require('../middlewares/check-permissions');
const router = express.Router();
router.get('/', checkPermissions('READ_CLAIM_REQUESTS'), wrapAsync(async (req, res) => {
const payload = await Claim_requestsService.findAll(req.query, req.currentUser);
res.status(200).send(payload);
}));
router.get('/:id', checkPermissions('READ_CLAIM_REQUESTS'), wrapAsync(async (req, res) => {
const payload = await Claim_requestsService.findBy(req.params.id);
res.status(200).send(payload);
}));
router.post('/', wrapAsync(async (req, res) => {
const payload = await Claim_requestsService.create(req.body.data, req.currentUser);
res.status(200).send(payload);
}));
router.put('/:id', checkPermissions('UPDATE_CLAIM_REQUESTS'), wrapAsync(async (req, res) => {
const payload = await Claim_requestsService.update(req.params.id, req.body.data, req.currentUser);
res.status(200).send(payload);
}));
module.exports = router;

View File

@ -0,0 +1,50 @@
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('/', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
const role = req.currentUser.app_role ? req.currentUser.app_role.name : null;
if (role === 'Verified Business Owner') {
const payload = await DashboardService.getBusinessMetrics(req.currentUser);
res.status(200).send(payload);
} else if (role === 'Administrator' || role === 'Platform Owner') {
const payload = await DashboardService.getAdminMetrics();
res.status(200).send(payload);
} else {
// Default or other roles
res.status(200).send({
totalViews: 0,
activeLeads: 0,
conversionRate: 0,
});
}
}));
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

@ -22,6 +22,8 @@ router.use(checkCrudPermissions('search'));
* properties: * properties:
* searchQuery: * searchQuery:
* type: string * type: string
* location:
* type: string
* required: * required:
* - searchQuery * - searchQuery
* responses: * responses:
@ -34,14 +36,14 @@ router.use(checkCrudPermissions('search'));
*/ */
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const { searchQuery } = req.body; const { searchQuery, location } = req.body;
if (!searchQuery) { if (!searchQuery) {
return res.status(400).json({ error: 'Please enter a search query' }); return res.status(400).json({ error: 'Please enter a search query' });
} }
try { try {
const foundMatches = await SearchService.search(searchQuery, req.currentUser ); const foundMatches = await SearchService.search(searchQuery, req.currentUser, location );
res.json(foundMatches); res.json(foundMatches);
} catch (error) { } catch (error) {
console.error('Internal Server Error', error); console.error('Internal Server Error', error);

View File

@ -2,20 +2,83 @@ const db = require('../db/models');
const BusinessesDBApi = require('../db/api/businesses'); const BusinessesDBApi = require('../db/api/businesses');
const processFile = require("../middlewares/upload"); const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
const { v4: uuidv4 } = require('uuid');
module.exports = class BusinessesService { module.exports = class BusinessesService {
static _sanitize(data, currentUser) {
const numericFields = ['lat', 'lng', 'reliability_score', 'response_time_median_minutes', 'rating'];
numericFields.forEach(field => {
if (data[field] === '') {
data[field] = null;
}
});
// Hide internal fields from client forms
if (currentUser?.app_role?.name === 'Verified Business Owner') {
const internalFields = [
'tenant_key',
'owner_userId',
'owner_user',
'createdAt',
'updatedAt',
'created_at_ts',
'updated_at_ts',
'reliability_score',
'reliability_breakdown_json',
'hours_json',
'is_claimed',
'is_active'
];
internalFields.forEach(field => {
delete data[field];
});
}
return data;
}
static async findBy(id, currentUser) {
const business = await BusinessesDBApi.findBy({ id });
if (!business) {
throw new ValidationError('businessesNotFound');
}
// Ownership check for Verified Business Owner
if (currentUser?.app_role?.name === 'Verified Business Owner') {
// Allow viewing if owner, or if no owner (public search results might call this)
// But the requirement says "only edit businesses where ownerUserId == currentUser.id"
// findBy is often used for view/edit.
}
return business;
}
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await BusinessesDBApi.create( data = this._sanitize(data, currentUser);
// For VBOs, force the owner to be the current user
if (currentUser?.app_role?.name === 'Verified Business Owner') {
data.owner_userId = currentUser.id;
data.is_active = true; // Ensure new business owner listings are active
// Auto-generate internal fields if missing
if (!data.slug && data.name) {
data.slug = data.name.toLowerCase().replace(/ /g, '-').replace(/[^\w-]+/g, '') + '-' + uuidv4().substring(0, 4);
}
if (!data.tenant_key) {
data.tenant_key = 'TENANT-' + uuidv4().substring(0, 8).toUpperCase();
}
}
const business = await BusinessesDBApi.create(
data, data,
{ {
currentUser, currentUser,
@ -23,13 +86,62 @@ 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(); await transaction.commit();
return business;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; };
static async claim(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const business = await db.businesses.findByPk(id, { transaction });
if (!business) {
throw new ValidationError('businessNotFound');
}
if (business.owner_userId) {
throw new ValidationError('businessAlreadyClaimed');
}
// Check for pending claim
const pendingRequest = await db.claim_requests.findOne({
where: {
businessId: id,
userId: currentUser.id,
status: 'PENDING'
},
transaction
});
if (pendingRequest) {
throw new ValidationError('claimRequestPending');
}
// Create Claim Request
const claim_request = await db.claim_requests.create({
businessId: id,
userId: currentUser.id,
status: 'PENDING',
createdById: currentUser.id
}, { transaction });
await transaction.commit();
return claim_request;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res, sendInvitationEmails = true, host) { static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -38,7 +150,7 @@ module.exports = class BusinessesService {
const bufferStream = new stream.PassThrough(); const bufferStream = new stream.PassThrough();
const results = []; 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) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
@ -68,17 +180,33 @@ module.exports = class BusinessesService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
let businesses = await BusinessesDBApi.findBy( data = this._sanitize(data, currentUser);
let business = await BusinessesDBApi.findBy(
{id}, {id},
{transaction}, {transaction},
); );
if (!businesses) { if (!business) {
throw new ValidationError( throw new ValidationError(
'businessesNotFound', 'businessesNotFound',
); );
} }
// Ownership check for Verified Business Owner
if (currentUser?.app_role?.name === 'Verified Business Owner') {
if (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
// Prevent transferring ownership or changing internal fields
delete data.owner_user;
delete data.owner_userId;
delete data.slug;
delete data.tenant_key;
delete data.is_active;
delete data.is_claimed;
}
const updatedBusinesses = await BusinessesDBApi.update( const updatedBusinesses = await BusinessesDBApi.update(
id, id,
data, data,
@ -101,6 +229,23 @@ module.exports = class BusinessesService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
// Ownership check for Verified Business Owner
if (currentUser?.app_role?.name === 'Verified Business Owner') {
const records = await db.businesses.findAll({
where: {
id: { [db.Sequelize.Op.in]: ids },
[db.Sequelize.Op.and]: [
{ owner_userId: { [db.Sequelize.Op.ne]: currentUser.id } },
{ id: { [db.Sequelize.Op.ne]: currentUser.businessId || null } }
]
},
transaction
});
if (records.length > 0) {
throw new ForbiddenError('forbidden');
}
}
await BusinessesDBApi.deleteByIds(ids, { await BusinessesDBApi.deleteByIds(ids, {
currentUser, currentUser,
transaction, transaction,
@ -117,6 +262,13 @@ module.exports = class BusinessesService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
let business = await db.businesses.findByPk(id, { transaction });
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');
}
await BusinessesDBApi.remove( await BusinessesDBApi.remove(
id, id,
{ {
@ -134,5 +286,3 @@ module.exports = class BusinessesService {
}; };

View File

@ -0,0 +1,83 @@
const db = require('../db/models');
const Claim_requestsDBApi = require('../db/api/claim_requests');
const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
module.exports = class Claim_requestsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const business = await db.businesses.findByPk(data.businessId, { transaction });
if (!business) throw new ValidationError('businessNotFound');
if (business.owner_userId) throw new ValidationError('businessAlreadyOwned');
const existingRequest = await db.claim_requests.findOne({
where: {
businessId: data.businessId,
userId: currentUser.id,
status: 'PENDING'
},
transaction
});
if (existingRequest) throw new ValidationError('claimRequestPending');
const claim_request = await Claim_requestsDBApi.create(
{
businessId: data.businessId,
userId: currentUser.id,
status: 'PENDING',
},
{ currentUser, transaction },
);
await transaction.commit();
return claim_request;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(id, data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const claim_request = await Claim_requestsDBApi.findBy({ id }, { transaction });
if (!claim_request) throw new ValidationError('claimRequestNotFound');
// Only admin can approve/reject
if (currentUser.app_role?.name !== 'Administrator') {
throw new ForbiddenError('forbidden');
}
const updatedRequest = await Claim_requestsDBApi.update(id, data, { currentUser, transaction });
// If approved, update business ownership
if (data.status === 'APPROVED') {
await db.businesses.update(
{ owner_userId: claim_request.userId, is_claimed: true },
{ where: { id: claim_request.businessId }, transaction }
);
// Also link to user record
await db.users.update(
{ businessId: claim_request.businessId },
{ where: { id: claim_request.userId }, transaction }
);
}
await transaction.commit();
return updatedRequest;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async findBy(id) {
return await Claim_requestsDBApi.findBy({ id });
}
static async findAll(query, currentUser) {
return await Claim_requestsDBApi.findAll(query, { currentUser });
}
};

View File

@ -0,0 +1,228 @@
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', 'is_active'],
include: [{ model: db.plans, as: 'plan' }]
});
if (!businesses.length) {
return {
no_business: true,
totalViews: 0,
activeLeads: 0,
conversionRate: 0,
verificationStatus: 'N/A',
accountStanding: 'N/A'
};
}
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 ? Math.round((won30d / (won30d + lost30d)) * 100) : 0;
// --- Recent Messages ---
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 viewConversionRate = views30d > 0 ? Math.round((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);
}
});
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');
// --- Verification & Standing ---
const lastSubmission = await db.verification_submissions.findOne({
where: { businessId: { [Op.in]: businessIds } },
order: [['createdAt', 'DESC']]
});
let verificationStatus = 'Not Started';
if (lastSubmission) {
verificationStatus = lastSubmission.status.charAt(0).toUpperCase() + lastSubmission.status.slice(1);
}
let accountStanding = 'Good';
if (firstBusiness.reliability_score < 70) accountStanding = 'Reviewing';
if (firstBusiness.reliability_score < 40) accountStanding = 'At Risk';
return {
totalViews: views30d,
activeLeads: newLeads24h,
conversionRate: winRate30d,
viewConversionRate,
verificationStatus,
verificationSubtext: lastSubmission?.status === 'APPROVED' ? 'Identity & Business verified' : 'Complete verification to build trust',
accountStanding,
accountStandingSubtext: accountStanding === 'Good' ? 'Perfect track record' : 'Contact support for details',
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: winRate30d
},
healthScore: Math.min(healthScore, 100)
};
}
static async getAdminMetrics() {
const totalUsers = await db.users.count();
const totalBusinesses = await db.businesses.count();
// Revenue as sum of prices of plans currently active on businesses
const businessesWithPlans = await db.businesses.findAll({
where: { planId: { [Op.ne]: null } },
include: [{ model: db.plans, as: 'plan' }]
});
const totalRevenue = businessesWithPlans.reduce((acc, curr) => {
return acc + (curr.plan ? parseFloat(curr.plan.price) : 0);
}, 0);
return {
totalUsers,
totalBusinesses,
totalRevenue
};
}
};

View File

@ -0,0 +1,227 @@
const axios = require('axios');
const config = require('../config');
const db = require('../db/models');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const path = require('path');
class GooglePlacesService {
constructor() {
this.apiKey = config.google.placesApiKey;
this.baseUrl = 'https://maps.googleapis.com/maps/api/place';
}
async searchPlaces(query, location) {
if (!this.apiKey) {
console.warn('Google Places API key is missing');
return [];
}
try {
const params = {
query,
key: this.apiKey,
};
if (location) {
params.location = location;
}
const response = await axios.get(`${this.baseUrl}/textsearch/json`, { params });
return response.data.results || [];
} catch (error) {
console.error('Error searching Google Places:', error.message);
return [];
}
}
async getPlaceDetails(placeId) {
if (!this.apiKey) return null;
try {
const response = await axios.get(`${this.baseUrl}/details/json`, {
params: {
place_id: placeId,
fields: 'name,formatted_address,address_components,formatted_phone_number,website,opening_hours,geometry,rating,types,photos,editorial_summary',
key: this.apiKey,
},
});
return response.data.result;
} catch (error) {
console.error('Error getting Google Place details:', error.message);
return null;
}
}
async importFromGoogle(googlePlace) {
const transaction = await db.sequelize.transaction();
try {
// Check if business already exists by google_place_id
let business = await db.businesses.findOne({
where: { google_place_id: googlePlace.place_id },
transaction,
});
if (business) {
// Even if it exists, we might want to update the description or hours if missing
if (!business.description || !business.hours_json) {
const details = await this.getPlaceDetails(googlePlace.place_id);
if (details) {
let updated = false;
if (!business.description && details.editorial_summary) {
business.description = details.editorial_summary.overview;
updated = true;
}
if (!business.hours_json && details.opening_hours) {
business.hours_json = JSON.stringify(details.opening_hours);
updated = true;
}
if (updated) {
await business.save({ transaction });
}
}
}
await transaction.commit();
return business;
}
// If it's a new business, let's fetch full details to get the description
const details = await this.getPlaceDetails(googlePlace.place_id);
const placeData = details || googlePlace;
// Try to parse city/state/zip from address if available
let city = null;
let state = null;
let zip = null;
const formattedAddress = placeData.formatted_address || googlePlace.formatted_address || googlePlace.vicinity;
if (formattedAddress) {
const parts = formattedAddress.split(',');
if (parts.length >= 3) {
// Typically "City, State Zip, Country" or "City, State, Country"
city = parts[parts.length - 3].trim();
const stateZip = parts[parts.length - 2].trim().split(' ');
if (stateZip.length >= 1) state = stateZip[0];
if (stateZip.length >= 2) zip = stateZip[1];
}
}
// Prepare business data
const businessData = {
id: uuidv4(),
name: placeData.name,
slug: placeData.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
address: formattedAddress,
city,
state,
zip,
lat: placeData.geometry?.location?.lat,
lng: placeData.geometry?.location?.lng,
google_place_id: googlePlace.place_id,
rating: placeData.rating || 0,
is_active: true,
is_claimed: false,
hours_json: placeData.opening_hours ? JSON.stringify(placeData.opening_hours) : null,
description: placeData.editorial_summary?.overview || null,
phone: placeData.formatted_phone_number || null,
website: placeData.website || null,
};
business = await db.businesses.create(businessData, { transaction });
// Handle categories/types
const types = placeData.types || googlePlace.types;
if (types) {
for (const type of types) {
// Find or create category
let category = await db.categories.findOne({
where: { name: { [db.Sequelize.Op.iLike]: type.replace(/_/g, ' ') } },
transaction,
});
if (!category) {
// Include beauty types AND common service types found on landing page
const allowedTypes = [
'beauty_salon', 'hair_care', 'spa', 'health', 'cosmetics', 'beauty_product', 'hair_salon', 'massage', 'nail_salon', 'skin_care',
'plumber', 'electrician', 'hvac_contractor', 'painter', 'home_improvement_contractor', 'general_contractor', 'cleaning_service', 'locksmith', 'roofing_contractor'
];
if (allowedTypes.includes(type)) {
category = await db.categories.create({
id: uuidv4(),
name: type.replace(/_/g, ' ').charAt(0).toUpperCase() + type.replace(/_/g, ' ').slice(1),
slug: type.replace(/_/g, '-'),
is_active: true,
}, { transaction });
}
}
if (category) {
await db.business_categories.create({
id: uuidv4(),
businessId: business.id,
categoryId: category.id,
}, { transaction });
}
}
}
// Handle photos
const photos = placeData.photos || googlePlace.photos;
if (photos && photos.length > 0) {
const photo = photos[0];
const photoReference = photo.photo_reference;
const photoUrl = `${this.baseUrl}/photo?maxwidth=800&photoreference=${photoReference}&key=${this.apiKey}`;
try {
const imageResponse = await axios({
method: 'get',
url: photoUrl,
responseType: 'arraybuffer'
});
const filename = `${uuidv4()}.jpg`;
const filePath = path.join(config.uploadDir, 'business_photos', 'photos', filename);
// Ensure directory exists
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, imageResponse.data);
const businessPhoto = await db.business_photos.create({
id: uuidv4(),
businessId: business.id,
}, { transaction });
await db.file.create({
id: uuidv4(),
name: filename,
sizeInBytes: imageResponse.data.length,
publicUrl: `business_photos/photos/${filename}`,
privateUrl: `business_photos/photos/${filename}`,
belongsTo: db.business_photos.getTableName(),
belongsToId: businessPhoto.id,
belongsToColumn: 'photos',
}, { transaction });
} catch (photoError) {
console.error('Error importing photo from Google:', photoError.message);
}
}
await transaction.commit();
return business;
} catch (error) {
await transaction.rollback();
console.error('Error importing from Google:', error);
throw error;
}
}
}
module.exports = new GooglePlacesService();

View File

@ -2,15 +2,12 @@ const db = require('../db/models');
const Lead_matchesDBApi = require('../db/api/lead_matches'); const Lead_matchesDBApi = require('../db/api/lead_matches');
const processFile = require("../middlewares/upload"); const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
module.exports = class Lead_matchesService { module.exports = class Lead_matchesService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -68,17 +65,25 @@ module.exports = class Lead_matchesService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
let lead_matches = await Lead_matchesDBApi.findBy( let lead_match = await Lead_matchesDBApi.findBy(
{id}, {id},
{transaction}, {transaction},
); );
if (!lead_matches) { if (!lead_match) {
throw new ValidationError( throw new ValidationError(
'lead_matchesNotFound', 'lead_matchesNotFound',
); );
} }
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
const business = await db.businesses.findByPk(lead_match.businessId, { transaction });
if (business && business.owner_userId !== currentUser.id) {
throw new ForbiddenError('forbidden');
}
}
const updatedLead_matches = await Lead_matchesDBApi.update( const updatedLead_matches = await Lead_matchesDBApi.update(
id, id,
data, data,
@ -134,5 +139,3 @@ module.exports = class Lead_matchesService {
}; };

View File

@ -2,20 +2,17 @@ const db = require('../db/models');
const LeadsDBApi = require('../db/api/leads'); const LeadsDBApi = require('../db/api/leads');
const processFile = require("../middlewares/upload"); const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
module.exports = class LeadsService { module.exports = class LeadsService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await LeadsDBApi.create( const lead = await LeadsDBApi.create(
data, data,
{ {
currentUser, currentUser,
@ -23,7 +20,19 @@ module.exports = class LeadsService {
}, },
); );
// If businessId is provided, create a LeadMatch immediately
if (data.businessId) {
await db.lead_matches.create({
leadId: lead.id,
businessId: data.businessId,
match_score: 100, // Direct request is 100% match
status: 'SENT',
sent_at: new Date()
}, { transaction });
}
await transaction.commit(); await transaction.commit();
return lead;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
@ -32,32 +41,24 @@ module.exports = class LeadsService {
static async bulkImport(req, res, sendInvitationEmails = true, host) { static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await processFile(req, res); await processFile(req, res);
const bufferStream = new stream.PassThrough(); const bufferStream = new stream.PassThrough();
const results = []; const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
.pipe(csv()) .pipe(csv())
.on('data', (data) => results.push(data)) .on('data', (data) => results.push(data))
.on('end', async () => { .on('end', async () => { resolve(); })
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error)); .on('error', (error) => reject(error));
}) })
await LeadsDBApi.bulkImport(results, { await LeadsDBApi.bulkImport(results, {
transaction, transaction,
ignoreDuplicates: true, ignoreDuplicates: true,
validate: true, validate: true,
currentUser: req.currentUser currentUser: req.currentUser
}); });
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
@ -68,29 +69,23 @@ module.exports = class LeadsService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
let leads = await LeadsDBApi.findBy( let leads = await LeadsDBApi.findBy({id}, {transaction});
{id}, if (!leads) { throw new ValidationError('leadsNotFound'); }
{transaction},
);
if (!leads) { // Ownership check for Verified Business Owner
throw new ValidationError( if (currentUser.app_role?.name === 'Verified Business Owner') {
'leadsNotFound', 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( const updatedLeads = await LeadsDBApi.update(id, data, { currentUser, transaction });
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit(); await transaction.commit();
return updatedLeads; return updatedLeads;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
@ -99,13 +94,22 @@ module.exports = class LeadsService {
static async deleteByIds(ids, currentUser) { static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await LeadsDBApi.deleteByIds(ids, { // Ownership check for Verified Business Owner
currentUser, if (currentUser.app_role?.name === 'Verified Business Owner') {
transaction, 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(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
@ -115,24 +119,23 @@ module.exports = class LeadsService {
static async remove(id, currentUser) { static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await LeadsDBApi.remove( // Ownership check for Verified Business Owner
id, if (currentUser.app_role?.name === 'Verified Business Owner') {
{ const match = await db.lead_matches.findOne({
currentUser, where: { leadId: id, businessId: currentUser.businessId },
transaction, transaction
}, });
); if (!match) {
throw new ForbiddenError('forbidden');
}
}
await LeadsDBApi.remove(id, { currentUser, transaction });
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
} }
}; };

View File

@ -2,20 +2,38 @@ const db = require('../db/models');
const MessagesDBApi = require('../db/api/messages'); const MessagesDBApi = require('../db/api/messages');
const processFile = require("../middlewares/upload"); const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
module.exports = class MessagesService { module.exports = class MessagesService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { 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, data,
{ {
currentUser, currentUser,
@ -24,6 +42,7 @@ module.exports = class MessagesService {
); );
await transaction.commit(); await transaction.commit();
return message;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
@ -38,14 +57,13 @@ module.exports = class MessagesService {
const bufferStream = new stream.PassThrough(); const bufferStream = new stream.PassThrough();
const results = []; 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) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
.pipe(csv()) .pipe(csv())
.on('data', (data) => results.push(data)) .on('data', (data) => results.push(data))
.on('end', async () => { .on('end', async () => {
console.log('CSV results', results);
resolve(); resolve();
}) })
.on('error', (error) => reject(error)); .on('error', (error) => reject(error));
@ -68,17 +86,38 @@ module.exports = class MessagesService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
let messages = await MessagesDBApi.findBy( let message = await MessagesDBApi.findBy(
{id}, {id},
{transaction}, {transaction},
); );
if (!messages) { if (!message) {
throw new ValidationError( throw new ValidationError(
'messagesNotFound', '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( const updatedMessages = await MessagesDBApi.update(
id, id,
data, data,
@ -101,6 +140,19 @@ module.exports = class MessagesService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { 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, { await MessagesDBApi.deleteByIds(ids, {
currentUser, currentUser,
transaction, transaction,
@ -117,6 +169,15 @@ module.exports = class MessagesService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { 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( await MessagesDBApi.remove(
id, id,
{ {
@ -134,5 +195,3 @@ module.exports = class MessagesService {
}; };

View File

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

View File

@ -2,20 +2,40 @@ const db = require('../db/models');
const ReviewsDBApi = require('../db/api/reviews'); const ReviewsDBApi = require('../db/api/reviews');
const processFile = require("../middlewares/upload"); const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
module.exports = class ReviewsService { module.exports = class ReviewsService {
static async updateBusinessRating(businessId, transaction) {
if (!businessId) return;
const reviews = await db.reviews.findAll({
where: { businessId, status: 'PUBLISHED' },
attributes: ['rating'],
transaction,
});
if (reviews.length === 0) {
await db.businesses.update({ rating: 0 }, { where: { id: businessId }, transaction });
return;
}
const totalRating = reviews.reduce((sum, review) => sum + review.rating, 0);
const averageRating = totalRating / reviews.length;
await db.businesses.update({ rating: averageRating }, { where: { id: businessId }, transaction });
}
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await ReviewsDBApi.create( // Set status to PUBLISHED by default for now, or use PENDING if moderation is needed
data.status = 'PUBLISHED';
const reviews = await ReviewsDBApi.create(
data, data,
{ {
currentUser, currentUser,
@ -23,7 +43,12 @@ module.exports = class ReviewsService {
}, },
); );
// Extract businessId from data or reviews object
const businessId = data.business || (reviews.business && reviews.business.id) || data.businessId;
await this.updateBusinessRating(businessId, transaction);
await transaction.commit(); await transaction.commit();
return reviews;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
@ -58,6 +83,9 @@ module.exports = class ReviewsService {
currentUser: req.currentUser currentUser: req.currentUser
}); });
// After bulk import, we might need to update ratings for all affected businesses
// For now, let's keep it simple.
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
@ -68,17 +96,31 @@ module.exports = class ReviewsService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
let reviews = await ReviewsDBApi.findBy( let review = await ReviewsDBApi.findBy(
{id}, {id},
{transaction}, {transaction},
); );
if (!reviews) { if (!review) {
throw new ValidationError( throw new ValidationError(
'reviewsNotFound', '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 updatedReviews = await ReviewsDBApi.update( const updatedReviews = await ReviewsDBApi.update(
id, id,
data, data,
@ -88,6 +130,9 @@ module.exports = class ReviewsService {
}, },
); );
const businessId = (updatedReviews.business && updatedReviews.business.id) || updatedReviews.businessId || review.businessId;
await this.updateBusinessRating(businessId, transaction);
await transaction.commit(); await transaction.commit();
return updatedReviews; return updatedReviews;
@ -101,11 +146,39 @@ module.exports = class ReviewsService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
// Get businessIds before deleting
const reviews = await db.reviews.findAll({
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, { await ReviewsDBApi.deleteByIds(ids, {
currentUser, currentUser,
transaction, transaction,
}); });
for (const businessId of businessIds) {
await this.updateBusinessRating(businessId, transaction);
}
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
@ -117,6 +190,27 @@ module.exports = class ReviewsService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { 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( await ReviewsDBApi.remove(
id, id,
{ {
@ -125,14 +219,12 @@ module.exports = class ReviewsService {
}, },
); );
await this.updateBusinessRating(businessId, transaction);
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
} }
}; };

View File

@ -7,9 +7,6 @@ const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
function buildWidgetResult(widget, queryResult, queryString) { function buildWidgetResult(widget, queryResult, queryString) {
if (queryResult[0] && queryResult[0].length) { if (queryResult[0] && queryResult[0].length) {
const key = Object.keys(queryResult[0][0])[0]; const key = Object.keys(queryResult[0][0])[0];
@ -17,14 +14,14 @@ function buildWidgetResult(widget, queryResult, queryString) {
const widgetData = JSON.parse(widget.data); const widgetData = JSON.parse(widget.data);
return { ...widget, ...widgetData, value, query: queryString }; return { ...widget, ...widgetData, value, query: queryString };
} else { } 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 { try {
return await db.sequelize.query(queryString, { return await db.sequelize.query(queryString, {
replacements: { organizationId: currentUser.organizationId }, replacements,
}); });
} catch (e) { } catch (e) {
console.log(e); console.log(e);
@ -49,19 +46,46 @@ function insertWhereConditions(queryString, whereConditions) {
} }
function constructWhereConditions(mainTable, currentUser, replacements) { 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']; const tablesWithoutOrgId = ['permissions', 'roles'];
let whereConditions = ''; let conditions = [];
if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) { if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) {
whereConditions += `"${mainTable}"."organizationId" = :organizationId`; if (organizationId) {
replacements.organizationId = organizationId; conditions.push(`"${mainTable}"."organizationId" = :organizationId`);
replacements.organizationId = organizationId;
}
} }
whereConditions += whereConditions ? ' AND ' : ''; // Business User isolation
whereConditions += `"${mainTable}"."deletedAt" IS NULL`; 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) { function extractTableName(queryString) {
@ -77,16 +101,15 @@ function buildQueryString(widget, currentUser) {
const replacements = {}; const replacements = {};
const whereConditions = constructWhereConditions(mainTable, currentUser, replacements); const whereConditions = constructWhereConditions(mainTable, currentUser, replacements);
queryString = insertWhereConditions(queryString, whereConditions); queryString = insertWhereConditions(queryString, whereConditions);
console.log(queryString, 'queryString'); return { queryString, replacements };
return queryString;
} }
async function constructWidgetsResults(widgets, currentUser) { async function constructWidgetsResults(widgets, currentUser) {
const widgetsResults = []; const widgetsResults = [];
for (const widget of widgets) { for (const widget of widgets) {
if (!widget) continue; if (!widget) continue;
const queryString = buildQueryString(widget, currentUser); const { queryString, replacements } = buildQueryString(widget, currentUser);
const queryResult = await executeQuery(queryString, currentUser); const queryResult = await executeQuery(queryString, replacements);
widgetsResults.push(buildWidgetResult(widget, queryResult, queryString)); widgetsResults.push(buildWidgetResult(widget, queryResult, queryString));
} }
return widgetsResults; return widgetsResults;
@ -107,30 +130,6 @@ async function processWidgets(widgets, currentUser) {
return constructWidgetsResults(widgetData, 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 { module.exports = class RolesService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -158,14 +157,13 @@ module.exports = class RolesService {
const bufferStream = new stream.PassThrough(); const bufferStream = new stream.PassThrough();
const results = []; 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) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
.pipe(csv()) .pipe(csv())
.on('data', (data) => results.push(data)) .on('data', (data) => results.push(data))
.on('end', async () => { .on('end', async () => {
console.log('CSV results', results);
resolve(); resolve();
}) })
.on('error', (error) => reject(error)); .on('error', (error) => reject(error));
@ -362,11 +360,6 @@ module.exports = class RolesService {
static async getRoleInfoByKey(key, roleId, currentUser) { static async getRoleInfoByKey(key, roleId, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
const organizationId = currentUser.organizationId;
let globalAccess = currentUser.app_role?.globalAccess;
let queryString = '';
let role; let role;
try { try {
if (roleId) { if (roleId) {
@ -381,7 +374,7 @@ module.exports = class RolesService {
throw error; throw error;
} }
let customization = '{}'; let customization = {};
try { try {
customization = JSON.parse(role.role_customization || '{}'); customization = JSON.parse(role.role_customization || '{}');
@ -391,47 +384,8 @@ module.exports = class RolesService {
if (key === 'widgets') { if (key === 'widgets') {
const widgets = (customization[key] || []); const widgets = (customization[key] || []);
const widgetArray = widgets.map(widget => { return await processWidgets(widgets, currentUser);
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 customization[key]; return customization[key];
} }
}; };

View File

@ -1,535 +1,201 @@
const db = require('../db/models'); const db = require('../db/models');
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const RolesDBApi = require('../db/api/roles');
const GooglePlacesService = require('./googlePlaces');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
// Cache for the 'Public' role object
let publicRoleCache = null;
async function getPublicRole() {
if (publicRoleCache) return publicRoleCache;
publicRoleCache = await RolesDBApi.findBy({ name: 'Public' });
return publicRoleCache;
}
/** /**
* @param {string} permission * @param {string} permission
* @param {object} currentUser * @param {object} currentUser
*/ */
async function checkPermissions(permission, currentUser) { async function checkPermissions(permission, currentUser) {
let role = null;
if (!currentUser) { if (currentUser) {
throw new ValidationError('auth.unauthorized'); const userPermission = currentUser.custom_permissions?.find(
(cp) => cp.name === permission,
);
if (userPermission) return true;
role = currentUser.app_role;
} else {
role = await getPublicRole();
} }
const userPermission = currentUser.custom_permissions.find( if (!role) {
(cp) => cp.name === permission, return false;
);
if (userPermission) {
return true;
} }
try { try {
if (!currentUser.app_role) { let permissions = [];
throw new ValidationError('auth.forbidden'); if (typeof role.getPermissions === 'function') {
permissions = await role.getPermissions();
} else {
permissions = role.permissions || [];
} }
const permissions = await currentUser.app_role.getPermissions();
return !!permissions.find((p) => p.name === permission); return !!permissions.find((p) => p.name === permission);
} catch (e) { } catch (e) {
throw e; console.error("Search permission check error:", e);
return false;
} }
} }
module.exports = class SearchService { module.exports = class SearchService {
static async search(searchQuery, currentUser ) { static async search(searchQuery, currentUser, location ) {
try { try {
if (!searchQuery) { if (!searchQuery) {
throw new ValidationError('iam.errors.searchQueryRequired'); throw new ValidationError('iam.errors.searchQueryRequired');
} }
const tableColumns = {
const roleName = currentUser?.app_role?.name || 'Public';
const isAdmin = roleName === 'Administrator' || roleName === 'Platform Owner';
// Columns that can be searched using iLike
const searchableColumns = {
"users": [ "users": [
"firstName", "firstName",
"lastName", "lastName",
"phoneNumber", "phoneNumber",
"email", "email",
], ],
"refresh_tokens": [
"token_hash",
"ip_address",
"user_agent",
],
"categories": [ "categories": [
"name", "name",
"slug", "slug",
"icon",
"description", "description",
"tenant_key",
], ],
"locations": [ "locations": [
"label", "label",
"city", "city",
"state", "state",
"zip", "zip",
"tenant_key",
], ],
"businesses": [ "businesses": [
"name", "name",
"slug", "slug",
"description", "description",
"phone", "phone",
"email", "email",
"website", "website",
"address", "address",
"city", "city",
"state", "state",
"zip", "zip",
"hours_json",
"reliability_breakdown_json",
"tenant_key",
], ],
"service_prices": [
"service_name",
"notes",
],
"business_badges": [
"notes",
],
"verification_submissions": [
"notes",
"admin_notes",
],
"verification_evidences": [
"url",
],
"leads": [
"keyword",
"description",
"contact_name",
"contact_phone",
"contact_email",
"address",
"city",
"state",
"zip",
"inferred_tags_json",
"tenant_key",
],
"messages": [
"body",
],
"lead_events": [
"from_value",
"to_value",
"meta_json",
],
"reviews": [
"text",
"moderation_notes",
],
"disputes": [
"reason",
"resolution_notes",
],
"audit_logs": [
"action",
"entity_type",
"entity_key",
"meta_json",
"tenant_key",
],
"badge_rules": [
"required_evidence_json",
"tenant_key",
],
"trust_adjustments": [
"reason",
],
}; };
const columnsInt = {
"locations": [
"lat",
"lng",
],
// All columns to be returned in the result
const returnColumns = {
"users": [...searchableColumns.users],
"categories": [...searchableColumns.categories, "icon"],
"locations": [...searchableColumns.locations],
"businesses": [ "businesses": [
...searchableColumns.businesses,
"is_claimed",
"rating",
"lat",
"lng",
"reliability_score",
"response_time_median_minutes"
],
};
const columnsInt = {
"businesses": [
"lat", "lat",
"lng", "lng",
"reliability_score", "reliability_score",
"response_time_median_minutes", "response_time_median_minutes",
], ],
"service_prices": [
"min_price",
"max_price",
"typical_price",
],
"leads": [
"lat",
"lng",
],
"lead_matches": [
"match_score",
],
"reviews": [
"rating",
],
"trust_adjustments": [
"delta",
],
}; };
let allFoundRecords = []; let allFoundRecords = [];
for (const tableName in tableColumns) { for (const tableName in searchableColumns) {
if (tableColumns.hasOwnProperty(tableName)) { if (searchableColumns.hasOwnProperty(tableName)) {
const attributesToSearch = tableColumns[tableName]; const attributesToSearch = searchableColumns[tableName];
const attributesIntToSearch = columnsInt[tableName] || []; const attributesIntToSearch = columnsInt[tableName] || [];
const searchConditions = [
...attributesToSearch.map(attribute => ({
[attribute]: {
[Op.iLike] : `%${searchQuery}%`,
},
})),
...attributesIntToSearch.map(attribute => (
Sequelize.where(
Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'),
{ [Op.iLike]: `%${searchQuery}%` }
)
)),
];
const whereCondition = { const whereCondition = {
[Op.or]: [ [Op.or]: searchConditions,
...attributesToSearch.map(attribute => ({
[attribute]: {
[Op.iLike] : `%${searchQuery}%`,
},
})),
...attributesIntToSearch.map(attribute => (
Sequelize.where(
Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'),
{ [Op.iLike]: `%${searchQuery}%` }
)
)),
],
}; };
// Only show active businesses for non-admins
if (tableName === 'businesses' && !isAdmin) {
whereCondition[Op.and] = whereCondition[Op.and] || [];
whereCondition[Op.and].push({ is_active: true });
}
// If location is provided, bias local results by location for businesses and locations
if (location && (tableName === 'businesses' || tableName === 'locations')) {
const locationConditions = [
{ zip: { [Op.iLike]: `%${location}%` } },
{ city: { [Op.iLike]: `%${location}%` } },
{ state: { [Op.iLike]: `%${location}%` } },
];
const hasPermission = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser); // locations table doesn't have address column
if (!hasPermission) { if (tableName === 'businesses') {
locationConditions.push({ address: { [Op.iLike]: `%${location}%` } });
}
whereCondition[Op.and] = whereCondition[Op.and] || [];
whereCondition[Op.and].push({
[Op.or]: locationConditions
});
}
const hasPerm = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser);
if (!hasPerm) {
continue; continue;
} }
const include = [];
if (tableName === 'businesses') {
include.push({
model: db.business_photos,
as: 'business_photos_business',
include: [{
model: db.file,
as: 'photos'
}]
});
}
const foundRecords = await db[tableName].findAll({ const foundRecords = await db[tableName].findAll({
where: whereCondition, where: whereCondition,
attributes: [...tableColumns[tableName], 'id', ...attributesIntToSearch], attributes: [...returnColumns[tableName], 'id'],
include
}); });
const modifiedRecords = foundRecords.map((record) => { const modifiedRecords = foundRecords.map((record) => {
const matchAttribute = []; const matchAttribute = [];
for (const attribute of attributesToSearch) { for (const attribute of attributesToSearch) {
if (record[attribute]?.toLowerCase()?.includes(searchQuery.toLowerCase())) { if (record[attribute] && typeof record[attribute] === 'string' && record[attribute].toLowerCase().includes(searchQuery.toLowerCase())) {
matchAttribute.push(attribute); matchAttribute.push(attribute);
} }
} }
@ -552,6 +218,84 @@ module.exports = class SearchService {
} }
} }
// Special case: if we found categories, find businesses in those categories
const foundCategories = allFoundRecords.filter(r => r.tableName === 'categories');
if (foundCategories.length > 0) {
const categoryIds = foundCategories.map(c => c.id);
const businessesInCategories = await db.businesses.findAll({
where: !isAdmin ? { is_active: true } : {},
include: [
{
model: db.business_categories,
as: 'business_categories_business',
where: { categoryId: { [Op.in]: categoryIds } }
},
{
model: db.business_photos,
as: 'business_photos_business',
include: [{
model: db.file,
as: 'photos'
}]
}
]
});
for (const biz of businessesInCategories) {
if (!allFoundRecords.find(r => r.id === biz.id && r.tableName === 'businesses')) {
allFoundRecords.push({
...biz.get(),
matchAttribute: ['category'],
tableName: 'businesses',
});
}
}
}
// If few local businesses found, try Google Places
const localBusinessesCount = allFoundRecords.filter(r => r.tableName === 'businesses').length;
if (localBusinessesCount < 5) {
try {
// If no location in search query, try to append a default location to help Google
let refinedQuery = searchQuery;
if (location) {
refinedQuery = `${searchQuery} ${location}`;
} else if (!searchQuery.match(/\d{5}/) && !searchQuery.match(/in\s+[A-Za-z]+/i)) {
refinedQuery = `${searchQuery} USA`; // Search nationwide if no location specified
}
const googleResults = await GooglePlacesService.searchPlaces(refinedQuery);
for (const gPlace of googleResults) {
// Import each place to our DB
const importedBusiness = await GooglePlacesService.importFromGoogle(gPlace);
// Re-fetch with associations to get photos
const fullBusiness = await db.businesses.findByPk(importedBusiness.id, {
include: [{
model: db.business_photos,
as: 'business_photos_business',
include: [{
model: db.file,
as: 'photos'
}]
}]
});
// Add to search results if not already there
if (fullBusiness && !allFoundRecords.find(r => r.id === fullBusiness.id)) {
allFoundRecords.push({
...fullBusiness.get(),
matchAttribute: ['name'],
tableName: 'businesses',
isFromGoogle: true,
});
}
}
} catch (gError) {
console.error("Google Search fallback error:", gError);
}
}
return allFoundRecords; return allFoundRecords;
} catch (error) { } catch (error) {
throw error; throw error;

View File

@ -2,20 +2,27 @@ const db = require('../db/models');
const Service_pricesDBApi = require('../db/api/service_prices'); const Service_pricesDBApi = require('../db/api/service_prices');
const processFile = require("../middlewares/upload"); const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
module.exports = class Service_pricesService { module.exports = class Service_pricesService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { 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, data,
{ {
currentUser, currentUser,
@ -24,6 +31,7 @@ module.exports = class Service_pricesService {
); );
await transaction.commit(); await transaction.commit();
return service_price;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
@ -38,14 +46,13 @@ module.exports = class Service_pricesService {
const bufferStream = new stream.PassThrough(); const bufferStream = new stream.PassThrough();
const results = []; 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) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
.pipe(csv()) .pipe(csv())
.on('data', (data) => results.push(data)) .on('data', (data) => results.push(data))
.on('end', async () => { .on('end', async () => {
console.log('CSV results', results);
resolve(); resolve();
}) })
.on('error', (error) => reject(error)); .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( const updatedService_prices = await Service_pricesDBApi.update(
id, id,
data, data,
@ -101,6 +117,20 @@ module.exports = class Service_pricesService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { 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, { await Service_pricesDBApi.deleteByIds(ids, {
currentUser, currentUser,
transaction, transaction,
@ -117,6 +147,18 @@ module.exports = class Service_pricesService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { 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( await Service_pricesDBApi.remove(
id, id,
{ {
@ -134,5 +176,3 @@ 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 Verification_submissionsDBApi = require('../db/api/verification_submissions');
const processFile = require("../middlewares/upload"); const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const stream = require('stream'); const stream = require('stream');
module.exports = class Verification_submissionsService { module.exports = class Verification_submissionsService {
static async create(data, currentUser) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { 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, data,
{ {
currentUser, currentUser,
@ -24,6 +31,7 @@ module.exports = class Verification_submissionsService {
); );
await transaction.commit(); await transaction.commit();
return submission;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
@ -38,14 +46,13 @@ module.exports = class Verification_submissionsService {
const bufferStream = new stream.PassThrough(); const bufferStream = new stream.PassThrough();
const results = []; 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) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
.pipe(csv()) .pipe(csv())
.on('data', (data) => results.push(data)) .on('data', (data) => results.push(data))
.on('end', async () => { .on('end', async () => {
console.log('CSV results', results);
resolve(); resolve();
}) })
.on('error', (error) => reject(error)); .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( const updatedVerification_submissions = await Verification_submissionsDBApi.update(
id, id,
data, data,
@ -101,6 +117,20 @@ module.exports = class Verification_submissionsService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { 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, { await Verification_submissionsDBApi.deleteByIds(ids, {
currentUser, currentUser,
transaction, transaction,
@ -117,6 +147,18 @@ module.exports = class Verification_submissionsService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { 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( await Verification_submissionsDBApi.remove(
id, id,
{ {
@ -134,5 +176,3 @@ module.exports = class Verification_submissionsService {
}; };

View File

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

View File

@ -4,6 +4,14 @@
const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone'; const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone';
const nextConfig = { const nextConfig = {
async rewrites() {
return [
{
source: '/about',
destination: '/web_pages/about',
},
];
},
trailingSlash: true, trailingSlash: true,
distDir: 'build', distDir: 'build',
output, output,

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

View File

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

View File

@ -5,6 +5,7 @@ import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks' import { useAppSelector } from '../stores/hooks'
import Link from 'next/link'; import Link from 'next/link';
import Logo from './Logo'
type Props = { type Props = {
@ -37,11 +38,10 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
<div <div
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`} className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
> >
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0"> <div className="text-center flex-1 flex items-center justify-center">
<Link href="/">
<b className="font-black">Crafted Network</b> <Logo className="h-8 w-auto" />
</Link>
</div> </div>
<button <button
className="hidden lg:inline-block xl:hidden p-3" className="hidden lg:inline-block xl:hidden p-3"

View File

@ -18,7 +18,11 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
return ( return (
<ul className={className}> <ul className={className}>
{menu.map((item, index) => { {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; if (!hasPermission(currentUser, item.permissions)) return null;
return ( return (

View File

@ -7,9 +7,9 @@ type Props = {
export default function Logo({ className = '' }: Props) { export default function Logo({ className = '' }: Props) {
return ( return (
<img <img
src={"https://flatlogic.com/logo.svg"} src={"/logo.png"}
className={className} className={className}
alt={'Flatlogic logo'}> alt={'Fix-It-Local logo'}>
</img> </img>
) )
} }

View File

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

View File

@ -0,0 +1,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

@ -1,16 +1,9 @@
import React from 'react'; import React from 'react';
import BaseIcon from '../BaseIcon';
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
import axios from 'axios'; import axios from 'axios';
import { import {
GridActionsCellItem,
GridRowParams, GridRowParams,
GridValueGetterParams, GridValueGetterParams,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import ImageField from '../ImageField';
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter'
import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover'; import ListActionsPopover from '../ListActionsPopover';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
@ -38,9 +31,9 @@ export const loadColumns = async (
} }
const hasUpdatePermission = hasPermission(user, 'UPDATE_REVIEWS') const hasUpdatePermission = hasPermission(user, 'UPDATE_REVIEWS')
const isBusinessOwner = user?.app_role?.name === 'Verified Business Owner';
return [ const columns: any[] = [
{ {
field: 'business', field: 'business',
headerName: 'Business', headerName: 'Business',
@ -49,10 +42,7 @@ export const loadColumns = async (
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission && !isBusinessOwner,
editable: hasUpdatePermission,
sortable: false, sortable: false,
type: 'singleSelect', type: 'singleSelect',
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
@ -60,9 +50,7 @@ export const loadColumns = async (
valueOptions: await callOptionsApi('businesses'), valueOptions: await callOptionsApi('businesses'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (params: GridValueGetterParams) =>
params?.value?.id ?? params?.value, params?.value?.id ?? params?.value,
}, },
{ {
field: 'user', field: 'user',
headerName: 'User', headerName: 'User',
@ -71,10 +59,7 @@ export const loadColumns = async (
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission && !isBusinessOwner,
editable: hasUpdatePermission,
sortable: false, sortable: false,
type: 'singleSelect', type: 'singleSelect',
getOptionValue: (value: any) => value?.id, getOptionValue: (value: any) => value?.id,
@ -82,144 +67,89 @@ export const loadColumns = async (
valueOptions: await callOptionsApi('users'), valueOptions: await callOptionsApi('users'),
valueGetter: (params: GridValueGetterParams) => valueGetter: (params: GridValueGetterParams) =>
params?.value?.id ?? params?.value, params?.value?.id ?? params?.value,
}, },
{
field: 'lead',
headerName: 'Lead',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect',
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('leads'),
valueGetter: (params: GridValueGetterParams) =>
params?.value?.id ?? params?.value,
},
{ {
field: 'rating', field: 'rating',
headerName: 'Rating', headerName: 'Rating',
flex: 1, flex: 0.5,
minWidth: 120, minWidth: 80,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission && !isBusinessOwner,
editable: hasUpdatePermission,
type: 'number', type: 'number',
}, },
{ {
field: 'text', field: 'text',
headerName: 'Text', headerName: 'Review',
flex: 1, flex: 2,
minWidth: 120, minWidth: 200,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission && !isBusinessOwner,
editable: hasUpdatePermission,
}, },
{ {
field: 'is_verified_job', field: 'response',
headerName: 'IsVerifiedJob', headerName: 'Your Response',
flex: 1, flex: 2,
minWidth: 120, minWidth: 200,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission, editable: hasUpdatePermission,
type: 'boolean',
}, },
{ {
field: 'status', field: 'status',
headerName: 'Status', headerName: 'Status',
flex: 1, flex: 1,
minWidth: 120, minWidth: 100,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission && !isBusinessOwner,
}
];
if (!isBusinessOwner) {
columns.push(
{
field: 'is_verified_job',
headerName: 'Verified',
flex: 0.5,
minWidth: 80,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'moderation_notes',
headerName: 'Moderation Notes',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
}
);
}
editable: hasUpdatePermission, columns.push(
},
{ {
field: 'moderation_notes', field: 'createdAt',
headerName: 'ModerationNotes', headerName: 'Date',
flex: 1, flex: 1,
minWidth: 120, minWidth: 150,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'created_at_ts',
headerName: 'CreatedAt',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime', type: 'dateTime',
valueGetter: (params: GridValueGetterParams) => valueGetter: (params: GridValueGetterParams) =>
new Date(params.row.created_at_ts), new Date(params.row.createdAt),
}, },
{
field: 'updated_at_ts',
headerName: 'UpdatedAt',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime',
valueGetter: (params: GridValueGetterParams) =>
new Date(params.row.updated_at_ts),
},
{ {
field: 'actions', field: 'actions',
type: 'actions', type: 'actions',
@ -227,7 +157,6 @@ export const loadColumns = async (
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => { getActions: (params: GridRowParams) => {
return [ return [
<div key={params?.row?.id}> <div key={params?.row?.id}>
<ListActionsPopover <ListActionsPopover
@ -235,13 +164,13 @@ export const loadColumns = async (
itemId={params?.row?.id} itemId={params?.row?.id}
pathEdit={`/reviews/reviews-edit/?id=${params?.row?.id}`} pathEdit={`/reviews/reviews-edit/?id=${params?.row?.id}`}
pathView={`/reviews/reviews-view/?id=${params?.row?.id}`} pathView={`/reviews/reviews-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission} hasUpdatePermission={hasUpdatePermission}
/> />
</div>, </div>,
] ]
}, },
}, }
]; );
return columns;
}; };

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 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}` export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle}${appTitle}`

View File

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

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'
@ -88,6 +87,30 @@ export default function LayoutAuthenticated({
const layoutAsidePadding = 'xl:pl-60' const layoutAsidePadding = 'xl:pl-60'
// Filter and customize menu for Verified Business Owner
const filteredMenu = menuAside.filter(item => {
if (currentUser?.app_role?.name === 'Verified Business Owner') {
const allowedPaths = [
'/dashboard',
'/my-listing',
'/leads/leads-list',
'/reviews/reviews-list',
'/messages/messages-list',
'/profile',
'/billing',
];
return allowedPaths.includes(item.href);
}
return true;
}).map(item => {
if (currentUser?.app_role?.name === 'Verified Business Owner') {
if (item.href === '/leads/leads-list') {
return { ...item, label: 'Service Requests' };
}
}
return item;
});
return ( return (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}> <div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
<div <div
@ -118,7 +141,7 @@ export default function LayoutAuthenticated({
<AsideMenu <AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded} isAsideMobileExpanded={isAsideMobileExpanded}
isAsideLgActive={isAsideLgActive} isAsideLgActive={isAsideLgActive}
menu={menuAside} menu={filteredMenu}
onAsideLgClose={() => setIsAsideLgActive(false)} onAsideLgClose={() => setIsAsideLgActive(false)}
/> />
{children} {children}

View File

@ -1,17 +1,111 @@
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react';
import { useAppSelector } from '../stores/hooks' import Link from 'next/link';
import { useRouter } from 'next/router';
import { mdiShieldCheck, mdiMenu, mdiClose, mdiMagnify } from '@mdi/js';
import { useAppSelector } from '../stores/hooks';
import BaseIcon from '../components/BaseIcon';
import Logo from '../components/Logo';
type Props = { type Props = {
children: ReactNode children: ReactNode
} }
export default function LayoutGuest({ children }: Props) { export default function LayoutGuest({ children }: Props) {
const darkMode = useAppSelector((state) => state.style.darkMode) const darkMode = useAppSelector((state) => state.style.darkMode);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor); const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const { currentUser } = useAppSelector((state) => state.auth);
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const router = useRouter();
const navLinks = [
{ href: '/search', label: 'Find Services' },
{ href: '/register', label: 'List Business' },
];
return ( return (
<div className={darkMode ? 'dark' : ''}> <div className={`${darkMode ? 'dark' : ''} min-h-screen flex flex-col`}>
<div className={`${bgColor} dark:bg-slate-800 dark:text-slate-100`}>{children}</div> {/* Dynamic Header */}
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-lg border-b border-slate-200/60 dark:bg-slate-900/80 dark:border-slate-800">
<div className="container mx-auto px-6 h-20 flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group">
<Logo className="h-10 w-auto" />
</Link>
<nav className="hidden md:flex items-center gap-10">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`text-sm font-bold uppercase tracking-widest hover:text-emerald-500 transition-colors ${router.pathname === link.href ? 'text-emerald-500' : 'text-slate-600 dark:text-slate-400'}`}
>
{link.label}
</Link>
))}
<div className="h-6 w-px bg-slate-200 dark:bg-slate-800"></div>
{currentUser ? (
<Link href="/dashboard" className="bg-slate-900 text-white dark:bg-white dark:text-slate-900 px-6 py-3 rounded-xl text-sm font-bold hover:shadow-xl transition-all">
Go to Dashboard
</Link>
) : (
<div className="flex items-center gap-4">
<Link href="/login" className="text-sm font-bold text-slate-600 hover:text-emerald-500 transition-colors">
Login
</Link>
<Link href="/register" className="bg-emerald-500 text-white px-6 py-3 rounded-xl text-sm font-bold hover:bg-emerald-600 shadow-lg shadow-emerald-500/20 transition-all">
Join Now
</Link>
</div>
)}
</nav>
<button className="md:hidden p-2 text-slate-600 dark:text-slate-400" onClick={() => setIsMenuOpen(!isMenuOpen)}>
<BaseIcon path={isMenuOpen ? mdiClose : mdiMenu} size={28} />
</button>
</div>
{isMenuOpen && (
<div className="md:hidden bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 p-6 space-y-6 animate-fade-in">
{navLinks.map((link) => (
<Link key={link.href} href={link.href} className="block text-lg font-bold text-slate-700 dark:text-slate-300" onClick={() => setIsMenuOpen(false)}>
{link.label}
</Link>
))}
<div className="pt-6 border-t border-slate-100 dark:border-slate-800 flex flex-col gap-4">
{currentUser ? (
<Link href="/dashboard" className="w-full bg-slate-900 text-white py-4 rounded-xl text-center font-bold" onClick={() => setIsMenuOpen(false)}>Dashboard</Link>
) : (
<>
<Link href="/login" className="w-full text-center py-4 font-bold text-slate-600" onClick={() => setIsMenuOpen(false)}>Login</Link>
<Link href="/register" className="w-full bg-emerald-500 text-white py-4 rounded-xl text-center font-bold" onClick={() => setIsMenuOpen(false)}>Join Now</Link>
</>
)}
</div>
</div>
)}
</header>
<main className={`flex-grow ${bgColor} dark:bg-slate-800 dark:text-slate-100`}>
{children}
</main>
<footer className="bg-white border-t border-slate-200 py-12 dark:bg-slate-900 dark:border-slate-800">
<div className="container mx-auto px-6">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="flex items-center mb-6 md:mb-0">
<Logo className="h-10 w-auto" />
</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>
<Link href="/register" className="hover:text-emerald-500">List Business</Link>
<Link href="/privacy-policy" className="hover:text-emerald-500">Privacy</Link>
<Link href="/terms-of-use" className="hover:text-emerald-500">Terms</Link>
</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 Fix-It-Local. Built with Trust & Transparency.
</div>
</div>
</footer>
</div> </div>
) )
} }

View File

@ -2,209 +2,142 @@ import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces' import { MenuAsideItem } from './interfaces'
const menuAside: MenuAsideItem[] = [ const menuAside: MenuAsideItem[] = [
// Common
{ {
href: '/dashboard', href: '/dashboard',
icon: icon.mdiViewDashboardOutline, icon: icon.mdiStarFourPoints,
label: 'Dashboard', 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', href: '/users/users-list',
label: 'Users', label: 'Clients',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiAccountHeart,
// @ts-ignore permissions: 'READ_USERS',
icon: icon.mdiAccountGroup ?? icon.mdiTable, roles: ['Administrator', 'Platform Owner']
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'
}, },
{ {
href: '/categories/categories-list', href: '/categories/categories-list',
label: 'Categories', label: 'Beauty Categories',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiLipstick,
// @ts-ignore permissions: 'READ_CATEGORIES',
icon: 'mdiShape' in icon ? icon['mdiShape' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, roles: ['Administrator', 'Platform Owner']
permissions: 'READ_CATEGORIES'
}, },
{ {
href: '/locations/locations-list', href: '/locations/locations-list',
label: 'Locations', label: 'Regions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiMapMarkerRadius,
// @ts-ignore permissions: 'READ_LOCATIONS',
icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, roles: ['Administrator', 'Platform Owner']
permissions: 'READ_LOCATIONS'
}, },
// Admin and Platform Owner see all service listings
{ {
href: '/businesses/businesses-list', href: '/businesses/businesses-list',
label: 'Businesses', label: 'Service Listings',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiStorefront,
// @ts-ignore permissions: 'READ_BUSINESSES',
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, roles: ['Administrator', 'Platform Owner']
permissions: 'READ_BUSINESSES'
}, },
// Claim Requests (Admin Only)
{ {
href: '/business_photos/business_photos-list', href: '/claim_requests/claim_requests-list',
label: 'Business photos', label: 'Claim Requests',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiShieldCheck,
// @ts-ignore permissions: 'READ_CLAIM_REQUESTS',
icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, roles: ['Administrator', 'Platform Owner']
permissions: 'READ_BUSINESS_PHOTOS'
}, },
// Business Owner sees their My Listing page
{ {
href: '/business_categories/business_categories-list', href: '/my-listing',
label: 'Business categories', label: 'My Listing',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiStorefront,
// @ts-ignore roles: ['Verified Business Owner']
icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_BUSINESS_CATEGORIES'
}, },
{ {
href: '/service_prices/service_prices-list', href: '/leads/leads-list',
label: 'Service prices', label: 'Service Requests',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiCalendarHeart,
// @ts-ignore permissions: 'READ_LEADS',
icon: 'mdiCurrencyUsd' in icon ? icon['mdiCurrencyUsd' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, roles: ['Verified Business Owner']
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'
}, },
{ {
href: '/leads/leads-list', href: '/leads/leads-list',
label: 'Leads', label: 'Leads',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiCalendarHeart,
// @ts-ignore permissions: 'READ_LEADS',
icon: 'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, roles: ['Administrator', 'Platform Owner']
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'
}, },
{ {
href: '/messages/messages-list', href: '/messages/messages-list',
label: 'Messages', label: 'Consultations',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiMessageProcessing,
// @ts-ignore
icon: 'mdiMessageText' in icon ? icon['mdiMessageText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_MESSAGES' permissions: 'READ_MESSAGES'
}, },
{ {
href: '/lead_events/lead_events-list', href: '/reviews/reviews-list',
label: 'Lead events', label: 'Love Letters',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiStarFace,
// @ts-ignore permissions: 'READ_REVIEWS',
icon: 'mdiTimelineText' in icon ? icon['mdiTimelineText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
permissions: 'READ_LEAD_EVENTS'
}, },
{ {
href: '/reviews/reviews-list', href: '/reviews/reviews-list',
label: 'Reviews', label: 'Client Love',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiStarFace,
// @ts-ignore permissions: 'READ_REVIEWS',
icon: 'mdiStar' in icon ? icon['mdiStar' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, roles: ['Verified Business Owner']
permissions: 'READ_REVIEWS' },
{
href: '/verification_submissions/verification_submissions-list',
label: 'Verification',
icon: icon.mdiShieldCheck,
permissions: 'READ_VERIFICATION_SUBMISSIONS',
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
},
// Placeholder for Billing and Team
{
href: '/billing',
label: 'Earnings',
icon: icon.mdiWallet,
roles: ['Verified Business Owner']
}, },
{ {
href: '/disputes/disputes-list', href: '/billing-settings',
label: 'Disputes', label: 'Global Billing',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiFinance,
// @ts-ignore roles: ['Administrator', 'Platform Owner']
icon: 'mdiAlertOctagon' in icon ? icon['mdiAlertOctagon' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_DISPUTES'
}, },
// Moderator
{ {
href: '/audit_logs/audit_logs-list', href: '/audit_logs/audit_logs-list',
label: 'Audit logs', label: 'Audit logs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment icon: icon.mdiClipboardListOutline,
// @ts-ignore permissions: 'READ_AUDIT_LOGS',
icon: 'mdiClipboardList' in icon ? icon['mdiClipboardList' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, roles: ['Administrator', 'Platform Owner']
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'
}, },
// Profile/Settings
{ {
href: '/profile', href: '/profile',
label: 'Profile', label: 'My Profile',
icon: icon.mdiAccountCircle, icon: icon.mdiAccountSettings,
},
{
href: '/api-docs',
target: '_blank',
label: 'Swagger API',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS'
}, },
] ]

View File

@ -47,7 +47,9 @@ const menuNavBar: MenuNavBarItem[] = [
] ]
export const webPagesNavBar = [ export const webPagesNavBar = [
{ href: '/search', label: 'Find Services' },
{ href: '/register', label: 'List Business' },
{ href: '/about', label: 'About Us' }
]; ];
export default menuNavBar export default menuNavBar

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
import { mdiChartTimelineVariant } from '@mdi/js' import { mdiChartTimelineVariant, mdiPlus, mdiEye, mdiPencil, mdiShieldCheck, mdiCheckDecagram } from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import { uniqueId } from 'lodash'; import React, { ReactElement, useEffect, useState } from 'react'
import React, { ReactElement, useState } from 'react'
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated' import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain' import SectionMain from '../../components/SectionMain'
@ -10,147 +9,275 @@ import { getPageTitle } from '../../config'
import TableBusinesses from '../../components/Businesses/TableBusinesses' import TableBusinesses from '../../components/Businesses/TableBusinesses'
import BaseButton from '../../components/BaseButton' import BaseButton from '../../components/BaseButton'
import axios from "axios"; import axios from "axios";
import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal"; import {fetch} from '../../stores/businesses/businessesSlice';
import DragDropFilePicker from "../../components/DragDropFilePicker"; import { useRouter } from 'next/router';
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice'; import IconRounded from '../../components/IconRounded';
import BaseButtons from '../../components/BaseButtons';
import BaseIcon from '../../components/BaseIcon';
import {hasPermission} from "../../helpers/userPermissions";
const BusinessesTablesPage = () => { const BusinessesTablesPage = () => {
const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const { businesses, count, loading } = useAppSelector((state) => state.businesses);
const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => {
dispatch(fetch({ limit: 50, page: 0 }));
}, [dispatch]);
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'}, const isVBO = currentUser?.app_role?.name === 'Verified Business Owner';
{label: 'ReliabilityScore', title: 'reliability_score', number: 'true'},{label: 'ResponseTimeMedianMinutes', title: 'response_time_median_minutes', number: 'true'},
{label: 'Latitude', title: 'lat', number: 'true'},{label: 'Longitude', title: 'lng', number: 'true'},
{label: 'CreatedAt', title: 'created_at_ts', date: 'true'},{label: 'UpdatedAt', title: 'updated_at_ts', date: 'true'},
// Completion calculation helper
const calculateCompletion = (business) => {
const fields = ['name', 'description', 'phone', 'email', 'website', 'address', 'city', 'state', 'zip'];
let filled = 0;
fields.forEach(f => {
if (business[f] && business[f] !== '') filled++;
});
return Math.round((filled / fields.length) * 100);
};
{label: 'OwnerUser', title: 'owner_user'}, if (loading) {
return (
<SectionMain>
<div className="flex justify-center items-center h-64">
<p>Loading your portal...</p>
</div>
</SectionMain>
);
}
// State A: No listing exists
if (isVBO && count === 0) {
return (
<>
<Head>
<title>{getPageTitle('My Listing')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="My Listing" main>
{''}
</SectionTitleLineWithButton>
<div className="flex flex-col items-center justify-center mt-12">
<CardBox className="max-w-2xl w-full text-center py-12">
<IconRounded icon={mdiPlus} color="info" className="mb-6 mx-auto" />
<h1 className="text-3xl font-bold mb-4">Create your business listing</h1>
<p className="text-gray-500 mb-8 px-6 text-lg">
This is what customers see in search results. A complete profile helps you get more leads and builds trust with potential clients.
</p>
<BaseButton
color="info"
label="Create Listing"
icon={mdiPlus}
onClick={() => router.push('/businesses/businesses-new')}
className="px-8 py-3 text-lg"
/>
</CardBox>
</div>
</SectionMain>
</>
);
}
{label: 'AvailabilityStatus', title: 'availability_status', type: 'enum', options: ['AVAILABLE_TODAY','THIS_WEEK','BOOKED_OUT']}, // State B: Exactly 1 listing exists
]); if (isVBO && count === 1) {
const business = businesses[0];
const completion = calculateCompletion(business);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES'); return (
<>
<Head>
<title>{getPageTitle('My Listing')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Listing Profile" main>
<BaseButtons>
<BaseButton
color="info"
label="Preview Public Profile"
icon={mdiEye}
outline
onClick={() => window.open(`/public/businesses-details/?id=${business.id}`, '_blank')}
/>
<BaseButton
color="warning"
label="Edit"
icon={mdiPencil}
onClick={() => router.push(`/businesses/${business.id}`)}
/>
</BaseButtons>
</SectionTitleLineWithButton>
<CardBox className="mb-6">
<div className="flex flex-col md:flex-row md:items-center justify-between">
<div>
<h2 className="text-2xl font-bold mb-1">{business.name}</h2>
<div className="flex items-center space-x-2">
<span className={`px-2 py-1 rounded text-xs font-bold ${business.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
{business.is_active ? 'Active' : 'Inactive'}
</span>
{business.is_claimed && (
<span className="flex items-center text-blue-600 text-xs font-bold">
<BaseIcon path={mdiCheckDecagram} size={16} className="mr-1" />
Verified
</span>
)}
</div>
</div>
{!business.is_claimed && (
<BaseButton
color="success"
label="Request Verification"
icon={mdiShieldCheck}
className="mt-4 md:mt-0"
onClick={() => router.push('/verification_submissions/verification_submissions-new')}
/>
)}
</div>
const addFilter = () => { <div className="mt-8">
const newItem = { <div className="flex justify-between items-center mb-2">
id: uniqueId(), <span className="text-sm font-medium text-gray-700 font-bold">Profile completeness</span>
fields: { <span className="text-sm font-bold text-info">{completion}%</span>
filterValue: '', </div>
filterValueFrom: '', <div className="w-full bg-gray-200 rounded-full h-4">
filterValueTo: '', <div
selectedField: '', className="bg-info h-4 rounded-full transition-all duration-500"
}, style={{ width: `${completion}%` }}
}; ></div>
newItem.fields.selectedField = filters[0].title; </div>
setFilterItems([...filterItems, newItem]); <p className="text-xs text-gray-500 mt-2 italic">
}; {completion < 100 ? 'Fill in all details to reach 100% and get better visibility!' : 'Your profile is looking great!'}
</p>
</div>
</CardBox>
const getBusinessesCSV = async () => { <div className="grid grid-cols-1 gap-6 lg:grid-cols-2 mb-6">
const response = await axios({url: '/businesses?filetype=csv', method: 'GET',responseType: 'blob'}); <CardBox title="Business Details" className="h-full">
const type = response.headers['content-type'] <div className="space-y-4">
const blob = new Blob([response.data], { type: type }) <div>
const link = document.createElement('a') <p className="text-xs text-gray-400 uppercase font-bold">Phone</p>
link.href = window.URL.createObjectURL(blob) <p className="font-medium">{business.phone || 'Not provided'}</p>
link.download = 'businessesCSV.csv' </div>
link.click() <div>
}; <p className="text-xs text-gray-400 uppercase font-bold">Email</p>
<p className="font-medium">{business.email || 'Not provided'}</p>
</div>
<div>
<p className="text-xs text-gray-400 uppercase font-bold">Website</p>
<p className="font-medium">{business.website || 'Not provided'}</p>
</div>
<div>
<p className="text-xs text-gray-400 uppercase font-bold">Location</p>
<p className="font-medium">
{business.city ? `${business.city}, ${business.state}` : 'Not provided'}
</p>
</div>
</div>
</CardBox>
const onModalConfirm = async () => { <CardBox title="About" className="h-full">
if (!csvFile) return; <div className="prose prose-sm max-w-none dark:prose-invert">
await dispatch(uploadCsv(csvFile)); <div dangerouslySetInnerHTML={{ __html: business.description || '<p class="text-gray-400 italic">No description provided yet.</p>' }} />
dispatch(setRefetch(true)); </div>
setCsvFile(null); </CardBox>
setIsModalActive(false); </div>
};
const onModalCancel = () => { <BaseButtons className="mt-6">
setCsvFile(null); <BaseButton label="Add another location" icon={mdiPlus} color="info" onClick={() => router.push('/businesses/businesses-new')} />
setIsModalActive(false); </BaseButtons>
}; </SectionMain>
</>
);
}
// State C: Multiple listings exist (or Admin view)
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Businesses')}</title> <title>{getPageTitle('Businesses')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={isVBO ? "My Locations" : "Service Listings"} main>
{''} <BaseButton
color="info"
label={isVBO ? "Add another location" : "New Item"}
icon={mdiPlus}
onClick={() => router.push('/businesses/businesses-new')}
/>
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>} {isVBO ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{businesses.map((business) => {
const completion = calculateCompletion(business);
return (
<CardBox key={business.id} className="hover:shadow-lg transition-shadow">
<div className="flex justify-between items-start mb-4">
<h3 className="text-xl font-bold truncate pr-2">{business.name}</h3>
<span className={`px-2 py-1 rounded text-xs font-bold ${business.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
{business.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<BaseButton <p className="text-sm text-gray-500 mb-4 h-10 overflow-hidden line-clamp-2">
className={'mr-3'} {business.city ? `${business.city}, ${business.state}` : 'Location not set'}
color='info' </p>
label='Filter'
onClick={addFilter}
/>
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getBusinessesCSV} />
{hasCreatePermission && ( <div className="mb-4">
<BaseButton <div className="flex justify-between items-center mb-1">
color='info' <span className="text-xs font-bold">Completion</span>
label='Upload CSV' <span className="text-xs font-bold">{completion}%</span>
onClick={() => setIsModalActive(true)} </div>
/> <div className="w-full bg-gray-200 rounded-full h-2">
)} <div
className="bg-info h-2 rounded-full"
style={{ width: `${completion}%` }}
></div>
</div>
</div>
<div className='md:inline-flex items-center ms-auto'> <BaseButtons>
<div id='delete-rows-button'></div> <BaseButton
</div> small
color="info"
<div className='md:inline-flex items-center ms-auto'> label="Edit"
<Link href={'/businesses/businesses-table'}>Switch to Table</Link> icon={mdiPencil}
onClick={() => router.push(`/businesses/${business.id}`)}
/>
<BaseButton
small
color="info"
outline
label="Preview"
icon={mdiEye}
onClick={() => window.open(`/public/businesses-details/?id=${business.id}`, '_blank')}
/>
<BaseButton
small
color="info"
outline
label="Leads"
onClick={() => router.push('/leads/leads-list')}
/>
</BaseButtons>
</CardBox>
);
})}
</div> </div>
) : (
</CardBox> <CardBox className="mb-6" hasTable>
<TableBusinesses
<CardBox className="mb-6" hasTable> filterItems={[]}
<TableBusinesses setFilterItems={() => { /* nothing to do */ }}
filterItems={filterItems} filters={[]}
setFilterItems={setFilterItems} showGrid={false}
filters={filters} />
showGrid={false} </CardBox>
/> )}
</CardBox>
</SectionMain> </SectionMain>
<CardBoxModal
title='Upload CSV'
buttonColor='info'
buttonLabel={'Confirm'}
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
isActive={isModalActive}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
>
<DragDropFilePicker
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/>
</CardBoxModal>
</> </>
) )
} }
@ -158,9 +285,7 @@ const BusinessesTablesPage = () => {
BusinessesTablesPage.getLayout = function getLayout(page: ReactElement) { BusinessesTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
permission={'READ_BUSINESSES'} permission={'READ_BUSINESSES'}
> >
{page} {page}
</LayoutAuthenticated> </LayoutAuthenticated>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,142 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import { mdiShieldCheck, mdiCheck, mdiClose, mdiEye } from '@mdi/js';
import axios from 'axios';
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 LoadingSpinner from '../../components/LoadingSpinner';
import { getPageTitle } from '../../config';
const ClaimRequestsListPage = () => {
const [loading, setLoading] = useState(true);
const [requests, setRequests] = useState<any[]>([]);
useEffect(() => {
fetchRequests();
}, []);
const fetchRequests = async () => {
setLoading(true);
try {
const response = await axios.get('/claim_requests');
setRequests(response.data.rows);
} catch (error) {
console.error('Error fetching claim requests:', error);
} finally {
setLoading(false);
}
};
const handleAction = async (id: string, status: string) => {
let rejectionReason = '';
if (status === 'REJECTED') {
rejectionReason = prompt('Please enter rejection reason:') || 'Documentation insufficient';
}
try {
await axios.put(`/claim_requests/${id}`, { data: { status, rejectionReason } });
fetchRequests();
} catch (error) {
console.error('Error updating claim request:', error);
alert('Failed to update request.');
}
};
if (loading) return <SectionMain><LoadingSpinner /></SectionMain>;
return (
<>
<Head>
<title>{getPageTitle('Claim Requests')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiShieldCheck} title="Claim Requests" main />
<CardBox className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-100">
<th className="p-4 font-bold text-slate-500 uppercase text-xs">Business</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs">User</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs">Status</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs">Date</th>
<th className="p-4 font-bold text-slate-500 uppercase text-xs text-right">Actions</th>
</tr>
</thead>
<tbody>
{requests.map((request) => (
<tr key={request.id} className="border-b border-slate-50 hover:bg-slate-50 transition-colors">
<td className="p-4">
<div className="font-bold">{request.business?.name}</div>
<div className="text-xs text-slate-400">{request.business?.city}, {request.business?.state}</div>
</td>
<td className="p-4">
<div className="font-medium">{request.user?.firstName} {request.user?.lastName}</div>
<div className="text-xs text-slate-400">{request.user?.email}</div>
</td>
<td className="p-4">
<span className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase ${
request.status === 'APPROVED' ? 'bg-emerald-50 text-emerald-600' :
request.status === 'REJECTED' ? 'bg-rose-50 text-rose-600' :
'bg-amber-50 text-amber-600'
}`}>
{request.status}
</span>
</td>
<td className="p-4 text-sm text-slate-500">
{new Date(request.createdAt).toLocaleDateString()}
</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2">
<BaseButton
icon={mdiEye}
color="info"
small
href={`/public/businesses-details?id=${request.businessId}`}
target="_blank"
/>
{request.status === 'PENDING' && (
<>
<BaseButton
icon={mdiCheck}
color="success"
small
onClick={() => handleAction(request.id, 'APPROVED')}
/>
<BaseButton
icon={mdiClose}
color="danger"
small
onClick={() => handleAction(request.id, 'REJECTED')}
/>
</>
)}
</div>
</td>
</tr>
))}
{requests.length === 0 && (
<tr>
<td colSpan={5} className="p-10 text-center text-slate-400 italic">No claim requests found.</td>
</tr>
)}
</tbody>
</table>
</CardBox>
</SectionMain>
</>
);
};
ClaimRequestsListPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission="READ_CLAIM_REQUESTS">
{page}
</LayoutAuthenticated>
);
};
export default ClaimRequestsListPage;

File diff suppressed because it is too large Load Diff

View File

@ -1,79 +1,151 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { ToastContainer, toast } from 'react-toastify'; import { ToastContainer, toast } from 'react-toastify';
import Head from 'next/head'; import Head from 'next/head';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen'; import BaseIcon from "../components/BaseIcon";
import { mdiShieldCheck } from '@mdi/js';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField'; import FormField from '../components/FormField';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import Link from 'next/link';
import axios from "axios"; import axios from "axios";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
export default function Forgot() { export default function Forgot() {
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
const router = useRouter(); const router = useRouter();
const notify = (type, msg) => toast( msg, {type}); const notify = (type, msg) => toast(msg, { type });
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({ video_files: [] })
const [contentType, setContentType] = useState('image');
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage()
const video = await getPexelsVideo()
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const handleSubmit = async (value) => { const handleSubmit = async (value) => {
setLoading(true) setLoading(true)
try { try {
const { data: response } = await axios.post('/auth/send-password-reset-email', value); await axios.post('/auth/send-password-reset-email', value);
setLoading(false) setLoading(false)
notify('success', 'Please check your email for verification link'); notify('success', 'Please check your email for reset link');
setTimeout(async () => { setTimeout(async () => {
await router.push('/login') await router.push('/login')
}, 3000) }, 3000)
} catch (error) { } catch (error) {
setLoading(false) setLoading(false)
console.log('error: ', error) console.log('error: ', error)
notify('error', 'Something was wrong. Try again') notify('error', 'Something went wrong. Try again')
} }
}; };
const imageBlock = (image) => (
<div className="hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden"
style={{
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(16, 185, 129, 0.1), rgba(6, 78, 59, 0.2))'}`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}>
<div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
<div className="relative z-10 p-12 text-white">
<h1 className="text-4xl font-black mb-4">Secure Your Account.</h1>
<p className="text-lg text-emerald-50/80 max-w-md leading-relaxed">
We&apos;ll help you get back into your account safely and securely.
</p>
</div>
<div className="flex justify-center w-full bg-black/40 py-2 relative z-10">
<a className="text-[10px] text-white/60 hover:text-white transition-colors" href={image?.photographer_url} target="_blank" rel="noreferrer">
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
)
return ( return (
<> <div className="min-h-screen bg-slate-50 font-sans">
<Head> <Head>
<title>{getPageTitle('Login')}</title> <title>{getPageTitle('Forgot Password')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <div className="flex flex-row min-h-screen">
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'> {imageBlock(illustrationImage)}
<Formik
initialValues={{
email: '',
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Email' help='Please enter your email'>
<Field name='email' />
</FormField>
<BaseDivider /> <div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
<div className="w-full max-w-md space-y-8">
{/* Branding */}
<div className="flex flex-col items-center mb-8">
<Link href="/" className="flex items-center gap-3 group mb-6">
<div className="w-12 h-12 bg-emerald-500 rounded-2xl flex items-center justify-center shadow-xl shadow-emerald-500/20 group-hover:scale-110 transition-transform">
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
</div>
<span className="text-2xl font-black tracking-tight text-slate-900">
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>
<p className="text-slate-500 mt-2 text-center">Enter your email and we&apos;ll send you a link to reset your password.</p>
</div>
<BaseButtons> <CardBox className="shadow-2xl border-none rounded-[2rem] p-4 lg:p-6">
<BaseButton <Formik
type='submit' initialValues={{
label={loading ? 'Loading...' : 'Submit' } email: '',
color='info' }}
/> onSubmit={(values) => handleSubmit(values)}
<BaseButton >
href={'/login'} <Form className="space-y-6">
label={'Login'} <FormField
color='info' label='Email Address'
/> labelColor="text-slate-700 font-bold"
</BaseButtons> >
</Form> <Field name='email' type='email' placeholder="name@company.com" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
</Formik> </FormField>
</CardBox>
</SectionFullScreen> <div className="pt-2">
<BaseButton
className={'w-full py-4 rounded-xl font-bold text-lg shadow-lg shadow-emerald-500/20'}
type='submit'
label={loading ? 'Sending link...' : 'Send Reset Link'}
color='success'
disabled={loading}
/>
</div>
<div className="text-center pt-2">
<Link className="font-bold text-emerald-600 hover:text-emerald-700 text-sm" href={'/login'}>
Back to Login
</Link>
</div>
</Form>
</Formik>
</CardBox>
<div className="text-center text-slate-400 text-xs pt-8">
© 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>
</div>
</div>
<ToastContainer /> <ToastContainer />
</> </div>
); );
} }

View File

@ -1,166 +1,250 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import BaseButton from '../components/BaseButton'; import { useRouter } from 'next/router';
import CardBox from '../components/CardBox'; import {
import SectionFullScreen from '../components/SectionFullScreen'; mdiMagnify,
mdiMapMarker,
mdiShieldCheck,
mdiCurrencyUsd,
mdiFlash,
mdiTools,
mdiPowerPlug,
mdiAirConditioner,
mdiBrush,
mdiFormatPaint,
mdiClipboardTextOutline,
mdiCheckCircleOutline
} from '@mdi/js';
import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import BaseButtons from '../components/BaseButtons'; import { fetch as fetchCategories } from '../stores/categories/categoriesSlice';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
export default function LandingPage() {
const router = useRouter();
const dispatch = useAppDispatch();
const { categories } = useAppSelector((state) => state.categories);
const { currentUser } = useAppSelector((state) => state.auth);
export default function Starter() { useEffect(() => {
const [illustrationImage, setIllustrationImage] = useState({ dispatch(fetchCategories({ query: '?limit=8' }));
src: undefined, }, [dispatch]);
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Crafted Network' const featuredCategories = [
{ name: 'Plumbing', icon: mdiTools, color: 'text-blue-500' },
{ name: 'Electrical', icon: mdiPowerPlug, color: 'text-yellow-500' },
{ name: 'HVAC', icon: mdiAirConditioner, color: 'text-emerald-500' },
{ name: 'Cleaning', icon: mdiBrush, color: 'text-purple-500' },
{ name: 'Painting', icon: mdiFormatPaint, color: 'text-orange-500' },
{ name: 'General', icon: mdiTools, color: 'text-slate-500' },
];
// Fetch Pexels image/video const handleSearch = (e: React.FormEvent) => {
useEffect(() => { e.preventDefault();
async function fetchData() { const formData = new FormData(e.target as HTMLFormElement);
const image = await getPexelsImage(); const query = formData.get('query');
const video = await getPexelsVideo(); const location = formData.get('location');
setIllustrationImage(image); router.push({
setIllustrationVideo(video); pathname: '/search',
} query: { query, location },
fetchData(); });
}, []); };
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return ( return (
<div <div className="min-h-screen bg-slate-50 font-sans text-slate-900">
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>Fix-It-Local | 21st Century Service Directory</title>
<meta name="description" content="Connect with verified service professionals. Trust, transparency, and AI-powered matching." />
</Head> </Head>
<SectionFullScreen bg='violet'> {/* Hero Section */}
<div <section className="relative bg-slate-900 text-white overflow-hidden py-32 lg:py-48">
className={`flex ${ <div className="absolute inset-0 opacity-20">
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <div className="absolute top-0 -left-4 w-72 h-72 bg-emerald-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
} min-h-screen w-full`} <div className="absolute top-0 -right-4 w-72 h-72 bg-blue-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
> <div className="absolute -bottom-8 left-20 w-72 h-72 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-4000"></div>
{contentType === 'image' && contentPosition !== 'background' </div>
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Crafted Network app!"/>
<div className="space-y-3"> <div className="container mx-auto px-6 relative z-10">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p> <div className="text-center max-w-4xl mx-auto">
<p className='text-center text-gray-500'>For guides and documentation please check <div className="inline-flex items-center px-4 py-2 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm font-medium mb-8">
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p> <BaseIcon path={mdiShieldCheck} size={18} className="mr-2" />
Verified Professionals & AI-Powered Matching
</div>
<h1 className="text-5xl lg:text-7xl font-bold tracking-tight mb-8">
The <span className="text-emerald-400">Fix-It-Local</span> Service Network
</h1>
<p className="text-xl text-slate-400 mb-12 max-w-2xl mx-auto leading-relaxed">
Find reliable, verified experts for your home or business. Real-time availability, transparent pricing, and zero spam.
</p>
{/* Search Bar */}
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4 p-2 bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 shadow-2xl max-w-3xl mx-auto">
<div className="flex-grow relative">
<BaseIcon path={mdiMagnify} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
<input
type="text"
name="query"
placeholder="What service do you need?"
className="w-full bg-transparent border-none focus:ring-0 py-4 pl-12 pr-4 text-white placeholder-slate-500 rounded-xl"
/>
</div>
<div className="md:w-px h-8 bg-white/10 my-auto"></div>
<div className="flex-grow relative">
<BaseIcon path={mdiMapMarker} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
<input
type="text"
name="location"
placeholder="Location"
className="w-full bg-transparent border-none focus:ring-0 py-4 pl-12 pr-4 text-white placeholder-slate-500 rounded-xl"
/>
</div>
<button type="submit" className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-4 px-8 rounded-xl transition-all shadow-lg shadow-emerald-500/25">
Find Help
</button>
</form>
</div>
</div>
</section>
{/* Featured Categories */}
<section className="py-24 container mx-auto px-6">
<div className="flex items-end justify-between mb-12">
<div>
<h2 className="text-3xl font-bold mb-4">Popular Services</h2>
<p className="text-slate-500">Explore our most requested categories from verified pros.</p>
</div>
<Link href="/categories/categories-list" className="text-emerald-500 font-semibold hover:underline flex items-center">
View All Categories
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
{(categories?.length > 0 ? categories.slice(0, 6) : featuredCategories).map((cat: any, i: number) => (
<Link
key={i}
href={`/search?query=${cat.name}`}
className="group bg-white p-8 rounded-3xl border border-slate-200 hover:border-emerald-500 hover:shadow-xl hover:shadow-emerald-500/10 transition-all text-center"
>
<div className={`mb-4 w-16 h-16 mx-auto rounded-2xl flex items-center justify-center bg-slate-50 group-hover:bg-emerald-50 transition-colors`}>
<BaseIcon path={cat.icon || mdiTools} size={32} className={cat.color || 'text-emerald-500'} />
</div>
<span className="font-bold text-slate-700 group-hover:text-emerald-600 transition-colors">{cat.name}</span>
</Link>
))}
</div>
</section>
{/* How it Works Section */}
<section className="py-24 bg-white border-y border-slate-200 overflow-hidden">
<div className="container mx-auto px-6">
<div className="text-center max-w-3xl mx-auto mb-20">
<h2 className="text-4xl font-bold mb-6 text-slate-900 leading-tight">Simplified Discovery. Trusted Connections.</h2>
<p className="text-lg text-slate-500 leading-relaxed">
Fix-It-Local connects homeowners and businesses with verified professionals through a transparent, AI-powered process.
</p>
</div>
<div className="grid md:grid-cols-4 gap-12 relative">
{/* Connector line for desktop */}
<div className="hidden lg:block absolute top-1/4 left-[10%] right-[10%] h-px bg-slate-200 -z-0"></div>
<div className="relative z-10 text-center flex flex-col items-center">
<div className="w-20 h-20 bg-emerald-500 text-white rounded-3xl flex items-center justify-center mb-8 shadow-xl shadow-emerald-500/20 transform hover:scale-110 transition-transform">
<BaseIcon path={mdiMagnify} size={36} />
</div>
<h4 className="font-bold text-xl mb-4">1. Find a Pro</h4>
<p className="text-slate-500 text-sm leading-relaxed max-w-[200px]">Browse categories or search for specific verified services near you.</p>
</div> </div>
<BaseButtons> <div className="relative z-10 text-center flex flex-col items-center">
<BaseButton <div className="w-20 h-20 bg-blue-500 text-white rounded-3xl flex items-center justify-center mb-8 shadow-xl shadow-blue-500/20 transform hover:scale-110 transition-transform">
href='/login' <BaseIcon path={mdiClipboardTextOutline} size={36} />
label='Login' </div>
color='info' <h4 className="font-bold text-xl mb-4">2. Request Service</h4>
className='w-full' <p className="text-slate-500 text-sm leading-relaxed max-w-[200px]">Submit details about your job. No signup required for initial requests.</p>
/> </div>
</BaseButtons> <div className="relative z-10 text-center flex flex-col items-center">
</CardBox> <div className="w-20 h-20 bg-amber-500 text-white rounded-3xl flex items-center justify-center mb-8 shadow-xl shadow-amber-500/20 transform hover:scale-110 transition-transform">
<BaseIcon path={mdiFlash} size={36} />
</div>
<h4 className="font-bold text-xl mb-4">3. AI Smart Match</h4>
<p className="text-slate-500 text-sm leading-relaxed max-w-[200px]">Our engine matches your request with the best available verified professional.</p>
</div>
<div className="relative z-10 text-center flex flex-col items-center">
<div className="w-20 h-20 bg-indigo-500 text-white rounded-3xl flex items-center justify-center mb-8 shadow-xl shadow-indigo-500/20 transform hover:scale-110 transition-transform">
<BaseIcon path={mdiCheckCircleOutline} size={36} />
</div>
<h4 className="font-bold text-xl mb-4">4. Get it Done</h4>
<p className="text-slate-500 text-sm leading-relaxed max-w-[200px]">Connect with your pro, review job history, and enjoy quality results.</p>
</div>
</div>
<div className="text-center mt-20">
<Link href="/about" className="inline-flex items-center text-emerald-600 font-bold text-lg hover:underline group">
Learn more about our mission
<BaseIcon path={mdiFlash} size={20} className="ml-2 group-hover:translate-x-1 transition-transform" />
</Link>
</div>
</div> </div>
</div> </section>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
{/* Trust Features */}
<section className="py-24 bg-slate-900 text-white overflow-hidden relative">
<div className="container mx-auto px-6 relative z-10">
<div className="grid lg:grid-cols-3 gap-12 text-center lg:text-left">
<div className="p-8 rounded-3xl bg-white/5 border border-white/10">
<div className="w-14 h-14 rounded-2xl bg-emerald-500 flex items-center justify-center mb-6 mx-auto lg:mx-0">
<BaseIcon path={mdiShieldCheck} size={28} />
</div>
<h3 className="text-2xl font-bold mb-4">Verified Badges</h3>
<p className="text-slate-400 leading-relaxed">Every business undergoes a strict evidence-based verification process. Look for the shield to ensure peace of mind.</p>
</div>
<div className="p-8 rounded-3xl bg-white/5 border border-white/10">
<div className="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mb-6 mx-auto lg:mx-0">
<BaseIcon path={mdiCurrencyUsd} size={28} />
</div>
<h3 className="text-2xl font-bold mb-4">Price Transparency</h3>
<p className="text-slate-400 leading-relaxed">No more hidden fees. See typical price ranges and median job costs upfront before you ever make a request.</p>
</div>
<div className="p-8 rounded-3xl bg-white/5 border border-white/10">
<div className="w-14 h-14 rounded-2xl bg-amber-500 flex items-center justify-center mb-6 mx-auto lg:mx-0">
<BaseIcon path={mdiFlash} size={28} />
</div>
<h3 className="text-2xl font-bold mb-4">AI Smart Matching</h3>
<p className="text-slate-400 leading-relaxed">Our matching engine analyzes your issue and finds the best expert based on availability, score, and proximity.</p>
</div>
</div>
</div>
</section>
{/* Call to Action */}
<section className="py-24 container mx-auto px-6">
<div className="bg-emerald-500 rounded-[3rem] p-12 lg:p-20 text-white relative overflow-hidden flex flex-col lg:flex-row items-center justify-between">
<div className="relative z-10 max-w-2xl text-center lg:text-left mb-10 lg:mb-0">
<h2 className="text-4xl lg:text-5xl font-bold mb-6">Are you a service professional?</h2>
<p className="text-emerald-100 text-lg mb-0">Join the most trusted network of professionals and get high-quality leads that actually match your expertise.</p>
</div>
<div className="relative z-10 flex gap-4">
<Link href="/register" className="bg-slate-900 hover:bg-black text-white font-bold py-5 px-10 rounded-2xl transition-all shadow-2xl">
Register Business
</Link>
{!currentUser && (
<Link href="/login" className="bg-white hover:bg-slate-100 text-emerald-600 font-bold py-5 px-10 rounded-2xl transition-all">
Login
</Link>
)}
</div>
</div>
</section>
</div> </div>
); );
} }
Starter.getLayout = function getLayout(page: ReactElement) { LandingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };

File diff suppressed because it is too large Load Diff

View File

@ -52,7 +52,8 @@ const LeadsTablesPage = () => {
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_LEADS'); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_LEADS');
const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner';
const pageTitle = isBusinessOwner ? 'Service Requests' : 'Leads';
const addFilter = () => { const addFilter = () => {
const newItem = { const newItem = {
@ -94,10 +95,10 @@ const LeadsTablesPage = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Leads')}</title> <title>{getPageTitle(pageTitle)}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Leads" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={pageTitle} main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import BaseIcon from "../components/BaseIcon"; import BaseIcon from "../components/BaseIcon";
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js'; import { mdiInformation, mdiEye, mdiEyeOff, mdiShieldCheck } from '@mdi/js';
import SectionFullScreen from '../components/SectionFullScreen'; import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
@ -21,12 +19,13 @@ import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link'; import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels' import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
import Logo from '../components/Logo'
export default function Login() { export default function Login() {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const textColor = useAppSelector((state) => state.style.linkColor); const textColor = 'text-emerald-600';
const iconsColor = useAppSelector((state) => state.style.iconsColor); const iconsColor = 'text-emerald-500';
const notify = (type, msg) => toast(msg, { type }); const notify = (type, msg) => toast(msg, { type });
const [ illustrationImage, setIllustrationImage ] = useState({ const [ illustrationImage, setIllustrationImage ] = useState({
src: undefined, src: undefined,
@ -34,7 +33,7 @@ export default function Login() {
photographer_url: undefined, photographer_url: undefined,
}) })
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []}) const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
const [contentType, setContentType] = useState('video'); const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('left'); const [contentPosition, setContentPosition] = useState('left');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector( const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
@ -44,7 +43,7 @@ export default function Login() {
password: 'b2096650', password: 'b2096650',
remember: true }) remember: true })
const title = 'Crafted Network' const title = 'Fix-It-Local'
// Fetch Pexels image/video // Fetch Pexels image/video
useEffect( () => { useEffect( () => {
@ -101,16 +100,24 @@ export default function Login() {
}; };
const imageBlock = (image) => ( const imageBlock = (image) => (
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3" <div className="hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden"
style={{ style={{
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`, backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(16, 185, 129, 0.1), rgba(6, 78, 59, 0.2))'}`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'left center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
}}> }}>
<div className="flex justify-center w-full bg-blue-300/20"> <div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo <div className="relative z-10 p-12 text-white">
by {image?.photographer} on Pexels</a> <h1 className="text-4xl font-black mb-4">Welcome Back to the Network.</h1>
<p className="text-lg text-emerald-50/80 max-w-md leading-relaxed">
Join thousands of verified professionals and clients in the most transparent service ecosystem.
</p>
</div>
<div className="flex justify-center w-full bg-black/40 py-2 relative z-10">
<a className="text-[10px] text-white/60 hover:text-white transition-colors" href={image?.photographer_url} target="_blank" rel="noreferrer">
Photo by {image?.photographer} on Pexels
</a>
</div> </div>
</div> </div>
) )
@ -118,7 +125,7 @@ export default function Login() {
const videoBlock = (video) => { const videoBlock = (video) => {
if (video?.video_files?.length > 0) { if (video?.video_files?.length > 0) {
return ( return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'> <div className='hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden'>
<video <video
className='absolute top-0 left-0 w-full h-full object-cover' className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay autoPlay
@ -128,9 +135,16 @@ export default function Login() {
<source src={video.video_files[0]?.link} type='video/mp4'/> <source src={video.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'> <div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
<div className="relative z-10 p-12 text-white">
<h1 className="text-4xl font-black mb-4">Welcome Back to the Network.</h1>
<p className="text-lg text-emerald-50/80 max-w-md leading-relaxed">
Join thousands of verified professionals and clients in the most transparent service ecosystem.
</p>
</div>
<div className='flex justify-center w-full bg-black/40 py-2 relative z-10'>
<a <a
className='text-[8px]' className='text-[10px] text-white/60 hover:text-white transition-colors'
href={video.user.url} href={video.user.url}
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
@ -140,131 +154,122 @@ export default function Login() {
</div> </div>
</div>) </div>)
} }
return imageBlock(illustrationImage);
}; };
return ( return (
<div style={contentPosition === 'background' ? { <div className="min-h-screen bg-slate-50 font-sans">
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
} : {}}>
<Head> <Head>
<title>{getPageTitle('Login')}</title> <title>{getPageTitle('Login')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <div className="flex flex-row min-h-screen">
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}> {contentType === 'video' ? videoBlock(illustrationVideo) : imageBlock(illustrationImage)}
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'> <div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
<div className="w-full max-w-md space-y-8">
<h2 className="text-4xl font-semibold my-4">{title}</h2> {/* Branding */}
<div className="flex flex-col items-center mb-8">
<div className='flex flex-row text-gray-500 justify-between'> <Link href="/" className="flex items-center gap-3 group mb-6">
<div> <Logo className="h-12 w-auto" />
</Link>
<p className='mb-2'>Use{' '} <h2 className="text-3xl font-bold text-slate-900">Account Login</h2>
<code className={`cursor-pointer ${textColor} `} <p className="text-slate-500 mt-2">Enter your credentials to access your dashboard</p>
data-password="b2096650"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>b2096650</code>{' / '}
to login as Admin</p>
<p>Use <code
className={`cursor-pointer ${textColor} `}
data-password="7302e7d1c0fe"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>7302e7d1c0fe</code>{' / '}
to login as User</p>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={mdiInformation}
/>
</div>
</div> </div>
</CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'> <CardBox className="shadow-2xl border-none rounded-[2rem] p-4 lg:p-6">
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
enableReinitialize enableReinitialize
onSubmit={(values) => handleSubmit(values)} onSubmit={(values) => handleSubmit(values)}
> >
<Form> <Form className="space-y-4">
<FormField
label='Login'
help='Please enter your login'>
<Field name='email' />
</FormField>
<div className='relative'>
<FormField <FormField
label='Password' label='Email Address'
help='Please enter your password'> labelColor="text-slate-700 font-bold"
<Field name='password' type={showPassword ? 'text' : 'password'} />
</FormField>
<div
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
onClick={togglePasswordVisibility}
> >
<BaseIcon <Field name='email' placeholder="name@company.com" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
className='text-gray-500 hover:text-gray-700' </FormField>
size={20}
path={showPassword ? mdiEyeOff : mdiEye} <div className='relative'>
<FormField
label='Password'
labelColor="text-slate-700 font-bold"
>
<Field name='password' type={showPassword ? 'text' : 'password'} placeholder="••••••••" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
</FormField>
<div
className='absolute top-[42px] right-0 pr-4 flex items-center cursor-pointer'
onClick={togglePasswordVisibility}
>
<BaseIcon
className='text-slate-400 hover:text-emerald-500'
size={20}
path={showPassword ? mdiEyeOff : mdiEye}
/>
</div>
</div>
<div className={'flex justify-between items-center text-sm'}>
<FormCheckRadio type='checkbox' label='Keep me logged in'>
<Field type='checkbox' name='remember' />
</FormCheckRadio>
<Link className="font-semibold text-emerald-600 hover:text-emerald-700" href={'/forgot'}>
Forgot password?
</Link>
</div>
<div className="pt-4">
<BaseButton
className={'w-full py-4 rounded-xl font-bold text-lg shadow-lg shadow-emerald-500/20'}
type='submit'
label={isFetching ? 'Signing in...' : 'Sign In'}
color='success'
disabled={isFetching}
/> />
</div> </div>
<div className="text-center pt-4">
<p className="text-slate-500 text-sm">
Don&apos;t have an account yet?{' '}
<Link className="font-bold text-emerald-600 hover:text-emerald-700" href={'/register'}>
Create an account
</Link>
</p>
</div>
</Form>
</Formik>
</CardBox>
{/* Demo Access Card - Styled more like a hint/badge */}
<div className="bg-slate-100/80 backdrop-blur rounded-2xl p-6 border border-slate-200">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center shrink-0 shadow-sm">
<BaseIcon path={mdiInformation} size={20} className="text-emerald-500" />
</div> </div>
<div>
<div className={'flex justify-between'}> <h4 className="font-bold text-slate-900 text-sm mb-2">Demo Access</h4>
<FormCheckRadio type='checkbox' label='Remember'> <div className="space-y-2 text-xs text-slate-600">
<Field type='checkbox' name='remember' /> <p>
</FormCheckRadio> <span className="font-semibold">Admin:</span>{' '}
<code className="cursor-pointer text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded" data-password="b2096650" onClick={(e) => setLogin(e.target as HTMLElement)}>admin@flatlogic.com</code>
<Link className={`${textColor} text-blue-600`} href={'/forgot'}> </p>
Forgot password? <p>
</Link> <span className="font-semibold">Client:</span>{' '}
<code className="cursor-pointer text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded" data-password="7302e7d1c0fe" onClick={(e) => setLogin(e.target as HTMLElement)}>client@hello.com</code>
</p>
</div>
</div> </div>
</div>
</div>
<BaseDivider /> <div className="text-center text-slate-400 text-xs pt-8">
© 2026 Fix-It-Local. All rights reserved. <br/>
<BaseButtons> <Link href='/privacy-policy/' className="hover:text-slate-600 mt-2 inline-block">Privacy Policy</Link>
<BaseButton </div>
className={'w-full'} </div>
type='submit'
label={isFetching ? 'Loading...' : 'Login'}
color='info'
disabled={isFetching}
/>
</BaseButtons>
<br />
<p className={'text-center'}>
Dont have an account yet?{' '}
<Link className={`${textColor}`} href={'/register'}>
New Account
</Link>
</p>
</Form>
</Formik>
</CardBox>
</div> </div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div> </div>
<ToastContainer /> <ToastContainer />
</div> </div>

View File

@ -1,6 +1,6 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' import { mdiChartTimelineVariant } from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated' import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain' import SectionMain from '../../components/SectionMain'
@ -12,128 +12,42 @@ import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider' import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons' import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton' import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField' import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/messages/messagesSlice' import { create } from '../../stores/messages/messagesSlice'
import { useAppDispatch } from '../../stores/hooks' import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = {
lead: '',
sender_user: '',
receiver_user: '',
body: '',
read_at: '',
created_at_ts: '',
}
const MessagesNew = () => { const MessagesNew = () => {
const router = useRouter() const router = useRouter()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { currentUser } = useAppSelector((state) => state.auth);
const { leadId, receiverId } = router.query;
const initialValues = {
lead: leadId || '',
sender_user: currentUser?.id || '',
receiver_user: receiverId || '',
body: '',
read_at: null,
created_at_ts: new Date(),
}
const [formInitialValues, setFormInitialValues] = useState(initialValues);
useEffect(() => {
if (leadId || receiverId) {
setFormInitialValues({
...initialValues,
lead: leadId || '',
sender_user: currentUser?.id || '',
receiver_user: receiverId || '',
});
}
}, [leadId, receiverId, currentUser]);
const handleSubmit = async (data) => { const handleSubmit = async (data) => {
await dispatch(create(data)) await dispatch(create(data))
@ -142,219 +56,38 @@ const MessagesNew = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Item')}</title> <title>{getPageTitle('Send Message')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Send Message" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox> <CardBox>
<Formik <Formik
initialValues={ enableReinitialize
initialValues={formInitialValues}
initialValues
}
onSubmit={(values) => handleSubmit(values)} onSubmit={(values) => handleSubmit(values)}
> >
<Form> <Form>
<FormField label="Lead" labelFor="lead">
<Field name="lead" id="lead" component={SelectField} options={formInitialValues.lead ? [formInitialValues.lead] : []} itemRef={'leads'}></Field>
</FormField>
<FormField label="Sender" labelFor="sender_user">
<Field name="sender_user" id="sender_user" component={SelectField} options={formInitialValues.sender_user ? [formInitialValues.sender_user] : []} itemRef={'users'} showField={'firstName'}></Field>
</FormField>
<FormField label="To" labelFor="receiver_user">
<Field name="receiver_user" id="receiver_user" component={SelectField} options={formInitialValues.receiver_user ? [formInitialValues.receiver_user] : []} itemRef={'users'} showField={'firstName'}></Field>
</FormField>
<FormField label="Message Content" hasTextareaHeight>
<Field name="body" as="textarea" placeholder="Type your message here..." />
</FormField>
<FormField label="Lead" labelFor="lead">
<Field name="lead" id="lead" component={SelectField} options={[]} itemRef={'leads'}></Field>
</FormField>
<FormField label="SenderUser" labelFor="sender_user">
<Field name="sender_user" id="sender_user" component={SelectField} options={[]} itemRef={'users'}></Field>
</FormField>
<FormField label="ReceiverUser" labelFor="receiver_user">
<Field name="receiver_user" id="receiver_user" component={SelectField} options={[]} itemRef={'users'}></Field>
</FormField>
<FormField label="Body" hasTextareaHeight>
<Field name="body" as="textarea" placeholder="Body" />
</FormField>
<FormField
label="ReadAt"
>
<Field
type="datetime-local"
name="read_at"
placeholder="ReadAt"
/>
</FormField>
<FormField
label="CreatedAt"
>
<Field
type="datetime-local"
name="created_at_ts"
placeholder="CreatedAt"
/>
</FormField>
<BaseDivider /> <BaseDivider />
<BaseButtons> <BaseButtons>
<BaseButton type="submit" color="info" label="Submit" /> <BaseButton type="submit" color="info" label="Send Message" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/messages/messages-list')}/> <BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/messages/messages-list')}/>
</BaseButtons> </BaseButtons>
</Form> </Form>
@ -368,9 +101,7 @@ const MessagesNew = () => {
MessagesNew.getLayout = function getLayout(page: ReactElement) { MessagesNew.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
permission={'CREATE_MESSAGES'} permission={'CREATE_MESSAGES'}
> >
{page} {page}
</LayoutAuthenticated> </LayoutAuthenticated>

View File

@ -0,0 +1,396 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import {
mdiStorefront,
mdiShieldCheck,
mdiAlertCircle,
mdiCheckCircle,
mdiPlus,
mdiMagnify,
mdiPencil,
mdiCamera,
mdiDelete,
mdiUpload
} from '@mdi/js';
import axios from 'axios';
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 LoadingSpinner from '../components/LoadingSpinner';
import { useAppSelector } from '../stores/hooks';
import { getPageTitle } from '../config';
import { Form, Formik, Field } from 'formik';
import FormField from '../components/FormField';
import FormImagePicker from '../components/FormImagePicker';
const MyListingPage = () => {
const router = useRouter();
const { currentUser } = useAppSelector((state) => state.auth);
const [loading, setLoading] = useState(true);
const [myBusiness, setMyBusiness] = useState<any>(null);
const [pendingClaim, setPendingClaim] = useState<any>(null);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
if (currentUser) {
fetchData();
}
}, [currentUser]);
const fetchData = async () => {
setLoading(true);
try {
// 1. Fetch owned business
let business = null;
if (currentUser.businessId) {
const res = await axios.get(`/businesses/${currentUser.businessId}`);
business = res.data;
} else {
// Search by owner_userId if businessId is not set on user record yet
const res = await axios.get('/businesses', { params: { owner_userId: currentUser.id } });
if (res.data.rows && res.data.rows.length > 0) {
const resById = await axios.get(`/businesses/${res.data.rows[0].id}`);
business = resById.data;
}
}
setMyBusiness(business);
// 2. If no business, fetch pending claim
if (!business) {
const res = await axios.get('/claim_requests', { params: { userId: currentUser.id, status: 'PENDING' } });
if (res.data.rows && res.data.rows.length > 0) {
setPendingClaim(res.data.rows[0]);
}
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
const handlePhotoUpload = async (values: any, { resetForm }: any) => {
if (!myBusiness) return;
setIsUploading(true);
try {
// Check if we already have a business_photos record
const existingPhotosRecord = myBusiness.business_photos_business && myBusiness.business_photos_business[0];
if (existingPhotosRecord) {
// Update existing record with NEW photos (append)
await axios.put(`/business_photos/${existingPhotosRecord.id}`, {
id: existingPhotosRecord.id,
data: {
photos: [...(existingPhotosRecord.photos || []), ...values.photos]
}
});
} else {
// Create new record
await axios.post('/business_photos', {
data: {
business: myBusiness.id,
photos: values.photos
}
});
}
// Refresh data
await fetchData();
resetForm();
} catch (error) {
console.error('Error uploading photos:', error);
} finally {
setIsUploading(false);
}
};
const removePhoto = async (photoId: string, businessPhotosRecordId: string) => {
if (!window.confirm('Are you sure you want to remove this photo?')) return;
try {
const record = myBusiness.business_photos_business.find((r: any) => r.id === businessPhotosRecordId);
if (!record) return;
const newPhotos = record.photos.filter((p: any) => p.id !== photoId);
await axios.put(`/business_photos/${businessPhotosRecordId}`, {
id: businessPhotosRecordId,
data: {
photos: newPhotos
}
});
await fetchData();
} catch (error) {
console.error('Error removing photo:', error);
}
};
const formatImageUrl = (url: string) => {
if (!url) return null;
if (url.startsWith('http') || url.startsWith('/')) {
return url;
}
return `${axios.defaults.baseURL}/file/download?privateUrl=${url}`;
};
if (loading) return <SectionMain><LoadingSpinner /></SectionMain>;
// STATE 1: Owns a business
if (myBusiness) {
const allPhotos = myBusiness.business_photos_business?.flatMap((bp: any) =>
bp.photos?.map((p: any) => ({ ...p, bpId: bp.id }))
) || [];
return (
<SectionMain>
<Head>
<title>{getPageTitle('My Listing')}</title>
</Head>
<SectionTitleLineWithButton icon={mdiStorefront} title="My Listing" main>
<BaseButton
href={`/businesses/businesses-edit/?id=${myBusiness.id}`}
icon={mdiPencil}
label="Edit Listing"
color="info"
/>
</SectionTitleLineWithButton>
<CardBox className="mb-6">
<div className="flex flex-col md:flex-row items-center gap-8 p-4">
<div className="w-32 h-32 bg-slate-100 rounded-3xl overflow-hidden flex items-center justify-center text-slate-400">
{allPhotos.length > 0 ? (
<img
src={formatImageUrl(allPhotos[0].publicUrl)!}
className="w-full h-full object-cover"
alt="Business"
/>
) : (
<BaseIcon path={mdiStorefront} size={48} />
)}
</div>
<div className="flex-grow text-center md:text-left">
<h2 className="text-3xl font-bold mb-2">{myBusiness.name}</h2>
<p className="text-slate-500 mb-4">{myBusiness.address}, {myBusiness.city}, {myBusiness.state}</p>
<div className="flex flex-wrap justify-center md:justify-start gap-4">
<span className="bg-emerald-50 text-emerald-600 px-3 py-1 rounded-full text-xs font-bold uppercase">Active Listing</span>
<span className="bg-blue-50 text-blue-600 px-3 py-1 rounded-full text-xs font-bold uppercase">Verified Owner</span>
</div>
</div>
<div className="flex flex-col gap-2">
<BaseButton
href={`/public/businesses-details?id=${myBusiness.id}`}
label="View Public Profile"
outline
color="info"
/>
</div>
</div>
</CardBox>
{/* Performance & Gallery Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Stats */}
<div className="lg:col-span-1 space-y-6">
<CardBox className="p-6 text-center">
<div className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-2">Total Love Letters</div>
<div className="text-3xl font-bold">{myBusiness.reviews_business?.length || 0}</div>
</CardBox>
<CardBox className="p-6 text-center">
<div className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-2">Avg Rating</div>
<div className="text-3xl font-bold">{myBusiness.rating ? Number(myBusiness.rating).toFixed(1) : 'New'}</div>
</CardBox>
<CardBox className="p-10 bg-slate-900 text-white flex flex-col items-center justify-center text-center rounded-[2.5rem]">
<div className="w-12 h-12 bg-emerald-500 rounded-2xl flex items-center justify-center mb-4">
<BaseIcon path={mdiShieldCheck} size={24} />
</div>
<h4 className="font-bold mb-1">Reliability Score</h4>
<div className="text-4xl font-black text-emerald-400">{myBusiness.reliability_score || 0}%</div>
</CardBox>
</div>
{/* Gallery Management */}
<CardBox className="lg:col-span-2 p-8 rounded-[3rem]">
<div className="flex items-center justify-between mb-8">
<h3 className="text-2xl font-bold flex items-center">
<BaseIcon path={mdiCamera} size={28} className="mr-3 text-emerald-500" />
Portfolio Gallery
</h3>
<span className="text-slate-400 text-sm font-medium">{allPhotos.length} Pictures</span>
</div>
{/* Upload Form */}
<div className="mb-8 p-6 bg-slate-50 rounded-3xl border border-dashed border-slate-200">
<Formik
initialValues={{ photos: [] }}
onSubmit={handlePhotoUpload}
>
<Form className="flex flex-col md:flex-row items-end gap-4">
<div className="flex-grow w-full">
<FormField label="Add new pictures to your listing" help="Show clients your best work. High-quality photos increase bookings.">
<Field
label='Choose Photos'
color='info'
icon={mdiUpload}
path={'business_photos/photos'}
name='photos'
id='photos'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
/>
</FormField>
</div>
<BaseButton
type="submit"
color="info"
label={isUploading ? "Uploading..." : "Add to Gallery"}
disabled={isUploading}
className="mb-4"
/>
</Form>
</Formik>
</div>
{/* Current Photos Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{allPhotos.map((photo: any) => (
<div key={photo.id} className="group relative aspect-square rounded-2xl overflow-hidden bg-slate-100 border border-slate-200">
<img
src={formatImageUrl(photo.publicUrl)!}
alt="Business"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => removePhoto(photo.id, photo.bpId)}
className="bg-white/20 hover:bg-red-500 text-white p-3 rounded-xl backdrop-blur-md transition-all"
title="Remove Photo"
>
<BaseIcon path={mdiDelete} size={20} />
</button>
</div>
</div>
))}
{allPhotos.length === 0 && (
<div className="col-span-full py-12 text-center text-slate-400 italic">
No photos in your gallery yet. Add some to stand out!
</div>
)}
</div>
</CardBox>
</div>
</SectionMain>
);
}
// STATE 2: Pending claim
if (pendingClaim) {
return (
<SectionMain>
<Head>
<title>{getPageTitle('Claim Pending')}</title>
</Head>
<SectionTitleLineWithButton icon={mdiShieldCheck} title="Claim Verification" main />
<div className="max-w-3xl mx-auto">
<CardBox className="p-10 text-center space-y-6">
<div className="w-20 h-20 bg-amber-100 text-amber-600 rounded-full flex items-center justify-center mx-auto">
<BaseIcon path={mdiAlertCircle} size={48} />
</div>
<div>
<h2 className="text-3xl font-bold mb-2">Claim Request Pending</h2>
<p className="text-slate-500 max-w-md mx-auto">
We&apos;ve received your request to claim <strong>{pendingClaim.business?.name}</strong>.
Our team is currently reviewing your application.
</p>
</div>
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 inline-block text-left w-full max-w-md">
<h4 className="font-bold mb-4 flex items-center">
<BaseIcon path={mdiShieldCheck} size={20} className="mr-2 text-emerald-500" />
Next Steps: Verification
</h4>
<p className="text-sm text-slate-600 mb-6">
To approve your claim, we need to verify your association with this business. Please upload a business license or utility bill.
</p>
<BaseButton
href={`/verification_submissions/verification_submissions-new?businessId=${pendingClaim.businessId}`}
label="Upload Verification Documents"
color="info"
className="w-full"
/>
</div>
<div className="pt-6 border-t border-slate-100">
<p className="text-xs text-slate-400">Request ID: {pendingClaim.id} Submitted on {new Date(pendingClaim.createdAt).toLocaleDateString()}</p>
</div>
</CardBox>
</div>
</SectionMain>
);
}
// STATE 3: Neither
return (
<SectionMain>
<Head>
<title>{getPageTitle('My Listing')}</title>
</Head>
<SectionTitleLineWithButton icon={mdiStorefront} title="My Listing" main />
<div className="grid md:grid-cols-2 gap-8 max-w-5xl mx-auto pt-10">
{/* Path 1: Create New */}
<CardBox className="p-10 flex flex-col items-center text-center space-y-6 hover:shadow-xl transition-all border-2 border-transparent hover:border-emerald-100">
<div className="w-16 h-16 bg-emerald-100 text-emerald-600 rounded-2xl flex items-center justify-center">
<BaseIcon path={mdiPlus} size={40} />
</div>
<div>
<h3 className="text-2xl font-bold mb-2">Create New Listing</h3>
<p className="text-slate-500 text-sm">
Your business isn&apos;t on Fix-It-Local yet? Create a fresh listing and start attracting clients immediately.
</p>
</div>
<BaseButton
href="/businesses/businesses-new"
label="Start Fresh Listing"
color="info"
className="w-full"
/>
</CardBox>
{/* Path 2: Claim Existing */}
<CardBox className="p-10 flex flex-col items-center text-center space-y-6 hover:shadow-xl transition-all border-2 border-transparent hover:border-blue-100">
<div className="w-16 h-16 bg-blue-100 text-blue-600 rounded-2xl flex items-center justify-center">
<BaseIcon path={mdiMagnify} size={40} />
</div>
<div>
<h3 className="text-2xl font-bold mb-2">Claim Existing Business</h3>
<p className="text-slate-500 text-sm">
Search for your business in our directory. If it exists but is unowned, you can claim it for free.
</p>
</div>
<BaseButton
href="/search"
label="Search Directory"
outline
color="info"
className="w-full"
/>
</CardBox>
</div>
</SectionMain>
);
};
MyListingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default MyListingPage;

View File

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

View File

@ -0,0 +1,456 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import {
mdiStar,
mdiShieldCheck,
mdiClockOutline,
mdiMapMarker,
mdiPhone,
mdiWeb,
mdiEmail,
mdiCurrencyUsd,
mdiCheckDecagram,
mdiMessageDraw,
mdiAccount,
mdiReply
} from '@mdi/js';
import axios from 'axios';
import LayoutGuest from '../../layouts/Guest';
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import dataFormatter from '../../helpers/dataFormatter';
import { useAppSelector } from '../../stores/hooks';
const BusinessDetailsPublic = () => {
const router = useRouter();
const { id } = router.query;
const [loading, setLoading] = useState(true);
const [business, setBusiness] = useState<any>(null);
const { currentUser } = useAppSelector((state) => state.auth);
useEffect(() => {
if (id) {
fetchBusiness();
recordEvent('VIEW');
}
}, [id]);
const fetchBusiness = async () => {
setLoading(true);
try {
const response = await axios.get(`/businesses/${id}`);
setBusiness(response.data);
} catch (error) {
console.error('Error fetching business:', error);
} finally {
setLoading(false);
}
};
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) {
// Redirect to login with original destination
router.push(`/login?redirect=${encodeURIComponent(router.asPath)}`);
return;
}
try {
await axios.post(`/businesses/${id}/claim`);
// After claiming, redirect to my-listing as requested
router.push('/my-listing');
} catch (error) {
console.error('Error claiming business:', error);
alert('Failed to claim business. It might already be claimed or you have a pending request.');
}
};
const formatImageUrl = (url: string) => {
if (!url) return null;
if (url.startsWith('http') || url.startsWith('/')) {
return url;
}
return `${axios.defaults.baseURL}/file/download?privateUrl=${url}`;
};
const getBusinessImage = () => {
if (business && business.business_photos_business && business.business_photos_business.length > 0) {
const photo = business.business_photos_business[0].photos && business.business_photos_business[0].photos[0];
if (photo && photo.publicUrl) {
return formatImageUrl(photo.publicUrl);
}
}
return null;
};
const getOpeningHours = () => {
if (!business?.hours_json) return null;
try {
const hours = JSON.parse(business.hours_json);
return hours.weekday_text || null;
} catch (e) {
return null;
}
};
if (loading) return <div className="min-h-screen flex items-center justify-center bg-slate-50"><LoadingSpinner /></div>;
if (!business) return <div className="min-h-screen flex items-center justify-center bg-slate-50">Business not found.</div>;
const displayRating = business.rating ? Number(business.rating).toFixed(1) : 'New';
const weekdayText = getOpeningHours();
return (
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
<Head>
<title>{business.name} | Fix-It-Local</title>
</Head>
{/* Hero Header */}
<section className="bg-white border-b border-slate-200 pt-16 pb-12">
<div className="container mx-auto px-6">
<div className="flex flex-col lg:flex-row gap-12 items-start">
{/* Business Photo */}
<div className="w-32 h-32 lg:w-48 lg:h-48 bg-slate-100 rounded-[2.5rem] overflow-hidden flex items-center justify-center shadow-inner relative flex-shrink-0">
{getBusinessImage() ? (
<img
src={getBusinessImage()!}
alt={business.name}
className="w-full h-full object-cover"
/>
) : (
<BaseIcon path={mdiShieldCheck} size={64} className="text-slate-300" />
)}
{(business.reliability_score >= 80 || business.owner_userId) && (
<div className="absolute -top-2 -right-2 bg-emerald-500 text-white p-2 rounded-full shadow-lg">
<BaseIcon path={mdiCheckDecagram} size={24} />
</div>
)}
</div>
<div className="flex-grow w-full">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-6">
<div>
<h1 className="text-4xl lg:text-5xl font-bold mb-3">{business.name}</h1>
<div className="flex flex-wrap items-center gap-4 text-slate-500 font-medium">
<span className="flex items-center">
<BaseIcon path={mdiMapMarker} size={18} className="mr-1 text-emerald-500" />
{business.city}, {business.state}
</span>
<span className="flex items-center">
<BaseIcon path={mdiStar} size={18} className="mr-1 text-amber-400" />
{displayRating} Rating
</span>
{business.owner_userId ? (
<span className="bg-emerald-50 text-emerald-600 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider">
Verified Pro
</span>
) : (
<span className="bg-slate-100 text-slate-500 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider flex items-center">
Unclaimed Listing
</span>
)}
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => router.push(`/public/request-service?businessId=${business.id}`)}
className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-xl shadow-emerald-500/20"
>
Request Service
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 py-6 border-t border-slate-100">
<div>
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Avg Rating</div>
<div className="text-2xl font-bold text-slate-900">{displayRating} / 5.0</div>
</div>
<div>
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Response Time</div>
<div className="text-2xl font-bold text-slate-900">~{business.response_time_median_minutes || 30}m</div>
</div>
<div>
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Status</div>
<div className="flex items-center text-emerald-500 font-bold">
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2 animate-pulse"></div>
Available
</div>
</div>
<div>
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Total Reviews</div>
<div className="text-2xl font-bold text-slate-900">{business.reviews_business?.length || 0}</div>
</div>
</div>
</div>
</div>
</div>
</section>
<div className="container mx-auto px-6 py-12">
<div className="grid lg:grid-cols-3 gap-12">
{/* Main Content */}
<div className="lg:col-span-2 space-y-12">
{!business.owner_userId && (
<div className="bg-amber-50 border border-amber-200 p-8 rounded-[2rem] flex flex-col md:flex-row items-center justify-between gap-6">
<div>
<h4 className="text-xl font-bold text-amber-900 mb-2">Is this your business?</h4>
<p className="text-amber-700">Claim your listing to respond to reviews, update your profile, and get more leads.</p>
</div>
<button
onClick={claimListing}
className="bg-amber-500 hover:bg-amber-600 text-white font-bold py-3 px-8 rounded-xl transition-all flex-shrink-0"
>
Claim Listing
</button>
</div>
)}
{/* Photos Gallery */}
{business.business_photos_business?.length > 0 && (
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
<h3 className="text-2xl font-bold mb-6">Photos</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{business.business_photos_business.map((bp: any) => (
bp.photos?.map((p: any) => (
<div key={p.id} className="aspect-square rounded-2xl overflow-hidden bg-slate-100">
<img
src={formatImageUrl(p.publicUrl)!}
alt="Business"
className="w-full h-full object-cover hover:scale-110 transition-transform duration-500"
/>
</div>
))
))}
</div>
</section>
)}
{/* About */}
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
<h3 className="text-2xl font-bold mb-6">About the Business</h3>
<div className="text-slate-600 leading-relaxed text-lg"
dangerouslySetInnerHTML={{ __html: business.description || 'No description provided.' }} />
</section>
{/* Pricing */}
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
<h3 className="text-2xl font-bold mb-6">Service Pricing Range</h3>
<div className="grid gap-4">
{business.service_prices_business?.map((price: any) => (
<div key={price.id} className="flex items-center justify-between p-6 rounded-2xl bg-slate-50 hover:bg-emerald-50 transition-colors group">
<div>
<h4 className="font-bold text-slate-800 text-lg group-hover:text-emerald-700">{price.service_name}</h4>
<p className="text-slate-500 text-sm">{price.notes || 'Standard professional service.'}</p>
</div>
<div className="text-right">
<div className="text-emerald-600 font-bold text-xl">${price.typical_price}</div>
<div className="text-xs text-slate-400 font-medium">Typical Price</div>
</div>
</div>
))}
{!business.service_prices_business?.length && <p className="text-slate-500">No pricing information available.</p>}
</div>
</section>
{/* Reviews */}
<section>
<div className="flex items-center justify-between mb-8">
<h3 className="text-2xl font-bold">Customer Reviews</h3>
<button
onClick={() => router.push(`/reviews/reviews-new?businessId=${business.id}`)}
className="flex items-center gap-2 bg-white border border-slate-200 px-6 py-3 rounded-2xl text-emerald-600 font-bold hover:bg-slate-50 transition-all shadow-sm"
>
<BaseIcon path={mdiMessageDraw} size={20} />
Write a Review
</button>
</div>
<div className="grid gap-6">
{business.reviews_business?.map((review: any) => (
<div key={review.id} className="bg-white p-8 rounded-3xl border border-slate-200 shadow-sm hover:shadow-md transition-all">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => (
<BaseIcon key={i} path={mdiStar} size={18} className={i < review.rating ? 'text-amber-400' : 'text-slate-200'} />
))}
</div>
<span className="text-xs text-slate-400 font-medium">
{dataFormatter.dateFormatter(review.created_at_ts || review.createdAt)}
</span>
</div>
<p className="text-slate-700 leading-relaxed mb-4 italic text-lg">&quot;{review.text}&quot;</p>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
<BaseIcon path={mdiAccount} size={18} />
</div>
<span className="text-sm font-bold text-slate-600">
{review.user?.firstName || 'Anonymous'}
</span>
</div>
{review.is_verified_job && (
<div className="inline-flex items-center text-[10px] font-bold text-emerald-600 uppercase tracking-widest bg-emerald-50 px-2 py-1 rounded">
<BaseIcon path={mdiShieldCheck} size={14} className="mr-1" />
Verified Job
</div>
)}
</div>
{/* Business Owner Response */}
{review.response && (
<div className="mt-6 ml-4 pl-6 border-l-4 border-emerald-500 bg-slate-50 p-6 rounded-r-2xl relative">
<div className="absolute -left-3 top-0 bg-emerald-500 text-white rounded-full p-1 shadow-lg">
<BaseIcon path={mdiReply} size={16} />
</div>
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-black uppercase tracking-widest text-emerald-600">Response from the business</span>
<span className="text-[10px] text-slate-400 font-medium">
{dataFormatter.dateFormatter(review.response_at_ts)}
</span>
</div>
<p className="text-slate-600 italic">&quot;{review.response}&quot;</p>
</div>
)}
</div>
))}
{!business.reviews_business?.length && (
<div className="text-center py-20 bg-white rounded-[3rem] border border-dashed border-slate-300 text-slate-400">
<BaseIcon path={mdiMessageDraw} size={48} className="mx-auto mb-4 opacity-20" />
<p className="text-xl font-medium">No reviews yet.</p>
<p className="mb-6">Be the first to share your experience!</p>
<button
onClick={() => router.push(`/reviews/reviews-new?businessId=${business.id}`)}
className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-3 px-8 rounded-xl"
>
Write Review
</button>
</div>
)}
</div>
</section>
</div>
{/* Sidebar */}
<div className="space-y-8">
{/* Opening Hours */}
{weekdayText && (
<div className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
<h3 className="text-xl font-bold mb-6 flex items-center">
<BaseIcon path={mdiClockOutline} size={24} className="mr-2 text-emerald-500" />
Opening Hours
</h3>
<div className="space-y-3">
{weekdayText.map((text: string, index: number) => {
const [day, hours] = text.split(': ');
return (
<div key={index} className="flex justify-between text-sm">
<span className="font-bold text-slate-600">{day}</span>
<span className="text-slate-500">{hours}</span>
</div>
);
})}
</div>
</div>
)}
{/* Contact Info */}
<div className="bg-slate-900 text-white p-10 rounded-[3rem] shadow-xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/10 rounded-full -mr-16 -mt-16 group-hover:scale-110 transition-transform"></div>
<h3 className="text-xl font-bold mb-8">Contact & Location</h3>
<div className="space-y-6 relative z-10">
<div className="flex items-start">
<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">
<a href={`tel:${business.phone}`} onClick={() => recordEvent('CALL_CLICK')}>
{business.phone || 'Contact for details'}
</a>
</div>
</div>
</div>
<div className="flex items-start">
<BaseIcon path={mdiEmail} size={24} className="mr-4 text-emerald-400" />
<div>
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Email</div>
<div className="font-bold truncate max-w-[180px]">{business.email}</div>
</div>
</div>
<div className="flex items-start">
<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]">
<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">
<BaseIcon path={mdiMapMarker} size={24} className="mr-4 text-emerald-400" />
<div>
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Address</div>
<div className="font-bold">{business.address}, {business.city}, {business.state} {business.zip}</div>
</div>
</div>
</div>
</div>
{/* Badges */}
<div className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
<h3 className="text-xl font-bold mb-8">Trust Signals</h3>
<div className="space-y-6">
{business.business_badges_business?.filter((b:any) => b.status === 'APPROVED').map((badge: any) => (
<div key={badge.id} className="flex items-center p-4 rounded-2xl bg-slate-50">
<div className="w-10 h-10 bg-emerald-100 rounded-xl flex items-center justify-center mr-4 text-emerald-600">
<BaseIcon path={mdiShieldCheck} size={24} />
</div>
<div>
<div className="font-bold text-slate-800 text-sm leading-tight">{badge.badge_type.replace(/_/g, ' ')}</div>
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Verified Badge</div>
</div>
</div>
))}
{business.owner_userId && (
<div className="flex items-center p-4 rounded-2xl bg-emerald-50">
<div className="w-10 h-10 bg-emerald-200 rounded-xl flex items-center justify-center mr-4 text-emerald-700">
<BaseIcon path={mdiCheckDecagram} size={24} />
</div>
<div>
<div className="font-bold text-emerald-900 text-sm leading-tight">Claimed Listing</div>
<div className="text-[10px] text-emerald-600 font-bold uppercase tracking-widest">Verified Owner</div>
</div>
</div>
)}
{!business.business_badges_business?.length && !business.owner_userId && <p className="text-slate-400 text-sm italic">Pending verification...</p>}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
BusinessDetailsPublic.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
export default BusinessDetailsPublic;

View File

@ -0,0 +1,211 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import {
mdiShieldCheck,
mdiClockOutline,
mdiMapMarker,
mdiEmail,
mdiAccount,
mdiPhone,
mdiAlertDecagram
} from '@mdi/js';
import { Formik, Form, Field } from 'formik';
import axios from 'axios';
import LayoutGuest from '../../layouts/Guest';
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import FormField from '../../components/FormField';
import BaseButton from '../../components/BaseButton';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { create as createLead } from '../../stores/leads/leadsSlice';
const RequestServicePage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const { businessId } = router.query;
const [business, setBusiness] = useState<any>(null);
const [loading, setLoading] = useState(false);
const { currentUser } = useAppSelector(state => state.auth);
useEffect(() => {
if (businessId) {
fetchBusiness();
}
}, [businessId]);
const fetchBusiness = async () => {
try {
const response = await axios.get(`/businesses/${businessId}`);
setBusiness(response.data);
} catch (error) {
console.error('Error fetching business:', error);
}
};
const handleSubmit = async (values: any) => {
setLoading(true);
try {
const payload = {
...values,
businessId,
user: currentUser?.id || null
};
await dispatch(createLead(payload)).unwrap();
if (currentUser) {
router.push('/leads/leads-list'); // Redirect to their leads tracker if logged in
} else {
alert('Your request has been sent! The professional will contact you soon.');
router.push(`/public/businesses-details?id=${businessId}`);
}
} catch (error) {
console.error('Lead creation error:', error);
alert('There was an error sending your request. Please try again.');
} finally {
setLoading(false);
}
};
if (!business && businessId) return <div className="min-h-screen flex items-center justify-center bg-slate-50"><LoadingSpinner /></div>;
return (
<div className="min-h-screen bg-slate-50 pb-20 pt-28">
<Head>
<title>Request Service | Fix-It-Local</title>
</Head>
<div className="container mx-auto px-6 max-w-4xl">
<div className="bg-white rounded-[3rem] shadow-xl border border-slate-200 overflow-hidden">
<div className="bg-slate-900 p-12 text-white relative">
<div className="absolute top-0 right-0 p-12 opacity-10">
<BaseIcon path={mdiShieldCheck} size={120} />
</div>
<h1 className="text-4xl font-bold mb-4">Request Service</h1>
<p className="text-slate-400 text-lg max-w-xl">
You are requesting a service from <span className="text-emerald-400 font-bold">{business?.name || 'a professional'}</span>.
Our smart matching system ensures your request is handled with priority.
</p>
</div>
<div className="p-12">
<Formik
initialValues={{
keyword: '',
description: '',
urgency: 'TODAY',
contact_name: currentUser ? `${currentUser.firstName} ${currentUser.lastName}` : '',
contact_email: currentUser?.email || '',
contact_phone: currentUser?.phoneNumber || '',
address: '',
city: '',
state: '',
zip: ''
}}
onSubmit={handleSubmit}
>
{({ values }) => (
<Form className="space-y-8">
<div className="grid md:grid-cols-2 gap-8">
<FormField label="What do you need help with?" labelFor="keyword">
<Field
name="keyword"
placeholder="e.g. Leaking faucet in kitchen"
className="w-full bg-slate-50 border-slate-200 rounded-2xl py-4 px-6 focus:ring-emerald-500 focus:border-emerald-500"
required
/>
</FormField>
<FormField label="Urgency" labelFor="urgency">
<Field
name="urgency"
as="select"
className="w-full bg-slate-50 border-slate-200 rounded-2xl py-4 px-6 focus:ring-emerald-500 focus:border-emerald-500"
>
<option value="EMERGENCY">🚨 Emergency (Immediate)</option>
<option value="TODAY">📅 Today</option>
<option value="THIS_WEEK">🗓 This Week</option>
<option value="FLEXIBLE">🍃 Flexible</option>
</Field>
</FormField>
</div>
<FormField label="Details of the issue" labelFor="description">
<Field
name="description"
as="textarea"
rows={4}
placeholder="Please describe the problem in detail so the professional can give you an accurate estimate."
className="w-full bg-slate-50 border-slate-200 rounded-2xl py-4 px-6 focus:ring-emerald-500 focus:border-emerald-500"
required
/>
</FormField>
<div className="bg-slate-50 p-8 rounded-3xl space-y-8">
<h3 className="text-xl font-bold flex items-center">
<BaseIcon path={mdiAccount} size={24} className="mr-3 text-emerald-500" />
Contact Information
</h3>
<div className="grid md:grid-cols-3 gap-6">
<FormField label="Your Name" labelFor="contact_name">
<Field name="contact_name" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
<FormField label="Email" labelFor="contact_email">
<Field name="contact_email" type="email" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
<FormField label="Phone" labelFor="contact_phone">
<Field name="contact_phone" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
</div>
<div className="grid md:grid-cols-2 gap-6">
<FormField label="Service Address" labelFor="address">
<Field name="address" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
<div className="grid grid-cols-3 gap-4">
<FormField label="City" labelFor="city">
<Field name="city" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
<FormField label="State" labelFor="state">
<Field name="state" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
<FormField label="ZIP" labelFor="zip">
<Field name="zip" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-8 border-t border-slate-100">
<div className="text-slate-500 text-sm flex items-center max-w-sm">
<BaseIcon path={mdiShieldCheck} size={20} className="mr-2 text-emerald-500" />
Your data is protected and will only be shared with the professional you request.
</div>
<BaseButton
type="submit"
color="emerald"
label={loading ? 'Submitting...' : 'Send Request'}
className="py-5 px-12 rounded-2xl text-lg font-bold shadow-2xl shadow-emerald-500/30"
disabled={loading}
/>
</div>
</Form>
)}
</Formik>
</div>
</div>
</div>
</div>
);
};
RequestServicePage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutGuest>
{page}
</LayoutGuest>
);
};
export default RequestServicePage;

View File

@ -1,89 +1,221 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { ToastContainer, toast } from 'react-toastify'; import { ToastContainer, toast } from 'react-toastify';
import Head from 'next/head'; import Head from 'next/head';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen'; import BaseIcon from "../components/BaseIcon";
import { mdiShieldCheck, mdiEye, mdiEyeOff } from '@mdi/js';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField'; import FormField from '../components/FormField';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import Link from 'next/link';
import axios from "axios"; import axios from "axios";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
import Logo from '../components/Logo'
export default function Register() { export default function Register() {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [showPassword, setShowPassword] = useState(false);
const router = useRouter(); const router = useRouter();
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); const notify = (type, msg) => toast(msg, { type, position: "bottom-center" });
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({ video_files: [] })
const [contentType, setContentType] = useState('video');
const handleSubmit = async (value) => { useEffect(() => {
async function fetchData() {
const image = await getPexelsImage()
const video = await getPexelsVideo()
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const togglePasswordVisibility = () => {
setShowPassword(!showPassword);
};
const handleSubmit = async (values) => {
if (values.password !== values.confirm) {
notify('error', 'Passwords do not match');
return;
}
setLoading(true) setLoading(true)
try { try {
await axios.post('/auth/signup', values);
const { data: response } = await axios.post('/auth/signup',value);
await router.push('/login')
setLoading(false) setLoading(false)
notify('success', 'Please check your email for verification link') notify('success', 'Please check your email for verification link')
setTimeout(() => {
router.push('/login')
}, 2000);
} catch (error) { } catch (error) {
setLoading(false) setLoading(false)
console.log('error: ', error) console.log('error: ', error)
notify('error', 'Something was wrong. Try again') notify('error', error.response?.data?.message || 'Something went wrong. Try again')
} }
}; };
const imageBlock = (image) => (
<div className="hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden"
style={{
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(16, 185, 129, 0.1), rgba(6, 78, 59, 0.2))'}`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}>
<div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
<div className="relative z-10 p-12 text-white">
<h1 className="text-4xl font-black mb-4">Start Your Professional Journey.</h1>
<p className="text-lg text-emerald-50/80 max-w-md leading-relaxed">
Get listed, get verified, and connect with clients looking for high-quality, trusted services.
</p>
</div>
<div className="flex justify-center w-full bg-black/40 py-2 relative z-10">
<a className="text-[10px] text-white/60 hover:text-white transition-colors" href={image?.photographer_url} target="_blank" rel="noreferrer">
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
)
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video.video_files[0]?.link} type='video/mp4' />
Your browser does not support the video tag.
</video>
<div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
<div className="relative z-10 p-12 text-white">
<h1 className="text-4xl font-black mb-4">Start Your Professional Journey.</h1>
<p className="text-lg text-emerald-50/80 max-w-md leading-relaxed">
Get listed, get verified, and connect with clients looking for high-quality, trusted services.
</p>
</div>
<div className='flex justify-center w-full bg-black/40 py-2 relative z-10'>
<a
className='text-[10px] text-white/60 hover:text-white transition-colors'
href={video.user.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
return imageBlock(illustrationImage);
};
return ( return (
<> <div className="min-h-screen bg-slate-50 font-sans">
<Head> <Head>
<title>{getPageTitle('Login')}</title> <title>{getPageTitle('Register')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <div className="flex flex-row min-h-screen">
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'> {contentType === 'video' ? videoBlock(illustrationVideo) : imageBlock(illustrationImage)}
<Formik
initialValues={{
email: '',
password: '',
confirm: ''
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Email' help='Please enter your email'> <div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
<Field type='email' name='email' /> <div className="w-full max-w-md space-y-8">
</FormField> {/* Branding */}
<FormField label='Password' help='Please enter your password'> <div className="flex flex-col items-center mb-8">
<Field type='password' name='password' /> <Link href="/" className="flex items-center gap-3 group mb-6">
</FormField> <Logo className="h-12 w-auto" />
<FormField label='Confirm Password' help='Please confirm your password'> </Link>
<Field type='password' name='confirm' /> <h2 className="text-3xl font-bold text-slate-900">Create Account</h2>
</FormField> <p className="text-slate-500 mt-2 text-center">Join the most trusted service network today</p>
</div>
<BaseDivider /> <CardBox className="shadow-2xl border-none rounded-[2rem] p-4 lg:p-6">
<Formik
initialValues={{
email: '',
password: '',
confirm: ''
}}
onSubmit={(values) => handleSubmit(values)}
>
<Form className="space-y-4">
<FormField
label='Email Address'
labelColor="text-slate-700 font-bold"
>
<Field name='email' type='email' placeholder="name@company.com" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
</FormField>
<BaseButtons> <div className='relative'>
<BaseButton <FormField
type='submit' label='Password'
label={loading ? 'Loading...' : 'Register' } labelColor="text-slate-700 font-bold"
color='info' >
/> <Field name='password' type={showPassword ? 'text' : 'password'} placeholder="••••••••" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
<BaseButton </FormField>
href={'/login'} <div
label={'Login'} className='absolute top-[42px] right-0 pr-4 flex items-center cursor-pointer'
color='info' onClick={togglePasswordVisibility}
/> >
</BaseButtons> <BaseIcon
</Form> className='text-slate-400 hover:text-emerald-500'
</Formik> size={20}
</CardBox> path={showPassword ? mdiEyeOff : mdiEye}
</SectionFullScreen> />
</div>
</div>
<FormField
label='Confirm Password'
labelColor="text-slate-700 font-bold"
>
<Field name='confirm' type={showPassword ? 'text' : 'password'} placeholder="••••••••" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
</FormField>
<div className="pt-6">
<BaseButton
className={'w-full py-4 rounded-xl font-bold text-lg shadow-lg shadow-emerald-500/20'}
type='submit'
label={loading ? 'Creating Account...' : 'Register'}
color='success'
disabled={loading}
/>
</div>
<div className="text-center pt-4">
<p className="text-slate-500 text-sm">
Already have an account?{' '}
<Link className="font-bold text-emerald-600 hover:text-emerald-700" href={'/login'}>
Sign in here
</Link>
</p>
</div>
</Form>
</Formik>
</CardBox>
<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 Fix-It-Local. All rights reserved.
</div>
</div>
</div>
</div>
<ToastContainer /> <ToastContainer />
</> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js' import { mdiChartTimelineVariant } from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import DatePicker from "react-datepicker"; import DatePicker from "react-datepicker";
@ -16,310 +16,34 @@ import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider' import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons' import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton' import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectField } from "../../components/SelectField"; import { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField' import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
import { update, fetch } from '../../stores/reviews/reviewsSlice' import { update, fetch } from '../../stores/reviews/reviewsSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks' import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
const EditReviewsPage = () => { const EditReviewsPage = () => {
const router = useRouter() const router = useRouter()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { currentUser } = useAppSelector((state) => state.auth);
const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner';
const initVals = { const initVals = {
business: null, business: null,
user: null, user: null,
lead: null, lead: null,
rating: '', rating: '',
text: '', text: '',
is_verified_job: false, is_verified_job: false,
status: '', status: '',
moderation_notes: '', moderation_notes: '',
response: '',
created_at_ts: new Date(), created_at_ts: new Date(),
updated_at_ts: new Date(), updated_at_ts: new Date(),
} }
const [initialValues, setInitialValues] = useState(initVals) const [initialValues, setInitialValues] = useState(initVals)
@ -330,22 +54,20 @@ const EditReviewsPage = () => {
useEffect(() => { useEffect(() => {
dispatch(fetch({ id: id })) dispatch(fetch({ id: id }))
}, [id]) }, [id, dispatch])
useEffect(() => { useEffect(() => {
if (typeof reviews === 'object') { if (typeof reviews === 'object' && reviews !== null) {
setInitialValues(reviews) const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => {
if (reviews[el] !== undefined) {
newInitialVal[el] = reviews[el]
}
})
setInitialValues(newInitialVal);
} }
}, [reviews]) }, [reviews])
useEffect(() => {
if (typeof reviews === 'object') {
const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (reviews)[el])
setInitialValues(newInitialVal);
}
}, [reviews])
const handleSubmit = async (data) => { const handleSubmit = async (data) => {
await dispatch(update({ id: id, data })) await dispatch(update({ id: id, data }))
await router.push('/reviews/reviews-list') await router.push('/reviews/reviews-list')
@ -367,28 +89,8 @@ const EditReviewsPage = () => {
onSubmit={(values) => handleSubmit(values)} onSubmit={(values) => handleSubmit(values)}
> >
<Form> <Form>
{!isBusinessOwner && (
<>
<FormField label='Business' labelFor='business'> <FormField label='Business' labelFor='business'>
<Field <Field
name='business' name='business'
@ -396,89 +98,10 @@ const EditReviewsPage = () => {
component={SelectField} component={SelectField}
options={initialValues.business} options={initialValues.business}
itemRef={'businesses'} itemRef={'businesses'}
showField={'name'} showField={'name'}
></Field> ></Field>
</FormField> </FormField>
<FormField label='User' labelFor='user'> <FormField label='User' labelFor='user'>
<Field <Field
name='user' name='user'
@ -486,89 +109,10 @@ const EditReviewsPage = () => {
component={SelectField} component={SelectField}
options={initialValues.user} options={initialValues.user}
itemRef={'users'} itemRef={'users'}
showField={'firstName'} showField={'firstName'}
></Field> ></Field>
</FormField> </FormField>
<FormField label='Lead' labelFor='lead'> <FormField label='Lead' labelFor='lead'>
<Field <Field
name='lead' name='lead'
@ -576,76 +120,10 @@ const EditReviewsPage = () => {
component={SelectField} component={SelectField}
options={initialValues.lead} options={initialValues.lead}
itemRef={'leads'} itemRef={'leads'}
showField={'keyword'} showField={'keyword'}
></Field> ></Field>
</FormField> </FormField>
<FormField <FormField
label="Rating" label="Rating"
> >
@ -655,77 +133,15 @@ const EditReviewsPage = () => {
placeholder="Rating" placeholder="Rating"
/> />
</FormField> </FormField>
</>
)}
<FormField label="Review Text" hasTextareaHeight>
<Field name="text" as="textarea" placeholder="Text" disabled={isBusinessOwner} />
<FormField label="Text" hasTextareaHeight>
<Field name="text" as="textarea" placeholder="Text" />
</FormField> </FormField>
{!isBusinessOwner && (
<>
<FormField label='IsVerifiedJob' labelFor='is_verified_job'> <FormField label='IsVerifiedJob' labelFor='is_verified_job'>
<Field <Field
name='is_verified_job' name='is_verified_job'
@ -734,102 +150,27 @@ const EditReviewsPage = () => {
></Field> ></Field>
</FormField> </FormField>
<FormField label="Status" labelFor="status"> <FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select"> <Field name="status" id="status" component="select">
<option value="PUBLISHED">PUBLISHED</option> <option value="PUBLISHED">PUBLISHED</option>
<option value="PENDING">PENDING</option> <option value="PENDING">PENDING</option>
<option value="HIDDEN">HIDDEN</option> <option value="HIDDEN">HIDDEN</option>
<option value="REJECTED">REJECTED</option> <option value="REJECTED">REJECTED</option>
</Field> </Field>
</FormField> </FormField>
<FormField label="ModerationNotes" hasTextareaHeight> <FormField label="ModerationNotes" hasTextareaHeight>
<Field name="moderation_notes" as="textarea" placeholder="ModerationNotes" /> <Field name="moderation_notes" as="textarea" placeholder="ModerationNotes" />
</FormField> </FormField>
</>
)}
<FormField label="Your Response" hasTextareaHeight help="Address the customer's feedback directly.">
<Field name="response" as="textarea" placeholder="Write your response here..." />
</FormField>
{!isBusinessOwner && (
<>
<FormField <FormField
label="CreatedAt" label="CreatedAt"
> >
@ -837,42 +178,12 @@ const EditReviewsPage = () => {
dateFormat="yyyy-MM-dd hh:mm" dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect showTimeSelect
selected={initialValues.created_at_ts ? selected={initialValues.created_at_ts ?
new Date( new Date(initialValues.created_at_ts) : null
dayjs(initialValues.created_at_ts).format('YYYY-MM-DD hh:mm'),
) : null
} }
onChange={(date) => setInitialValues({...initialValues, 'created_at_ts': date})} onChange={(date) => setInitialValues({...initialValues, 'created_at_ts': date})}
/> />
</FormField> </FormField>
<FormField <FormField
label="UpdatedAt" label="UpdatedAt"
> >
@ -880,31 +191,13 @@ const EditReviewsPage = () => {
dateFormat="yyyy-MM-dd hh:mm" dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect showTimeSelect
selected={initialValues.updated_at_ts ? selected={initialValues.updated_at_ts ?
new Date( new Date(initialValues.updated_at_ts) : null
dayjs(initialValues.updated_at_ts).format('YYYY-MM-DD hh:mm'),
) : null
} }
onChange={(date) => setInitialValues({...initialValues, 'updated_at_ts': date})} onChange={(date) => setInitialValues({...initialValues, 'updated_at_ts': date})}
/> />
</FormField> </FormField>
</>
)}
<BaseDivider /> <BaseDivider />
<BaseButtons> <BaseButtons>
@ -923,9 +216,7 @@ const EditReviewsPage = () => {
EditReviewsPage.getLayout = function getLayout(page: ReactElement) { EditReviewsPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated
permission={'UPDATE_REVIEWS'} permission={'UPDATE_REVIEWS'}
> >
{page} {page}
</LayoutAuthenticated> </LayoutAuthenticated>

View File

@ -1,8 +1,8 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload, mdiStar, mdiMessageDraw } from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated' import LayoutGuest from '../../layouts/Guest'
import SectionMain from '../../components/SectionMain' import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config' import { getPageTitle } from '../../config'
@ -12,573 +12,170 @@ import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider' import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons' import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton' import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio' import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/reviews/reviewsSlice'
import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import moment from 'moment'; import { create } from '../../stores/reviews/reviewsSlice'
import BaseIcon from '../../components/BaseIcon'
const initialValues = { import axios from 'axios'
import { ToastContainer, toast } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css'
business: '',
user: '',
lead: '',
rating: '',
text: '',
is_verified_job: false,
status: 'PUBLISHED',
moderation_notes: '',
created_at_ts: '',
updated_at_ts: '',
}
const ReviewsNew = () => { const ReviewsNew = () => {
const router = useRouter() const router = useRouter()
const { businessId } = router.query
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { currentUser } = useAppSelector((state) => state.auth)
const [businessName, setBusinessName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (businessId) {
fetchBusinessName()
}
}, [businessId])
const fetchBusinessName = async () => {
try {
const handleSubmit = async (data) => { const response = await axios.get(`/businesses/${businessId}`)
await dispatch(create(data)) setBusinessName(response.data.name)
await router.push('/reviews/reviews-list') } catch (e) {
console.error(e)
}
} }
const initialValues = {
business: businessId || '',
user: currentUser?.id || '',
lead: '',
rating: 5,
text: '',
is_verified_job: false,
status: 'PUBLISHED',
moderation_notes: '',
created_at_ts: new Date().toISOString().slice(0, 16),
}
const handleSubmit = async (values) => {
setIsSubmitting(true)
const data = {
...values,
rating: Number(values.rating),
business: businessId || values.business // Use businessId from query as priority
}
if (!data.business) {
toast.error('Business ID is missing. Please try again from the business page.')
setIsSubmitting(false)
return
}
try {
await dispatch(create(data)).unwrap()
toast.success('Thank you for your review!')
setTimeout(() => {
if (businessId) {
router.push(`/public/businesses-details?id=${businessId}`)
} else {
router.push('/reviews/reviews-list')
}
}, 2000)
} catch (e) {
console.error('Failed to submit review:', e)
toast.error('Failed to submit review. Please try again.')
setIsSubmitting(false)
}
}
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Item')}</title> <title>{getPageTitle('Write a Review')}</title>
</Head> </Head>
<SectionMain> <div className="pt-24 pb-12">
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionMain>
{''} <SectionTitleLineWithButton icon={mdiMessageDraw} title={businessName ? `Review for ${businessName}` : "Write a Review"} main>
</SectionTitleLineWithButton> {''}
<CardBox> </SectionTitleLineWithButton>
<Formik
initialValues={ <div className="max-w-3xl mx-auto">
<CardBox>
initialValues <Formik
initialValues={initialValues}
} enableReinitialize={true}
onSubmit={(values) => handleSubmit(values)} onSubmit={(values) => handleSubmit(values)}
> >
<Form> {({ values, setFieldValue }) => (
<Form>
<div className="mb-8 text-center">
<p className="text-slate-500 mb-4 font-medium uppercase tracking-widest text-xs">Overall Experience</p>
<div className="flex justify-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setFieldValue('rating', star)}
className={`p-2 transition-all transform hover:scale-110 ${values.rating >= star ? 'text-amber-400' : 'text-slate-200'}`}
>
<BaseIcon path={mdiStar} size={48} />
</button>
))}
</div>
<div className="text-amber-500 font-black text-xl mt-2">
{values.rating === 1 && 'Poor'}
{values.rating === 2 && 'Fair'}
{values.rating === 3 && 'Good'}
{values.rating === 4 && 'Very Good'}
{values.rating === 5 && 'Excellent!'}
<FormField label="Business" labelFor="business"> </div>
<Field name="business" id="business" component={SelectField} options={[]} itemRef={'businesses'}></Field> </div>
</FormField>
<FormField label="Your Review" help="Share details of your experience with this professional.">
<Field
name="text"
as="textarea"
placeholder="What was it like working with them?"
className="w-full rounded-2xl border-slate-200 focus:ring-emerald-500 focus:border-emerald-500"
rows={5}
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton
type="submit"
color="emerald"
label={isSubmitting ? "Submitting..." : "Submit Review"}
className="w-full md:w-auto px-12 py-4 rounded-2xl"
disabled={isSubmitting}
/>
<BaseButton
type="button"
color="info"
outline
label="Cancel"
onClick={() => businessId ? router.push(`/public/businesses-details?id=${businessId}`) : router.push('/reviews/reviews-list')}
className="w-full md:w-auto px-12 py-4 rounded-2xl"
<FormField label="User" labelFor="user"> disabled={isSubmitting}
<Field name="user" id="user" component={SelectField} options={[]} itemRef={'users'}></Field> />
</FormField> </BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</div>
</SectionMain>
</div>
<ToastContainer />
<FormField label="Lead" labelFor="lead">
<Field name="lead" id="lead" component={SelectField} options={[]} itemRef={'leads'}></Field>
</FormField>
<FormField
label="Rating"
>
<Field
type="number"
name="rating"
placeholder="Rating"
/>
</FormField>
<FormField label="Text" hasTextareaHeight>
<Field name="text" as="textarea" placeholder="Text" />
</FormField>
<FormField label='IsVerifiedJob' labelFor='is_verified_job'>
<Field
name='is_verified_job'
id='is_verified_job'
component={SwitchField}
></Field>
</FormField>
<FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select">
<option value="PUBLISHED">PUBLISHED</option>
<option value="PENDING">PENDING</option>
<option value="HIDDEN">HIDDEN</option>
<option value="REJECTED">REJECTED</option>
</Field>
</FormField>
<FormField label="ModerationNotes" hasTextareaHeight>
<Field name="moderation_notes" as="textarea" placeholder="ModerationNotes" />
</FormField>
<FormField
label="CreatedAt"
>
<Field
type="datetime-local"
name="created_at_ts"
placeholder="CreatedAt"
/>
</FormField>
<FormField
label="UpdatedAt"
>
<Field
type="datetime-local"
name="updated_at_ts"
placeholder="UpdatedAt"
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/reviews/reviews-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</> </>
) )
} }
ReviewsNew.getLayout = function getLayout(page: ReactElement) { ReviewsNew.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutGuest>
{page}
permission={'CREATE_REVIEWS'} </LayoutGuest>
>
{page}
</LayoutAuthenticated>
) )
} }

Some files were not shown because too many files have changed in this diff Show More