Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
2
502.html
2
502.html
@ -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>Fix-It-Local</h2>
|
<h2>Crafted Network</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">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
# Fix-It-Local
|
# Crafted Network
|
||||||
|
|
||||||
|
|
||||||
## This project was generated by [Flatlogic Platform](https://flatlogic.com).
|
## This project was generated by [Flatlogic Platform](https://flatlogic.com).
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 214 KiB |
@ -12,4 +12,3 @@ 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
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
#Fix-It-Local - template backend,
|
#Crafted Network - template backend,
|
||||||
|
|
||||||
#### Run App on local machine:
|
#### Run App on local machine:
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "craftednetwork",
|
"name": "craftednetwork",
|
||||||
"description": "Fix-It-Local - template backend",
|
"description": "Crafted Network - 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",
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@ -29,7 +32,6 @@ 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 || '',
|
||||||
@ -37,7 +39,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
uploadDir: os.tmpdir(),
|
uploadDir: os.tmpdir(),
|
||||||
email: {
|
email: {
|
||||||
from: 'Fix-It-Local <app@flatlogic.app>',
|
from: 'Crafted Network <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: {
|
||||||
@ -67,7 +69,7 @@ const config = {
|
|||||||
|
|
||||||
config.pexelsKey = process.env.PEXELS_KEY || '';
|
config.pexelsKey = process.env.PEXELS_KEY || '';
|
||||||
|
|
||||||
config.pexelsQuery = 'home repair services';
|
config.pexelsQuery = 'Crafted bridge over calm river';
|
||||||
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}`;
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -14,63 +20,493 @@ 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,
|
|
||||||
status: data.status || null,
|
badge_type: data.badge_type
|
||||||
granted_at: data.granted_at || null,
|
||
|
||||||
revoked_at: data.revoked_at || null,
|
null
|
||||||
notes: data.notes || null,
|
,
|
||||||
created_at_ts: data.created_at_ts || null,
|
|
||||||
updated_at_ts: data.updated_at_ts || null,
|
status: data.status
|
||||||
importHash: data.importHash || null,
|
||
|
||||||
createdById: currentUser.id,
|
null
|
||||||
updatedById: currentUser.id,
|
,
|
||||||
},
|
|
||||||
{ transaction },
|
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 });
|
|
||||||
|
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;
|
return business_badges;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter, options) {
|
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 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!business_badges) {
|
||||||
|
return business_badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = business_badges.get({plain: true});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
output.business = await business_badges.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 transaction = (options && options.transaction) || undefined;
|
const orderBy = null;
|
||||||
const currentUser = options?.currentUser;
|
|
||||||
|
|
||||||
// Data Isolation
|
const transaction = (options && options.transaction) || undefined;
|
||||||
if (currentUser && currentUser.app_role) {
|
|
||||||
const roleName = currentUser.app_role.name;
|
let include = [
|
||||||
if (roleName === 'Verified Business Owner') {
|
|
||||||
if (currentUser.businessId) {
|
{
|
||||||
where.businessId = currentUser.businessId;
|
model: db.businesses,
|
||||||
} else {
|
as: 'business',
|
||||||
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,
|
||||||
@ -79,7 +515,8 @@ 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,
|
transaction: options?.transaction,
|
||||||
|
logging: console.log
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
if (!options?.countOnly) {
|
||||||
@ -87,80 +524,51 @@ module.exports = class Business_badgesDBApi {
|
|||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, count } = await db.business_badges.findAndCountAll(queryOptions);
|
try {
|
||||||
|
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);
|
||||||
static async findBy(where, options) {
|
throw error;
|
||||||
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) {
|
static async findAllAutocomplete(query, limit, offset, ) {
|
||||||
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' ],
|
attributes: [ 'id', 'notes' ],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
order: [['createdAt', 'desc']],
|
orderBy: [['notes', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
label: record.id,
|
label: record.notes,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -14,68 +20,355 @@ 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,
|
|
||||||
importHash: data.importHash || null,
|
created_at_ts: data.created_at_ts
|
||||||
createdById: currentUser.id,
|
||
|
||||||
updatedById: currentUser.id,
|
null
|
||||||
},
|
,
|
||||||
{ transaction },
|
|
||||||
);
|
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 });
|
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;
|
return business_categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter, options) {
|
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 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!business_categories) {
|
||||||
|
return business_categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 transaction = (options && options.transaction) || undefined;
|
const orderBy = null;
|
||||||
const currentUser = options?.currentUser;
|
|
||||||
|
|
||||||
// Data Isolation
|
const transaction = (options && options.transaction) || undefined;
|
||||||
if (currentUser && currentUser.app_role) {
|
|
||||||
const roleName = currentUser.app_role.name;
|
let include = [
|
||||||
if (roleName === 'Verified Business Owner') {
|
|
||||||
if (currentUser.businessId) {
|
{
|
||||||
where.businessId = currentUser.businessId;
|
model: db.businesses,
|
||||||
} else {
|
as: 'business',
|
||||||
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,
|
||||||
@ -84,7 +377,8 @@ 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,
|
transaction: options?.transaction,
|
||||||
|
logging: console.log
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
if (!options?.countOnly) {
|
||||||
@ -92,83 +386,51 @@ module.exports = class Business_categoriesDBApi {
|
|||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, count } = await db.business_categories.findAndCountAll(queryOptions);
|
try {
|
||||||
|
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);
|
||||||
static async findBy(where, options) {
|
throw error;
|
||||||
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) {
|
static async findAllAutocomplete(query, limit, offset, ) {
|
||||||
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' ],
|
attributes: [ 'id', 'created_at_ts' ],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
order: [['createdAt', 'desc']],
|
orderBy: [['created_at_ts', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
label: record.id,
|
label: record.created_at_ts,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -14,15 +20,28 @@ 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,
|
|
||||||
importHash: data.importHash || null,
|
created_at_ts: data.created_at_ts
|
||||||
createdById: currentUser.id,
|
||
|
||||||
updatedById: currentUser.id,
|
null
|
||||||
},
|
,
|
||||||
{ transaction },
|
|
||||||
);
|
importHash: data.importHash || null,
|
||||||
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
await business_photos.setBusiness( data.business || null, { transaction });
|
|
||||||
|
await business_photos.setBusiness( data.business || null, {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await FileDBApi.replaceRelationFiles(
|
await FileDBApi.replaceRelationFiles(
|
||||||
{
|
{
|
||||||
belongsTo: db.business_photos.getTableName(),
|
belongsTo: db.business_photos.getTableName(),
|
||||||
@ -32,52 +51,329 @@ module.exports = class Business_photosDBApi {
|
|||||||
data.photos,
|
data.photos,
|
||||||
options,
|
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;
|
return business_photos;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter, options) {
|
static async update(id, data, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
|
||||||
|
const business_photos = await db.business_photos.findByPk(id, {}, {transaction});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const updatePayload = {};
|
||||||
|
|
||||||
|
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||||
|
|
||||||
|
|
||||||
|
updatePayload.updatedById = currentUser.id;
|
||||||
|
|
||||||
|
await business_photos.update(updatePayload, {transaction});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (data.business !== undefined) {
|
||||||
|
await business_photos.setBusiness(
|
||||||
|
|
||||||
|
data.business,
|
||||||
|
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
await FileDBApi.replaceRelationFiles(
|
||||||
|
{
|
||||||
|
belongsTo: db.business_photos.getTableName(),
|
||||||
|
belongsToColumn: 'photos',
|
||||||
|
belongsToId: business_photos.id,
|
||||||
|
},
|
||||||
|
data.photos,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
return business_photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteByIds(ids, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
const business_photos = await db.business_photos.findAll({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[Op.in]: ids,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.sequelize.transaction(async (transaction) => {
|
||||||
|
for (const record of business_photos) {
|
||||||
|
await record.update(
|
||||||
|
{deletedBy: currentUser.id},
|
||||||
|
{transaction}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const record of business_photos) {
|
||||||
|
await record.destroy({transaction});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return business_photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
const business_photos = await db.business_photos.findByPk(id, options);
|
||||||
|
|
||||||
|
await business_photos.update({
|
||||||
|
deletedBy: currentUser.id
|
||||||
|
}, {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await business_photos.destroy({
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
|
||||||
|
return business_photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findBy(where, options) {
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
const business_photos = await db.business_photos.findOne(
|
||||||
|
{ where },
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!business_photos) {
|
||||||
|
return business_photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 transaction = (options && options.transaction) || undefined;
|
const orderBy = null;
|
||||||
const currentUser = options?.currentUser;
|
|
||||||
|
|
||||||
// Data Isolation
|
const transaction = (options && options.transaction) || undefined;
|
||||||
if (currentUser && currentUser.app_role) {
|
|
||||||
const roleName = currentUser.app_role.name;
|
let include = [
|
||||||
if (roleName === 'Verified Business Owner') {
|
|
||||||
if (currentUser.businessId) {
|
{
|
||||||
where.businessId = currentUser.businessId;
|
model: db.businesses,
|
||||||
} else {
|
as: 'business',
|
||||||
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,
|
||||||
@ -86,7 +382,8 @@ 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,
|
transaction: options?.transaction,
|
||||||
|
logging: console.log
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
if (!options?.countOnly) {
|
||||||
@ -94,91 +391,51 @@ module.exports = class Business_photosDBApi {
|
|||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, count } = await db.business_photos.findAndCountAll(queryOptions);
|
try {
|
||||||
|
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);
|
||||||
static async findBy(where, options) {
|
throw error;
|
||||||
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) {
|
static async findAllAutocomplete(query, limit, offset, ) {
|
||||||
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' ],
|
attributes: [ 'id', 'photos' ],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
order: [['createdAt', 'desc']],
|
orderBy: [['photos', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
label: record.id,
|
label: record.photos,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,70 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,103 +1,752 @@
|
|||||||
|
|
||||||
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) {
|
|
||||||
const limit = filter.limit || 0;
|
|
||||||
let offset = 0;
|
|
||||||
let where = {};
|
|
||||||
const currentPage = +filter.page;
|
|
||||||
offset = currentPage * limit;
|
|
||||||
|
|
||||||
const currentUser = options?.currentUser;
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
// Data Isolation for Fix-It-Local™
|
|
||||||
if (currentUser && currentUser.app_role) {
|
|
||||||
const roleName = currentUser.app_role.name;
|
|
||||||
if (roleName === 'Verified Business Owner') {
|
|
||||||
// Business owners only see matches for THEIR businesses
|
|
||||||
where['$business.owner_userId$'] = currentUser.id;
|
|
||||||
} else if (roleName === 'Consumer') {
|
|
||||||
// Consumers only see matches for THEIR leads
|
|
||||||
where['$lead.userId$'] = currentUser.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = {
|
|
||||||
where,
|
|
||||||
include,
|
|
||||||
distinct: true,
|
|
||||||
limit: options?.countOnly ? undefined : (limit ? Number(limit) : undefined),
|
|
||||||
offset: options?.countOnly ? undefined : (offset ? Number(offset) : undefined),
|
|
||||||
order: [['createdAt', 'desc']],
|
|
||||||
transaction
|
|
||||||
};
|
|
||||||
|
|
||||||
const { rows, count } = await db.lead_matches.findAndCountAll(queryOptions);
|
|
||||||
|
|
||||||
return {
|
|
||||||
rows: options?.countOnly ? [] : rows,
|
|
||||||
count: count
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static async findBy(where, options) {
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
const lead_matches = await db.lead_matches.findOne({
|
|
||||||
where,
|
|
||||||
include: [
|
|
||||||
{ model: db.leads, as: 'lead' },
|
|
||||||
{ model: db.businesses, as: 'business' }
|
|
||||||
],
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
return lead_matches ? lead_matches.get({plain: true}) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
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;
|
||||||
const record = await db.lead_matches.create({
|
|
||||||
...data,
|
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,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id
|
updatedById: currentUser.id,
|
||||||
}, { transaction });
|
},
|
||||||
return record;
|
{ 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) {
|
static async remove(id, 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;
|
||||||
const record = await db.lead_matches.findByPk(id, options);
|
|
||||||
await record.update({ deletedBy: currentUser.id }, { transaction });
|
const lead_matches = await db.lead_matches.findByPk(id, options);
|
||||||
await record.destroy({ transaction });
|
|
||||||
return record;
|
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;
|
||||||
|
let offset = 0;
|
||||||
|
let where = {};
|
||||||
|
const currentPage = +filter.page;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
offset = currentPage * limit;
|
||||||
|
|
||||||
|
const orderBy = null;
|
||||||
|
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
let include = [
|
||||||
|
|
||||||
|
{
|
||||||
|
model: db.leads,
|
||||||
|
as: 'lead',
|
||||||
|
|
||||||
|
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}%` }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} : {},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryOptions = {
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
distinct: true,
|
||||||
|
order: filter.field && filter.sort
|
||||||
|
? [[filter.field, filter.sort]]
|
||||||
|
: [['createdAt', 'desc']],
|
||||||
|
transaction: options?.transaction,
|
||||||
|
logging: console.log
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options?.countOnly) {
|
||||||
|
queryOptions.limit = limit ? Number(limit) : undefined;
|
||||||
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows, count } = await db.lead_matches.findAndCountAll(queryOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: options?.countOnly ? [] : rows,
|
||||||
|
count: count
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing query:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAllAutocomplete(query, limit, offset, ) {
|
||||||
|
let where = {};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
limit: limit ? Number(limit) : undefined,
|
||||||
|
offset: offset ? Number(offset) : undefined,
|
||||||
|
orderBy: [['status', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((record) => ({
|
||||||
|
id: record.id,
|
||||||
|
label: record.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,18 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -14,155 +20,148 @@ 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,
|
|
||||||
read_at: data.read_at || null,
|
body: data.body
|
||||||
created_at_ts: data.created_at_ts || null,
|
||
|
||||||
importHash: data.importHash || null,
|
null
|
||||||
createdById: currentUser.id,
|
,
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
read_at: data.read_at
|
||||||
{ transaction },
|
||
|
||||||
);
|
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 || currentUser.id, { transaction });
|
await messages.setLead( data.lead || null, {
|
||||||
await messages.setReceiver_user( data.receiver_user || null, { transaction });
|
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;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter, options) {
|
static async update(id, data, options) {
|
||||||
const limit = filter.limit || 0;
|
|
||||||
let offset = 0;
|
|
||||||
let where = {};
|
|
||||||
const currentPage = +filter.page;
|
|
||||||
offset = currentPage * limit;
|
|
||||||
|
|
||||||
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') {
|
|
||||||
const businessId = currentUser.businessId;
|
|
||||||
if (businessId) {
|
|
||||||
where[Op.or] = [
|
|
||||||
{ sender_userId: currentUser.id },
|
|
||||||
{ receiver_userId: currentUser.id },
|
|
||||||
{ '$lead.lead_matches_lead.businessId$': businessId }
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
where[Op.or] = [
|
|
||||||
{ sender_userId: currentUser.id },
|
|
||||||
{ receiver_userId: currentUser.id },
|
|
||||||
{ '$lead.lead_matches_lead.business.owner_userId$': currentUser.id }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
} else if (roleName === 'Consumer') {
|
|
||||||
where[Op.or] = [
|
|
||||||
{ sender_userId: currentUser.id },
|
|
||||||
{ 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 = {
|
|
||||||
where,
|
|
||||||
include,
|
|
||||||
distinct: true,
|
|
||||||
subQuery: false, // Fix for "missing FROM-clause entry" when using limit + nested includes
|
|
||||||
order: filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
|
||||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
|
||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rows, count } = await db.messages.findAndCountAll(queryOptions);
|
|
||||||
|
|
||||||
return {
|
|
||||||
rows: options?.countOnly ? [] : rows,
|
|
||||||
count: count
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
const messages = await db.messages.findByPk(id, {transaction});
|
|
||||||
if (!messages) return null;
|
|
||||||
|
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;
|
||||||
|
|
||||||
const updatePayload = { ...data, updatedById: currentUser.id };
|
|
||||||
await messages.update(updatePayload, {transaction});
|
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 });
|
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;
|
return messages;
|
||||||
}
|
}
|
||||||
@ -170,41 +169,358 @@ module.exports = class MessagesDBApi {
|
|||||||
static async deleteByIds(ids, options) {
|
static async deleteByIds(ids, 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;
|
||||||
const messages = await db.messages.findAll({ where: { id: { [Op.in]: ids } }, transaction });
|
|
||||||
|
|
||||||
for (const record of messages) {
|
const messages = await db.messages.findAll({
|
||||||
await record.update({deletedBy: currentUser.id}, {transaction});
|
where: {
|
||||||
await record.destroy({transaction});
|
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;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async remove(id, options) {
|
static async remove(id, 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;
|
||||||
|
|
||||||
const messages = await db.messages.findByPk(id, options);
|
const messages = await db.messages.findByPk(id, options);
|
||||||
await messages.update({ deletedBy: currentUser.id }, { transaction });
|
|
||||||
await messages.destroy({ transaction });
|
await messages.update({
|
||||||
|
deletedBy: currentUser.id
|
||||||
|
}, {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await messages.destroy({
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 findAll(
|
||||||
|
filter,
|
||||||
|
options
|
||||||
|
) {
|
||||||
|
const limit = filter.limit || 0;
|
||||||
|
let offset = 0;
|
||||||
let where = {};
|
let where = {};
|
||||||
|
const currentPage = +filter.page;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
offset = currentPage * limit;
|
||||||
|
|
||||||
|
const orderBy = null;
|
||||||
|
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
let include = [
|
||||||
|
|
||||||
|
{
|
||||||
|
model: db.leads,
|
||||||
|
as: 'lead',
|
||||||
|
|
||||||
|
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}%` }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} : {},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryOptions = {
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
distinct: true,
|
||||||
|
order: filter.field && filter.sort
|
||||||
|
? [[filter.field, filter.sort]]
|
||||||
|
: [['createdAt', 'desc']],
|
||||||
|
transaction: options?.transaction,
|
||||||
|
logging: console.log
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options?.countOnly) {
|
||||||
|
queryOptions.limit = limit ? Number(limit) : undefined;
|
||||||
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows, count } = await db.messages.findAndCountAll(queryOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: options?.countOnly ? [] : rows,
|
||||||
|
count: count
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing query:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAllAutocomplete(query, limit, offset, ) {
|
||||||
|
let where = {};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
{ ['body']: { [Op.iLike]: `%${query}%` } },
|
Utils.ilike(
|
||||||
|
'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,
|
||||||
order: [['body', 'ASC']],
|
orderBy: [['body', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
@ -212,4 +528,7 @@ module.exports = class MessagesDBApi {
|
|||||||
label: record.body,
|
label: record.body,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -45,9 +46,6 @@ 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
|
||||||
||
|
||
|
||||||
@ -172,11 +170,6 @@ 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;
|
||||||
@ -352,28 +345,12 @@ 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)) } },
|
||||||
@ -383,14 +360,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)) } },
|
||||||
@ -400,14 +377,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)) } },
|
||||||
@ -417,7 +394,7 @@ module.exports = class ReviewsDBApi {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
} : undefined,
|
} : {},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -598,7 +575,6 @@ 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']],
|
||||||
@ -658,3 +634,4 @@ module.exports = class ReviewsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -306,20 +307,6 @@ 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 = [
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -604,4 +591,5 @@ module.exports = class Service_pricesDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -84,7 +85,6 @@ 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,7 +298,6 @@ 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 +983,5 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -14,17 +20,38 @@ 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,
|
|
||||||
url: data.url || null,
|
evidence_type: data.evidence_type
|
||||||
created_at_ts: data.created_at_ts || null,
|
||
|
||||||
importHash: data.importHash || null,
|
null
|
||||||
createdById: currentUser.id,
|
,
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
url: data.url
|
||||||
{ transaction },
|
||
|
||||||
);
|
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 verification_evidences.setSubmission( data.submission || null, {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await FileDBApi.replaceRelationFiles(
|
await FileDBApi.replaceRelationFiles(
|
||||||
{
|
{
|
||||||
belongsTo: db.verification_evidences.getTableName(),
|
belongsTo: db.verification_evidences.getTableName(),
|
||||||
@ -34,53 +61,363 @@ module.exports = class Verification_evidencesDBApi {
|
|||||||
data.files,
|
data.files,
|
||||||
options,
|
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;
|
return verification_evidences;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter, options) {
|
static async update(id, data, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
|
||||||
|
const verification_evidences = await db.verification_evidences.findByPk(id, {}, {transaction});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const updatePayload = {};
|
||||||
|
|
||||||
|
if (data.evidence_type !== undefined) updatePayload.evidence_type = data.evidence_type;
|
||||||
|
|
||||||
|
|
||||||
|
if (data.url !== undefined) updatePayload.url = data.url;
|
||||||
|
|
||||||
|
|
||||||
|
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||||
|
|
||||||
|
|
||||||
|
updatePayload.updatedById = currentUser.id;
|
||||||
|
|
||||||
|
await verification_evidences.update(updatePayload, {transaction});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (data.submission !== undefined) {
|
||||||
|
await verification_evidences.setSubmission(
|
||||||
|
|
||||||
|
data.submission,
|
||||||
|
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
await FileDBApi.replaceRelationFiles(
|
||||||
|
{
|
||||||
|
belongsTo: db.verification_evidences.getTableName(),
|
||||||
|
belongsToColumn: 'files',
|
||||||
|
belongsToId: verification_evidences.id,
|
||||||
|
},
|
||||||
|
data.files,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
return verification_evidences;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteByIds(ids, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
const verification_evidences = await db.verification_evidences.findAll({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[Op.in]: ids,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.sequelize.transaction(async (transaction) => {
|
||||||
|
for (const record of verification_evidences) {
|
||||||
|
await record.update(
|
||||||
|
{deletedBy: currentUser.id},
|
||||||
|
{transaction}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const record of verification_evidences) {
|
||||||
|
await record.destroy({transaction});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return verification_evidences;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
const verification_evidences = await db.verification_evidences.findByPk(id, options);
|
||||||
|
|
||||||
|
await verification_evidences.update({
|
||||||
|
deletedBy: currentUser.id
|
||||||
|
}, {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await verification_evidences.destroy({
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
|
||||||
|
return verification_evidences;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findBy(where, options) {
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
const verification_evidences = await db.verification_evidences.findOne(
|
||||||
|
{ where },
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!verification_evidences) {
|
||||||
|
return verification_evidences;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 transaction = (options && options.transaction) || undefined;
|
const orderBy = null;
|
||||||
const currentUser = options?.currentUser;
|
|
||||||
|
|
||||||
// Data Isolation
|
const transaction = (options && options.transaction) || undefined;
|
||||||
if (currentUser && currentUser.app_role) {
|
|
||||||
const roleName = currentUser.app_role.name;
|
let include = [
|
||||||
if (roleName === 'Verified Business Owner') {
|
|
||||||
if (currentUser.businessId) {
|
{
|
||||||
where['$submission.businessId$'] = currentUser.businessId;
|
model: db.verification_submissions,
|
||||||
} else {
|
as: 'submission',
|
||||||
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,
|
||||||
@ -89,7 +426,8 @@ 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,
|
transaction: options?.transaction,
|
||||||
|
logging: console.log
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
if (!options?.countOnly) {
|
||||||
@ -97,90 +435,51 @@ module.exports = class Verification_evidencesDBApi {
|
|||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, count } = await db.verification_evidences.findAndCountAll(queryOptions);
|
try {
|
||||||
|
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);
|
||||||
static async findBy(where, options) {
|
throw error;
|
||||||
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) {
|
static async findAllAutocomplete(query, limit, offset, ) {
|
||||||
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' ],
|
attributes: [ 'id', 'url' ],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
order: [['createdAt', 'desc']],
|
orderBy: [['url', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
label: record.id,
|
label: record.url,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -297,20 +298,6 @@ 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 = [
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -537,4 +524,5 @@ module.exports = class Verification_submissionsDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
production: {
|
production: {
|
||||||
dialect: 'postgres',
|
dialect: 'postgres',
|
||||||
@ -10,12 +12,11 @@ module.exports = {
|
|||||||
seederStorage: 'sequelize',
|
seederStorage: 'sequelize',
|
||||||
},
|
},
|
||||||
development: {
|
development: {
|
||||||
|
username: 'postgres',
|
||||||
dialect: 'postgres',
|
dialect: 'postgres',
|
||||||
username: process.env.DB_USER || 'postgres',
|
password: '',
|
||||||
password: process.env.DB_PASS || '',
|
database: 'db_crafted_network',
|
||||||
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',
|
||||||
},
|
},
|
||||||
@ -29,4 +30,4 @@ module.exports = {
|
|||||||
logging: console.log,
|
logging: console.log,
|
||||||
seederStorage: 'sequelize',
|
seederStorage: 'sequelize',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
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) {
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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) {
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
'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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
|
|
||||||
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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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) {}
|
|
||||||
};
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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) {}
|
|
||||||
};
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -162,33 +162,6 @@ 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,
|
||||||
|
|
||||||
@ -322,15 +295,8 @@ 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',
|
||||||
@ -345,3 +311,5 @@ updated_at_ts: {
|
|||||||
|
|
||||||
return businesses;
|
return businesses;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@ -67,14 +67,6 @@ moderation_notes: {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
response: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
},
|
|
||||||
|
|
||||||
response_at_ts: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
},
|
|
||||||
|
|
||||||
created_at_ts: {
|
created_at_ts: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
@ -175,4 +167,6 @@ updated_at_ts: {
|
|||||||
|
|
||||||
|
|
||||||
return reviews;
|
return reviews;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -104,11 +104,6 @@ provider: {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
businessId: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
type: DataTypes.STRING(255),
|
type: DataTypes.STRING(255),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
@ -262,13 +257,7 @@ 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',
|
||||||
@ -333,4 +322,5 @@ function trimStringFields(users) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -299,7 +299,7 @@ const CategoriesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Plumbing",
|
"name": "Alan Turing",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -359,7 +359,7 @@ const CategoriesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Electrical",
|
"name": "Grace Hopper",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -419,7 +419,7 @@ const CategoriesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Plumbing",
|
"name": "Alan Turing",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -479,7 +479,7 @@ const CategoriesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Plumbing",
|
"name": "Alan Turing",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -798,7 +798,7 @@ const BusinessesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Plumbing",
|
"name": "Alan Turing",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -956,7 +956,7 @@ const BusinessesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Electrical",
|
"name": "Grace Hopper",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1114,7 +1114,7 @@ const BusinessesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Electrical",
|
"name": "Grace Hopper",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1272,7 +1272,7 @@ const BusinessesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Plumbing",
|
"name": "Alan Turing",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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) {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
'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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
'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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -18,7 +19,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');
|
||||||
|
|
||||||
@ -46,8 +47,6 @@ 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');
|
||||||
@ -79,8 +78,8 @@ const options = {
|
|||||||
openapi: "3.0.0",
|
openapi: "3.0.0",
|
||||||
info: {
|
info: {
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
title: "Fix-It-Local",
|
title: "Crafted Network",
|
||||||
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.",
|
description: "Crafted Network Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
|
||||||
},
|
},
|
||||||
servers: [
|
servers: [
|
||||||
{
|
{
|
||||||
@ -119,15 +118,6 @@ app.use(cors({origin: true}));
|
|||||||
require('./auth/auth');
|
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);
|
||||||
@ -143,27 +133,25 @@ 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', optionalAuth, categoriesRoutes);
|
app.use('/api/categories', passport.authenticate('jwt', {session: false}), categoriesRoutes);
|
||||||
|
|
||||||
app.use('/api/locations', optionalAuth, locationsRoutes);
|
app.use('/api/locations', passport.authenticate('jwt', {session: false}), locationsRoutes);
|
||||||
|
|
||||||
app.use('/api/businesses', optionalAuth, businessesRoutes);
|
app.use('/api/businesses', passport.authenticate('jwt', {session: false}), businessesRoutes);
|
||||||
|
|
||||||
app.use('/api/business_photos', optionalAuth, business_photosRoutes);
|
app.use('/api/business_photos', passport.authenticate('jwt', {session: false}), business_photosRoutes);
|
||||||
|
|
||||||
app.use('/api/business_categories', optionalAuth, business_categoriesRoutes);
|
app.use('/api/business_categories', passport.authenticate('jwt', {session: false}), business_categoriesRoutes);
|
||||||
|
|
||||||
app.use('/api/service_prices', optionalAuth, service_pricesRoutes);
|
app.use('/api/service_prices', passport.authenticate('jwt', {session: false}), service_pricesRoutes);
|
||||||
|
|
||||||
app.use('/api/business_badges', optionalAuth, business_badgesRoutes);
|
app.use('/api/business_badges', passport.authenticate('jwt', {session: false}), 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/claim_requests', passport.authenticate('jwt', {session: false}), claim_requestsRoutes);
|
app.use('/api/leads', passport.authenticate('jwt', {session: false}), leadsRoutes);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@ -173,7 +161,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', optionalAuth, reviewsRoutes);
|
app.use('/api/reviews', passport.authenticate('jwt', {session: false}), reviewsRoutes);
|
||||||
|
|
||||||
app.use('/api/disputes', passport.authenticate('jwt', {session: false}), disputesRoutes);
|
app.use('/api/disputes', passport.authenticate('jwt', {session: false}), disputesRoutes);
|
||||||
|
|
||||||
@ -194,12 +182,9 @@ 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',
|
||||||
@ -230,4 +215,4 @@ db.sequelize.sync().then(function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
@ -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,23 +131,6 @@ 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:
|
||||||
@ -404,6 +387,7 @@ 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 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -480,14 +464,15 @@ 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 BusinessesService.findBy(
|
const payload = await BusinessesDBApi.findBy(
|
||||||
req.params.id,
|
{ id: req.params.id },
|
||||||
req.currentUser
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.use('/', require('../helpers').commonErrorHandler);
|
router.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
|
|
||||||
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;
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -22,8 +22,6 @@ router.use(checkCrudPermissions('search'));
|
|||||||
* properties:
|
* properties:
|
||||||
* searchQuery:
|
* searchQuery:
|
||||||
* type: string
|
* type: string
|
||||||
* location:
|
|
||||||
* type: string
|
|
||||||
* required:
|
* required:
|
||||||
* - searchQuery
|
* - searchQuery
|
||||||
* responses:
|
* responses:
|
||||||
@ -36,14 +34,14 @@ router.use(checkCrudPermissions('search'));
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { searchQuery, location } = req.body;
|
const { searchQuery } = 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, location );
|
const foundMatches = await SearchService.search(searchQuery, req.currentUser );
|
||||||
res.json(foundMatches);
|
res.json(foundMatches);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Internal Server Error', error);
|
console.error('Internal Server Error', error);
|
||||||
|
|||||||
@ -2,83 +2,20 @@ 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 {
|
||||||
data = this._sanitize(data, currentUser);
|
await BusinessesDBApi.create(
|
||||||
|
|
||||||
// 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,
|
||||||
@ -86,62 +23,13 @@ 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();
|
||||||
|
|
||||||
@ -150,7 +38,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"));
|
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
|
||||||
@ -180,33 +68,17 @@ 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 {
|
||||||
data = this._sanitize(data, currentUser);
|
let businesses = await BusinessesDBApi.findBy(
|
||||||
|
|
||||||
let business = await BusinessesDBApi.findBy(
|
|
||||||
{id},
|
{id},
|
||||||
{transaction},
|
{transaction},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!business) {
|
if (!businesses) {
|
||||||
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,
|
||||||
@ -229,23 +101,6 @@ 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,
|
||||||
@ -262,13 +117,6 @@ 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,
|
||||||
{
|
{
|
||||||
@ -285,4 +133,6 @@ module.exports = class BusinessesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,228 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
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();
|
|
||||||
@ -2,12 +2,15 @@ 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();
|
||||||
@ -65,25 +68,17 @@ 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_match = await Lead_matchesDBApi.findBy(
|
let lead_matches = await Lead_matchesDBApi.findBy(
|
||||||
{id},
|
{id},
|
||||||
{transaction},
|
{transaction},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!lead_match) {
|
if (!lead_matches) {
|
||||||
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,
|
||||||
@ -138,4 +133,6 @@ module.exports = class Lead_matchesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,17 +2,20 @@ 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 {
|
||||||
const lead = await LeadsDBApi.create(
|
await LeadsDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -20,19 +23,7 @@ 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;
|
||||||
@ -41,24 +32,32 @@ 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 () => { resolve(); })
|
.on('end', async () => {
|
||||||
|
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();
|
||||||
@ -69,23 +68,29 @@ 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({id}, {transaction});
|
let leads = await LeadsDBApi.findBy(
|
||||||
if (!leads) { throw new ValidationError('leadsNotFound'); }
|
{id},
|
||||||
|
{transaction},
|
||||||
|
);
|
||||||
|
|
||||||
// Ownership check for Verified Business Owner
|
if (!leads) {
|
||||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
throw new ValidationError(
|
||||||
const match = await db.lead_matches.findOne({
|
'leadsNotFound',
|
||||||
where: { leadId: id, businessId: currentUser.businessId },
|
);
|
||||||
transaction
|
|
||||||
});
|
|
||||||
if (!match) {
|
|
||||||
throw new ForbiddenError('forbidden');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedLeads = await LeadsDBApi.update(id, data, { currentUser, transaction });
|
const updatedLeads = await LeadsDBApi.update(
|
||||||
|
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;
|
||||||
@ -94,22 +99,13 @@ 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 {
|
|
||||||
// Ownership check for Verified Business Owner
|
|
||||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
|
||||||
const count = await db.lead_matches.count({
|
|
||||||
where: {
|
|
||||||
leadId: { [db.Sequelize.Op.in]: ids },
|
|
||||||
businessId: currentUser.businessId
|
|
||||||
},
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
if (count !== ids.length) {
|
|
||||||
throw new ForbiddenError('forbidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await LeadsDBApi.deleteByIds(ids, { currentUser, transaction });
|
try {
|
||||||
|
await LeadsDBApi.deleteByIds(ids, {
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
@ -119,23 +115,24 @@ 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 {
|
|
||||||
// Ownership check for Verified Business Owner
|
|
||||||
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
|
||||||
const match = await db.lead_matches.findOne({
|
|
||||||
where: { leadId: id, businessId: currentUser.businessId },
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
if (!match) {
|
|
||||||
throw new ForbiddenError('forbidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await LeadsDBApi.remove(id, { currentUser, transaction });
|
try {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,38 +2,20 @@ 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 {
|
||||||
// For VBOs, ensure they can only message about their leads/businesses
|
await MessagesDBApi.create(
|
||||||
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,
|
||||||
@ -42,7 +24,6 @@ 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;
|
||||||
@ -57,13 +38,14 @@ 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"));
|
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 () => {
|
||||||
|
console.log('CSV results', results);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
@ -86,38 +68,17 @@ 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 message = await MessagesDBApi.findBy(
|
let messages = await MessagesDBApi.findBy(
|
||||||
{id},
|
{id},
|
||||||
{transaction},
|
{transaction},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!message) {
|
if (!messages) {
|
||||||
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,
|
||||||
@ -140,19 +101,6 @@ 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,
|
||||||
@ -169,15 +117,6 @@ 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,
|
||||||
{
|
{
|
||||||
@ -194,4 +133,6 @@ module.exports = class MessagesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
const errors = {
|
const errors = {
|
||||||
app: {
|
app: {
|
||||||
title: 'Fix-It-Local',
|
title: 'Crafted Network',
|
||||||
},
|
},
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@ -2,40 +2,20 @@ 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 {
|
||||||
// Set status to PUBLISHED by default for now, or use PENDING if moderation is needed
|
await ReviewsDBApi.create(
|
||||||
data.status = 'PUBLISHED';
|
|
||||||
|
|
||||||
const reviews = await ReviewsDBApi.create(
|
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -43,12 +23,7 @@ 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;
|
||||||
@ -83,9 +58,6 @@ 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();
|
||||||
@ -96,31 +68,17 @@ 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 review = await ReviewsDBApi.findBy(
|
let reviews = await ReviewsDBApi.findBy(
|
||||||
{id},
|
{id},
|
||||||
{transaction},
|
{transaction},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!review) {
|
if (!reviews) {
|
||||||
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,
|
||||||
@ -130,9 +88,6 @@ 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;
|
||||||
|
|
||||||
@ -146,39 +101,11 @@ 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();
|
||||||
@ -190,27 +117,6 @@ 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,
|
||||||
{
|
{
|
||||||
@ -219,12 +125,14 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,9 @@ 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];
|
||||||
@ -14,14 +17,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: widget.widget_type === 'scalar' ? 0 : [], query: queryString };
|
return { ...widget, value: [], query: queryString };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeQuery(queryString, replacements) {
|
async function executeQuery(queryString, currentUser) {
|
||||||
try {
|
try {
|
||||||
return await db.sequelize.query(queryString, {
|
return await db.sequelize.query(queryString, {
|
||||||
replacements,
|
replacements: { organizationId: currentUser.organizationId },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
@ -46,46 +49,19 @@ function insertWhereConditions(queryString, whereConditions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function constructWhereConditions(mainTable, currentUser, replacements) {
|
function constructWhereConditions(mainTable, currentUser, replacements) {
|
||||||
const organizationId = currentUser.organizationId;
|
const { organizationId, app_role: { globalAccess } } = currentUser;
|
||||||
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 conditions = [];
|
let whereConditions = '';
|
||||||
|
|
||||||
if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) {
|
if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) {
|
||||||
if (organizationId) {
|
whereConditions += `"${mainTable}"."organizationId" = :organizationId`;
|
||||||
conditions.push(`"${mainTable}"."organizationId" = :organizationId`);
|
replacements.organizationId = organizationId;
|
||||||
replacements.organizationId = organizationId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business User isolation
|
whereConditions += whereConditions ? ' AND ' : '';
|
||||||
if (roleName === 'Verified Business Owner') {
|
whereConditions += `"${mainTable}"."deletedAt" IS NULL`;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conditions.push(`"${mainTable}"."deletedAt" IS NULL`);
|
return whereConditions;
|
||||||
|
|
||||||
return conditions.join(' AND ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTableName(queryString) {
|
function extractTableName(queryString) {
|
||||||
@ -101,15 +77,16 @@ 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);
|
||||||
return { queryString, replacements };
|
console.log(queryString, 'queryString');
|
||||||
|
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, replacements } = buildQueryString(widget, currentUser);
|
const queryString = buildQueryString(widget, currentUser);
|
||||||
const queryResult = await executeQuery(queryString, replacements);
|
const queryResult = await executeQuery(queryString, currentUser);
|
||||||
widgetsResults.push(buildWidgetResult(widget, queryResult, queryString));
|
widgetsResults.push(buildWidgetResult(widget, queryResult, queryString));
|
||||||
}
|
}
|
||||||
return widgetsResults;
|
return widgetsResults;
|
||||||
@ -130,6 +107,30 @@ 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();
|
||||||
@ -157,13 +158,14 @@ 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"));
|
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 () => {
|
||||||
|
console.log('CSV results', results);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
@ -360,6 +362,11 @@ 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) {
|
||||||
@ -374,7 +381,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 || '{}');
|
||||||
@ -384,8 +391,47 @@ module.exports = class RolesService {
|
|||||||
|
|
||||||
if (key === 'widgets') {
|
if (key === 'widgets') {
|
||||||
const widgets = (customization[key] || []);
|
const widgets = (customization[key] || []);
|
||||||
return await processWidgets(widgets, currentUser);
|
const widgetArray = widgets.map(widget => {
|
||||||
|
return axios.get(`${config.flHost}/${config.project_uuid}/project_customization_widgets/${widget}.json`)
|
||||||
|
})
|
||||||
|
const widgetResults = await Promise.allSettled(widgetArray);
|
||||||
|
|
||||||
|
const fulfilledWidgets = widgetResults.map(result => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
return result.value.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const widgetsResults = [];
|
||||||
|
|
||||||
|
if (Array.isArray(fulfilledWidgets)) {
|
||||||
|
for (const widget of fulfilledWidgets) {
|
||||||
|
let result = [];
|
||||||
|
try {
|
||||||
|
result = await db.sequelize.query(widget.query);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result[0] && result[0].length) {
|
||||||
|
const key = Object.keys(result[0][0])[0];
|
||||||
|
const value =
|
||||||
|
widget.widget_type === 'scalar' ? result[0][0][key] : result[0];
|
||||||
|
const widgetData = JSON.parse(widget.data);
|
||||||
|
widgetsResults.push({ ...widget, ...widgetData, value });
|
||||||
|
} else {
|
||||||
|
widgetsResults.push({ ...widget, value: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return widgetsResults;
|
||||||
}
|
}
|
||||||
return customization[key];
|
return customization[key];
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,201 +1,535 @@
|
|||||||
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) {
|
||||||
const userPermission = currentUser.custom_permissions?.find(
|
throw new ValidationError('auth.unauthorized');
|
||||||
(cp) => cp.name === permission,
|
|
||||||
);
|
|
||||||
if (userPermission) return true;
|
|
||||||
role = currentUser.app_role;
|
|
||||||
} else {
|
|
||||||
role = await getPublicRole();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!role) {
|
const userPermission = currentUser.custom_permissions.find(
|
||||||
return false;
|
(cp) => cp.name === permission,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userPermission) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let permissions = [];
|
if (!currentUser.app_role) {
|
||||||
if (typeof role.getPermissions === 'function') {
|
throw new ValidationError('auth.forbidden');
|
||||||
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) {
|
||||||
console.error("Search permission check error:", e);
|
throw e;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = class SearchService {
|
module.exports = class SearchService {
|
||||||
static async search(searchQuery, currentUser, location ) {
|
static async search(searchQuery, currentUser ) {
|
||||||
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",
|
||||||
|
|
||||||
],
|
],
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// All columns to be returned in the result
|
|
||||||
const returnColumns = {
|
|
||||||
"users": [...searchableColumns.users],
|
|
||||||
"categories": [...searchableColumns.categories, "icon"],
|
|
||||||
"locations": [...searchableColumns.locations],
|
|
||||||
"businesses": [
|
|
||||||
...searchableColumns.businesses,
|
|
||||||
"is_claimed",
|
|
||||||
"rating",
|
|
||||||
"lat",
|
|
||||||
"lng",
|
|
||||||
"reliability_score",
|
"service_prices": [
|
||||||
"response_time_median_minutes"
|
|
||||||
|
"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 = {
|
const columnsInt = {
|
||||||
"businesses": [
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"locations": [
|
||||||
|
|
||||||
"lat",
|
"lat",
|
||||||
|
|
||||||
"lng",
|
"lng",
|
||||||
"reliability_score",
|
|
||||||
"response_time_median_minutes",
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"businesses": [
|
||||||
|
|
||||||
|
"lat",
|
||||||
|
|
||||||
|
"lng",
|
||||||
|
|
||||||
|
"reliability_score",
|
||||||
|
|
||||||
|
"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 searchableColumns) {
|
for (const tableName in tableColumns) {
|
||||||
if (searchableColumns.hasOwnProperty(tableName)) {
|
if (tableColumns.hasOwnProperty(tableName)) {
|
||||||
const attributesToSearch = searchableColumns[tableName];
|
const attributesToSearch = tableColumns[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]: searchConditions,
|
[Op.or]: [
|
||||||
|
...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
|
const hasPermission = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser);
|
||||||
if (location && (tableName === 'businesses' || tableName === 'locations')) {
|
if (!hasPermission) {
|
||||||
const locationConditions = [
|
|
||||||
{ zip: { [Op.iLike]: `%${location}%` } },
|
|
||||||
{ city: { [Op.iLike]: `%${location}%` } },
|
|
||||||
{ state: { [Op.iLike]: `%${location}%` } },
|
|
||||||
];
|
|
||||||
|
|
||||||
// locations table doesn't have address column
|
|
||||||
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: [...returnColumns[tableName], 'id'],
|
attributes: [...tableColumns[tableName], 'id', ...attributesIntToSearch],
|
||||||
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] && typeof record[attribute] === 'string' && record[attribute].toLowerCase().includes(searchQuery.toLowerCase())) {
|
if (record[attribute]?.toLowerCase()?.includes(searchQuery.toLowerCase())) {
|
||||||
matchAttribute.push(attribute);
|
matchAttribute.push(attribute);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -218,84 +552,6 @@ 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;
|
||||||
|
|||||||
@ -2,27 +2,20 @@ 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 {
|
||||||
// Ownership check for Verified Business Owner
|
await Service_pricesDBApi.create(
|
||||||
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,
|
||||||
@ -31,7 +24,6 @@ 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;
|
||||||
@ -46,13 +38,14 @@ 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"));
|
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 () => {
|
||||||
|
console.log('CSV results', results);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
@ -86,15 +79,6 @@ 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,
|
||||||
@ -117,20 +101,6 @@ 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,
|
||||||
@ -147,18 +117,6 @@ 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,
|
||||||
{
|
{
|
||||||
@ -175,4 +133,6 @@ module.exports = class Service_pricesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,27 +2,20 @@ 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 {
|
||||||
// Ownership check for Verified Business Owner
|
await Verification_submissionsDBApi.create(
|
||||||
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,
|
||||||
@ -31,7 +24,6 @@ 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;
|
||||||
@ -46,13 +38,14 @@ 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"));
|
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 () => {
|
||||||
|
console.log('CSV results', results);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
@ -86,15 +79,6 @@ 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,
|
||||||
@ -117,20 +101,6 @@ 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,
|
||||||
@ -147,18 +117,6 @@ 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,
|
||||||
{
|
{
|
||||||
@ -175,4 +133,6 @@ module.exports = class Verification_submissionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Fix-It-Local
|
# Crafted Network
|
||||||
|
|
||||||
## This project was generated by Flatlogic Platform.
|
## This project was generated by Flatlogic Platform.
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
@ -4,14 +4,6 @@
|
|||||||
|
|
||||||
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,
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 214 KiB |
@ -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-emerald-50/50"
|
export const colorBgBase = "bg-violet-50/50"
|
||||||
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-rose-500`
|
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500`
|
||||||
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`
|
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`
|
||||||
export const gradientBgDark = `${gradientBgBase} from-slate-800 via-slate-950 to-slate-900`;
|
export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`;
|
||||||
export const gradientBgPinkRed = `${gradientBgBase} from-rose-400 via-emerald-500 to-emerald-600`
|
export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`
|
||||||
|
|
||||||
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-slate-800 text-white dark:bg-white dark:text-black',
|
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
|
||||||
success: 'bg-emerald-500 border-emerald-500 dark:bg-emerald-600 dark:border-emerald-600 text-white',
|
success: 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
||||||
danger: 'bg-rose-500 border-rose-500 text-white',
|
danger: 'bg-red-500 border-red-500 text-white',
|
||||||
warning: 'bg-amber-500 border-amber-500 text-white',
|
warning: 'bg-yellow-500 border-yellow-500 text-white',
|
||||||
info: 'bg-emerald-600 border-emerald-600 dark:bg-emerald-700 dark:border-emerald-700 text-white',
|
info: 'bg-blue-500 border-blue-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const colorsText = {
|
export const colorsText = {
|
||||||
white: 'text-black dark:text-slate-100',
|
white: 'text-black dark:text-slate-100',
|
||||||
light: 'text-slate-700 dark:text-slate-400',
|
light: 'text-gray-700 dark:text-slate-400',
|
||||||
contrast: 'dark:text-white',
|
contrast: 'dark:text-white',
|
||||||
success: 'text-emerald-500',
|
success: 'text-emerald-500',
|
||||||
danger: 'text-rose-500',
|
danger: 'text-red-500',
|
||||||
warning: 'text-amber-500',
|
warning: 'text-yellow-500',
|
||||||
info: 'text-emerald-600',
|
info: 'text-blue-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const colorsOutline = {
|
export const colorsOutline = {
|
||||||
white: [colorsText.white, 'border-slate-100'].join(' '),
|
white: [colorsText.white, 'border-gray-100'].join(' '),
|
||||||
light: [colorsText.light, 'border-slate-100'].join(' '),
|
light: [colorsText.light, 'border-gray-100'].join(' '),
|
||||||
contrast: [colorsText.contrast, 'border-slate-900 dark:border-slate-100'].join(' '),
|
contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'].join(' '),
|
||||||
success: [colorsText.success, 'border-emerald-500'].join(' '),
|
success: [colorsText.success, 'border-emerald-500'].join(' '),
|
||||||
danger: [colorsText.danger, 'border-rose-500'].join(' '),
|
danger: [colorsText.danger, 'border-red-500'].join(' '),
|
||||||
warning: [colorsText.warning, 'border-amber-500'].join(' '),
|
warning: [colorsText.warning, 'border-yellow-500'].join(' '),
|
||||||
info: [colorsText.info, 'border-emerald-600'].join(' '),
|
info: [colorsText.info, 'border-blue-500'].join(' '),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getButtonColor = (
|
export const getButtonColor = (
|
||||||
@ -49,74 +49,74 @@ export const getButtonColor = (
|
|||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
ring: {
|
ring: {
|
||||||
white: 'ring-slate-200 dark:ring-slate-500',
|
white: 'ring-gray-200 dark:ring-gray-500',
|
||||||
whiteDark: 'ring-slate-200 dark:ring-dark-500',
|
whiteDark: 'ring-gray-200 dark:ring-dark-500',
|
||||||
lightDark: 'ring-slate-200 dark:ring-slate-500',
|
lightDark: 'ring-gray-200 dark:ring-gray-500',
|
||||||
contrast: 'ring-slate-300 dark:ring-slate-400',
|
contrast: 'ring-gray-300 dark:ring-gray-400',
|
||||||
success: 'ring-emerald-300 dark:ring-emerald-700',
|
success: 'ring-emerald-300 dark:ring-pavitra-blue',
|
||||||
danger: 'ring-rose-300 dark:ring-rose-700',
|
danger: 'ring-red-300 dark:ring-red-700',
|
||||||
warning: 'ring-amber-300 dark:ring-amber-700',
|
warning: 'ring-yellow-300 dark:ring-yellow-700',
|
||||||
info: "ring-emerald-300 dark:ring-emerald-700",
|
info: "ring-blue-300 dark:ring-pavitra-blue",
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
white: 'bg-slate-100',
|
white: 'bg-gray-100',
|
||||||
whiteDark: 'bg-slate-100 dark:bg-dark-800',
|
whiteDark: 'bg-gray-100 dark:bg-dark-800',
|
||||||
lightDark: 'bg-slate-200 dark:bg-slate-700',
|
lightDark: 'bg-gray-200 dark:bg-slate-700',
|
||||||
contrast: 'bg-slate-700 dark:bg-slate-100',
|
contrast: 'bg-gray-700 dark:bg-slate-100',
|
||||||
success: 'bg-emerald-700 dark:bg-emerald-800',
|
success: 'bg-emerald-700 dark:bg-pavitra-blue',
|
||||||
danger: 'bg-rose-700 dark:bg-rose-600',
|
danger: 'bg-red-700 dark:bg-red-600',
|
||||||
warning: 'bg-amber-700 dark:bg-amber-600',
|
warning: 'bg-yellow-700 dark:bg-yellow-600',
|
||||||
info: 'bg-emerald-700 dark:bg-emerald-800',
|
info: 'bg-blue-700 dark:bg-pavitra-blue',
|
||||||
},
|
},
|
||||||
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-slate-100 text-black dark:bg-slate-800 dark:text-white',
|
lightDark: 'bg-gray-100 text-black dark:bg-slate-800 dark:text-white',
|
||||||
contrast: 'bg-slate-800 text-white dark:bg-white dark:text-black',
|
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
|
||||||
success: 'bg-emerald-600 dark:bg-emerald-600 text-white',
|
success: 'bg-emerald-600 dark:bg-pavitra-blue text-white',
|
||||||
danger: 'bg-rose-600 text-white dark:bg-rose-500 ',
|
danger: 'bg-red-600 text-white dark:bg-red-500 ',
|
||||||
warning: 'bg-amber-600 dark:bg-amber-500 text-white',
|
warning: 'bg-yellow-600 dark:bg-yellow-500 text-white',
|
||||||
info: " bg-emerald-600 dark:bg-emerald-600 text-white ",
|
info: " bg-blue-600 dark:bg-pavitra-blue text-white ",
|
||||||
},
|
},
|
||||||
bgHover: {
|
bgHover: {
|
||||||
white: 'hover:bg-slate-100',
|
white: 'hover:bg-gray-100',
|
||||||
whiteDark: 'hover:bg-slate-100 hover:dark:bg-dark-800',
|
whiteDark: 'hover:bg-gray-100 hover:dark:bg-dark-800',
|
||||||
lightDark: 'hover:bg-slate-200 hover:dark:bg-slate-700',
|
lightDark: 'hover:bg-gray-200 hover:dark:bg-slate-700',
|
||||||
contrast: 'hover:bg-slate-700 hover:dark:bg-slate-100',
|
contrast: 'hover:bg-gray-700 hover:dark:bg-slate-100',
|
||||||
success:
|
success:
|
||||||
'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-emerald-800 hover:dark:border-emerald-800',
|
'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-pavitra-blue hover:dark:border-pavitra-blue',
|
||||||
danger:
|
danger:
|
||||||
'hover:bg-rose-700 hover:border-rose-700 hover:dark:bg-rose-600 hover:dark:border-rose-600',
|
'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600',
|
||||||
warning:
|
warning:
|
||||||
'hover:bg-amber-700 hover:border-amber-700 hover:dark:bg-amber-600 hover:dark:border-amber-600',
|
'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600',
|
||||||
info: "hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-emerald-800 hover:dark:border-emerald-800",
|
info: "hover:bg-blue-700 hover:border-blue-700 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80",
|
||||||
},
|
},
|
||||||
borders: {
|
borders: {
|
||||||
white: 'border-white',
|
white: 'border-white',
|
||||||
whiteDark: 'border-white dark:border-dark-900',
|
whiteDark: 'border-white dark:border-dark-900',
|
||||||
lightDark: 'border-slate-100 dark:border-slate-800',
|
lightDark: 'border-gray-100 dark:border-slate-800',
|
||||||
contrast: 'border-slate-800 dark:border-white',
|
contrast: 'border-gray-800 dark:border-white',
|
||||||
success: 'border-emerald-600 dark:border-emerald-600',
|
success: 'border-emerald-600 dark:border-pavitra-blue',
|
||||||
danger: 'border-rose-600 dark:border-rose-500',
|
danger: 'border-red-600 dark:border-red-500',
|
||||||
warning: 'border-amber-600 dark:border-amber-500',
|
warning: 'border-yellow-600 dark:border-yellow-500',
|
||||||
info: "border-emerald-600 border-emerald-600 dark:border-emerald-600",
|
info: "border-blue-600 border-blue-600 dark:border-pavitra-blue",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
contrast: 'dark:text-slate-100',
|
contrast: 'dark:text-slate-100',
|
||||||
success: 'text-emerald-600 dark:text-emerald-500',
|
success: 'text-emerald-600 dark:text-pavitra-blue',
|
||||||
danger: 'text-rose-600 dark:text-rose-500',
|
danger: 'text-red-600 dark:text-red-500',
|
||||||
warning: 'text-amber-600 dark:text-amber-500',
|
warning: 'text-yellow-600 dark:text-yellow-500',
|
||||||
info: 'text-emerald-600 dark:text-emerald-500',
|
info: 'text-blue-600 dark:text-pavitra-blue',
|
||||||
},
|
},
|
||||||
outlineHover: {
|
outlineHover: {
|
||||||
contrast:
|
contrast:
|
||||||
'hover:bg-slate-800 hover:text-slate-100 hover:dark:bg-slate-100 hover:dark:text-black',
|
'hover:bg-gray-800 hover:text-gray-100 hover:dark:bg-slate-100 hover:dark:text-black',
|
||||||
success: 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-emerald-600',
|
success: 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue',
|
||||||
danger:
|
danger:
|
||||||
'hover:bg-rose-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-rose-600',
|
'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600',
|
||||||
warning:
|
warning:
|
||||||
'hover:bg-amber-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-amber-600',
|
'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600',
|
||||||
info: "hover:bg-emerald-600 hover:bg-emerald-600 hover:text-white hover:dark:text-white hover:dark:border-emerald-600",
|
info: "hover:bg-blue-600 hover:bg-blue-600 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,4 +135,4 @@ export const getButtonColor = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return base.join(' ')
|
return base.join(' ')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ 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 = {
|
||||||
@ -38,10 +37,11 @@ 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 flex items-center justify-center">
|
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
||||||
<Link href="/">
|
|
||||||
<Logo className="h-8 w-auto" />
|
<b className="font-black">Crafted Network</b>
|
||||||
</Link>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="hidden lg:inline-block xl:hidden p-3"
|
className="hidden lg:inline-block xl:hidden p-3"
|
||||||
@ -60,4 +60,4 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,11 +18,7 @@ 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 (
|
||||||
@ -36,4 +32,4 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,9 @@ type Props = {
|
|||||||
export default function Logo({ className = '' }: Props) {
|
export default function Logo({ className = '' }: Props) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={"/logo.png"}
|
src={"https://flatlogic.com/logo.svg"}
|
||||||
className={className}
|
className={className}
|
||||||
alt={'Fix-It-Local logo'}>
|
alt={'Flatlogic logo'}>
|
||||||
</img>
|
</img>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, {useEffect, useRef} 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'
|
||||||
@ -128,4 +129,4 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div>
|
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,9 +1,16 @@
|
|||||||
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";
|
||||||
@ -31,9 +38,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';
|
|
||||||
|
|
||||||
const columns: any[] = [
|
return [
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'business',
|
field: 'business',
|
||||||
headerName: 'Business',
|
headerName: 'Business',
|
||||||
@ -42,7 +49,10 @@ 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,
|
||||||
@ -50,7 +60,9 @@ 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',
|
||||||
@ -59,7 +71,10 @@ 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,
|
||||||
@ -67,89 +82,144 @@ 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: 0.5,
|
flex: 1,
|
||||||
minWidth: 80,
|
minWidth: 120,
|
||||||
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: 'Review',
|
headerName: 'Text',
|
||||||
flex: 2,
|
flex: 1,
|
||||||
minWidth: 200,
|
minWidth: 120,
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
editable: hasUpdatePermission && !isBusinessOwner,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'response',
|
|
||||||
headerName: 'Your Response',
|
|
||||||
flex: 2,
|
|
||||||
minWidth: 200,
|
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
field: 'is_verified_job',
|
||||||
|
headerName: 'IsVerifiedJob',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
filterable: false,
|
||||||
|
headerClassName: 'datagrid--header',
|
||||||
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
|
type: 'boolean',
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'status',
|
field: 'status',
|
||||||
headerName: 'Status',
|
headerName: 'Status',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 100,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
editable: hasUpdatePermission && !isBusinessOwner,
|
|
||||||
}
|
|
||||||
];
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
columns.push(
|
|
||||||
{
|
{
|
||||||
field: 'createdAt',
|
field: 'moderation_notes',
|
||||||
headerName: 'Date',
|
headerName: 'ModerationNotes',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 150,
|
minWidth: 120,
|
||||||
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.createdAt),
|
new Date(params.row.created_at_ts),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
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',
|
||||||
@ -157,6 +227,7 @@ 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
|
||||||
@ -164,13 +235,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;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -8,8 +8,8 @@ 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 = 'Fix-It-Local'
|
export const appTitle = 'created by Flatlogic generator!'
|
||||||
|
|
||||||
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}`
|
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}`
|
||||||
|
|
||||||
export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || ''
|
export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || ''
|
||||||
|
|||||||
@ -14,7 +14,6 @@ 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 = {
|
||||||
@ -100,10 +99,6 @@ 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'
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { ReactNode, useEffect, useState } from 'react'
|
import React, { ReactNode, useEffect } 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'
|
||||||
@ -87,30 +88,6 @@ 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
|
||||||
@ -141,7 +118,7 @@ export default function LayoutAuthenticated({
|
|||||||
<AsideMenu
|
<AsideMenu
|
||||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||||
isAsideLgActive={isAsideLgActive}
|
isAsideLgActive={isAsideLgActive}
|
||||||
menu={filteredMenu}
|
menu={menuAside}
|
||||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
@ -149,4 +126,4 @@ export default function LayoutAuthenticated({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,111 +1,17 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react'
|
||||||
import Link from 'next/link';
|
import { useAppSelector } from '../stores/hooks'
|
||||||
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' : ''} min-h-screen flex flex-col`}>
|
<div className={darkMode ? 'dark' : ''}>
|
||||||
{/* Dynamic Header */}
|
<div className={`${bgColor} dark:bg-slate-800 dark:text-slate-100`}>{children}</div>
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,143 +2,210 @@ 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.mdiStarFourPoints,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Studio Hub',
|
label: 'Dashboard',
|
||||||
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: 'Clients',
|
label: 'Users',
|
||||||
icon: icon.mdiAccountHeart,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_USERS',
|
// @ts-ignore
|
||||||
roles: ['Administrator', 'Platform Owner']
|
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_USERS'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/roles/roles-list',
|
||||||
|
label: 'Roles',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_ROLES'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/permissions/permissions-list',
|
||||||
|
label: 'Permissions',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_PERMISSIONS'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/refresh_tokens/refresh_tokens-list',
|
||||||
|
label: 'Refresh tokens',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiLock' in icon ? icon['mdiLock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_REFRESH_TOKENS'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/categories/categories-list',
|
href: '/categories/categories-list',
|
||||||
label: 'Beauty Categories',
|
label: 'Categories',
|
||||||
icon: icon.mdiLipstick,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_CATEGORIES',
|
// @ts-ignore
|
||||||
roles: ['Administrator', 'Platform Owner']
|
icon: 'mdiShape' in icon ? icon['mdiShape' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_CATEGORIES'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/locations/locations-list',
|
href: '/locations/locations-list',
|
||||||
label: 'Regions',
|
label: 'Locations',
|
||||||
icon: icon.mdiMapMarkerRadius,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_LOCATIONS',
|
// @ts-ignore
|
||||||
roles: ['Administrator', 'Platform Owner']
|
icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_LOCATIONS'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Admin and Platform Owner see all service listings
|
|
||||||
{
|
{
|
||||||
href: '/businesses/businesses-list',
|
href: '/businesses/businesses-list',
|
||||||
label: 'Service Listings',
|
label: 'Businesses',
|
||||||
icon: icon.mdiStorefront,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_BUSINESSES',
|
// @ts-ignore
|
||||||
roles: ['Administrator', 'Platform Owner']
|
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_BUSINESSES'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Claim Requests (Admin Only)
|
|
||||||
{
|
{
|
||||||
href: '/claim_requests/claim_requests-list',
|
href: '/business_photos/business_photos-list',
|
||||||
label: 'Claim Requests',
|
label: 'Business photos',
|
||||||
icon: icon.mdiShieldCheck,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_CLAIM_REQUESTS',
|
// @ts-ignore
|
||||||
roles: ['Administrator', 'Platform Owner']
|
icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_BUSINESS_PHOTOS'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Business Owner sees their My Listing page
|
|
||||||
{
|
{
|
||||||
href: '/my-listing',
|
href: '/business_categories/business_categories-list',
|
||||||
label: 'My Listing',
|
label: 'Business categories',
|
||||||
icon: icon.mdiStorefront,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
roles: ['Verified Business Owner']
|
// @ts-ignore
|
||||||
|
icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_BUSINESS_CATEGORIES'
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/leads/leads-list',
|
href: '/service_prices/service_prices-list',
|
||||||
label: 'Service Requests',
|
label: 'Service prices',
|
||||||
icon: icon.mdiCalendarHeart,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_LEADS',
|
// @ts-ignore
|
||||||
roles: ['Verified Business Owner']
|
icon: 'mdiCurrencyUsd' in icon ? icon['mdiCurrencyUsd' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_SERVICE_PRICES'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/business_badges/business_badges-list',
|
||||||
|
label: 'Business badges',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiShieldCheck' in icon ? icon['mdiShieldCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_BUSINESS_BADGES'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verification_submissions/verification_submissions-list',
|
||||||
|
label: 'Verification submissions',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiFileCheck' in icon ? icon['mdiFileCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_VERIFICATION_SUBMISSIONS'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verification_evidences/verification_evidences-list',
|
||||||
|
label: 'Verification evidences',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiFileUpload' in icon ? icon['mdiFileUpload' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_VERIFICATION_EVIDENCES'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/leads/leads-list',
|
href: '/leads/leads-list',
|
||||||
label: 'Leads',
|
label: 'Leads',
|
||||||
icon: icon.mdiCalendarHeart,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_LEADS',
|
// @ts-ignore
|
||||||
roles: ['Administrator', 'Platform Owner']
|
icon: 'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_LEADS'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/lead_photos/lead_photos-list',
|
||||||
|
label: 'Lead photos',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiCamera' in icon ? icon['mdiCamera' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_LEAD_PHOTOS'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/lead_matches/lead_matches-list',
|
||||||
|
label: 'Lead matches',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiLinkVariant' in icon ? icon['mdiLinkVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_LEAD_MATCHES'
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/messages/messages-list',
|
href: '/messages/messages-list',
|
||||||
label: 'Consultations',
|
label: 'Messages',
|
||||||
icon: icon.mdiMessageProcessing,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiMessageText' in icon ? icon['mdiMessageText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
permissions: 'READ_MESSAGES'
|
permissions: 'READ_MESSAGES'
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/reviews/reviews-list',
|
href: '/lead_events/lead_events-list',
|
||||||
label: 'Love Letters',
|
label: 'Lead events',
|
||||||
icon: icon.mdiStarFace,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_REVIEWS',
|
// @ts-ignore
|
||||||
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
|
icon: 'mdiTimelineText' in icon ? icon['mdiTimelineText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_LEAD_EVENTS'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/reviews/reviews-list',
|
href: '/reviews/reviews-list',
|
||||||
label: 'Client Love',
|
label: 'Reviews',
|
||||||
icon: icon.mdiStarFace,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_REVIEWS',
|
// @ts-ignore
|
||||||
roles: ['Verified Business Owner']
|
icon: 'mdiStar' in icon ? icon['mdiStar' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
},
|
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: '/billing-settings',
|
href: '/disputes/disputes-list',
|
||||||
label: 'Global Billing',
|
label: 'Disputes',
|
||||||
icon: icon.mdiFinance,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
roles: ['Administrator', 'Platform Owner']
|
// @ts-ignore
|
||||||
|
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',
|
||||||
icon: icon.mdiClipboardListOutline,
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
permissions: 'READ_AUDIT_LOGS',
|
// @ts-ignore
|
||||||
roles: ['Administrator', 'Platform Owner']
|
icon: 'mdiClipboardList' in icon ? icon['mdiClipboardList' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_AUDIT_LOGS'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/badge_rules/badge_rules-list',
|
||||||
|
label: 'Badge rules',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiShieldCrown' in icon ? icon['mdiShieldCrown' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_BADGE_RULES'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/trust_adjustments/trust_adjustments-list',
|
||||||
|
label: 'Trust adjustments',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
icon: 'mdiTuneVariant' in icon ? icon['mdiTuneVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||||
|
permissions: 'READ_TRUST_ADJUSTMENTS'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Profile/Settings
|
|
||||||
{
|
{
|
||||||
href: '/profile',
|
href: '/profile',
|
||||||
label: 'My Profile',
|
label: 'Profile',
|
||||||
icon: icon.mdiAccountSettings,
|
icon: icon.mdiAccountCircle,
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/api-docs',
|
||||||
|
target: '_blank',
|
||||||
|
label: 'Swagger API',
|
||||||
|
icon: icon.mdiFileCode,
|
||||||
|
permissions: 'READ_API_DOCS'
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default menuAside
|
export default menuAside
|
||||||
|
|||||||
@ -47,9 +47,7 @@ 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
|
||||||
|
|||||||
@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
|||||||
setStepsEnabled(false);
|
setStepsEnabled(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = 'Fix-It-Local'
|
const title = 'Crafted Network'
|
||||||
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"
|
||||||
|
|||||||
@ -1,171 +0,0 @@
|
|||||||
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
@ -1,6 +1,7 @@
|
|||||||
import { mdiChartTimelineVariant, mdiPlus, mdiEye, mdiPencil, mdiShieldCheck, mdiCheckDecagram } 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 { uniqueId } from 'lodash';
|
||||||
|
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'
|
||||||
@ -9,275 +10,147 @@ 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 {fetch} from '../../stores/businesses/businessesSlice';
|
import CardBoxModal from "../../components/CardBoxModal";
|
||||||
import { useRouter } from 'next/router';
|
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||||
import IconRounded from '../../components/IconRounded';
|
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
||||||
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 isVBO = currentUser?.app_role?.name === 'Verified Business Owner';
|
const [filters] = useState([{label: 'Name', title: 'name'},{label: 'Slug', title: 'slug'},{label: 'Description', title: 'description'},{label: 'Phone', title: 'phone'},{label: 'Email', title: 'email'},{label: 'Website', title: 'website'},{label: 'Address', title: 'address'},{label: 'City', title: 'city'},{label: 'State', title: 'state'},{label: 'ZIP', title: 'zip'},{label: 'HoursJSON', title: 'hours_json'},{label: 'ReliabilityBreakdownJSON', title: 'reliability_breakdown_json'},{label: 'TenantKey', title: 'tenant_key'},
|
||||||
|
{label: 'ReliabilityScore', title: 'reliability_score', number: 'true'},{label: 'ResponseTimeMedianMinutes', title: 'response_time_median_minutes', number: 'true'},
|
||||||
// Completion calculation helper
|
{label: 'Latitude', title: 'lat', number: 'true'},{label: 'Longitude', title: 'lng', number: 'true'},
|
||||||
const calculateCompletion = (business) => {
|
{label: 'CreatedAt', title: 'created_at_ts', date: 'true'},{label: 'UpdatedAt', title: 'updated_at_ts', date: 'true'},
|
||||||
const fields = ['name', 'description', 'phone', 'email', 'website', 'address', 'city', 'state', 'zip'];
|
|
||||||
let filled = 0;
|
|
||||||
fields.forEach(f => {
|
{label: 'OwnerUser', title: 'owner_user'},
|
||||||
if (business[f] && business[f] !== '') filled++;
|
|
||||||
});
|
|
||||||
return Math.round((filled / fields.length) * 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
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">
|
{label: 'AvailabilityStatus', title: 'availability_status', type: 'enum', options: ['AVAILABLE_TODAY','THIS_WEEK','BOOKED_OUT']},
|
||||||
<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>
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// State B: Exactly 1 listing exists
|
const addFilter = () => {
|
||||||
if (isVBO && count === 1) {
|
const newItem = {
|
||||||
const business = businesses[0];
|
id: uniqueId(),
|
||||||
const completion = calculateCompletion(business);
|
fields: {
|
||||||
|
filterValue: '',
|
||||||
|
filterValueFrom: '',
|
||||||
|
filterValueTo: '',
|
||||||
|
selectedField: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
newItem.fields.selectedField = filters[0].title;
|
||||||
|
setFilterItems([...filterItems, newItem]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const getBusinessesCSV = async () => {
|
||||||
<>
|
const response = await axios({url: '/businesses?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||||
<Head>
|
const type = response.headers['content-type']
|
||||||
<title>{getPageTitle('My Listing')}</title>
|
const blob = new Blob([response.data], { type: type })
|
||||||
</Head>
|
const link = document.createElement('a')
|
||||||
<SectionMain>
|
link.href = window.URL.createObjectURL(blob)
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Listing Profile" main>
|
link.download = 'businessesCSV.csv'
|
||||||
<BaseButtons>
|
link.click()
|
||||||
<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">
|
const onModalConfirm = async () => {
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
if (!csvFile) return;
|
||||||
<div>
|
await dispatch(uploadCsv(csvFile));
|
||||||
<h2 className="text-2xl font-bold mb-1">{business.name}</h2>
|
dispatch(setRefetch(true));
|
||||||
<div className="flex items-center space-x-2">
|
setCsvFile(null);
|
||||||
<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'}`}>
|
setIsModalActive(false);
|
||||||
{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>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
const onModalCancel = () => {
|
||||||
<div className="flex justify-between items-center mb-2">
|
setCsvFile(null);
|
||||||
<span className="text-sm font-medium text-gray-700 font-bold">Profile completeness</span>
|
setIsModalActive(false);
|
||||||
<span className="text-sm font-bold text-info">{completion}%</span>
|
};
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-4">
|
|
||||||
<div
|
|
||||||
className="bg-info h-4 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${completion}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 mb-6">
|
|
||||||
<CardBox title="Business Details" className="h-full">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-400 uppercase font-bold">Phone</p>
|
|
||||||
<p className="font-medium">{business.phone || 'Not provided'}</p>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<CardBox title="About" className="h-full">
|
|
||||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: business.description || '<p class="text-gray-400 italic">No description provided yet.</p>' }} />
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BaseButtons className="mt-6">
|
|
||||||
<BaseButton label="Add another location" icon={mdiPlus} color="info" onClick={() => router.push('/businesses/businesses-new')} />
|
|
||||||
</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={isVBO ? "My Locations" : "Service Listings"} main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Businesses" 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'>
|
||||||
{isVBO ? (
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>}
|
||||||
{businesses.map((business) => {
|
|
||||||
const completion = calculateCompletion(business);
|
<BaseButton
|
||||||
return (
|
className={'mr-3'}
|
||||||
<CardBox key={business.id} className="hover:shadow-lg transition-shadow">
|
color='info'
|
||||||
<div className="flex justify-between items-start mb-4">
|
label='Filter'
|
||||||
<h3 className="text-xl font-bold truncate pr-2">{business.name}</h3>
|
onClick={addFilter}
|
||||||
<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'}
|
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getBusinessesCSV} />
|
||||||
</span>
|
|
||||||
</div>
|
{hasCreatePermission && (
|
||||||
|
<BaseButton
|
||||||
<p className="text-sm text-gray-500 mb-4 h-10 overflow-hidden line-clamp-2">
|
color='info'
|
||||||
{business.city ? `${business.city}, ${business.state}` : 'Location not set'}
|
label='Upload CSV'
|
||||||
</p>
|
onClick={() => setIsModalActive(true)}
|
||||||
|
/>
|
||||||
<div className="mb-4">
|
)}
|
||||||
<div className="flex justify-between items-center mb-1">
|
|
||||||
<span className="text-xs font-bold">Completion</span>
|
<div className='md:inline-flex items-center ms-auto'>
|
||||||
<span className="text-xs font-bold">{completion}%</span>
|
<div id='delete-rows-button'></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
<div className='md:inline-flex items-center ms-auto'>
|
||||||
className="bg-info h-2 rounded-full"
|
<Link href={'/businesses/businesses-table'}>Switch to Table</Link>
|
||||||
style={{ width: `${completion}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
small
|
|
||||||
color="info"
|
|
||||||
label="Edit"
|
|
||||||
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 className="mb-6" hasTable>
|
</CardBox>
|
||||||
<TableBusinesses
|
|
||||||
filterItems={[]}
|
<CardBox className="mb-6" hasTable>
|
||||||
setFilterItems={() => { /* nothing to do */ }}
|
<TableBusinesses
|
||||||
filters={[]}
|
filterItems={filterItems}
|
||||||
showGrid={false}
|
setFilterItems={setFilterItems}
|
||||||
/>
|
filters={filters}
|
||||||
</CardBox>
|
showGrid={false}
|
||||||
)}
|
/>
|
||||||
|
</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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -285,11 +158,13 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BusinessesTablesPage
|
export default BusinessesTablesPage
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,142 +0,0 @@
|
|||||||
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
@ -1,151 +1,79 @@
|
|||||||
|
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 BaseIcon from "../components/BaseIcon";
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
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 {
|
||||||
await axios.post('/auth/send-password-reset-email', value);
|
const { data: response } = await axios.post('/auth/send-password-reset-email', value);
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
notify('success', 'Please check your email for reset link');
|
notify('success', 'Please check your email for verification 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 went wrong. Try again')
|
notify('error', 'Something was 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'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('Forgot Password')}</title>
|
<title>{getPageTitle('Login')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<div className="flex flex-row min-h-screen">
|
<SectionFullScreen bg='violet'>
|
||||||
{imageBlock(illustrationImage)}
|
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||||
|
<Formik
|
||||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
|
initialValues={{
|
||||||
<div className="w-full max-w-md space-y-8">
|
email: '',
|
||||||
{/* Branding */}
|
}}
|
||||||
<div className="flex flex-col items-center mb-8">
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
<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">
|
<Form>
|
||||||
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
|
<FormField label='Email' help='Please enter your email'>
|
||||||
</div>
|
<Field name='email' />
|
||||||
<span className="text-2xl font-black tracking-tight text-slate-900">
|
</FormField>
|
||||||
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'll send you a link to reset your password.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardBox className="shadow-2xl border-none rounded-[2rem] p-4 lg:p-6">
|
<BaseDivider />
|
||||||
<Formik
|
|
||||||
initialValues={{
|
|
||||||
email: '',
|
|
||||||
}}
|
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
<Form className="space-y-6">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
<BaseButtons>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'w-full py-4 rounded-xl font-bold text-lg shadow-lg shadow-emerald-500/20'}
|
type='submit'
|
||||||
type='submit'
|
label={loading ? 'Loading...' : 'Submit' }
|
||||||
label={loading ? 'Sending link...' : 'Send Reset Link'}
|
color='info'
|
||||||
color='success'
|
/>
|
||||||
disabled={loading}
|
<BaseButton
|
||||||
/>
|
href={'/login'}
|
||||||
</div>
|
label={'Login'}
|
||||||
|
color='info'
|
||||||
<div className="text-center pt-2">
|
/>
|
||||||
<Link className="font-bold text-emerald-600 hover:text-emerald-700 text-sm" href={'/login'}>
|
</BaseButtons>
|
||||||
Back to Login
|
</Form>
|
||||||
</Link>
|
</Formik>
|
||||||
</div>
|
</CardBox>
|
||||||
</Form>
|
</SectionFullScreen>
|
||||||
</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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,250 +1,166 @@
|
|||||||
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 { useRouter } from 'next/router';
|
import BaseButton from '../components/BaseButton';
|
||||||
import {
|
import CardBox from '../components/CardBox';
|
||||||
mdiMagnify,
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
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 { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import BaseDivider from '../components/BaseDivider';
|
||||||
import { fetch as fetchCategories } from '../stores/categories/categoriesSlice';
|
import BaseButtons from '../components/BaseButtons';
|
||||||
|
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
export default function Starter() {
|
||||||
dispatch(fetchCategories({ query: '?limit=8' }));
|
const [illustrationImage, setIllustrationImage] = useState({
|
||||||
}, [dispatch]);
|
src: undefined,
|
||||||
|
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 featuredCategories = [
|
const title = 'Crafted Network'
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
// Fetch Pexels image/video
|
||||||
e.preventDefault();
|
useEffect(() => {
|
||||||
const formData = new FormData(e.target as HTMLFormElement);
|
async function fetchData() {
|
||||||
const query = formData.get('query');
|
const image = await getPexelsImage();
|
||||||
const location = formData.get('location');
|
const video = await getPexelsVideo();
|
||||||
router.push({
|
setIllustrationImage(image);
|
||||||
pathname: '/search',
|
setIllustrationVideo(video);
|
||||||
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 className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
<div
|
||||||
|
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>Fix-It-Local™ | 21st Century Service Directory</title>
|
<title>{getPageTitle('Starter Page')}</title>
|
||||||
<meta name="description" content="Connect with verified service professionals. Trust, transparency, and AI-powered matching." />
|
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
{/* Hero Section */}
|
<SectionFullScreen bg='violet'>
|
||||||
<section className="relative bg-slate-900 text-white overflow-hidden py-32 lg:py-48">
|
<div
|
||||||
<div className="absolute inset-0 opacity-20">
|
className={`flex ${
|
||||||
<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>
|
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||||
<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>
|
} min-h-screen w-full`}
|
||||||
<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>
|
>
|
||||||
</div>
|
{contentType === 'image' && contentPosition !== 'background'
|
||||||
|
? imageBlock(illustrationImage)
|
||||||
<div className="container mx-auto px-6 relative z-10">
|
: null}
|
||||||
<div className="text-center max-w-4xl mx-auto">
|
{contentType === 'video' && contentPosition !== 'background'
|
||||||
<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">
|
? videoBlock(illustrationVideo)
|
||||||
<BaseIcon path={mdiShieldCheck} size={18} className="mr-2" />
|
: null}
|
||||||
Verified Professionals & AI-Powered Matching
|
<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">
|
||||||
|
<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>
|
||||||
|
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||||
|
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||||
</div>
|
</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
|
<BaseButtons>
|
||||||
</h1>
|
<BaseButton
|
||||||
<p className="text-xl text-slate-400 mb-12 max-w-2xl mx-auto leading-relaxed">
|
href='/login'
|
||||||
Find reliable, verified experts for your home or business. Real-time availability, transparent pricing, and zero spam.
|
label='Login'
|
||||||
</p>
|
color='info'
|
||||||
|
className='w-full'
|
||||||
{/* 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>
|
</BaseButtons>
|
||||||
<div className="flex-grow relative">
|
</CardBox>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
{/* 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 className="relative z-10 text-center flex flex-col items-center">
|
|
||||||
<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">
|
|
||||||
<BaseIcon path={mdiClipboardTextOutline} size={36} />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-bold text-xl mb-4">2. Request Service</h4>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="relative z-10 text-center flex flex-col items-center">
|
|
||||||
<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>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
LandingPage.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -52,8 +52,7 @@ 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 = {
|
||||||
@ -95,10 +94,10 @@ const LeadsTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle(pageTitle)}</title>
|
<title>{getPageTitle('Leads')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={pageTitle} main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Leads" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
@ -170,4 +169,4 @@ LeadsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LeadsTablesPage
|
export default LeadsTablesPage
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,10 +1,12 @@
|
|||||||
|
|
||||||
|
|
||||||
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, mdiShieldCheck } from '@mdi/js';
|
import { mdiInformation, mdiEye, mdiEyeOff } 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';
|
||||||
@ -19,13 +21,12 @@ 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 = 'text-emerald-600';
|
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||||
const iconsColor = 'text-emerald-500';
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
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,
|
||||||
@ -33,7 +34,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('image');
|
const [contentType, setContentType] = useState('video');
|
||||||
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(
|
||||||
@ -43,7 +44,7 @@ export default function Login() {
|
|||||||
password: 'b2096650',
|
password: 'b2096650',
|
||||||
remember: true })
|
remember: true })
|
||||||
|
|
||||||
const title = 'Fix-It-Local'
|
const title = 'Crafted Network'
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
// Fetch Pexels image/video
|
||||||
useEffect( () => {
|
useEffect( () => {
|
||||||
@ -100,24 +101,16 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
const imageBlock = (image) => (
|
||||||
<div className="hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden"
|
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(16, 185, 129, 0.1), rgba(6, 78, 59, 0.2))'}`,
|
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'left center',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
}}>
|
}}>
|
||||||
<div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
|
<div className="flex justify-center w-full bg-blue-300/20">
|
||||||
<div className="relative z-10 p-12 text-white">
|
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
|
||||||
<h1 className="text-4xl font-black mb-4">Welcome Back to the Network.</h1>
|
by {image?.photographer} on Pexels</a>
|
||||||
<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>
|
||||||
)
|
)
|
||||||
@ -125,7 +118,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 lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden'>
|
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||||
<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
|
||||||
@ -135,16 +128,9 @@ 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="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
|
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||||
<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-[10px] text-white/60 hover:text-white transition-colors'
|
className='text-[8px]'
|
||||||
href={video.user.url}
|
href={video.user.url}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noreferrer'
|
rel='noreferrer'
|
||||||
@ -154,122 +140,131 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
return imageBlock(illustrationImage);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 font-sans">
|
<div 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('Login')}</title>
|
<title>{getPageTitle('Login')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<div className="flex flex-row min-h-screen">
|
<SectionFullScreen bg='violet'>
|
||||||
{contentType === 'video' ? videoBlock(illustrationVideo) : imageBlock(illustrationImage)}
|
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||||
|
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
|
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||||
{/* Branding */}
|
|
||||||
<div className="flex flex-col items-center mb-8">
|
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
<Link href="/" className="flex items-center gap-3 group mb-6">
|
|
||||||
<Logo className="h-12 w-auto" />
|
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
||||||
</Link>
|
|
||||||
<h2 className="text-3xl font-bold text-slate-900">Account Login</h2>
|
<div className='flex flex-row text-gray-500 justify-between'>
|
||||||
<p className="text-slate-500 mt-2">Enter your credentials to access your dashboard</p>
|
<div>
|
||||||
</div>
|
|
||||||
|
<p className='mb-2'>Use{' '}
|
||||||
<CardBox className="shadow-2xl border-none rounded-[2rem] p-4 lg:p-6">
|
<code className={`cursor-pointer ${textColor} `}
|
||||||
<Formik
|
data-password="b2096650"
|
||||||
initialValues={initialValues}
|
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||||
enableReinitialize
|
<code className={`${textColor}`}>b2096650</code>{' / '}
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
to login as Admin</p>
|
||||||
>
|
<p>Use <code
|
||||||
<Form className="space-y-4">
|
className={`cursor-pointer ${textColor} `}
|
||||||
<FormField
|
data-password="7302e7d1c0fe"
|
||||||
label='Email Address'
|
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
||||||
labelColor="text-slate-700 font-bold"
|
<code className={`${textColor}`}>7302e7d1c0fe</code>{' / '}
|
||||||
>
|
to login as User</p>
|
||||||
<Field name='email' placeholder="name@company.com" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
|
</div>
|
||||||
</FormField>
|
<div>
|
||||||
|
<BaseIcon
|
||||||
<div className='relative'>
|
className={`${iconsColor}`}
|
||||||
<FormField
|
w='w-16'
|
||||||
label='Password'
|
h='h-16'
|
||||||
labelColor="text-slate-700 font-bold"
|
size={48}
|
||||||
>
|
path={mdiInformation}
|
||||||
<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 className="text-center pt-4">
|
|
||||||
<p className="text-slate-500 text-sm">
|
|
||||||
Don'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>
|
|
||||||
<h4 className="font-bold text-slate-900 text-sm mb-2">Demo Access</h4>
|
|
||||||
<div className="space-y-2 text-xs text-slate-600">
|
|
||||||
<p>
|
|
||||||
<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>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<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>
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
enableReinitialize
|
||||||
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
<FormField
|
||||||
|
label='Login'
|
||||||
|
help='Please enter your login'>
|
||||||
|
<Field name='email' />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<div className="text-center text-slate-400 text-xs pt-8">
|
<div className='relative'>
|
||||||
© 2026 Fix-It-Local™. All rights reserved. <br/>
|
<FormField
|
||||||
<Link href='/privacy-policy/' className="hover:text-slate-600 mt-2 inline-block">Privacy Policy</Link>
|
label='Password'
|
||||||
</div>
|
help='Please enter your password'>
|
||||||
</div>
|
<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
|
||||||
|
className='text-gray-500 hover:text-gray-700'
|
||||||
|
size={20}
|
||||||
|
path={showPassword ? mdiEyeOff : mdiEye}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex justify-between'}>
|
||||||
|
<FormCheckRadio type='checkbox' label='Remember'>
|
||||||
|
<Field type='checkbox' name='remember' />
|
||||||
|
</FormCheckRadio>
|
||||||
|
|
||||||
|
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseDivider />
|
||||||
|
|
||||||
|
<BaseButtons>
|
||||||
|
<BaseButton
|
||||||
|
className={'w-full'}
|
||||||
|
type='submit'
|
||||||
|
label={isFetching ? 'Loading...' : 'Login'}
|
||||||
|
color='info'
|
||||||
|
disabled={isFetching}
|
||||||
|
/>
|
||||||
|
</BaseButtons>
|
||||||
|
<br />
|
||||||
|
<p className={'text-center'}>
|
||||||
|
Don’t 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>
|
||||||
@ -278,4 +273,4 @@ export default function Login() {
|
|||||||
|
|
||||||
Login.getLayout = function getLayout(page: ReactElement) {
|
Login.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import React, { ReactElement } 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,42 +12,128 @@ 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 { create } from '../../stores/messages/messagesSlice'
|
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
import {RichTextField} from "../../components/RichTextField";
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
|
|
||||||
|
import { create } from '../../stores/messages/messagesSlice'
|
||||||
|
import { useAppDispatch } from '../../stores/hooks'
|
||||||
|
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))
|
||||||
@ -56,38 +142,219 @@ const MessagesNew = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Send Message')}</title>
|
<title>{getPageTitle('New Item')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Send Message" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox>
|
<CardBox>
|
||||||
<Formik
|
<Formik
|
||||||
enableReinitialize
|
initialValues={
|
||||||
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="Send Message" />
|
<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('/messages/messages-list')}/>
|
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/messages/messages-list')}/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</Form>
|
</Form>
|
||||||
@ -101,11 +368,13 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MessagesNew
|
export default MessagesNew
|
||||||
|
|||||||
@ -1,396 +0,0 @@
|
|||||||
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'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'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;
|
|
||||||
@ -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 = 'Fix-It-Local'
|
const title = 'Crafted Network'
|
||||||
const [projectUrl, setProjectUrl] = useState('');
|
const [projectUrl, setProjectUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -1,456 +0,0 @@
|
|||||||
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">"{review.text}"</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">"{review.response}"</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;
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,224 +1,92 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React 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 BaseIcon from "../components/BaseIcon";
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
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');
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSubmit = async (value) => {
|
||||||
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', error.response?.data?.message || 'Something went wrong. Try again')
|
notify('error', 'Something was 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('Register')}</title>
|
<title>{getPageTitle('Login')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<div className="flex flex-row min-h-screen">
|
<SectionFullScreen bg='violet'>
|
||||||
{contentType === 'video' ? videoBlock(illustrationVideo) : imageBlock(illustrationImage)}
|
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirm: ''
|
||||||
|
}}
|
||||||
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
|
||||||
|
<FormField label='Email' help='Please enter your email'>
|
||||||
|
<Field type='email' name='email' />
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Password' help='Please enter your password'>
|
||||||
|
<Field type='password' name='password' />
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Confirm Password' help='Please confirm your password'>
|
||||||
|
<Field type='password' name='confirm' />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
|
<BaseDivider />
|
||||||
<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">
|
|
||||||
<Logo className="h-12 w-auto" />
|
|
||||||
</Link>
|
|
||||||
<h2 className="text-3xl font-bold text-slate-900">Create Account</h2>
|
|
||||||
<p className="text-slate-500 mt-2 text-center">Join the most trusted service network today</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardBox className="shadow-2xl border-none rounded-[2rem] p-4 lg:p-6">
|
<BaseButtons>
|
||||||
<Formik
|
<BaseButton
|
||||||
initialValues={{
|
type='submit'
|
||||||
email: '',
|
label={loading ? 'Loading...' : 'Register' }
|
||||||
password: '',
|
color='info'
|
||||||
confirm: ''
|
/>
|
||||||
}}
|
<BaseButton
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
href={'/login'}
|
||||||
>
|
label={'Login'}
|
||||||
<Form className="space-y-4">
|
color='info'
|
||||||
<FormField
|
/>
|
||||||
label='Email Address'
|
</BaseButtons>
|
||||||
labelColor="text-slate-700 font-bold"
|
</Form>
|
||||||
>
|
</Formik>
|
||||||
<Field name='email' type='email' placeholder="name@company.com" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
|
</CardBox>
|
||||||
</FormField>
|
</SectionFullScreen>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Register.getLayout = function getLayout(page: ReactElement) {
|
Register.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
import { mdiChartTimelineVariant, mdiUpload } 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,34 +16,310 @@ 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)
|
||||||
|
|
||||||
@ -54,20 +330,22 @@ const EditReviewsPage = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetch({ id: id }))
|
dispatch(fetch({ id: id }))
|
||||||
}, [id, dispatch])
|
}, [id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof reviews === 'object' && reviews !== null) {
|
if (typeof reviews === 'object') {
|
||||||
const newInitialVal = {...initVals};
|
setInitialValues(reviews)
|
||||||
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')
|
||||||
@ -89,8 +367,28 @@ 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'
|
||||||
@ -98,10 +396,89 @@ 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'
|
||||||
@ -109,10 +486,89 @@ 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'
|
||||||
@ -120,10 +576,76 @@ 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"
|
||||||
>
|
>
|
||||||
@ -133,15 +655,77 @@ 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'
|
||||||
@ -149,28 +733,103 @@ const EditReviewsPage = () => {
|
|||||||
component={SwitchField}
|
component={SwitchField}
|
||||||
></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"
|
||||||
>
|
>
|
||||||
@ -178,12 +837,42 @@ 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(initialValues.created_at_ts) : null
|
new Date(
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
@ -191,13 +880,31 @@ 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(initialValues.updated_at_ts) : null
|
new Date(
|
||||||
|
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>
|
||||||
@ -216,11 +923,13 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EditReviewsPage
|
export default EditReviewsPage
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload, mdiStar, mdiMessageDraw } from '@mdi/js'
|
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import CardBox from '../../components/CardBox'
|
import CardBox from '../../components/CardBox'
|
||||||
import LayoutGuest from '../../layouts/Guest'
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||||
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,171 +12,574 @@ 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 { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
import FormCheckRadio from '../../components/FormCheckRadio'
|
||||||
import { useRouter } from 'next/router'
|
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 { create } from '../../stores/reviews/reviewsSlice'
|
||||||
import BaseIcon from '../../components/BaseIcon'
|
import { useAppDispatch } from '../../stores/hooks'
|
||||||
import axios from 'axios'
|
import { useRouter } from 'next/router'
|
||||||
import { ToastContainer, toast } from 'react-toastify'
|
import moment from 'moment';
|
||||||
import 'react-toastify/dist/ReactToastify.css'
|
|
||||||
|
const initialValues = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 response = await axios.get(`/businesses/${businessId}`)
|
|
||||||
setBusinessName(response.data.name)
|
|
||||||
} 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 {
|
const handleSubmit = async (data) => {
|
||||||
await dispatch(create(data)).unwrap()
|
await dispatch(create(data))
|
||||||
toast.success('Thank you for your review!')
|
await router.push('/reviews/reviews-list')
|
||||||
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('Write a Review')}</title>
|
<title>{getPageTitle('New Item')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="pt-24 pb-12">
|
<SectionMain>
|
||||||
<SectionMain>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||||
<SectionTitleLineWithButton icon={mdiMessageDraw} title={businessName ? `Review for ${businessName}` : "Write a Review"} main>
|
{''}
|
||||||
{''}
|
</SectionTitleLineWithButton>
|
||||||
</SectionTitleLineWithButton>
|
<CardBox>
|
||||||
|
<Formik
|
||||||
|
initialValues={
|
||||||
|
|
||||||
|
initialValues
|
||||||
|
|
||||||
|
}
|
||||||
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
<CardBox>
|
|
||||||
<Formik
|
|
||||||
initialValues={initialValues}
|
|
||||||
enableReinitialize={true}
|
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
{({ 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!'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</BaseButtons>
|
<FormField label="Business" labelFor="business">
|
||||||
</Form>
|
<Field name="business" id="business" component={SelectField} options={[]} itemRef={'businesses'}></Field>
|
||||||
)}
|
</FormField>
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
</SectionMain>
|
|
||||||
</div>
|
|
||||||
<ToastContainer />
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FormField label="User" labelFor="user">
|
||||||
|
<Field name="user" id="user" component={SelectField} options={[]} itemRef={'users'}></Field>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<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 (
|
||||||
<LayoutGuest>
|
<LayoutAuthenticated
|
||||||
{page}
|
|
||||||
</LayoutGuest>
|
permission={'CREATE_REVIEWS'}
|
||||||
|
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</LayoutAuthenticated>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ReviewsNew
|
export default ReviewsNew
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user