Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff6518916f | ||
|
|
111693ae6e | ||
|
|
b03b911e99 | ||
|
|
f7df7e331e | ||
|
|
a004fcd820 | ||
|
|
15445f0bc1 | ||
|
|
2304edcdf3 | ||
|
|
777bc713b3 |
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>Crafted Network</h2>
|
<h2>Fix-It-Local</h2>
|
||||||
<p>Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking.</p>
|
<p>Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="loader-container">
|
<div class="loader-container">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
# Crafted Network
|
# Fix-It-Local
|
||||||
|
|
||||||
|
|
||||||
## This project was generated by [Flatlogic Platform](https://flatlogic.com).
|
## This project was generated by [Flatlogic Platform](https://flatlogic.com).
|
||||||
|
|||||||
BIN
assets/pasted-20260218-034356-d8337609.png
Normal file
BIN
assets/pasted-20260218-034356-d8337609.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
@ -12,3 +12,4 @@ EMAIL_USER=AKIAVEW7G4PQUBGM52OF
|
|||||||
EMAIL_PASS=BLnD4hKGb6YkSz3gaQrf8fnyLi3C3/EdjOOsLEDTDPTz
|
EMAIL_PASS=BLnD4hKGb6YkSz3gaQrf8fnyLi3C3/EdjOOsLEDTDPTz
|
||||||
SECRET_KEY=HUEyqESqgQ1yTwzVlO6wprC9Kf1J1xuA
|
SECRET_KEY=HUEyqESqgQ1yTwzVlO6wprC9Kf1J1xuA
|
||||||
PEXELS_KEY=Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18
|
PEXELS_KEY=Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18
|
||||||
|
GOOGLE_PLACES_API_KEY=AIzaSyDZlhJAIi-qFiy93MIiaYciCq28bZl6Y3Y
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
#Crafted Network - template backend,
|
#Fix-It-Local - template backend,
|
||||||
|
|
||||||
#### Run App on local machine:
|
#### Run App on local machine:
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "craftednetwork",
|
"name": "craftednetwork",
|
||||||
"description": "Crafted Network - template backend",
|
"description": "Fix-It-Local - template backend",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm run db:migrate && npm run db:seed && npm run watch",
|
"start": "npm run db:migrate && npm run db:seed && npm run watch",
|
||||||
"lint": "eslint . --ext .js",
|
"lint": "eslint . --ext .js",
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@ -32,6 +29,7 @@ const config = {
|
|||||||
google: {
|
google: {
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||||
|
placesApiKey: process.env.GOOGLE_PLACES_API_KEY || 'AIzaSyDZlhJAIi-qFiy93MIiaYciCq28bZl6Y3Y',
|
||||||
},
|
},
|
||||||
microsoft: {
|
microsoft: {
|
||||||
clientId: process.env.MS_CLIENT_ID || '',
|
clientId: process.env.MS_CLIENT_ID || '',
|
||||||
@ -39,7 +37,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
uploadDir: os.tmpdir(),
|
uploadDir: os.tmpdir(),
|
||||||
email: {
|
email: {
|
||||||
from: 'Crafted Network <app@flatlogic.app>',
|
from: 'Fix-It-Local <app@flatlogic.app>',
|
||||||
host: 'email-smtp.us-east-1.amazonaws.com',
|
host: 'email-smtp.us-east-1.amazonaws.com',
|
||||||
port: 587,
|
port: 587,
|
||||||
auth: {
|
auth: {
|
||||||
@ -69,7 +67,7 @@ const config = {
|
|||||||
|
|
||||||
config.pexelsKey = process.env.PEXELS_KEY || '';
|
config.pexelsKey = process.env.PEXELS_KEY || '';
|
||||||
|
|
||||||
config.pexelsQuery = 'Crafted bridge over calm river';
|
config.pexelsQuery = 'home repair services';
|
||||||
config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost";
|
config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost";
|
||||||
config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`;
|
config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`;
|
||||||
config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`;
|
config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`;
|
||||||
|
|||||||
@ -1,18 +1,12 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
module.exports = class Business_badgesDBApi {
|
module.exports = class Business_badgesDBApi {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
static async create(data, options) {
|
static async create(data, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
@ -20,493 +14,63 @@ module.exports = class Business_badgesDBApi {
|
|||||||
const business_badges = await db.business_badges.create(
|
const business_badges = await db.business_badges.create(
|
||||||
{
|
{
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
|
badge_type: data.badge_type || null,
|
||||||
badge_type: data.badge_type
|
status: data.status || null,
|
||||||
||
|
granted_at: data.granted_at || null,
|
||||||
null
|
revoked_at: data.revoked_at || null,
|
||||||
,
|
notes: data.notes || null,
|
||||||
|
created_at_ts: data.created_at_ts || null,
|
||||||
status: data.status
|
updated_at_ts: data.updated_at_ts || null,
|
||||||
||
|
importHash: data.importHash || null,
|
||||||
null
|
createdById: currentUser.id,
|
||||||
,
|
updatedById: currentUser.id,
|
||||||
|
|
||||||
granted_at: data.granted_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
revoked_at: data.revoked_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
notes: data.notes
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
created_at_ts: data.created_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
updated_at_ts: data.updated_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: data.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
await business_badges.setBusiness( data.business || null, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return business_badges;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static async bulkImport(data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
// Prepare data - wrapping individual data transformations in a map() method
|
|
||||||
const business_badgesData = data.map((item, index) => ({
|
|
||||||
id: item.id || undefined,
|
|
||||||
|
|
||||||
badge_type: item.badge_type
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
status: item.status
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
granted_at: item.granted_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
revoked_at: item.revoked_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
notes: item.notes
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
created_at_ts: item.created_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
updated_at_ts: item.updated_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: item.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
createdAt: new Date(Date.now() + index * 1000),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Bulk create items
|
|
||||||
const business_badges = await db.business_badges.bulkCreate(business_badgesData, { transaction });
|
|
||||||
|
|
||||||
// For each item created, replace relation files
|
|
||||||
|
|
||||||
|
|
||||||
return business_badges;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async update(id, data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
|
|
||||||
const business_badges = await db.business_badges.findByPk(id, {}, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updatePayload = {};
|
|
||||||
|
|
||||||
if (data.badge_type !== undefined) updatePayload.badge_type = data.badge_type;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.status !== undefined) updatePayload.status = data.status;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.granted_at !== undefined) updatePayload.granted_at = data.granted_at;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.revoked_at !== undefined) updatePayload.revoked_at = data.revoked_at;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.notes !== undefined) updatePayload.notes = data.notes;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.updated_at_ts !== undefined) updatePayload.updated_at_ts = data.updated_at_ts;
|
|
||||||
|
|
||||||
|
|
||||||
updatePayload.updatedById = currentUser.id;
|
|
||||||
|
|
||||||
await business_badges.update(updatePayload, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (data.business !== undefined) {
|
|
||||||
await business_badges.setBusiness(
|
|
||||||
|
|
||||||
data.business,
|
|
||||||
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return business_badges;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async deleteByIds(ids, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const business_badges = await db.business_badges.findAll({
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
[Op.in]: ids,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.sequelize.transaction(async (transaction) => {
|
|
||||||
for (const record of business_badges) {
|
|
||||||
await record.update(
|
|
||||||
{deletedBy: currentUser.id},
|
|
||||||
{transaction}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const record of business_badges) {
|
|
||||||
await record.destroy({transaction});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
return business_badges;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async remove(id, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const business_badges = await db.business_badges.findByPk(id, options);
|
|
||||||
|
|
||||||
await business_badges.update({
|
|
||||||
deletedBy: currentUser.id
|
|
||||||
}, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await business_badges.destroy({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
return business_badges;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async findBy(where, options) {
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const business_badges = await db.business_badges.findOne(
|
|
||||||
{ where },
|
|
||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!business_badges) {
|
await business_badges.setBusiness( data.business || null, { transaction });
|
||||||
return business_badges;
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = business_badges.get({plain: true});
|
return business_badges;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
output.business = await business_badges.getBusiness({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(
|
static async findAll(filter, options) {
|
||||||
filter,
|
|
||||||
options
|
|
||||||
) {
|
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let where = {};
|
let where = {};
|
||||||
const currentPage = +filter.page;
|
const currentPage = +filter.page;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const currentUser = options?.currentUser;
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
// Data Isolation
|
||||||
|
if (currentUser && currentUser.app_role) {
|
||||||
let include = [
|
const roleName = currentUser.app_role.name;
|
||||||
|
if (roleName === 'Verified Business Owner') {
|
||||||
{
|
if (currentUser.businessId) {
|
||||||
model: db.businesses,
|
where.businessId = currentUser.businessId;
|
||||||
as: 'business',
|
} else {
|
||||||
|
where['$business.owner_userId$'] = currentUser.id;
|
||||||
where: filter.business ? {
|
|
||||||
[Op.or]: [
|
|
||||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
} : {},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter) {
|
|
||||||
if (filter.id) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['id']: Utils.uuid(filter.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.notes) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
[Op.and]: Utils.ilike(
|
|
||||||
'business_badges',
|
|
||||||
'notes',
|
|
||||||
filter.notes,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.granted_atRange) {
|
|
||||||
const [start, end] = filter.granted_atRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
granted_at: {
|
|
||||||
...where.granted_at,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
granted_at: {
|
|
||||||
...where.granted_at,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.revoked_atRange) {
|
|
||||||
const [start, end] = filter.revoked_atRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
revoked_at: {
|
|
||||||
...where.revoked_at,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
revoked_at: {
|
|
||||||
...where.revoked_at,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.created_at_tsRange) {
|
|
||||||
const [start, end] = filter.created_at_tsRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.updated_at_tsRange) {
|
|
||||||
const [start, end] = filter.updated_at_tsRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
updated_at_ts: {
|
|
||||||
...where.updated_at_ts,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
updated_at_ts: {
|
|
||||||
...where.updated_at_ts,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
active: filter.active === true || filter.active === 'true'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.badge_type) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
badge_type: filter.badge_type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.status) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
status: filter.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let include = [
|
||||||
|
{
|
||||||
|
model: db.businesses,
|
||||||
|
as: 'business',
|
||||||
|
where: filter.business ? {
|
||||||
|
[Op.or]: [
|
||||||
|
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
||||||
|
{ name: { [Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||||
|
]
|
||||||
|
} : {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||||
|
}
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
where,
|
where,
|
||||||
@ -515,8 +79,7 @@ module.exports = class Business_badgesDBApi {
|
|||||||
order: filter.field && filter.sort
|
order: filter.field && filter.sort
|
||||||
? [[filter.field, filter.sort]]
|
? [[filter.field, filter.sort]]
|
||||||
: [['createdAt', 'desc']],
|
: [['createdAt', 'desc']],
|
||||||
transaction: options?.transaction,
|
transaction,
|
||||||
logging: console.log
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
if (!options?.countOnly) {
|
||||||
@ -524,51 +87,80 @@ module.exports = class Business_badgesDBApi {
|
|||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { rows, count } = await db.business_badges.findAndCountAll(queryOptions);
|
||||||
const { rows, count } = await db.business_badges.findAndCountAll(queryOptions);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: options?.countOnly ? [] : rows,
|
rows: options?.countOnly ? [] : rows,
|
||||||
count: count
|
count: count
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, ) {
|
static async findBy(where, options) {
|
||||||
let where = {};
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_badges = await db.business_badges.findOne({ where, transaction });
|
||||||
|
if (!business_badges) return null;
|
||||||
|
|
||||||
|
const output = business_badges.get({plain: true});
|
||||||
|
output.business = await business_badges.getBusiness({ transaction });
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id, data, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_badges = await db.business_badges.findByPk(id, {transaction});
|
||||||
|
|
||||||
|
const updatePayload = { ...data, updatedById: currentUser.id };
|
||||||
|
await business_badges.update(updatePayload, {transaction});
|
||||||
|
|
||||||
|
if (data.business !== undefined) await business_badges.setBusiness(data.business, { transaction });
|
||||||
|
|
||||||
|
return business_badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteByIds(ids, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_badges = await db.business_badges.findAll({ where: { id: { [Op.in]: ids } }, transaction });
|
||||||
|
|
||||||
|
for (const record of business_badges) {
|
||||||
|
await record.update({deletedBy: currentUser.id}, {transaction});
|
||||||
|
await record.destroy({transaction});
|
||||||
|
}
|
||||||
|
return business_badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_badges = await db.business_badges.findByPk(id, options);
|
||||||
|
await business_badges.update({ deletedBy: currentUser.id }, { transaction });
|
||||||
|
await business_badges.destroy({ transaction });
|
||||||
|
return business_badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAllAutocomplete(query, limit, offset) {
|
||||||
|
let where = {};
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
|
||||||
'business_badges',
|
|
||||||
'notes',
|
|
||||||
query,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await db.business_badges.findAll({
|
const records = await db.business_badges.findAll({
|
||||||
attributes: [ 'id', 'notes' ],
|
attributes: [ 'id' ],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
orderBy: [['notes', 'ASC']],
|
order: [['createdAt', 'desc']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
label: record.notes,
|
label: record.id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,18 +1,12 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
module.exports = class Business_categoriesDBApi {
|
module.exports = class Business_categoriesDBApi {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
static async create(data, options) {
|
static async create(data, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
@ -20,355 +14,68 @@ module.exports = class Business_categoriesDBApi {
|
|||||||
const business_categories = await db.business_categories.create(
|
const business_categories = await db.business_categories.create(
|
||||||
{
|
{
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
|
created_at_ts: data.created_at_ts || null,
|
||||||
created_at_ts: data.created_at_ts
|
importHash: data.importHash || null,
|
||||||
||
|
createdById: currentUser.id,
|
||||||
null
|
updatedById: currentUser.id,
|
||||||
,
|
|
||||||
|
|
||||||
importHash: data.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
await business_categories.setBusiness( data.business || null, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await business_categories.setCategory( data.category || null, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return business_categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static async bulkImport(data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
// Prepare data - wrapping individual data transformations in a map() method
|
|
||||||
const business_categoriesData = data.map((item, index) => ({
|
|
||||||
id: item.id || undefined,
|
|
||||||
|
|
||||||
created_at_ts: item.created_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: item.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
createdAt: new Date(Date.now() + index * 1000),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Bulk create items
|
|
||||||
const business_categories = await db.business_categories.bulkCreate(business_categoriesData, { transaction });
|
|
||||||
|
|
||||||
// For each item created, replace relation files
|
|
||||||
|
|
||||||
|
|
||||||
return business_categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async update(id, data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
|
|
||||||
const business_categories = await db.business_categories.findByPk(id, {}, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updatePayload = {};
|
|
||||||
|
|
||||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
|
||||||
|
|
||||||
|
|
||||||
updatePayload.updatedById = currentUser.id;
|
|
||||||
|
|
||||||
await business_categories.update(updatePayload, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (data.business !== undefined) {
|
|
||||||
await business_categories.setBusiness(
|
|
||||||
|
|
||||||
data.business,
|
|
||||||
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.category !== undefined) {
|
|
||||||
await business_categories.setCategory(
|
|
||||||
|
|
||||||
data.category,
|
|
||||||
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return business_categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async deleteByIds(ids, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const business_categories = await db.business_categories.findAll({
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
[Op.in]: ids,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.sequelize.transaction(async (transaction) => {
|
|
||||||
for (const record of business_categories) {
|
|
||||||
await record.update(
|
|
||||||
{deletedBy: currentUser.id},
|
|
||||||
{transaction}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const record of business_categories) {
|
|
||||||
await record.destroy({transaction});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
return business_categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async remove(id, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const business_categories = await db.business_categories.findByPk(id, options);
|
|
||||||
|
|
||||||
await business_categories.update({
|
|
||||||
deletedBy: currentUser.id
|
|
||||||
}, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await business_categories.destroy({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
return business_categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async findBy(where, options) {
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const business_categories = await db.business_categories.findOne(
|
|
||||||
{ where },
|
|
||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!business_categories) {
|
await business_categories.setBusiness( data.business || null, { transaction });
|
||||||
return business_categories;
|
await business_categories.setCategory( data.category || null, { transaction });
|
||||||
}
|
|
||||||
|
|
||||||
const output = business_categories.get({plain: true});
|
return business_categories;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
output.business = await business_categories.getBusiness({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
output.category = await business_categories.getCategory({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(
|
static async findAll(filter, options) {
|
||||||
filter,
|
|
||||||
options
|
|
||||||
) {
|
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let where = {};
|
let where = {};
|
||||||
const currentPage = +filter.page;
|
const currentPage = +filter.page;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const currentUser = options?.currentUser;
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
// Data Isolation
|
||||||
|
if (currentUser && currentUser.app_role) {
|
||||||
let include = [
|
const roleName = currentUser.app_role.name;
|
||||||
|
if (roleName === 'Verified Business Owner') {
|
||||||
{
|
if (currentUser.businessId) {
|
||||||
model: db.businesses,
|
where.businessId = currentUser.businessId;
|
||||||
as: 'business',
|
} else {
|
||||||
|
where['$business.owner_userId$'] = currentUser.id;
|
||||||
where: filter.business ? {
|
|
||||||
[Op.or]: [
|
|
||||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
} : {},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
model: db.categories,
|
|
||||||
as: 'category',
|
|
||||||
|
|
||||||
where: filter.category ? {
|
|
||||||
[Op.or]: [
|
|
||||||
{ id: { [Op.in]: filter.category.split('|').map(term => Utils.uuid(term)) } },
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.category.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
} : {},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter) {
|
|
||||||
if (filter.id) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['id']: Utils.uuid(filter.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.created_at_tsRange) {
|
|
||||||
const [start, end] = filter.created_at_tsRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
active: filter.active === true || filter.active === 'true'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let include = [
|
||||||
|
{
|
||||||
|
model: db.businesses,
|
||||||
|
as: 'business',
|
||||||
|
where: filter.business ? {
|
||||||
|
[Op.or]: [
|
||||||
|
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
||||||
|
{ name: { [Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||||
|
]
|
||||||
|
} : {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.categories,
|
||||||
|
as: 'category',
|
||||||
|
where: filter.category ? {
|
||||||
|
[Op.or]: [
|
||||||
|
{ id: { [Op.in]: filter.category.split('|').map(term => Utils.uuid(term)) } },
|
||||||
|
{ name: { [Op.or]: filter.category.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||||
|
]
|
||||||
|
} : {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||||
|
}
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
where,
|
where,
|
||||||
@ -377,8 +84,7 @@ module.exports = class Business_categoriesDBApi {
|
|||||||
order: filter.field && filter.sort
|
order: filter.field && filter.sort
|
||||||
? [[filter.field, filter.sort]]
|
? [[filter.field, filter.sort]]
|
||||||
: [['createdAt', 'desc']],
|
: [['createdAt', 'desc']],
|
||||||
transaction: options?.transaction,
|
transaction,
|
||||||
logging: console.log
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
if (!options?.countOnly) {
|
||||||
@ -386,51 +92,83 @@ module.exports = class Business_categoriesDBApi {
|
|||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { rows, count } = await db.business_categories.findAndCountAll(queryOptions);
|
||||||
const { rows, count } = await db.business_categories.findAndCountAll(queryOptions);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: options?.countOnly ? [] : rows,
|
rows: options?.countOnly ? [] : rows,
|
||||||
count: count
|
count: count
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, ) {
|
static async findBy(where, options) {
|
||||||
let where = {};
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_categories = await db.business_categories.findOne({ where, transaction });
|
||||||
|
if (!business_categories) return null;
|
||||||
|
|
||||||
|
const output = business_categories.get({plain: true});
|
||||||
|
output.business = await business_categories.getBusiness({ transaction });
|
||||||
|
output.category = await business_categories.getCategory({ transaction });
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id, data, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_categories = await db.business_categories.findByPk(id, {transaction});
|
||||||
|
|
||||||
|
const updatePayload = { updatedById: currentUser.id };
|
||||||
|
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||||
|
await business_categories.update(updatePayload, {transaction});
|
||||||
|
|
||||||
|
if (data.business !== undefined) await business_categories.setBusiness(data.business, { transaction });
|
||||||
|
if (data.category !== undefined) await business_categories.setCategory(data.category, { transaction });
|
||||||
|
|
||||||
|
return business_categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteByIds(ids, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_categories = await db.business_categories.findAll({ where: { id: { [Op.in]: ids } }, transaction });
|
||||||
|
|
||||||
|
for (const record of business_categories) {
|
||||||
|
await record.update({deletedBy: currentUser.id}, {transaction});
|
||||||
|
await record.destroy({transaction});
|
||||||
|
}
|
||||||
|
return business_categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_categories = await db.business_categories.findByPk(id, options);
|
||||||
|
await business_categories.update({ deletedBy: currentUser.id }, { transaction });
|
||||||
|
await business_categories.destroy({ transaction });
|
||||||
|
return business_categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAllAutocomplete(query, limit, offset) {
|
||||||
|
let where = {};
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
|
||||||
'business_categories',
|
|
||||||
'created_at_ts',
|
|
||||||
query,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await db.business_categories.findAll({
|
const records = await db.business_categories.findAll({
|
||||||
attributes: [ 'id', 'created_at_ts' ],
|
attributes: [ 'id' ],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
orderBy: [['created_at_ts', 'ASC']],
|
order: [['createdAt', 'desc']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
label: record.created_at_ts,
|
label: record.id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,18 +1,12 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
module.exports = class Business_photosDBApi {
|
module.exports = class Business_photosDBApi {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
static async create(data, options) {
|
static async create(data, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
@ -20,360 +14,70 @@ module.exports = class Business_photosDBApi {
|
|||||||
const business_photos = await db.business_photos.create(
|
const business_photos = await db.business_photos.create(
|
||||||
{
|
{
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
|
created_at_ts: data.created_at_ts || null,
|
||||||
created_at_ts: data.created_at_ts
|
importHash: data.importHash || null,
|
||||||
||
|
createdById: currentUser.id,
|
||||||
null
|
updatedById: currentUser.id,
|
||||||
,
|
|
||||||
|
|
||||||
importHash: data.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
await business_photos.setBusiness( data.business || null, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await FileDBApi.replaceRelationFiles(
|
|
||||||
{
|
|
||||||
belongsTo: db.business_photos.getTableName(),
|
|
||||||
belongsToColumn: 'photos',
|
|
||||||
belongsToId: business_photos.id,
|
|
||||||
},
|
},
|
||||||
data.photos,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
return business_photos;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static async bulkImport(data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
// Prepare data - wrapping individual data transformations in a map() method
|
|
||||||
const business_photosData = data.map((item, index) => ({
|
|
||||||
id: item.id || undefined,
|
|
||||||
|
|
||||||
created_at_ts: item.created_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: item.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
createdAt: new Date(Date.now() + index * 1000),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Bulk create items
|
|
||||||
const business_photos = await db.business_photos.bulkCreate(business_photosData, { transaction });
|
|
||||||
|
|
||||||
// For each item created, replace relation files
|
|
||||||
|
|
||||||
for (let i = 0; i < business_photos.length; i++) {
|
|
||||||
await FileDBApi.replaceRelationFiles(
|
|
||||||
{
|
|
||||||
belongsTo: db.business_photos.getTableName(),
|
|
||||||
belongsToColumn: 'photos',
|
|
||||||
belongsToId: business_photos[i].id,
|
|
||||||
},
|
|
||||||
data[i].photos,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return business_photos;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async update(id, data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
|
|
||||||
const business_photos = await db.business_photos.findByPk(id, {}, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updatePayload = {};
|
|
||||||
|
|
||||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
|
||||||
|
|
||||||
|
|
||||||
updatePayload.updatedById = currentUser.id;
|
|
||||||
|
|
||||||
await business_photos.update(updatePayload, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (data.business !== undefined) {
|
|
||||||
await business_photos.setBusiness(
|
|
||||||
|
|
||||||
data.business,
|
|
||||||
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await FileDBApi.replaceRelationFiles(
|
|
||||||
{
|
|
||||||
belongsTo: db.business_photos.getTableName(),
|
|
||||||
belongsToColumn: 'photos',
|
|
||||||
belongsToId: business_photos.id,
|
|
||||||
},
|
|
||||||
data.photos,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
return business_photos;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async deleteByIds(ids, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const business_photos = await db.business_photos.findAll({
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
[Op.in]: ids,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.sequelize.transaction(async (transaction) => {
|
|
||||||
for (const record of business_photos) {
|
|
||||||
await record.update(
|
|
||||||
{deletedBy: currentUser.id},
|
|
||||||
{transaction}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const record of business_photos) {
|
|
||||||
await record.destroy({transaction});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
return business_photos;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async remove(id, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const business_photos = await db.business_photos.findByPk(id, options);
|
|
||||||
|
|
||||||
await business_photos.update({
|
|
||||||
deletedBy: currentUser.id
|
|
||||||
}, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await business_photos.destroy({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
return business_photos;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async findBy(where, options) {
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const business_photos = await db.business_photos.findOne(
|
|
||||||
{ where },
|
|
||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!business_photos) {
|
await business_photos.setBusiness( data.business || null, { transaction });
|
||||||
return business_photos;
|
await FileDBApi.replaceRelationFiles(
|
||||||
}
|
{
|
||||||
|
belongsTo: db.business_photos.getTableName(),
|
||||||
|
belongsToColumn: 'photos',
|
||||||
|
belongsToId: business_photos.id,
|
||||||
|
},
|
||||||
|
data.photos,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
const output = business_photos.get({plain: true});
|
return business_photos;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
output.business = await business_photos.getBusiness({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
output.photos = await business_photos.getPhotos({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(
|
static async findAll(filter, options) {
|
||||||
filter,
|
|
||||||
options
|
|
||||||
) {
|
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let where = {};
|
let where = {};
|
||||||
const currentPage = +filter.page;
|
const currentPage = +filter.page;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const currentUser = options?.currentUser;
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
// Data Isolation
|
||||||
|
if (currentUser && currentUser.app_role) {
|
||||||
let include = [
|
const roleName = currentUser.app_role.name;
|
||||||
|
if (roleName === 'Verified Business Owner') {
|
||||||
{
|
if (currentUser.businessId) {
|
||||||
model: db.businesses,
|
where.businessId = currentUser.businessId;
|
||||||
as: 'business',
|
} else {
|
||||||
|
where['$business.owner_userId$'] = currentUser.id;
|
||||||
where: filter.business ? {
|
|
||||||
[Op.or]: [
|
|
||||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
} : {},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{
|
|
||||||
model: db.file,
|
|
||||||
as: 'photos',
|
|
||||||
},
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter) {
|
|
||||||
if (filter.id) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['id']: Utils.uuid(filter.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.created_at_tsRange) {
|
|
||||||
const [start, end] = filter.created_at_tsRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
active: filter.active === true || filter.active === 'true'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let include = [
|
||||||
|
{
|
||||||
|
model: db.businesses,
|
||||||
|
as: 'business',
|
||||||
|
where: filter.business ? {
|
||||||
|
[Op.or]: [
|
||||||
|
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
||||||
|
{ name: { [Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||||
|
]
|
||||||
|
} : {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.file,
|
||||||
|
as: 'photos',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||||
|
}
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
where,
|
where,
|
||||||
@ -382,8 +86,7 @@ module.exports = class Business_photosDBApi {
|
|||||||
order: filter.field && filter.sort
|
order: filter.field && filter.sort
|
||||||
? [[filter.field, filter.sort]]
|
? [[filter.field, filter.sort]]
|
||||||
: [['createdAt', 'desc']],
|
: [['createdAt', 'desc']],
|
||||||
transaction: options?.transaction,
|
transaction,
|
||||||
logging: console.log
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
if (!options?.countOnly) {
|
||||||
@ -391,51 +94,91 @@ module.exports = class Business_photosDBApi {
|
|||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { rows, count } = await db.business_photos.findAndCountAll(queryOptions);
|
||||||
const { rows, count } = await db.business_photos.findAndCountAll(queryOptions);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: options?.countOnly ? [] : rows,
|
rows: options?.countOnly ? [] : rows,
|
||||||
count: count
|
count: count
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, ) {
|
static async findBy(where, options) {
|
||||||
let where = {};
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_photos = await db.business_photos.findOne({ where, transaction });
|
||||||
|
if (!business_photos) return null;
|
||||||
|
|
||||||
|
const output = business_photos.get({plain: true});
|
||||||
|
output.business = await business_photos.getBusiness({ transaction });
|
||||||
|
output.photos = await business_photos.getPhotos({ transaction });
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id, data, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_photos = await db.business_photos.findByPk(id, {transaction});
|
||||||
|
|
||||||
|
const updatePayload = { updatedById: currentUser.id };
|
||||||
|
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||||
|
await business_photos.update(updatePayload, {transaction});
|
||||||
|
|
||||||
|
if (data.business !== undefined) await business_photos.setBusiness(data.business, { transaction });
|
||||||
|
await FileDBApi.replaceRelationFiles(
|
||||||
|
{
|
||||||
|
belongsTo: db.business_photos.getTableName(),
|
||||||
|
belongsToColumn: 'photos',
|
||||||
|
belongsToId: business_photos.id,
|
||||||
|
},
|
||||||
|
data.photos,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
return business_photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteByIds(ids, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_photos = await db.business_photos.findAll({ where: { id: { [Op.in]: ids } }, transaction });
|
||||||
|
|
||||||
|
for (const record of business_photos) {
|
||||||
|
await record.update({deletedBy: currentUser.id}, {transaction});
|
||||||
|
await record.destroy({transaction});
|
||||||
|
}
|
||||||
|
return business_photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const business_photos = await db.business_photos.findByPk(id, options);
|
||||||
|
await business_photos.update({ deletedBy: currentUser.id }, { transaction });
|
||||||
|
await business_photos.destroy({ transaction });
|
||||||
|
return business_photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAllAutocomplete(query, limit, offset) {
|
||||||
|
let where = {};
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
|
||||||
'business_photos',
|
|
||||||
'photos',
|
|
||||||
query,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await db.business_photos.findAll({
|
const records = await db.business_photos.findAll({
|
||||||
attributes: [ 'id', 'photos' ],
|
attributes: [ 'id' ],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
orderBy: [['photos', 'ASC']],
|
order: [['createdAt', 'desc']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
label: record.photos,
|
label: record.id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
70
backend/src/db/api/claim_requests.js
Normal file
70
backend/src/db/api/claim_requests.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
const db = require('../models');
|
||||||
|
|
||||||
|
module.exports = class Claim_requestsDBApi {
|
||||||
|
static async create(data, { currentUser, transaction }) {
|
||||||
|
const claim_request = await db.claim_requests.create(
|
||||||
|
{
|
||||||
|
businessId: data.businessId,
|
||||||
|
userId: data.userId,
|
||||||
|
status: data.status || 'PENDING',
|
||||||
|
createdById: currentUser?.id,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
return claim_request;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id, data, { currentUser, transaction }) {
|
||||||
|
const claim_request = await db.claim_requests.findByPk(id, { transaction });
|
||||||
|
if (!claim_request) throw new Error('Claim request not found');
|
||||||
|
|
||||||
|
await claim_request.update(
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
updatedById: currentUser?.id,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
return claim_request;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findBy(where, options = {}) {
|
||||||
|
return await db.claim_requests.findOne({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{ model: db.businesses, as: 'business' },
|
||||||
|
{ model: db.users, as: 'user' },
|
||||||
|
],
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAll(query = {}, { currentUser } = {}) {
|
||||||
|
const { limit, offset, filter } = query;
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
// Support direct query params
|
||||||
|
if (query.userId) where.userId = query.userId;
|
||||||
|
if (query.status) where.status = query.status;
|
||||||
|
if (query.businessId) where.businessId = query.businessId;
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
// Support filter object if provided
|
||||||
|
if (filter.userId) where.userId = filter.userId;
|
||||||
|
if (filter.status) where.status = filter.status;
|
||||||
|
if (filter.businessId) where.businessId = filter.businessId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, count } = await db.claim_requests.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{ model: db.businesses, as: 'business' },
|
||||||
|
{ model: db.users, as: 'user' },
|
||||||
|
],
|
||||||
|
limit: limit ? parseInt(limit) : undefined,
|
||||||
|
offset: offset ? parseInt(offset) : undefined,
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
});
|
||||||
|
return { rows, count };
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,752 +1,103 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
module.exports = class Lead_matchesDBApi {
|
module.exports = class Lead_matchesDBApi {
|
||||||
|
static async findAll(filter, options) {
|
||||||
|
|
||||||
|
|
||||||
static async create(data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const lead_matches = await db.lead_matches.create(
|
|
||||||
{
|
|
||||||
id: data.id || undefined,
|
|
||||||
|
|
||||||
match_score: data.match_score
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
status: data.status
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
sent_at: data.sent_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
viewed_at: data.viewed_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
responded_at: data.responded_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
scheduled_at: data.scheduled_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
completed_at: data.completed_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
declined_at: data.declined_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
created_at_ts: data.created_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
updated_at_ts: data.updated_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: data.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
await lead_matches.setLead( data.lead || null, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await lead_matches.setBusiness( data.business || null, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return lead_matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static async bulkImport(data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
// Prepare data - wrapping individual data transformations in a map() method
|
|
||||||
const lead_matchesData = data.map((item, index) => ({
|
|
||||||
id: item.id || undefined,
|
|
||||||
|
|
||||||
match_score: item.match_score
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
status: item.status
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
sent_at: item.sent_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
viewed_at: item.viewed_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
responded_at: item.responded_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
scheduled_at: item.scheduled_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
completed_at: item.completed_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
declined_at: item.declined_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
created_at_ts: item.created_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
updated_at_ts: item.updated_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: item.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
createdAt: new Date(Date.now() + index * 1000),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Bulk create items
|
|
||||||
const lead_matches = await db.lead_matches.bulkCreate(lead_matchesData, { transaction });
|
|
||||||
|
|
||||||
// For each item created, replace relation files
|
|
||||||
|
|
||||||
|
|
||||||
return lead_matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async update(id, data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
|
|
||||||
const lead_matches = await db.lead_matches.findByPk(id, {}, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updatePayload = {};
|
|
||||||
|
|
||||||
if (data.match_score !== undefined) updatePayload.match_score = data.match_score;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.status !== undefined) updatePayload.status = data.status;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.sent_at !== undefined) updatePayload.sent_at = data.sent_at;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.viewed_at !== undefined) updatePayload.viewed_at = data.viewed_at;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.responded_at !== undefined) updatePayload.responded_at = data.responded_at;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.scheduled_at !== undefined) updatePayload.scheduled_at = data.scheduled_at;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.completed_at !== undefined) updatePayload.completed_at = data.completed_at;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.declined_at !== undefined) updatePayload.declined_at = data.declined_at;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.updated_at_ts !== undefined) updatePayload.updated_at_ts = data.updated_at_ts;
|
|
||||||
|
|
||||||
|
|
||||||
updatePayload.updatedById = currentUser.id;
|
|
||||||
|
|
||||||
await lead_matches.update(updatePayload, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (data.lead !== undefined) {
|
|
||||||
await lead_matches.setLead(
|
|
||||||
|
|
||||||
data.lead,
|
|
||||||
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.business !== undefined) {
|
|
||||||
await lead_matches.setBusiness(
|
|
||||||
|
|
||||||
data.business,
|
|
||||||
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return lead_matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async deleteByIds(ids, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const lead_matches = await db.lead_matches.findAll({
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
[Op.in]: ids,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.sequelize.transaction(async (transaction) => {
|
|
||||||
for (const record of lead_matches) {
|
|
||||||
await record.update(
|
|
||||||
{deletedBy: currentUser.id},
|
|
||||||
{transaction}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const record of lead_matches) {
|
|
||||||
await record.destroy({transaction});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
return lead_matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async remove(id, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const lead_matches = await db.lead_matches.findByPk(id, options);
|
|
||||||
|
|
||||||
await lead_matches.update({
|
|
||||||
deletedBy: currentUser.id
|
|
||||||
}, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await lead_matches.destroy({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
return lead_matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async findBy(where, options) {
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const lead_matches = await db.lead_matches.findOne(
|
|
||||||
{ where },
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!lead_matches) {
|
|
||||||
return lead_matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = lead_matches.get({plain: true});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
output.lead = await lead_matches.getLead({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
output.business = await lead_matches.getBusiness({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async findAll(
|
|
||||||
filter,
|
|
||||||
options
|
|
||||||
) {
|
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let where = {};
|
let where = {};
|
||||||
const currentPage = +filter.page;
|
const currentPage = +filter.page;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
const currentUser = options?.currentUser;
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
// Data Isolation for Fix-It-Local™
|
||||||
|
if (currentUser && currentUser.app_role) {
|
||||||
let include = [
|
const roleName = currentUser.app_role.name;
|
||||||
|
if (roleName === 'Verified Business Owner') {
|
||||||
{
|
// Business owners only see matches for THEIR businesses
|
||||||
model: db.leads,
|
where['$business.owner_userId$'] = currentUser.id;
|
||||||
as: 'lead',
|
} else if (roleName === 'Consumer') {
|
||||||
|
// Consumers only see matches for THEIR leads
|
||||||
where: filter.lead ? {
|
where['$lead.userId$'] = currentUser.id;
|
||||||
[Op.or]: [
|
|
||||||
{ id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } },
|
|
||||||
{
|
|
||||||
keyword: {
|
|
||||||
[Op.or]: filter.lead.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
} : {},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
model: db.businesses,
|
|
||||||
as: 'business',
|
|
||||||
|
|
||||||
where: filter.business ? {
|
|
||||||
[Op.or]: [
|
|
||||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.business.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
} : {},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter) {
|
|
||||||
if (filter.id) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['id']: Utils.uuid(filter.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.match_scoreRange) {
|
|
||||||
const [start, end] = filter.match_scoreRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
match_score: {
|
|
||||||
...where.match_score,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
match_score: {
|
|
||||||
...where.match_score,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.sent_atRange) {
|
|
||||||
const [start, end] = filter.sent_atRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
sent_at: {
|
|
||||||
...where.sent_at,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
sent_at: {
|
|
||||||
...where.sent_at,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.viewed_atRange) {
|
|
||||||
const [start, end] = filter.viewed_atRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
viewed_at: {
|
|
||||||
...where.viewed_at,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
viewed_at: {
|
|
||||||
...where.viewed_at,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.responded_atRange) {
|
|
||||||
const [start, end] = filter.responded_atRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
responded_at: {
|
|
||||||
...where.responded_at,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
responded_at: {
|
|
||||||
...where.responded_at,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.scheduled_atRange) {
|
|
||||||
const [start, end] = filter.scheduled_atRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
scheduled_at: {
|
|
||||||
...where.scheduled_at,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
scheduled_at: {
|
|
||||||
...where.scheduled_at,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.completed_atRange) {
|
|
||||||
const [start, end] = filter.completed_atRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
completed_at: {
|
|
||||||
...where.completed_at,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
completed_at: {
|
|
||||||
...where.completed_at,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.declined_atRange) {
|
|
||||||
const [start, end] = filter.declined_atRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
declined_at: {
|
|
||||||
...where.declined_at,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
declined_at: {
|
|
||||||
...where.declined_at,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.created_at_tsRange) {
|
|
||||||
const [start, end] = filter.created_at_tsRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.updated_at_tsRange) {
|
|
||||||
const [start, end] = filter.updated_at_tsRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
updated_at_ts: {
|
|
||||||
...where.updated_at_ts,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
updated_at_ts: {
|
|
||||||
...where.updated_at_ts,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
active: filter.active === true || filter.active === 'true'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.status) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
status: filter.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let include = [
|
||||||
|
{ model: db.leads, as: 'lead' },
|
||||||
|
{ model: db.businesses, as: 'business' }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||||
|
if (filter.status) where.status = filter.status;
|
||||||
|
}
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
distinct: true,
|
distinct: true,
|
||||||
order: filter.field && filter.sort
|
limit: options?.countOnly ? undefined : (limit ? Number(limit) : undefined),
|
||||||
? [[filter.field, filter.sort]]
|
offset: options?.countOnly ? undefined : (offset ? Number(offset) : undefined),
|
||||||
: [['createdAt', 'desc']],
|
order: [['createdAt', 'desc']],
|
||||||
transaction: options?.transaction,
|
transaction
|
||||||
logging: console.log
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
const { rows, count } = await db.lead_matches.findAndCountAll(queryOptions);
|
||||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
|
||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
return {
|
||||||
const { rows, count } = await db.lead_matches.findAndCountAll(queryOptions);
|
rows: options?.countOnly ? [] : rows,
|
||||||
|
count: count
|
||||||
return {
|
};
|
||||||
rows: options?.countOnly ? [] : rows,
|
|
||||||
count: count
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, ) {
|
static async findBy(where, options) {
|
||||||
let where = {};
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const lead_matches = await db.lead_matches.findOne({
|
||||||
|
|
||||||
|
|
||||||
if (query) {
|
|
||||||
where = {
|
|
||||||
[Op.or]: [
|
|
||||||
{ ['id']: Utils.uuid(query) },
|
|
||||||
Utils.ilike(
|
|
||||||
'lead_matches',
|
|
||||||
'status',
|
|
||||||
query,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const records = await db.lead_matches.findAll({
|
|
||||||
attributes: [ 'id', 'status' ],
|
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
include: [
|
||||||
offset: offset ? Number(offset) : undefined,
|
{ model: db.leads, as: 'lead' },
|
||||||
orderBy: [['status', 'ASC']],
|
{ model: db.businesses, as: 'business' }
|
||||||
|
],
|
||||||
|
transaction
|
||||||
});
|
});
|
||||||
|
return lead_matches ? lead_matches.get({plain: true}) : null;
|
||||||
return records.map((record) => ({
|
|
||||||
id: record.id,
|
|
||||||
label: record.status,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async update(id, data, options) {
|
||||||
};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const record = await db.lead_matches.findByPk(id, {transaction});
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
const updatePayload = { ...data, updatedById: currentUser.id };
|
||||||
|
await record.update(updatePayload, {transaction});
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(data, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const record = await db.lead_matches.create({
|
||||||
|
...data,
|
||||||
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id
|
||||||
|
}, { transaction });
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const record = await db.lead_matches.findByPk(id, options);
|
||||||
|
await record.update({ deletedBy: currentUser.id }, { transaction });
|
||||||
|
await record.destroy({ transaction });
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,12 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
module.exports = class MessagesDBApi {
|
module.exports = class MessagesDBApi {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
static async create(data, options) {
|
static async create(data, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
@ -20,463 +14,115 @@ module.exports = class MessagesDBApi {
|
|||||||
const messages = await db.messages.create(
|
const messages = await db.messages.create(
|
||||||
{
|
{
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
|
body: data.body || null,
|
||||||
body: data.body
|
read_at: data.read_at || null,
|
||||||
||
|
created_at_ts: data.created_at_ts || null,
|
||||||
null
|
importHash: data.importHash || null,
|
||||||
,
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
read_at: data.read_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
created_at_ts: data.created_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: data.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
await messages.setLead( data.lead || null, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await messages.setSender_user( data.sender_user || null, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await messages.setReceiver_user( data.receiver_user || null, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static async bulkImport(data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
// Prepare data - wrapping individual data transformations in a map() method
|
|
||||||
const messagesData = data.map((item, index) => ({
|
|
||||||
id: item.id || undefined,
|
|
||||||
|
|
||||||
body: item.body
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
read_at: item.read_at
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
created_at_ts: item.created_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: item.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
createdAt: new Date(Date.now() + index * 1000),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Bulk create items
|
|
||||||
const messages = await db.messages.bulkCreate(messagesData, { transaction });
|
|
||||||
|
|
||||||
// For each item created, replace relation files
|
|
||||||
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async update(id, data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
|
|
||||||
const messages = await db.messages.findByPk(id, {}, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updatePayload = {};
|
|
||||||
|
|
||||||
if (data.body !== undefined) updatePayload.body = data.body;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.read_at !== undefined) updatePayload.read_at = data.read_at;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
|
||||||
|
|
||||||
|
|
||||||
updatePayload.updatedById = currentUser.id;
|
|
||||||
|
|
||||||
await messages.update(updatePayload, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (data.lead !== undefined) {
|
|
||||||
await messages.setLead(
|
|
||||||
|
|
||||||
data.lead,
|
|
||||||
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.sender_user !== undefined) {
|
|
||||||
await messages.setSender_user(
|
|
||||||
|
|
||||||
data.sender_user,
|
|
||||||
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.receiver_user !== undefined) {
|
|
||||||
await messages.setReceiver_user(
|
|
||||||
|
|
||||||
data.receiver_user,
|
|
||||||
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async deleteByIds(ids, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const messages = await db.messages.findAll({
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
[Op.in]: ids,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.sequelize.transaction(async (transaction) => {
|
|
||||||
for (const record of messages) {
|
|
||||||
await record.update(
|
|
||||||
{deletedBy: currentUser.id},
|
|
||||||
{transaction}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const record of messages) {
|
|
||||||
await record.destroy({transaction});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async remove(id, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const messages = await db.messages.findByPk(id, options);
|
|
||||||
|
|
||||||
await messages.update({
|
|
||||||
deletedBy: currentUser.id
|
|
||||||
}, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await messages.destroy({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async findBy(where, options) {
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const messages = await db.messages.findOne(
|
|
||||||
{ where },
|
|
||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!messages) {
|
await messages.setLead( data.lead || null, { transaction });
|
||||||
return messages;
|
await messages.setSender_user( data.sender_user || currentUser.id, { transaction });
|
||||||
}
|
await messages.setReceiver_user( data.receiver_user || null, { transaction });
|
||||||
|
|
||||||
const output = messages.get({plain: true});
|
return messages;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
output.lead = await messages.getLead({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
output.sender_user = await messages.getSender_user({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
output.receiver_user = await messages.getReceiver_user({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(
|
static async findAll(filter, options) {
|
||||||
filter,
|
|
||||||
options
|
|
||||||
) {
|
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let where = {};
|
let where = {};
|
||||||
const currentPage = +filter.page;
|
const currentPage = +filter.page;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const currentUser = options?.currentUser;
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
// Data Isolation
|
||||||
|
if (currentUser && currentUser.app_role) {
|
||||||
let include = [
|
const roleName = currentUser.app_role.name;
|
||||||
|
if (roleName === 'Verified Business Owner') {
|
||||||
{
|
const businessId = currentUser.businessId;
|
||||||
model: db.leads,
|
if (businessId) {
|
||||||
as: 'lead',
|
where[Op.or] = [
|
||||||
|
{ sender_userId: currentUser.id },
|
||||||
where: filter.lead ? {
|
{ receiver_userId: currentUser.id },
|
||||||
[Op.or]: [
|
{ '$lead.lead_matches_lead.businessId$': businessId }
|
||||||
{ id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } },
|
];
|
||||||
{
|
} else {
|
||||||
keyword: {
|
where[Op.or] = [
|
||||||
[Op.or]: filter.lead.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
{ sender_userId: currentUser.id },
|
||||||
}
|
{ receiver_userId: currentUser.id },
|
||||||
},
|
{ '$lead.lead_matches_lead.business.owner_userId$': currentUser.id }
|
||||||
]
|
];
|
||||||
} : {},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
model: db.users,
|
|
||||||
as: 'sender_user',
|
|
||||||
|
|
||||||
where: filter.sender_user ? {
|
|
||||||
[Op.or]: [
|
|
||||||
{ id: { [Op.in]: filter.sender_user.split('|').map(term => Utils.uuid(term)) } },
|
|
||||||
{
|
|
||||||
firstName: {
|
|
||||||
[Op.or]: filter.sender_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
} : {},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
model: db.users,
|
|
||||||
as: 'receiver_user',
|
|
||||||
|
|
||||||
where: filter.receiver_user ? {
|
|
||||||
[Op.or]: [
|
|
||||||
{ id: { [Op.in]: filter.receiver_user.split('|').map(term => Utils.uuid(term)) } },
|
|
||||||
{
|
|
||||||
firstName: {
|
|
||||||
[Op.or]: filter.receiver_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
} : {},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter) {
|
|
||||||
if (filter.id) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['id']: Utils.uuid(filter.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.body) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
[Op.and]: Utils.ilike(
|
|
||||||
'messages',
|
|
||||||
'body',
|
|
||||||
filter.body,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.read_atRange) {
|
|
||||||
const [start, end] = filter.read_atRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
read_at: {
|
|
||||||
...where.read_at,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
read_at: {
|
|
||||||
...where.read_at,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.created_at_tsRange) {
|
|
||||||
const [start, end] = filter.created_at_tsRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
active: filter.active === true || filter.active === 'true'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
} else if (roleName === 'Consumer') {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ sender_userId: currentUser.id },
|
||||||
|
{ receiver_userId: currentUser.id },
|
||||||
|
{ '$lead.userId$': currentUser.id }
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let include = [
|
||||||
|
{
|
||||||
|
model: db.leads,
|
||||||
|
as: 'lead',
|
||||||
|
required: false,
|
||||||
|
include: [{
|
||||||
|
model: db.lead_matches,
|
||||||
|
as: 'lead_matches_lead',
|
||||||
|
include: [{ model: db.businesses, as: 'business' }]
|
||||||
|
}],
|
||||||
|
where: filter.lead ? {
|
||||||
|
[Op.or]: [
|
||||||
|
{ id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } },
|
||||||
|
{ keyword: { [Op.or]: filter.lead.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||||
|
]
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.users,
|
||||||
|
as: 'sender_user',
|
||||||
|
required: false,
|
||||||
|
where: filter.sender_user ? {
|
||||||
|
[Op.or]: [
|
||||||
|
{ id: { [Op.in]: filter.sender_user.split('|').map(term => Utils.uuid(term)) } },
|
||||||
|
{ firstName: { [Op.or]: filter.sender_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||||
|
]
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.users,
|
||||||
|
as: 'receiver_user',
|
||||||
|
required: false,
|
||||||
|
where: filter.receiver_user ? {
|
||||||
|
[Op.or]: [
|
||||||
|
{ id: { [Op.in]: filter.receiver_user.split('|').map(term => Utils.uuid(term)) } },
|
||||||
|
{ firstName: { [Op.or]: filter.receiver_user.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||||
|
]
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||||
|
if (filter.body) where.body = { [Op.iLike]: `%${filter.body}%` };
|
||||||
|
}
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
distinct: true,
|
distinct: true,
|
||||||
|
subQuery: false, // Fix for "missing FROM-clause entry" when using limit + nested includes
|
||||||
order: filter.field && filter.sort
|
order: filter.field && filter.sort
|
||||||
? [[filter.field, filter.sort]]
|
? [[filter.field, filter.sort]]
|
||||||
: [['createdAt', 'desc']],
|
: [['createdAt', 'desc']],
|
||||||
transaction: options?.transaction,
|
transaction,
|
||||||
logging: console.log
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
if (!options?.countOnly) {
|
||||||
@ -484,43 +130,81 @@ module.exports = class MessagesDBApi {
|
|||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { rows, count } = await db.messages.findAndCountAll(queryOptions);
|
||||||
const { rows, count } = await db.messages.findAndCountAll(queryOptions);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: options?.countOnly ? [] : rows,
|
rows: options?.countOnly ? [] : rows,
|
||||||
count: count
|
count: count
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, ) {
|
static async findBy(where, options) {
|
||||||
let where = {};
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const messages = await db.messages.findOne({ where, transaction });
|
||||||
|
if (!messages) return null;
|
||||||
|
|
||||||
|
const output = messages.get({plain: true});
|
||||||
|
output.lead = await messages.getLead({ transaction });
|
||||||
|
output.sender_user = await messages.getSender_user({ transaction });
|
||||||
|
output.receiver_user = await messages.getReceiver_user({ transaction });
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id, data, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const messages = await db.messages.findByPk(id, {transaction});
|
||||||
|
if (!messages) return null;
|
||||||
|
|
||||||
|
const updatePayload = { ...data, updatedById: currentUser.id };
|
||||||
|
await messages.update(updatePayload, {transaction});
|
||||||
|
|
||||||
|
if (data.lead !== undefined) await messages.setLead(data.lead, { transaction });
|
||||||
|
if (data.sender_user !== undefined) await messages.setSender_user(data.sender_user, { transaction });
|
||||||
|
if (data.receiver_user !== undefined) await messages.setReceiver_user(data.receiver_user, { transaction });
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteByIds(ids, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const messages = await db.messages.findAll({ where: { id: { [Op.in]: ids } }, transaction });
|
||||||
|
|
||||||
|
for (const record of messages) {
|
||||||
|
await record.update({deletedBy: currentUser.id}, {transaction});
|
||||||
|
await record.destroy({transaction});
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const messages = await db.messages.findByPk(id, options);
|
||||||
|
await messages.update({ deletedBy: currentUser.id }, { transaction });
|
||||||
|
await messages.destroy({ transaction });
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAllAutocomplete(query, limit, offset) {
|
||||||
|
let where = {};
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
{ ['body']: { [Op.iLike]: `%${query}%` } },
|
||||||
'messages',
|
|
||||||
'body',
|
|
||||||
query,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await db.messages.findAll({
|
const records = await db.messages.findAll({
|
||||||
attributes: [ 'id', 'body' ],
|
attributes: ['id', 'body'],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
orderBy: [['body', 'ASC']],
|
order: [['body', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
@ -528,7 +212,4 @@ module.exports = class MessagesDBApi {
|
|||||||
label: record.body,
|
label: record.body,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -46,6 +45,9 @@ module.exports = class ReviewsDBApi {
|
|||||||
||
|
||
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
|
response: data.response || null,
|
||||||
|
response_at_ts: data.response ? new Date() : null,
|
||||||
|
|
||||||
created_at_ts: data.created_at_ts
|
created_at_ts: data.created_at_ts
|
||||||
||
|
||
|
||||||
@ -170,6 +172,11 @@ module.exports = class ReviewsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
if (data.moderation_notes !== undefined) updatePayload.moderation_notes = data.moderation_notes;
|
if (data.moderation_notes !== undefined) updatePayload.moderation_notes = data.moderation_notes;
|
||||||
|
|
||||||
|
if (data.response !== undefined) {
|
||||||
|
updatePayload.response = data.response;
|
||||||
|
updatePayload.response_at_ts = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
||||||
@ -345,12 +352,28 @@ module.exports = class ReviewsDBApi {
|
|||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
const currentUser = options?.currentUser;
|
||||||
|
|
||||||
|
// Data Isolation
|
||||||
|
if (currentUser && currentUser.app_role) {
|
||||||
|
const roleName = currentUser.app_role.name;
|
||||||
|
if (roleName === 'Verified Business Owner') {
|
||||||
|
if (currentUser.businessId) {
|
||||||
|
where.businessId = currentUser.businessId;
|
||||||
|
} else {
|
||||||
|
where['$business.owner_userId$'] = currentUser.id;
|
||||||
|
}
|
||||||
|
} else if (roleName === 'Consumer') {
|
||||||
|
where.userId = currentUser.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.businesses,
|
model: db.businesses,
|
||||||
as: 'business',
|
as: 'business',
|
||||||
|
required: false,
|
||||||
where: filter.business ? {
|
where: filter.business ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
|
||||||
@ -360,14 +383,14 @@ module.exports = class ReviewsDBApi {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
} : {},
|
} : undefined,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.users,
|
model: db.users,
|
||||||
as: 'user',
|
as: 'user',
|
||||||
|
required: false,
|
||||||
where: filter.user ? {
|
where: filter.user ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ id: { [Op.in]: filter.user.split('|').map(term => Utils.uuid(term)) } },
|
{ id: { [Op.in]: filter.user.split('|').map(term => Utils.uuid(term)) } },
|
||||||
@ -377,14 +400,14 @@ module.exports = class ReviewsDBApi {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
} : {},
|
} : undefined,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.leads,
|
model: db.leads,
|
||||||
as: 'lead',
|
as: 'lead',
|
||||||
|
required: false,
|
||||||
where: filter.lead ? {
|
where: filter.lead ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } },
|
{ id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } },
|
||||||
@ -394,7 +417,7 @@ module.exports = class ReviewsDBApi {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
} : {},
|
} : undefined,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -575,6 +598,7 @@ module.exports = class ReviewsDBApi {
|
|||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
distinct: true,
|
distinct: true,
|
||||||
|
subQuery: false,
|
||||||
order: filter.field && filter.sort
|
order: filter.field && filter.sort
|
||||||
? [[filter.field, filter.sort]]
|
? [[filter.field, filter.sort]]
|
||||||
: [['createdAt', 'desc']],
|
: [['createdAt', 'desc']],
|
||||||
@ -634,4 +658,3 @@ module.exports = class ReviewsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -307,6 +306,20 @@ module.exports = class Service_pricesDBApi {
|
|||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
const currentUser = options?.currentUser;
|
||||||
|
|
||||||
|
// Data Isolation
|
||||||
|
if (currentUser && currentUser.app_role) {
|
||||||
|
const roleName = currentUser.app_role.name;
|
||||||
|
if (roleName === 'Verified Business Owner') {
|
||||||
|
if (currentUser.businessId) {
|
||||||
|
where.businessId = currentUser.businessId;
|
||||||
|
} else {
|
||||||
|
where['$business.owner_userId$'] = currentUser.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -591,5 +604,4 @@ module.exports = class Service_pricesDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -85,6 +84,7 @@ module.exports = class UsersDBApi {
|
|||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
|
businessId: data.data.businessId || null,
|
||||||
importHash: data.data.importHash || null,
|
importHash: data.data.importHash || null,
|
||||||
createdById: currentUser.id,
|
createdById: currentUser.id,
|
||||||
updatedById: currentUser.id,
|
updatedById: currentUser.id,
|
||||||
@ -298,6 +298,7 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
if (data.provider !== undefined) updatePayload.provider = data.provider;
|
if (data.provider !== undefined) updatePayload.provider = data.provider;
|
||||||
|
|
||||||
|
if (data.businessId !== undefined) updatePayload.businessId = data.businessId;
|
||||||
|
|
||||||
updatePayload.updatedById = currentUser.id;
|
updatePayload.updatedById = currentUser.id;
|
||||||
|
|
||||||
@ -983,5 +984,4 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1,18 +1,12 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
module.exports = class Verification_evidencesDBApi {
|
module.exports = class Verification_evidencesDBApi {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
static async create(data, options) {
|
static async create(data, options) {
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
@ -20,404 +14,73 @@ module.exports = class Verification_evidencesDBApi {
|
|||||||
const verification_evidences = await db.verification_evidences.create(
|
const verification_evidences = await db.verification_evidences.create(
|
||||||
{
|
{
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
|
evidence_type: data.evidence_type || null,
|
||||||
evidence_type: data.evidence_type
|
url: data.url || null,
|
||||||
||
|
created_at_ts: data.created_at_ts || null,
|
||||||
null
|
importHash: data.importHash || null,
|
||||||
,
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
url: data.url
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
created_at_ts: data.created_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: data.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
await verification_evidences.setSubmission( data.submission || null, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await FileDBApi.replaceRelationFiles(
|
|
||||||
{
|
|
||||||
belongsTo: db.verification_evidences.getTableName(),
|
|
||||||
belongsToColumn: 'files',
|
|
||||||
belongsToId: verification_evidences.id,
|
|
||||||
},
|
},
|
||||||
data.files,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
return verification_evidences;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static async bulkImport(data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
// Prepare data - wrapping individual data transformations in a map() method
|
|
||||||
const verification_evidencesData = data.map((item, index) => ({
|
|
||||||
id: item.id || undefined,
|
|
||||||
|
|
||||||
evidence_type: item.evidence_type
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
url: item.url
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
created_at_ts: item.created_at_ts
|
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: item.importHash || null,
|
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
createdAt: new Date(Date.now() + index * 1000),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Bulk create items
|
|
||||||
const verification_evidences = await db.verification_evidences.bulkCreate(verification_evidencesData, { transaction });
|
|
||||||
|
|
||||||
// For each item created, replace relation files
|
|
||||||
|
|
||||||
for (let i = 0; i < verification_evidences.length; i++) {
|
|
||||||
await FileDBApi.replaceRelationFiles(
|
|
||||||
{
|
|
||||||
belongsTo: db.verification_evidences.getTableName(),
|
|
||||||
belongsToColumn: 'files',
|
|
||||||
belongsToId: verification_evidences[i].id,
|
|
||||||
},
|
|
||||||
data[i].files,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return verification_evidences;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async update(id, data, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
|
|
||||||
const verification_evidences = await db.verification_evidences.findByPk(id, {}, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updatePayload = {};
|
|
||||||
|
|
||||||
if (data.evidence_type !== undefined) updatePayload.evidence_type = data.evidence_type;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.url !== undefined) updatePayload.url = data.url;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.created_at_ts !== undefined) updatePayload.created_at_ts = data.created_at_ts;
|
|
||||||
|
|
||||||
|
|
||||||
updatePayload.updatedById = currentUser.id;
|
|
||||||
|
|
||||||
await verification_evidences.update(updatePayload, {transaction});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (data.submission !== undefined) {
|
|
||||||
await verification_evidences.setSubmission(
|
|
||||||
|
|
||||||
data.submission,
|
|
||||||
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await FileDBApi.replaceRelationFiles(
|
|
||||||
{
|
|
||||||
belongsTo: db.verification_evidences.getTableName(),
|
|
||||||
belongsToColumn: 'files',
|
|
||||||
belongsToId: verification_evidences.id,
|
|
||||||
},
|
|
||||||
data.files,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
return verification_evidences;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async deleteByIds(ids, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || { id: null };
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const verification_evidences = await db.verification_evidences.findAll({
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
[Op.in]: ids,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.sequelize.transaction(async (transaction) => {
|
|
||||||
for (const record of verification_evidences) {
|
|
||||||
await record.update(
|
|
||||||
{deletedBy: currentUser.id},
|
|
||||||
{transaction}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const record of verification_evidences) {
|
|
||||||
await record.destroy({transaction});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
return verification_evidences;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async remove(id, options) {
|
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const verification_evidences = await db.verification_evidences.findByPk(id, options);
|
|
||||||
|
|
||||||
await verification_evidences.update({
|
|
||||||
deletedBy: currentUser.id
|
|
||||||
}, {
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
await verification_evidences.destroy({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
return verification_evidences;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async findBy(where, options) {
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
const verification_evidences = await db.verification_evidences.findOne(
|
|
||||||
{ where },
|
|
||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!verification_evidences) {
|
await verification_evidences.setSubmission( data.submission || null, { transaction });
|
||||||
return verification_evidences;
|
await FileDBApi.replaceRelationFiles(
|
||||||
}
|
{
|
||||||
|
belongsTo: db.verification_evidences.getTableName(),
|
||||||
|
belongsToColumn: 'files',
|
||||||
|
belongsToId: verification_evidences.id,
|
||||||
|
},
|
||||||
|
data.files,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
const output = verification_evidences.get({plain: true});
|
return verification_evidences;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
output.submission = await verification_evidences.getSubmission({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
output.files = await verification_evidences.getFiles({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(
|
static async findAll(filter, options) {
|
||||||
filter,
|
|
||||||
options
|
|
||||||
) {
|
|
||||||
const limit = filter.limit || 0;
|
const limit = filter.limit || 0;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let where = {};
|
let where = {};
|
||||||
const currentPage = +filter.page;
|
const currentPage = +filter.page;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const currentUser = options?.currentUser;
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
// Data Isolation
|
||||||
|
if (currentUser && currentUser.app_role) {
|
||||||
let include = [
|
const roleName = currentUser.app_role.name;
|
||||||
|
if (roleName === 'Verified Business Owner') {
|
||||||
{
|
if (currentUser.businessId) {
|
||||||
model: db.verification_submissions,
|
where['$submission.businessId$'] = currentUser.businessId;
|
||||||
as: 'submission',
|
} else {
|
||||||
|
where['$submission.business.owner_userId$'] = currentUser.id;
|
||||||
where: filter.submission ? {
|
|
||||||
[Op.or]: [
|
|
||||||
{ id: { [Op.in]: filter.submission.split('|').map(term => Utils.uuid(term)) } },
|
|
||||||
{
|
|
||||||
notes: {
|
|
||||||
[Op.or]: filter.submission.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
} : {},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{
|
|
||||||
model: db.file,
|
|
||||||
as: 'files',
|
|
||||||
},
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter) {
|
|
||||||
if (filter.id) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['id']: Utils.uuid(filter.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.url) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
[Op.and]: Utils.ilike(
|
|
||||||
'verification_evidences',
|
|
||||||
'url',
|
|
||||||
filter.url,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.created_at_tsRange) {
|
|
||||||
const [start, end] = filter.created_at_tsRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
created_at_ts: {
|
|
||||||
...where.created_at_ts,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
active: filter.active === true || filter.active === 'true'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.evidence_type) {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
evidence_type: filter.evidence_type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.gte]: start,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where = {
|
|
||||||
...where,
|
|
||||||
['createdAt']: {
|
|
||||||
...where.createdAt,
|
|
||||||
[Op.lte]: end,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let include = [
|
||||||
|
{
|
||||||
|
model: db.verification_submissions,
|
||||||
|
as: 'submission',
|
||||||
|
include: [{ model: db.businesses, as: 'business' }],
|
||||||
|
where: filter.submission ? {
|
||||||
|
[Op.or]: [
|
||||||
|
{ id: { [Op.in]: filter.submission.split('|').map(term => Utils.uuid(term)) } },
|
||||||
|
{ notes: { [Op.or]: filter.submission.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } },
|
||||||
|
]
|
||||||
|
} : {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.file,
|
||||||
|
as: 'files',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||||
|
}
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
where,
|
where,
|
||||||
@ -426,8 +89,7 @@ module.exports = class Verification_evidencesDBApi {
|
|||||||
order: filter.field && filter.sort
|
order: filter.field && filter.sort
|
||||||
? [[filter.field, filter.sort]]
|
? [[filter.field, filter.sort]]
|
||||||
: [['createdAt', 'desc']],
|
: [['createdAt', 'desc']],
|
||||||
transaction: options?.transaction,
|
transaction,
|
||||||
logging: console.log
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options?.countOnly) {
|
if (!options?.countOnly) {
|
||||||
@ -435,51 +97,90 @@ module.exports = class Verification_evidencesDBApi {
|
|||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { rows, count } = await db.verification_evidences.findAndCountAll(queryOptions);
|
||||||
const { rows, count } = await db.verification_evidences.findAndCountAll(queryOptions);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: options?.countOnly ? [] : rows,
|
rows: options?.countOnly ? [] : rows,
|
||||||
count: count
|
count: count
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, ) {
|
static async findBy(where, options) {
|
||||||
let where = {};
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const verification_evidences = await db.verification_evidences.findOne({ where, transaction });
|
||||||
|
if (!verification_evidences) return null;
|
||||||
|
|
||||||
|
const output = verification_evidences.get({plain: true});
|
||||||
|
output.submission = await verification_evidences.getSubmission({ transaction });
|
||||||
|
output.files = await verification_evidences.getFiles({ transaction });
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id, data, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const verification_evidences = await db.verification_evidences.findByPk(id, {transaction});
|
||||||
|
|
||||||
|
const updatePayload = { ...data, updatedById: currentUser.id };
|
||||||
|
await verification_evidences.update(updatePayload, {transaction});
|
||||||
|
|
||||||
|
if (data.submission !== undefined) await verification_evidences.setSubmission(data.submission, { transaction });
|
||||||
|
await FileDBApi.replaceRelationFiles(
|
||||||
|
{
|
||||||
|
belongsTo: db.verification_evidences.getTableName(),
|
||||||
|
belongsToColumn: 'files',
|
||||||
|
belongsToId: verification_evidences.id,
|
||||||
|
},
|
||||||
|
data.files,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
return verification_evidences;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteByIds(ids, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || { id: null };
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const verification_evidences = await db.verification_evidences.findAll({ where: { id: { [Op.in]: ids } }, transaction });
|
||||||
|
|
||||||
|
for (const record of verification_evidences) {
|
||||||
|
await record.update({deletedBy: currentUser.id}, {transaction});
|
||||||
|
await record.destroy({transaction});
|
||||||
|
}
|
||||||
|
return verification_evidences;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id, options) {
|
||||||
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const verification_evidences = await db.verification_evidences.findByPk(id, options);
|
||||||
|
await verification_evidences.update({ deletedBy: currentUser.id }, { transaction });
|
||||||
|
await verification_evidences.destroy({ transaction });
|
||||||
|
return verification_evidences;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAllAutocomplete(query, limit, offset) {
|
||||||
|
let where = {};
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
|
||||||
'verification_evidences',
|
|
||||||
'url',
|
|
||||||
query,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await db.verification_evidences.findAll({
|
const records = await db.verification_evidences.findAll({
|
||||||
attributes: [ 'id', 'url' ],
|
attributes: [ 'id' ],
|
||||||
where,
|
where,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
orderBy: [['url', 'ASC']],
|
order: [['createdAt', 'desc']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
label: record.url,
|
label: record.id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -298,6 +297,20 @@ module.exports = class Verification_submissionsDBApi {
|
|||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
|
const currentUser = options?.currentUser;
|
||||||
|
|
||||||
|
// Data Isolation
|
||||||
|
if (currentUser && currentUser.app_role) {
|
||||||
|
const roleName = currentUser.app_role.name;
|
||||||
|
if (roleName === 'Verified Business Owner') {
|
||||||
|
if (currentUser.businessId) {
|
||||||
|
where.businessId = currentUser.businessId;
|
||||||
|
} else {
|
||||||
|
where['$business.owner_userId$'] = currentUser.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -524,5 +537,4 @@ module.exports = class Verification_submissionsDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
production: {
|
production: {
|
||||||
dialect: 'postgres',
|
dialect: 'postgres',
|
||||||
@ -12,11 +10,12 @@ module.exports = {
|
|||||||
seederStorage: 'sequelize',
|
seederStorage: 'sequelize',
|
||||||
},
|
},
|
||||||
development: {
|
development: {
|
||||||
username: 'postgres',
|
|
||||||
dialect: 'postgres',
|
dialect: 'postgres',
|
||||||
password: '',
|
username: process.env.DB_USER || 'postgres',
|
||||||
database: 'db_crafted_network',
|
password: process.env.DB_PASS || '',
|
||||||
|
database: process.env.DB_NAME || 'db_crafted_network',
|
||||||
host: process.env.DB_HOST || 'localhost',
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
logging: console.log,
|
logging: console.log,
|
||||||
seederStorage: 'sequelize',
|
seederStorage: 'sequelize',
|
||||||
},
|
},
|
||||||
@ -30,4 +29,4 @@ module.exports = {
|
|||||||
logging: console.log,
|
logging: console.log,
|
||||||
seederStorage: 'sequelize',
|
seederStorage: 'sequelize',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const [[publicRole]] = await queryInterface.sequelize.query(
|
||||||
|
"SELECT id FROM roles WHERE name = 'Public' LIMIT 1"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!publicRole) return;
|
||||||
|
|
||||||
|
const [permissions] = await queryInterface.sequelize.query(
|
||||||
|
"SELECT id FROM permissions WHERE name IN ('CREATE_SEARCH', 'CREATE_BUSINESSES')"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!permissions.length) return;
|
||||||
|
|
||||||
|
// Avoid duplicate inserts
|
||||||
|
for (const permission of permissions) {
|
||||||
|
const [[existing]] = await queryInterface.sequelize.query(
|
||||||
|
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${publicRole.id}' AND "permissionId" = '${permission.id}'`
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`INSERT INTO "rolesPermissionsPermissions" ("roles_permissionsId", "permissionId", "createdAt", "updatedAt") VALUES ('${publicRole.id}', '${permission.id}', NOW(), NOW())`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
* @param {QueryInterface} queryInterface
|
||||||
|
* @param {Sequelize} Sequelize
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
'businesses',
|
||||||
|
'google_place_id',
|
||||||
|
{
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
'businesses',
|
||||||
|
'is_claimed',
|
||||||
|
{
|
||||||
|
type: Sequelize.DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
'businesses',
|
||||||
|
'rating',
|
||||||
|
{
|
||||||
|
type: Sequelize.DataTypes.DECIMAL(3, 2),
|
||||||
|
defaultValue: 0,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {QueryInterface} queryInterface
|
||||||
|
* @param {Sequelize} Sequelize
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
await queryInterface.removeColumn('businesses', 'google_place_id', { transaction });
|
||||||
|
await queryInterface.removeColumn('businesses', 'is_claimed', { transaction });
|
||||||
|
await queryInterface.removeColumn('businesses', 'rating', { transaction });
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const [[publicRole]] = await queryInterface.sequelize.query(
|
||||||
|
"SELECT id FROM roles WHERE name = 'Public' LIMIT 1"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!publicRole) return;
|
||||||
|
|
||||||
|
const [permissions] = await queryInterface.sequelize.query(
|
||||||
|
"SELECT id FROM permissions WHERE name IN ('READ_BUSINESSES', 'READ_CATEGORIES', 'READ_LOCATIONS', 'READ_REVIEWS')"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!permissions.length) return;
|
||||||
|
|
||||||
|
// Avoid duplicate inserts
|
||||||
|
for (const permission of permissions) {
|
||||||
|
const [[existing]] = await queryInterface.sequelize.query(
|
||||||
|
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${publicRole.id}' AND "permissionId" = '${permission.id}'`
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`INSERT INTO "rolesPermissionsPermissions" ("roles_permissionsId", "permissionId", "createdAt", "updatedAt") VALUES ('${publicRole.id}', '${permission.id}', NOW(), NOW())`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** @type {import('sequelize-cli').Migration} */
|
||||||
|
module.exports = {
|
||||||
|
async up (queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn('reviews', 'response', {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
await queryInterface.addColumn('reviews', 'response_at_ts', {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down (queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeColumn('reviews', 'response');
|
||||||
|
await queryInterface.removeColumn('reviews', 'response_at_ts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
* @param {import("sequelize").QueryInterface} queryInterface
|
||||||
|
* @param {import("sequelize").Sequelize} Sequelize
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn('users', 'businessId', {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
model: 'businesses',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
allowNull: true,
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeColumn('users', 'businessId');
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
* @param {import("sequelize").QueryInterface} queryInterface
|
||||||
|
* @param {import("sequelize").Sequelize} Sequelize
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const roles = await queryInterface.sequelize.query(
|
||||||
|
`SELECT id FROM roles WHERE name = 'Trust & Safety Lead'`,
|
||||||
|
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
if (!roles || roles.length === 0) return;
|
||||||
|
const roleId = roles[0].id;
|
||||||
|
|
||||||
|
const permissionsToGrant = [
|
||||||
|
'READ_VERIFICATION_SUBMISSIONS',
|
||||||
|
'UPDATE_VERIFICATION_SUBMISSIONS',
|
||||||
|
'READ_VERIFICATION_EVIDENCES',
|
||||||
|
'READ_REVIEWS',
|
||||||
|
'UPDATE_REVIEWS',
|
||||||
|
'DELETE_REVIEWS',
|
||||||
|
'READ_BUSINESSES',
|
||||||
|
'READ_USERS'
|
||||||
|
];
|
||||||
|
|
||||||
|
const permissions = await queryInterface.sequelize.query(
|
||||||
|
`SELECT id, name FROM permissions WHERE name IN (${permissionsToGrant.map(p => `'${p}'`).join(',')})`,
|
||||||
|
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
const rolesPermissions = [];
|
||||||
|
for (const p of permissions) {
|
||||||
|
const existing = await queryInterface.sequelize.query(
|
||||||
|
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${roleId}' AND "permissionId" = '${p.id}'`,
|
||||||
|
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
rolesPermissions.push({
|
||||||
|
roles_permissionsId: roleId,
|
||||||
|
permissionId: p.id,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rolesPermissions.length > 0) {
|
||||||
|
await queryInterface.bulkInsert('rolesPermissionsPermissions', rolesPermissions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
// Optionally implement down migration
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.createTable('listing_events', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
businessId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'businesses',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
event_type: {
|
||||||
|
type: Sequelize.ENUM('VIEW', 'CALL_CLICK', 'WEBSITE_CLICK'),
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: Sequelize.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.dropTable('listing_events');
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.createTable('plans', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: Sequelize.DECIMAL(10, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
type: Sequelize.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addColumn('businesses', 'planId', {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'plans',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addColumn('businesses', 'renewal_date', {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed basic plans
|
||||||
|
const now = new Date();
|
||||||
|
const plans = [
|
||||||
|
{
|
||||||
|
id: '00000000-0000-0000-0000-000000000001',
|
||||||
|
name: 'Basic (Free)',
|
||||||
|
price: 0,
|
||||||
|
features: JSON.stringify(['Limited Leads', 'Standard Listing']),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-0000-0000-000000000002',
|
||||||
|
name: 'Professional',
|
||||||
|
price: 49.99,
|
||||||
|
features: JSON.stringify(['Unlimited Leads', 'Priority Support', 'Enhanced Profile']),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-0000-0000-000000000003',
|
||||||
|
name: 'Enterprise',
|
||||||
|
price: 199.99,
|
||||||
|
features: JSON.stringify(['Custom Branding', 'API Access', 'Dedicated Manager']),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('plans', plans);
|
||||||
|
|
||||||
|
// Assign free plan to existing businesses
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`UPDATE businesses SET "planId" = '00000000-0000-0000-0000-000000000001', "renewal_date" = '${new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()).toISOString()}'`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn('businesses', 'planId');
|
||||||
|
await queryInterface.removeColumn('businesses', 'renewal_date');
|
||||||
|
await queryInterface.dropTable('plans');
|
||||||
|
},
|
||||||
|
};
|
||||||
10
backend/src/db/migrations/20260218040000-fix-client-role.js
Normal file
10
backend/src/db/migrations/20260218040000-fix-client-role.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
const [roles] = await queryInterface.sequelize.query("SELECT id FROM roles WHERE name = 'Verified Business Owner' LIMIT 1;");
|
||||||
|
if (roles && roles.length > 0) {
|
||||||
|
const vboRoleId = roles[0].id;
|
||||||
|
await queryInterface.sequelize.query(`UPDATE users SET "app_roleId" = '${vboRoleId}' WHERE email = 'client@hello.com';`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async down(queryInterface) {}
|
||||||
|
};
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
const createdAt = new Date();
|
||||||
|
const updatedAt = new Date();
|
||||||
|
|
||||||
|
const [roles] = await queryInterface.sequelize.query("SELECT id FROM roles WHERE name = 'Verified Business Owner' LIMIT 1;");
|
||||||
|
const [permissions] = await queryInterface.sequelize.query("SELECT id FROM permissions WHERE name = 'CREATE_BUSINESSES' LIMIT 1;");
|
||||||
|
|
||||||
|
if (roles && roles.length > 0 && permissions && permissions.length > 0) {
|
||||||
|
const vboRoleId = roles[0].id;
|
||||||
|
const createPermId = permissions[0].id;
|
||||||
|
|
||||||
|
// Check if permission already exists for this role
|
||||||
|
const [existing] = await queryInterface.sequelize.query(`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${vboRoleId}' AND "permissionId" = '${createPermId}';`);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await queryInterface.bulkInsert("rolesPermissionsPermissions", [
|
||||||
|
{ createdAt, updatedAt, roles_permissionsId: vboRoleId, permissionId: createPermId }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async down(queryInterface) {}
|
||||||
|
};
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
await queryInterface.createTable('claim_requests', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
defaultValue: Sequelize.DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
businessId: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'businesses',
|
||||||
|
},
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.DataTypes.ENUM('PENDING', 'APPROVED', 'REJECTED'),
|
||||||
|
defaultValue: 'PENDING',
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
rejectionReason: {
|
||||||
|
type: Sequelize.DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
key: 'id',
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
updatedAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
deletedAt: { type: Sequelize.DataTypes.DATE },
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
await queryInterface.dropTable('claim_requests', { transaction });
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
const { v4: uuid } = require("uuid");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
const createdAt = new Date();
|
||||||
|
const updatedAt = new Date();
|
||||||
|
|
||||||
|
const permissions = [
|
||||||
|
{ id: uuid(), name: 'CREATE_CLAIM_REQUESTS', createdAt, updatedAt },
|
||||||
|
{ id: uuid(), name: 'READ_CLAIM_REQUESTS', createdAt, updatedAt },
|
||||||
|
{ id: uuid(), name: 'UPDATE_CLAIM_REQUESTS', createdAt, updatedAt },
|
||||||
|
{ id: uuid(), name: 'DELETE_CLAIM_REQUESTS', createdAt, updatedAt },
|
||||||
|
];
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('permissions', permissions);
|
||||||
|
|
||||||
|
const roles = await queryInterface.sequelize.query(
|
||||||
|
`SELECT id, name FROM "roles" WHERE name IN ('Administrator', 'Platform Owner', 'Verified Business Owner')`,
|
||||||
|
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminRole = roles.find(r => r.name === 'Administrator');
|
||||||
|
const ownerRole = roles.find(r => r.name === 'Platform Owner');
|
||||||
|
const vboRole = roles.find(r => r.name === 'Verified Business Owner');
|
||||||
|
|
||||||
|
const rolePerms = [];
|
||||||
|
|
||||||
|
permissions.forEach(p => {
|
||||||
|
if (adminRole) {
|
||||||
|
rolePerms.push({
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
roles_permissionsId: adminRole.id,
|
||||||
|
permissionId: p.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ownerRole) {
|
||||||
|
rolePerms.push({
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
roles_permissionsId: ownerRole.id,
|
||||||
|
permissionId: p.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// VBO can only create and read
|
||||||
|
if (vboRole && (p.name === 'CREATE_CLAIM_REQUESTS' || p.name === 'READ_CLAIM_REQUESTS')) {
|
||||||
|
rolePerms.push({
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
roles_permissionsId: vboRole.id,
|
||||||
|
permissionId: p.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('rolesPermissionsPermissions', rolePerms);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
// No need to implement down for this simple permission addition in a dev environment
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -162,6 +162,33 @@ tenant_key: {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
google_place_id: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
is_claimed: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
rating: {
|
||||||
|
type: DataTypes.DECIMAL(3, 2),
|
||||||
|
defaultValue: 0,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
planId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
renewal_date: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
|
||||||
created_at_ts: {
|
created_at_ts: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
@ -295,8 +322,15 @@ updated_at_ts: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
db.businesses.belongsTo(db.plans, {
|
||||||
|
as: 'plan',
|
||||||
|
foreignKey: 'planId',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.businesses.hasMany(db.listing_events, {
|
||||||
|
as: 'listing_events',
|
||||||
|
foreignKey: 'businessId',
|
||||||
|
});
|
||||||
|
|
||||||
db.businesses.belongsTo(db.users, {
|
db.businesses.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
@ -311,5 +345,3 @@ updated_at_ts: {
|
|||||||
|
|
||||||
return businesses;
|
return businesses;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
58
backend/src/db/models/claim_requests.js
Normal file
58
backend/src/db/models/claim_requests.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
|
||||||
|
module.exports = function(sequelize, DataTypes) {
|
||||||
|
const claim_requests = sequelize.define(
|
||||||
|
'claim_requests',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('PENDING', 'APPROVED', 'REJECTED'),
|
||||||
|
defaultValue: 'PENDING',
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
rejectionReason: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdAt: { type: DataTypes.DATE },
|
||||||
|
updatedAt: { type: DataTypes.DATE },
|
||||||
|
deletedAt: { type: DataTypes.DATE },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
claim_requests.associate = (db) => {
|
||||||
|
db.claim_requests.belongsTo(db.businesses, {
|
||||||
|
as: 'business',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'businessId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.claim_requests.belongsTo(db.users, {
|
||||||
|
as: 'user',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'userId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.claim_requests.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.claim_requests.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return claim_requests;
|
||||||
|
};
|
||||||
47
backend/src/db/models/listing_events.js
Normal file
47
backend/src/db/models/listing_events.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
module.exports = function(sequelize, DataTypes) {
|
||||||
|
const listing_events = sequelize.define(
|
||||||
|
'listing_events',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
businessId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
event_type: {
|
||||||
|
type: DataTypes.ENUM('VIEW', 'CALL_CLICK', 'WEBSITE_CLICK'),
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
listing_events.associate = (db) => {
|
||||||
|
db.listing_events.belongsTo(db.businesses, {
|
||||||
|
as: 'business',
|
||||||
|
foreignKey: 'businessId',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.listing_events.belongsTo(db.users, {
|
||||||
|
as: 'user',
|
||||||
|
foreignKey: 'userId',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return listing_events;
|
||||||
|
};
|
||||||
37
backend/src/db/models/plans.js
Normal file
37
backend/src/db/models/plans.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
module.exports = function(sequelize, DataTypes) {
|
||||||
|
const plans = sequelize.define(
|
||||||
|
'plans',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
plans.associate = (db) => {
|
||||||
|
db.plans.hasMany(db.businesses, {
|
||||||
|
as: 'businesses',
|
||||||
|
foreignKey: 'planId',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return plans;
|
||||||
|
};
|
||||||
@ -67,6 +67,14 @@ moderation_notes: {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
response: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
|
||||||
|
response_at_ts: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
},
|
||||||
|
|
||||||
created_at_ts: {
|
created_at_ts: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
|
|
||||||
@ -167,6 +175,4 @@ updated_at_ts: {
|
|||||||
|
|
||||||
|
|
||||||
return reviews;
|
return reviews;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -104,6 +104,11 @@ provider: {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
businessId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
type: DataTypes.STRING(255),
|
type: DataTypes.STRING(255),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
@ -257,7 +262,13 @@ provider: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
db.users.belongsTo(db.businesses, {
|
||||||
|
as: 'business',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'businessId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
db.users.hasMany(db.file, {
|
db.users.hasMany(db.file, {
|
||||||
as: 'avatar',
|
as: 'avatar',
|
||||||
@ -322,5 +333,4 @@ 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": "Alan Turing",
|
"name": "Plumbing",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -359,7 +359,7 @@ const CategoriesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Grace Hopper",
|
"name": "Electrical",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -419,7 +419,7 @@ const CategoriesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Alan Turing",
|
"name": "Plumbing",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -479,7 +479,7 @@ const CategoriesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Alan Turing",
|
"name": "Plumbing",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -798,7 +798,7 @@ const BusinessesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Alan Turing",
|
"name": "Plumbing",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -956,7 +956,7 @@ const BusinessesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Grace Hopper",
|
"name": "Electrical",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1114,7 +1114,7 @@ const BusinessesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Grace Hopper",
|
"name": "Electrical",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1272,7 +1272,7 @@ const BusinessesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"name": "Alan Turing",
|
"name": "Plumbing",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,88 @@
|
|||||||
|
const { v4: uuid } = require("uuid");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
* @param{import("sequelize").QueryInterface} queryInterface
|
||||||
|
* @return {Promise<void>}
|
||||||
|
*/
|
||||||
|
async up(queryInterface) {
|
||||||
|
const createdAt = new Date();
|
||||||
|
const updatedAt = new Date();
|
||||||
|
|
||||||
|
/** @type {Map<string, string>} */
|
||||||
|
const idMap = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
function getId(key) {
|
||||||
|
if (idMap.has(key)) {
|
||||||
|
return idMap.get(key);
|
||||||
|
}
|
||||||
|
const id = uuid();
|
||||||
|
idMap.set(key, id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since we are updating, we should try to fetch existing IDs if possible,
|
||||||
|
// but in a seeder for this kind of platform it's often better to just recreate or use fixed IDs if they were fixed.
|
||||||
|
// However, the previous seeder used random UUIDs.
|
||||||
|
// To update permissions, I'll need to fetch the roles.
|
||||||
|
|
||||||
|
const roles = await queryInterface.sequelize.query(
|
||||||
|
`SELECT id, name FROM "roles";`,
|
||||||
|
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = await queryInterface.sequelize.query(
|
||||||
|
`SELECT id, name FROM "permissions";`,
|
||||||
|
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRoleId = (name) => roles.find(r => r.name === name)?.id;
|
||||||
|
const getPermId = (name) => permissions.find(p => p.name === name)?.id;
|
||||||
|
|
||||||
|
const vboRoleId = getRoleId("Verified Business Owner");
|
||||||
|
|
||||||
|
if (vboRoleId) {
|
||||||
|
const newPerms = [
|
||||||
|
"CREATE_BUSINESSES",
|
||||||
|
"UPDATE_REVIEWS",
|
||||||
|
"CREATE_BUSINESS_PHOTOS",
|
||||||
|
"CREATE_BUSINESS_CATEGORIES",
|
||||||
|
"CREATE_SERVICE_PRICES",
|
||||||
|
"CREATE_LEAD_MATCHES", // Maybe?
|
||||||
|
"CREATE_MESSAGES",
|
||||||
|
];
|
||||||
|
|
||||||
|
const rolePermsToInsert = [];
|
||||||
|
for (const p of newPerms) {
|
||||||
|
const permId = getPermId(p);
|
||||||
|
if (permId) {
|
||||||
|
// Check if it already exists
|
||||||
|
const existing = await queryInterface.sequelize.query(
|
||||||
|
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${vboRoleId}' AND "permissionId" = '${permId}';`,
|
||||||
|
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
rolePermsToInsert.push({
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
roles_permissionsId: vboRoleId,
|
||||||
|
permissionId: permId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rolePermsToInsert.length > 0) {
|
||||||
|
await queryInterface.bulkInsert("rolesPermissionsPermissions", rolePermsToInsert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
// No easy way to undo this without more logic
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
const { v4: uuid } = require("uuid");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
const createdAt = new Date();
|
||||||
|
const updatedAt = new Date();
|
||||||
|
|
||||||
|
const roles = await queryInterface.sequelize.query(
|
||||||
|
`SELECT id, name FROM "roles";`,
|
||||||
|
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = await queryInterface.sequelize.query(
|
||||||
|
`SELECT id, name FROM "permissions";`,
|
||||||
|
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRoleId = (name) => roles.find(r => r.name === name)?.id;
|
||||||
|
const getPermId = (name) => permissions.find(p => p.name === name)?.id;
|
||||||
|
|
||||||
|
const vboRoleId = getRoleId("Verified Business Owner");
|
||||||
|
const createDisputesPermId = getPermId("CREATE_DISPUTES");
|
||||||
|
|
||||||
|
if (vboRoleId && createDisputesPermId) {
|
||||||
|
const existing = await queryInterface.sequelize.query(
|
||||||
|
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${vboRoleId}' AND "permissionId" = '${createDisputesPermId}';`,
|
||||||
|
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await queryInterface.bulkInsert("rolesPermissionsPermissions", [{
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
roles_permissionsId: vboRoleId,
|
||||||
|
permissionId: createDisputesPermId
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
const createdAt = new Date();
|
||||||
|
const updatedAt = new Date();
|
||||||
|
|
||||||
|
const [roles] = await queryInterface.sequelize.query(
|
||||||
|
`SELECT id FROM roles WHERE name = 'Verified Business Owner';`
|
||||||
|
);
|
||||||
|
|
||||||
|
const [permissions] = await queryInterface.sequelize.query(
|
||||||
|
`SELECT id FROM permissions WHERE name = 'DELETE_BUSINESS_PHOTOS';`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (roles.length > 0 && permissions.length > 0) {
|
||||||
|
const roleId = roles[0].id;
|
||||||
|
const permissionId = permissions[0].id;
|
||||||
|
|
||||||
|
// Check if it already exists to avoid duplicates
|
||||||
|
const [existing] = await queryInterface.sequelize.query(
|
||||||
|
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${roleId}' AND "permissionId" = '${permissionId}';`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await queryInterface.bulkInsert('rolesPermissionsPermissions', [
|
||||||
|
{
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
roles_permissionsId: roleId,
|
||||||
|
permissionId: permissionId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
// Logic to remove the permission if needed
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
const createdAt = new Date();
|
||||||
|
const updatedAt = new Date();
|
||||||
|
|
||||||
|
const [publicRole] = await queryInterface.sequelize.query(
|
||||||
|
"SELECT id FROM roles WHERE name = 'Public' LIMIT 1"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [createLeadsPermission] = await queryInterface.sequelize.query(
|
||||||
|
"SELECT id FROM permissions WHERE name = 'CREATE_LEADS' LIMIT 1"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (publicRole.length && createLeadsPermission.length) {
|
||||||
|
// Check if already exists
|
||||||
|
const [existing] = await queryInterface.sequelize.query(
|
||||||
|
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${publicRole[0].id}' AND "permissionId" = '${createLeadsPermission[0].id}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing.length) {
|
||||||
|
await queryInterface.bulkInsert('rolesPermissionsPermissions', [{
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
roles_permissionsId: publicRole[0].id,
|
||||||
|
permissionId: createLeadsPermission[0].id,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
// Logic to revert if needed
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -19,7 +18,7 @@ const pexelsRoutes = require('./routes/pexels');
|
|||||||
|
|
||||||
const openaiRoutes = require('./routes/openai');
|
const openaiRoutes = require('./routes/openai');
|
||||||
|
|
||||||
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
|
|
||||||
const usersRoutes = require('./routes/users');
|
const usersRoutes = require('./routes/users');
|
||||||
|
|
||||||
@ -47,6 +46,8 @@ const verification_submissionsRoutes = require('./routes/verification_submission
|
|||||||
|
|
||||||
const verification_evidencesRoutes = require('./routes/verification_evidences');
|
const verification_evidencesRoutes = require('./routes/verification_evidences');
|
||||||
|
|
||||||
|
const claim_requestsRoutes = require('./routes/claim_requests');
|
||||||
|
|
||||||
const leadsRoutes = require('./routes/leads');
|
const leadsRoutes = require('./routes/leads');
|
||||||
|
|
||||||
const lead_photosRoutes = require('./routes/lead_photos');
|
const lead_photosRoutes = require('./routes/lead_photos');
|
||||||
@ -78,8 +79,8 @@ const options = {
|
|||||||
openapi: "3.0.0",
|
openapi: "3.0.0",
|
||||||
info: {
|
info: {
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
title: "Crafted Network",
|
title: "Fix-It-Local",
|
||||||
description: "Crafted Network Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
|
description: "Fix-It-Local Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
|
||||||
},
|
},
|
||||||
servers: [
|
servers: [
|
||||||
{
|
{
|
||||||
@ -118,6 +119,15 @@ 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);
|
||||||
@ -133,25 +143,27 @@ app.use('/api/permissions', passport.authenticate('jwt', {session: false}), perm
|
|||||||
|
|
||||||
app.use('/api/refresh_tokens', passport.authenticate('jwt', {session: false}), refresh_tokensRoutes);
|
app.use('/api/refresh_tokens', passport.authenticate('jwt', {session: false}), refresh_tokensRoutes);
|
||||||
|
|
||||||
app.use('/api/categories', passport.authenticate('jwt', {session: false}), categoriesRoutes);
|
app.use('/api/categories', optionalAuth, categoriesRoutes);
|
||||||
|
|
||||||
app.use('/api/locations', passport.authenticate('jwt', {session: false}), locationsRoutes);
|
app.use('/api/locations', optionalAuth, locationsRoutes);
|
||||||
|
|
||||||
app.use('/api/businesses', passport.authenticate('jwt', {session: false}), businessesRoutes);
|
app.use('/api/businesses', optionalAuth, businessesRoutes);
|
||||||
|
|
||||||
app.use('/api/business_photos', passport.authenticate('jwt', {session: false}), business_photosRoutes);
|
app.use('/api/business_photos', optionalAuth, business_photosRoutes);
|
||||||
|
|
||||||
app.use('/api/business_categories', passport.authenticate('jwt', {session: false}), business_categoriesRoutes);
|
app.use('/api/business_categories', optionalAuth, business_categoriesRoutes);
|
||||||
|
|
||||||
app.use('/api/service_prices', passport.authenticate('jwt', {session: false}), service_pricesRoutes);
|
app.use('/api/service_prices', optionalAuth, service_pricesRoutes);
|
||||||
|
|
||||||
app.use('/api/business_badges', passport.authenticate('jwt', {session: false}), business_badgesRoutes);
|
app.use('/api/business_badges', optionalAuth, business_badgesRoutes);
|
||||||
|
|
||||||
app.use('/api/verification_submissions', passport.authenticate('jwt', {session: false}), verification_submissionsRoutes);
|
app.use('/api/verification_submissions', passport.authenticate('jwt', {session: false}), verification_submissionsRoutes);
|
||||||
|
|
||||||
app.use('/api/verification_evidences', passport.authenticate('jwt', {session: false}), verification_evidencesRoutes);
|
app.use('/api/verification_evidences', passport.authenticate('jwt', {session: false}), verification_evidencesRoutes);
|
||||||
|
|
||||||
app.use('/api/leads', passport.authenticate('jwt', {session: false}), leadsRoutes);
|
app.use('/api/claim_requests', passport.authenticate('jwt', {session: false}), claim_requestsRoutes);
|
||||||
|
|
||||||
|
app.use('/api/leads', optionalAuth, leadsRoutes);
|
||||||
|
|
||||||
app.use('/api/lead_photos', passport.authenticate('jwt', {session: false}), lead_photosRoutes);
|
app.use('/api/lead_photos', passport.authenticate('jwt', {session: false}), lead_photosRoutes);
|
||||||
|
|
||||||
@ -161,7 +173,7 @@ app.use('/api/messages', passport.authenticate('jwt', {session: false}), message
|
|||||||
|
|
||||||
app.use('/api/lead_events', passport.authenticate('jwt', {session: false}), lead_eventsRoutes);
|
app.use('/api/lead_events', passport.authenticate('jwt', {session: false}), lead_eventsRoutes);
|
||||||
|
|
||||||
app.use('/api/reviews', passport.authenticate('jwt', {session: false}), reviewsRoutes);
|
app.use('/api/reviews', optionalAuth, reviewsRoutes);
|
||||||
|
|
||||||
app.use('/api/disputes', passport.authenticate('jwt', {session: false}), disputesRoutes);
|
app.use('/api/disputes', passport.authenticate('jwt', {session: false}), disputesRoutes);
|
||||||
|
|
||||||
@ -182,9 +194,12 @@ app.use(
|
|||||||
openaiRoutes,
|
openaiRoutes,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/api/dashboard',
|
||||||
|
dashboardRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/search',
|
'/api/search',
|
||||||
passport.authenticate('jwt', { session: false }),
|
|
||||||
searchRoutes);
|
searchRoutes);
|
||||||
app.use(
|
app.use(
|
||||||
'/api/sql',
|
'/api/sql',
|
||||||
@ -215,4 +230,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,6 +131,23 @@ router.post('/', wrapAsync(async (req, res) => {
|
|||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.post('/google-search', wrapAsync(async (req, res) => {
|
||||||
|
const { query, location } = req.body;
|
||||||
|
const results = await GooglePlacesService.searchPlaces(query, location);
|
||||||
|
res.status(200).send(results);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/google-import', wrapAsync(async (req, res) => {
|
||||||
|
const { googlePlace } = req.body;
|
||||||
|
const business = await GooglePlacesService.importFromGoogle(googlePlace);
|
||||||
|
res.status(200).send(business);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/:id/claim', wrapAsync(async (req, res) => {
|
||||||
|
const business = await BusinessesService.claim(req.params.id, req.currentUser);
|
||||||
|
res.status(200).send(business);
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/budgets/bulk-import:
|
* /api/budgets/bulk-import:
|
||||||
@ -387,7 +404,6 @@ router.get('/count', wrapAsync(async (req, res) => {
|
|||||||
const currentUser = req.currentUser;
|
const currentUser = req.currentUser;
|
||||||
const payload = await BusinessesDBApi.findAll(
|
const payload = await BusinessesDBApi.findAll(
|
||||||
req.query,
|
req.query,
|
||||||
null,
|
|
||||||
{ countOnly: true, currentUser }
|
{ countOnly: true, currentUser }
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -464,15 +480,14 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
* description: Some server error
|
* description: Some server error
|
||||||
*/
|
*/
|
||||||
router.get('/:id', wrapAsync(async (req, res) => {
|
router.get('/:id', wrapAsync(async (req, res) => {
|
||||||
const payload = await BusinessesDBApi.findBy(
|
const payload = await BusinessesService.findBy(
|
||||||
{ id: req.params.id },
|
req.params.id,
|
||||||
|
req.currentUser
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.use('/', require('../helpers').commonErrorHandler);
|
router.use('/', require('../helpers').commonErrorHandler);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
29
backend/src/routes/claim_requests.js
Normal file
29
backend/src/routes/claim_requests.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const Claim_requestsService = require('../services/claim_requests');
|
||||||
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
const { checkPermissions } = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', checkPermissions('READ_CLAIM_REQUESTS'), wrapAsync(async (req, res) => {
|
||||||
|
const payload = await Claim_requestsService.findAll(req.query, req.currentUser);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/:id', checkPermissions('READ_CLAIM_REQUESTS'), wrapAsync(async (req, res) => {
|
||||||
|
const payload = await Claim_requestsService.findBy(req.params.id);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/', wrapAsync(async (req, res) => {
|
||||||
|
const payload = await Claim_requestsService.create(req.body.data, req.currentUser);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put('/:id', checkPermissions('UPDATE_CLAIM_REQUESTS'), wrapAsync(async (req, res) => {
|
||||||
|
const payload = await Claim_requestsService.update(req.params.id, req.body.data, req.currentUser);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
50
backend/src/routes/dashboard.js
Normal file
50
backend/src/routes/dashboard.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const DashboardService = require('../services/dashboard');
|
||||||
|
const { wrapAsync } = require('../helpers');
|
||||||
|
const db = require('../db/models');
|
||||||
|
const passport = require('passport');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
|
||||||
|
const role = req.currentUser.app_role ? req.currentUser.app_role.name : null;
|
||||||
|
|
||||||
|
if (role === 'Verified Business Owner') {
|
||||||
|
const payload = await DashboardService.getBusinessMetrics(req.currentUser);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
} else if (role === 'Administrator' || role === 'Platform Owner') {
|
||||||
|
const payload = await DashboardService.getAdminMetrics();
|
||||||
|
res.status(200).send(payload);
|
||||||
|
} else {
|
||||||
|
// Default or other roles
|
||||||
|
res.status(200).send({
|
||||||
|
totalViews: 0,
|
||||||
|
activeLeads: 0,
|
||||||
|
conversionRate: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/business-metrics', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
|
||||||
|
const payload = await DashboardService.getBusinessMetrics(req.currentUser);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/record-event', (req, res, next) => {
|
||||||
|
passport.authenticate('jwt', { session: false }, (err, user) => {
|
||||||
|
req.currentUser = user || null;
|
||||||
|
next();
|
||||||
|
})(req, res, next);
|
||||||
|
}, wrapAsync(async (req, res) => {
|
||||||
|
const { businessId, event_type, metadata } = req.body;
|
||||||
|
|
||||||
|
const event = await db.listing_events.create({
|
||||||
|
businessId,
|
||||||
|
event_type,
|
||||||
|
userId: req.currentUser ? req.currentUser.id : null,
|
||||||
|
metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(event);
|
||||||
|
}));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -22,6 +22,8 @@ router.use(checkCrudPermissions('search'));
|
|||||||
* properties:
|
* properties:
|
||||||
* searchQuery:
|
* searchQuery:
|
||||||
* type: string
|
* type: string
|
||||||
|
* location:
|
||||||
|
* type: string
|
||||||
* required:
|
* required:
|
||||||
* - searchQuery
|
* - searchQuery
|
||||||
* responses:
|
* responses:
|
||||||
@ -34,14 +36,14 @@ router.use(checkCrudPermissions('search'));
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { searchQuery } = req.body;
|
const { searchQuery, location } = req.body;
|
||||||
|
|
||||||
if (!searchQuery) {
|
if (!searchQuery) {
|
||||||
return res.status(400).json({ error: 'Please enter a search query' });
|
return res.status(400).json({ error: 'Please enter a search query' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const foundMatches = await SearchService.search(searchQuery, req.currentUser );
|
const foundMatches = await SearchService.search(searchQuery, req.currentUser, location );
|
||||||
res.json(foundMatches);
|
res.json(foundMatches);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Internal Server Error', error);
|
console.error('Internal Server Error', error);
|
||||||
|
|||||||
@ -2,20 +2,83 @@ const db = require('../db/models');
|
|||||||
const BusinessesDBApi = require('../db/api/businesses');
|
const BusinessesDBApi = require('../db/api/businesses');
|
||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = class BusinessesService {
|
module.exports = class BusinessesService {
|
||||||
|
static _sanitize(data, currentUser) {
|
||||||
|
const numericFields = ['lat', 'lng', 'reliability_score', 'response_time_median_minutes', 'rating'];
|
||||||
|
numericFields.forEach(field => {
|
||||||
|
if (data[field] === '') {
|
||||||
|
data[field] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide internal fields from client forms
|
||||||
|
if (currentUser?.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const internalFields = [
|
||||||
|
'tenant_key',
|
||||||
|
'owner_userId',
|
||||||
|
'owner_user',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'created_at_ts',
|
||||||
|
'updated_at_ts',
|
||||||
|
'reliability_score',
|
||||||
|
'reliability_breakdown_json',
|
||||||
|
'hours_json',
|
||||||
|
'is_claimed',
|
||||||
|
'is_active'
|
||||||
|
];
|
||||||
|
internalFields.forEach(field => {
|
||||||
|
delete data[field];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findBy(id, currentUser) {
|
||||||
|
const business = await BusinessesDBApi.findBy({ id });
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
throw new ValidationError('businessesNotFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser?.app_role?.name === 'Verified Business Owner') {
|
||||||
|
// Allow viewing if owner, or if no owner (public search results might call this)
|
||||||
|
// But the requirement says "only edit businesses where ownerUserId == currentUser.id"
|
||||||
|
// findBy is often used for view/edit.
|
||||||
|
}
|
||||||
|
|
||||||
|
return business;
|
||||||
|
}
|
||||||
|
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await BusinessesDBApi.create(
|
data = this._sanitize(data, currentUser);
|
||||||
|
|
||||||
|
// For VBOs, force the owner to be the current user
|
||||||
|
if (currentUser?.app_role?.name === 'Verified Business Owner') {
|
||||||
|
data.owner_userId = currentUser.id;
|
||||||
|
data.is_active = true; // Ensure new business owner listings are active
|
||||||
|
|
||||||
|
// Auto-generate internal fields if missing
|
||||||
|
if (!data.slug && data.name) {
|
||||||
|
data.slug = data.name.toLowerCase().replace(/ /g, '-').replace(/[^\w-]+/g, '') + '-' + uuidv4().substring(0, 4);
|
||||||
|
}
|
||||||
|
if (!data.tenant_key) {
|
||||||
|
data.tenant_key = 'TENANT-' + uuidv4().substring(0, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const business = await BusinessesDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -23,13 +86,62 @@ module.exports = class BusinessesService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Link business to user if they don't have one set yet
|
||||||
|
if (currentUser?.app_role?.name === 'Verified Business Owner' && !currentUser.businessId) {
|
||||||
|
await db.users.update({ businessId: business.id }, {
|
||||||
|
where: { id: currentUser.id },
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
return business;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static async claim(id, currentUser) {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const business = await db.businesses.findByPk(id, { transaction });
|
||||||
|
if (!business) {
|
||||||
|
throw new ValidationError('businessNotFound');
|
||||||
|
}
|
||||||
|
if (business.owner_userId) {
|
||||||
|
throw new ValidationError('businessAlreadyClaimed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for pending claim
|
||||||
|
const pendingRequest = await db.claim_requests.findOne({
|
||||||
|
where: {
|
||||||
|
businessId: id,
|
||||||
|
userId: currentUser.id,
|
||||||
|
status: 'PENDING'
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (pendingRequest) {
|
||||||
|
throw new ValidationError('claimRequestPending');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Claim Request
|
||||||
|
const claim_request = await db.claim_requests.create({
|
||||||
|
businessId: id,
|
||||||
|
userId: currentUser.id,
|
||||||
|
status: 'PENDING',
|
||||||
|
createdById: currentUser.id
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
return claim_request;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
@ -38,7 +150,7 @@ module.exports = class BusinessesService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
@ -68,17 +180,33 @@ module.exports = class BusinessesService {
|
|||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
let businesses = await BusinessesDBApi.findBy(
|
data = this._sanitize(data, currentUser);
|
||||||
|
|
||||||
|
let business = await BusinessesDBApi.findBy(
|
||||||
{id},
|
{id},
|
||||||
{transaction},
|
{transaction},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!businesses) {
|
if (!business) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
'businessesNotFound',
|
'businessesNotFound',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser?.app_role?.name === 'Verified Business Owner') {
|
||||||
|
if (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
// Prevent transferring ownership or changing internal fields
|
||||||
|
delete data.owner_user;
|
||||||
|
delete data.owner_userId;
|
||||||
|
delete data.slug;
|
||||||
|
delete data.tenant_key;
|
||||||
|
delete data.is_active;
|
||||||
|
delete data.is_claimed;
|
||||||
|
}
|
||||||
|
|
||||||
const updatedBusinesses = await BusinessesDBApi.update(
|
const updatedBusinesses = await BusinessesDBApi.update(
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
@ -101,6 +229,23 @@ module.exports = class BusinessesService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser?.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const records = await db.businesses.findAll({
|
||||||
|
where: {
|
||||||
|
id: { [db.Sequelize.Op.in]: ids },
|
||||||
|
[db.Sequelize.Op.and]: [
|
||||||
|
{ owner_userId: { [db.Sequelize.Op.ne]: currentUser.id } },
|
||||||
|
{ id: { [db.Sequelize.Op.ne]: currentUser.businessId || null } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (records.length > 0) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await BusinessesDBApi.deleteByIds(ids, {
|
await BusinessesDBApi.deleteByIds(ids, {
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
@ -117,6 +262,13 @@ module.exports = class BusinessesService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let business = await db.businesses.findByPk(id, { transaction });
|
||||||
|
if (!business) throw new ValidationError('businessesNotFound');
|
||||||
|
|
||||||
|
if (currentUser?.app_role?.name === 'Verified Business Owner' && business.owner_userId !== currentUser.id && business.id !== currentUser.businessId) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
await BusinessesDBApi.remove(
|
await BusinessesDBApi.remove(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
@ -133,6 +285,4 @@ module.exports = class BusinessesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
83
backend/src/services/claim_requests.js
Normal file
83
backend/src/services/claim_requests.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
|
||||||
|
const db = require('../db/models');
|
||||||
|
const Claim_requestsDBApi = require('../db/api/claim_requests');
|
||||||
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
|
|
||||||
|
module.exports = class Claim_requestsService {
|
||||||
|
static async create(data, currentUser) {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const business = await db.businesses.findByPk(data.businessId, { transaction });
|
||||||
|
if (!business) throw new ValidationError('businessNotFound');
|
||||||
|
if (business.owner_userId) throw new ValidationError('businessAlreadyOwned');
|
||||||
|
|
||||||
|
const existingRequest = await db.claim_requests.findOne({
|
||||||
|
where: {
|
||||||
|
businessId: data.businessId,
|
||||||
|
userId: currentUser.id,
|
||||||
|
status: 'PENDING'
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (existingRequest) throw new ValidationError('claimRequestPending');
|
||||||
|
|
||||||
|
const claim_request = await Claim_requestsDBApi.create(
|
||||||
|
{
|
||||||
|
businessId: data.businessId,
|
||||||
|
userId: currentUser.id,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
{ currentUser, transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
return claim_request;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id, data, currentUser) {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const claim_request = await Claim_requestsDBApi.findBy({ id }, { transaction });
|
||||||
|
if (!claim_request) throw new ValidationError('claimRequestNotFound');
|
||||||
|
|
||||||
|
// Only admin can approve/reject
|
||||||
|
if (currentUser.app_role?.name !== 'Administrator') {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRequest = await Claim_requestsDBApi.update(id, data, { currentUser, transaction });
|
||||||
|
|
||||||
|
// If approved, update business ownership
|
||||||
|
if (data.status === 'APPROVED') {
|
||||||
|
await db.businesses.update(
|
||||||
|
{ owner_userId: claim_request.userId, is_claimed: true },
|
||||||
|
{ where: { id: claim_request.businessId }, transaction }
|
||||||
|
);
|
||||||
|
// Also link to user record
|
||||||
|
await db.users.update(
|
||||||
|
{ businessId: claim_request.businessId },
|
||||||
|
{ where: { id: claim_request.userId }, transaction }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
return updatedRequest;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findBy(id) {
|
||||||
|
return await Claim_requestsDBApi.findBy({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAll(query, currentUser) {
|
||||||
|
return await Claim_requestsDBApi.findAll(query, { currentUser });
|
||||||
|
}
|
||||||
|
};
|
||||||
228
backend/src/services/dashboard.js
Normal file
228
backend/src/services/dashboard.js
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
const { Op } = require('sequelize');
|
||||||
|
const db = require('../db/models');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
|
module.exports = class DashboardService {
|
||||||
|
static async getBusinessMetrics(currentUser) {
|
||||||
|
// 1. Get businesses owned by current user
|
||||||
|
const businesses = await db.businesses.findAll({
|
||||||
|
where: { owner_userId: currentUser.id },
|
||||||
|
attributes: ['id', 'name', 'planId', 'renewal_date', 'reliability_score', 'description', 'phone', 'website', 'address', 'hours_json', 'is_active'],
|
||||||
|
include: [{ model: db.plans, as: 'plan' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!businesses.length) {
|
||||||
|
return {
|
||||||
|
no_business: true,
|
||||||
|
totalViews: 0,
|
||||||
|
activeLeads: 0,
|
||||||
|
conversionRate: 0,
|
||||||
|
verificationStatus: 'N/A',
|
||||||
|
accountStanding: 'N/A'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessIds = businesses.map(b => b.id);
|
||||||
|
const last24h = moment().subtract(24, 'hours').toDate();
|
||||||
|
const last30d = moment().subtract(30, 'days').toDate();
|
||||||
|
const last7d = moment().subtract(7, 'days').toDate();
|
||||||
|
|
||||||
|
// --- Action Queue ---
|
||||||
|
// New leads last 24h
|
||||||
|
const newLeads24h = await db.lead_matches.count({
|
||||||
|
where: {
|
||||||
|
businessId: { [Op.in]: businessIds },
|
||||||
|
createdAt: { [Op.gte]: last24h }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leads needing response (status NEW in lead_matches OR unread messages)
|
||||||
|
const leadsNeedingResponse = await db.lead_matches.count({
|
||||||
|
where: {
|
||||||
|
businessId: { [Op.in]: businessIds },
|
||||||
|
[Op.or]: [
|
||||||
|
{ status: 'SENT' }, // SENT means it's new for the business
|
||||||
|
{ leadId: { [Op.in]: db.sequelize.literal(`(SELECT "leadId" FROM messages WHERE "receiver_userId" = '${currentUser.id}' AND "read_at" IS NULL)`) } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const verificationPending = await db.verification_submissions.count({
|
||||||
|
where: {
|
||||||
|
businessId: { [Op.in]: businessIds },
|
||||||
|
status: 'PENDING'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Lead Pipeline Snapshot ---
|
||||||
|
const pipelineStats = await db.lead_matches.findAll({
|
||||||
|
where: { businessId: { [Op.in]: businessIds } },
|
||||||
|
attributes: [
|
||||||
|
'status',
|
||||||
|
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']
|
||||||
|
],
|
||||||
|
group: ['status']
|
||||||
|
});
|
||||||
|
|
||||||
|
const pipeline = pipelineStats.reduce((acc, curr) => {
|
||||||
|
acc[curr.status] = parseInt(curr.get('count'));
|
||||||
|
return acc;
|
||||||
|
}, { SENT: 0, VIEWED: 0, RESPONDED: 0, SCHEDULED: 0, COMPLETED: 0, DECLINED: 0 });
|
||||||
|
|
||||||
|
const won30d = await db.lead_matches.count({
|
||||||
|
where: {
|
||||||
|
businessId: { [Op.in]: businessIds },
|
||||||
|
status: 'COMPLETED',
|
||||||
|
updatedAt: { [Op.gte]: last30d }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const lost30d = await db.lead_matches.count({
|
||||||
|
where: {
|
||||||
|
businessId: { [Op.in]: businessIds },
|
||||||
|
status: 'DECLINED',
|
||||||
|
updatedAt: { [Op.gte]: last30d }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const winRate30d = (won30d + lost30d) > 0 ? Math.round((won30d / (won30d + lost30d)) * 100) : 0;
|
||||||
|
|
||||||
|
// --- Recent Messages ---
|
||||||
|
const recentMessages = await db.messages.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ sender_userId: currentUser.id },
|
||||||
|
{ receiver_userId: currentUser.id }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit: 5,
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
include: [
|
||||||
|
{ model: db.leads, as: 'lead' },
|
||||||
|
{ model: db.users, as: 'sender_user', attributes: ['firstName', 'lastName'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Performance ---
|
||||||
|
const getEventCount = async (type, since) => {
|
||||||
|
return await db.listing_events.count({
|
||||||
|
where: {
|
||||||
|
businessId: { [Op.in]: businessIds },
|
||||||
|
event_type: type,
|
||||||
|
createdAt: { [Op.gte]: since }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const views7d = await getEventCount('VIEW', last7d);
|
||||||
|
const views30d = await getEventCount('VIEW', last30d);
|
||||||
|
const calls7d = await getEventCount('CALL_CLICK', last7d);
|
||||||
|
const calls30d = await getEventCount('CALL_CLICK', last30d);
|
||||||
|
const website7d = await getEventCount('WEBSITE_CLICK', last7d);
|
||||||
|
const website30d = await getEventCount('WEBSITE_CLICK', last30d);
|
||||||
|
|
||||||
|
const totalClicks30d = calls30d + website30d;
|
||||||
|
const viewConversionRate = views30d > 0 ? Math.round((totalClicks30d / views30d) * 100) : 0;
|
||||||
|
|
||||||
|
// --- Health Score ---
|
||||||
|
const firstBusiness = businesses[0];
|
||||||
|
let healthScore = 0;
|
||||||
|
const missingFields = [];
|
||||||
|
const fieldsToCheck = [
|
||||||
|
{ name: 'name', weight: 10, label: 'Name' },
|
||||||
|
{ name: 'description', weight: 20, label: 'Description' },
|
||||||
|
{ name: 'phone', weight: 10, label: 'Phone' },
|
||||||
|
{ name: 'website', weight: 10, label: 'Website' },
|
||||||
|
{ name: 'address', weight: 10, label: 'Address' },
|
||||||
|
{ name: 'hours_json', weight: 10, label: 'Business Hours' },
|
||||||
|
];
|
||||||
|
|
||||||
|
fieldsToCheck.forEach(f => {
|
||||||
|
if (firstBusiness[f.name] && firstBusiness[f.name] !== '') {
|
||||||
|
healthScore += f.weight;
|
||||||
|
} else {
|
||||||
|
missingFields.push(f.label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const photoCount = await db.business_photos.count({ where: { businessId: firstBusiness.id } });
|
||||||
|
if (photoCount > 0) healthScore += 15; else missingFields.push('Photos');
|
||||||
|
|
||||||
|
const categoryCount = await db.business_categories.count({ where: { businessId: firstBusiness.id } });
|
||||||
|
if (categoryCount > 0) healthScore += 10; else missingFields.push('Categories');
|
||||||
|
|
||||||
|
const priceCount = await db.service_prices.count({ where: { businessId: firstBusiness.id } });
|
||||||
|
if (priceCount > 0) healthScore += 5; else missingFields.push('Service Prices');
|
||||||
|
|
||||||
|
// --- Verification & Standing ---
|
||||||
|
const lastSubmission = await db.verification_submissions.findOne({
|
||||||
|
where: { businessId: { [Op.in]: businessIds } },
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
let verificationStatus = 'Not Started';
|
||||||
|
if (lastSubmission) {
|
||||||
|
verificationStatus = lastSubmission.status.charAt(0).toUpperCase() + lastSubmission.status.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let accountStanding = 'Good';
|
||||||
|
if (firstBusiness.reliability_score < 70) accountStanding = 'Reviewing';
|
||||||
|
if (firstBusiness.reliability_score < 40) accountStanding = 'At Risk';
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalViews: views30d,
|
||||||
|
activeLeads: newLeads24h,
|
||||||
|
conversionRate: winRate30d,
|
||||||
|
viewConversionRate,
|
||||||
|
verificationStatus,
|
||||||
|
verificationSubtext: lastSubmission?.status === 'APPROVED' ? 'Identity & Business verified' : 'Complete verification to build trust',
|
||||||
|
accountStanding,
|
||||||
|
accountStandingSubtext: accountStanding === 'Good' ? 'Perfect track record' : 'Contact support for details',
|
||||||
|
businesses,
|
||||||
|
action_queue: {
|
||||||
|
newLeads24h,
|
||||||
|
leadsNeedingResponse,
|
||||||
|
verificationPending,
|
||||||
|
missingFields
|
||||||
|
},
|
||||||
|
pipeline: {
|
||||||
|
NEW: pipeline.SENT,
|
||||||
|
CONTACTED: pipeline.VIEWED + pipeline.RESPONDED,
|
||||||
|
SCHEDULED: pipeline.SCHEDULED,
|
||||||
|
WON: pipeline.COMPLETED,
|
||||||
|
LOST: pipeline.DECLINED,
|
||||||
|
winRate30d
|
||||||
|
},
|
||||||
|
recentMessages,
|
||||||
|
performance: {
|
||||||
|
views7d,
|
||||||
|
views30d,
|
||||||
|
calls7d,
|
||||||
|
calls30d,
|
||||||
|
website7d,
|
||||||
|
website30d,
|
||||||
|
conversionRate: winRate30d
|
||||||
|
},
|
||||||
|
healthScore: Math.min(healthScore, 100)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getAdminMetrics() {
|
||||||
|
const totalUsers = await db.users.count();
|
||||||
|
const totalBusinesses = await db.businesses.count();
|
||||||
|
|
||||||
|
// Revenue as sum of prices of plans currently active on businesses
|
||||||
|
const businessesWithPlans = await db.businesses.findAll({
|
||||||
|
where: { planId: { [Op.ne]: null } },
|
||||||
|
include: [{ model: db.plans, as: 'plan' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalRevenue = businessesWithPlans.reduce((acc, curr) => {
|
||||||
|
return acc + (curr.plan ? parseFloat(curr.plan.price) : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers,
|
||||||
|
totalBusinesses,
|
||||||
|
totalRevenue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
227
backend/src/services/googlePlaces.js
Normal file
227
backend/src/services/googlePlaces.js
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const config = require('../config');
|
||||||
|
const db = require('../db/models');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
class GooglePlacesService {
|
||||||
|
constructor() {
|
||||||
|
this.apiKey = config.google.placesApiKey;
|
||||||
|
this.baseUrl = 'https://maps.googleapis.com/maps/api/place';
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchPlaces(query, location) {
|
||||||
|
if (!this.apiKey) {
|
||||||
|
console.warn('Google Places API key is missing');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
query,
|
||||||
|
key: this.apiKey,
|
||||||
|
};
|
||||||
|
if (location) {
|
||||||
|
params.location = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const response = await axios.get(`${this.baseUrl}/textsearch/json`, { params });
|
||||||
|
|
||||||
|
|
||||||
|
return response.data.results || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching Google Places:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlaceDetails(placeId) {
|
||||||
|
if (!this.apiKey) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.baseUrl}/details/json`, {
|
||||||
|
params: {
|
||||||
|
place_id: placeId,
|
||||||
|
fields: 'name,formatted_address,address_components,formatted_phone_number,website,opening_hours,geometry,rating,types,photos,editorial_summary',
|
||||||
|
key: this.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting Google Place details:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async importFromGoogle(googlePlace) {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
// Check if business already exists by google_place_id
|
||||||
|
let business = await db.businesses.findOne({
|
||||||
|
where: { google_place_id: googlePlace.place_id },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (business) {
|
||||||
|
// Even if it exists, we might want to update the description or hours if missing
|
||||||
|
if (!business.description || !business.hours_json) {
|
||||||
|
const details = await this.getPlaceDetails(googlePlace.place_id);
|
||||||
|
if (details) {
|
||||||
|
let updated = false;
|
||||||
|
if (!business.description && details.editorial_summary) {
|
||||||
|
business.description = details.editorial_summary.overview;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (!business.hours_json && details.opening_hours) {
|
||||||
|
business.hours_json = JSON.stringify(details.opening_hours);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
await business.save({ transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await transaction.commit();
|
||||||
|
return business;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a new business, let's fetch full details to get the description
|
||||||
|
const details = await this.getPlaceDetails(googlePlace.place_id);
|
||||||
|
const placeData = details || googlePlace;
|
||||||
|
|
||||||
|
// Try to parse city/state/zip from address if available
|
||||||
|
let city = null;
|
||||||
|
let state = null;
|
||||||
|
let zip = null;
|
||||||
|
|
||||||
|
const formattedAddress = placeData.formatted_address || googlePlace.formatted_address || googlePlace.vicinity;
|
||||||
|
if (formattedAddress) {
|
||||||
|
const parts = formattedAddress.split(',');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
// Typically "City, State Zip, Country" or "City, State, Country"
|
||||||
|
city = parts[parts.length - 3].trim();
|
||||||
|
const stateZip = parts[parts.length - 2].trim().split(' ');
|
||||||
|
if (stateZip.length >= 1) state = stateZip[0];
|
||||||
|
if (stateZip.length >= 2) zip = stateZip[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare business data
|
||||||
|
const businessData = {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: placeData.name,
|
||||||
|
slug: placeData.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||||
|
address: formattedAddress,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
zip,
|
||||||
|
lat: placeData.geometry?.location?.lat,
|
||||||
|
lng: placeData.geometry?.location?.lng,
|
||||||
|
google_place_id: googlePlace.place_id,
|
||||||
|
rating: placeData.rating || 0,
|
||||||
|
is_active: true,
|
||||||
|
is_claimed: false,
|
||||||
|
hours_json: placeData.opening_hours ? JSON.stringify(placeData.opening_hours) : null,
|
||||||
|
description: placeData.editorial_summary?.overview || null,
|
||||||
|
phone: placeData.formatted_phone_number || null,
|
||||||
|
website: placeData.website || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
business = await db.businesses.create(businessData, { transaction });
|
||||||
|
|
||||||
|
// Handle categories/types
|
||||||
|
const types = placeData.types || googlePlace.types;
|
||||||
|
if (types) {
|
||||||
|
for (const type of types) {
|
||||||
|
// Find or create category
|
||||||
|
let category = await db.categories.findOne({
|
||||||
|
where: { name: { [db.Sequelize.Op.iLike]: type.replace(/_/g, ' ') } },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
// Include beauty types AND common service types found on landing page
|
||||||
|
const allowedTypes = [
|
||||||
|
'beauty_salon', 'hair_care', 'spa', 'health', 'cosmetics', 'beauty_product', 'hair_salon', 'massage', 'nail_salon', 'skin_care',
|
||||||
|
'plumber', 'electrician', 'hvac_contractor', 'painter', 'home_improvement_contractor', 'general_contractor', 'cleaning_service', 'locksmith', 'roofing_contractor'
|
||||||
|
];
|
||||||
|
if (allowedTypes.includes(type)) {
|
||||||
|
category = await db.categories.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
name: type.replace(/_/g, ' ').charAt(0).toUpperCase() + type.replace(/_/g, ' ').slice(1),
|
||||||
|
slug: type.replace(/_/g, '-'),
|
||||||
|
is_active: true,
|
||||||
|
}, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
await db.business_categories.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
businessId: business.id,
|
||||||
|
categoryId: category.id,
|
||||||
|
}, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle photos
|
||||||
|
const photos = placeData.photos || googlePlace.photos;
|
||||||
|
if (photos && photos.length > 0) {
|
||||||
|
const photo = photos[0];
|
||||||
|
const photoReference = photo.photo_reference;
|
||||||
|
const photoUrl = `${this.baseUrl}/photo?maxwidth=800&photoreference=${photoReference}&key=${this.apiKey}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageResponse = await axios({
|
||||||
|
method: 'get',
|
||||||
|
url: photoUrl,
|
||||||
|
responseType: 'arraybuffer'
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `${uuidv4()}.jpg`;
|
||||||
|
const filePath = path.join(config.uploadDir, 'business_photos', 'photos', filename);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, imageResponse.data);
|
||||||
|
|
||||||
|
const businessPhoto = await db.business_photos.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
businessId: business.id,
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await db.file.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
name: filename,
|
||||||
|
sizeInBytes: imageResponse.data.length,
|
||||||
|
publicUrl: `business_photos/photos/${filename}`,
|
||||||
|
privateUrl: `business_photos/photos/${filename}`,
|
||||||
|
belongsTo: db.business_photos.getTableName(),
|
||||||
|
belongsToId: businessPhoto.id,
|
||||||
|
belongsToColumn: 'photos',
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
} catch (photoError) {
|
||||||
|
console.error('Error importing photo from Google:', photoError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
return business;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
console.error('Error importing from Google:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new GooglePlacesService();
|
||||||
@ -2,15 +2,12 @@ const db = require('../db/models');
|
|||||||
const Lead_matchesDBApi = require('../db/api/lead_matches');
|
const Lead_matchesDBApi = require('../db/api/lead_matches');
|
||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = class Lead_matchesService {
|
module.exports = class Lead_matchesService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -68,17 +65,25 @@ module.exports = class Lead_matchesService {
|
|||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
let lead_matches = await Lead_matchesDBApi.findBy(
|
let lead_match = await Lead_matchesDBApi.findBy(
|
||||||
{id},
|
{id},
|
||||||
{transaction},
|
{transaction},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!lead_matches) {
|
if (!lead_match) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
'lead_matchesNotFound',
|
'lead_matchesNotFound',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const business = await db.businesses.findByPk(lead_match.businessId, { transaction });
|
||||||
|
if (business && business.owner_userId !== currentUser.id) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedLead_matches = await Lead_matchesDBApi.update(
|
const updatedLead_matches = await Lead_matchesDBApi.update(
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
@ -133,6 +138,4 @@ module.exports = class Lead_matchesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -2,20 +2,17 @@ const db = require('../db/models');
|
|||||||
const LeadsDBApi = require('../db/api/leads');
|
const LeadsDBApi = require('../db/api/leads');
|
||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = class LeadsService {
|
module.exports = class LeadsService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await LeadsDBApi.create(
|
const lead = await LeadsDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -23,7 +20,19 @@ module.exports = class LeadsService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If businessId is provided, create a LeadMatch immediately
|
||||||
|
if (data.businessId) {
|
||||||
|
await db.lead_matches.create({
|
||||||
|
leadId: lead.id,
|
||||||
|
businessId: data.businessId,
|
||||||
|
match_score: 100, // Direct request is 100% match
|
||||||
|
status: 'SENT',
|
||||||
|
sent_at: new Date()
|
||||||
|
}, { transaction });
|
||||||
|
}
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
return lead;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
@ -32,32 +41,24 @@ module.exports = class LeadsService {
|
|||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await processFile(req, res);
|
await processFile(req, res);
|
||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (data) => results.push(data))
|
.on('data', (data) => results.push(data))
|
||||||
.on('end', async () => {
|
.on('end', async () => { resolve(); })
|
||||||
console.log('CSV results', results);
|
|
||||||
resolve();
|
|
||||||
})
|
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
})
|
||||||
|
|
||||||
await LeadsDBApi.bulkImport(results, {
|
await LeadsDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
validate: true,
|
validate: true,
|
||||||
currentUser: req.currentUser
|
currentUser: req.currentUser
|
||||||
});
|
});
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
@ -68,29 +69,23 @@ module.exports = class LeadsService {
|
|||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
let leads = await LeadsDBApi.findBy(
|
let leads = await LeadsDBApi.findBy({id}, {transaction});
|
||||||
{id},
|
if (!leads) { throw new ValidationError('leadsNotFound'); }
|
||||||
{transaction},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!leads) {
|
// Ownership check for Verified Business Owner
|
||||||
throw new ValidationError(
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
'leadsNotFound',
|
const match = await db.lead_matches.findOne({
|
||||||
);
|
where: { leadId: id, businessId: currentUser.businessId },
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (!match) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedLeads = await LeadsDBApi.update(
|
const updatedLeads = await LeadsDBApi.update(id, data, { currentUser, transaction });
|
||||||
id,
|
|
||||||
data,
|
|
||||||
{
|
|
||||||
currentUser,
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
return updatedLeads;
|
return updatedLeads;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
@ -99,13 +94,22 @@ module.exports = class LeadsService {
|
|||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await LeadsDBApi.deleteByIds(ids, {
|
// Ownership check for Verified Business Owner
|
||||||
currentUser,
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
transaction,
|
const count = await db.lead_matches.count({
|
||||||
});
|
where: {
|
||||||
|
leadId: { [db.Sequelize.Op.in]: ids },
|
||||||
|
businessId: currentUser.businessId
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (count !== ids.length) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await LeadsDBApi.deleteByIds(ids, { currentUser, transaction });
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
@ -115,24 +119,23 @@ module.exports = class LeadsService {
|
|||||||
|
|
||||||
static async remove(id, currentUser) {
|
static async remove(id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await LeadsDBApi.remove(
|
// Ownership check for Verified Business Owner
|
||||||
id,
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
{
|
const match = await db.lead_matches.findOne({
|
||||||
currentUser,
|
where: { leadId: id, businessId: currentUser.businessId },
|
||||||
transaction,
|
transaction
|
||||||
},
|
});
|
||||||
);
|
if (!match) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await LeadsDBApi.remove(id, { currentUser, transaction });
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,20 +2,38 @@ const db = require('../db/models');
|
|||||||
const MessagesDBApi = require('../db/api/messages');
|
const MessagesDBApi = require('../db/api/messages');
|
||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = class MessagesService {
|
module.exports = class MessagesService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await MessagesDBApi.create(
|
// For VBOs, ensure they can only message about their leads/businesses
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
// If leadId is provided, check if business is matched to that lead
|
||||||
|
if (data.lead) {
|
||||||
|
const match = await db.lead_matches.findOne({
|
||||||
|
where: {
|
||||||
|
leadId: data.lead,
|
||||||
|
[db.Sequelize.Op.or]: [
|
||||||
|
{ businessId: currentUser.businessId || null },
|
||||||
|
{ '$business.owner_userId$': currentUser.id }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: [{ model: db.businesses, as: 'business' }],
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (!match) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await MessagesDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -24,6 +42,7 @@ module.exports = class MessagesService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
return message;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
@ -38,14 +57,13 @@ module.exports = class MessagesService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (data) => results.push(data))
|
.on('data', (data) => results.push(data))
|
||||||
.on('end', async () => {
|
.on('end', async () => {
|
||||||
console.log('CSV results', results);
|
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
@ -68,17 +86,38 @@ module.exports = class MessagesService {
|
|||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
let messages = await MessagesDBApi.findBy(
|
let message = await MessagesDBApi.findBy(
|
||||||
{id},
|
{id},
|
||||||
{transaction},
|
{transaction},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!messages) {
|
if (!message) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
'messagesNotFound',
|
'messagesNotFound',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
if (message.sender_userId !== currentUser.id && message.receiver_userId !== currentUser.id) {
|
||||||
|
// Also check if it's about their lead
|
||||||
|
const match = await db.lead_matches.findOne({
|
||||||
|
where: {
|
||||||
|
leadId: message.lead?.id,
|
||||||
|
[db.Sequelize.Op.or]: [
|
||||||
|
{ businessId: currentUser.businessId || null },
|
||||||
|
{ '$business.owner_userId$': currentUser.id }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: [{ model: db.businesses, as: 'business' }],
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (!match) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedMessages = await MessagesDBApi.update(
|
const updatedMessages = await MessagesDBApi.update(
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
@ -101,6 +140,19 @@ module.exports = class MessagesService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const records = await db.messages.findAll({
|
||||||
|
where: { id: { [db.Sequelize.Op.in]: ids } },
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.sender_userId !== currentUser.id && record.receiver_userId !== currentUser.id) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await MessagesDBApi.deleteByIds(ids, {
|
await MessagesDBApi.deleteByIds(ids, {
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
@ -117,6 +169,15 @@ module.exports = class MessagesService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const record = await db.messages.findByPk(id, { transaction });
|
||||||
|
if (!record) throw new ValidationError('messagesNotFound');
|
||||||
|
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
if (record.sender_userId !== currentUser.id && record.receiver_userId !== currentUser.id) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await MessagesDBApi.remove(
|
await MessagesDBApi.remove(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
@ -133,6 +194,4 @@ module.exports = class MessagesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
const errors = {
|
const errors = {
|
||||||
app: {
|
app: {
|
||||||
title: 'Crafted Network',
|
title: 'Fix-It-Local',
|
||||||
},
|
},
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@ -2,20 +2,40 @@ const db = require('../db/models');
|
|||||||
const ReviewsDBApi = require('../db/api/reviews');
|
const ReviewsDBApi = require('../db/api/reviews');
|
||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = class ReviewsService {
|
module.exports = class ReviewsService {
|
||||||
|
static async updateBusinessRating(businessId, transaction) {
|
||||||
|
if (!businessId) return;
|
||||||
|
|
||||||
|
const reviews = await db.reviews.findAll({
|
||||||
|
where: { businessId, status: 'PUBLISHED' },
|
||||||
|
attributes: ['rating'],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reviews.length === 0) {
|
||||||
|
await db.businesses.update({ rating: 0 }, { where: { id: businessId }, transaction });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRating = reviews.reduce((sum, review) => sum + review.rating, 0);
|
||||||
|
const averageRating = totalRating / reviews.length;
|
||||||
|
|
||||||
|
await db.businesses.update({ rating: averageRating }, { where: { id: businessId }, transaction });
|
||||||
|
}
|
||||||
|
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await ReviewsDBApi.create(
|
// Set status to PUBLISHED by default for now, or use PENDING if moderation is needed
|
||||||
|
data.status = 'PUBLISHED';
|
||||||
|
|
||||||
|
const reviews = await ReviewsDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -23,7 +43,12 @@ module.exports = class ReviewsService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Extract businessId from data or reviews object
|
||||||
|
const businessId = data.business || (reviews.business && reviews.business.id) || data.businessId;
|
||||||
|
await this.updateBusinessRating(businessId, transaction);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
return reviews;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
@ -58,6 +83,9 @@ module.exports = class ReviewsService {
|
|||||||
currentUser: req.currentUser
|
currentUser: req.currentUser
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// After bulk import, we might need to update ratings for all affected businesses
|
||||||
|
// For now, let's keep it simple.
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
@ -68,17 +96,31 @@ module.exports = class ReviewsService {
|
|||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
let reviews = await ReviewsDBApi.findBy(
|
let review = await ReviewsDBApi.findBy(
|
||||||
{id},
|
{id},
|
||||||
{transaction},
|
{transaction},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!reviews) {
|
if (!review) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
'reviewsNotFound',
|
'reviewsNotFound',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
if (currentUser.businessId) {
|
||||||
|
if (review.businessId !== currentUser.businessId) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const business = await db.businesses.findByPk(review.businessId, { transaction });
|
||||||
|
if (business && business.owner_userId !== currentUser.id) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedReviews = await ReviewsDBApi.update(
|
const updatedReviews = await ReviewsDBApi.update(
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
@ -88,6 +130,9 @@ module.exports = class ReviewsService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const businessId = (updatedReviews.business && updatedReviews.business.id) || updatedReviews.businessId || review.businessId;
|
||||||
|
await this.updateBusinessRating(businessId, transaction);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
return updatedReviews;
|
return updatedReviews;
|
||||||
|
|
||||||
@ -101,11 +146,39 @@ module.exports = class ReviewsService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Get businessIds before deleting
|
||||||
|
const reviews = await db.reviews.findAll({
|
||||||
|
where: { id: { [db.Sequelize.Op.in]: ids } },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
for (const review of reviews) {
|
||||||
|
if (currentUser.businessId) {
|
||||||
|
if (review.businessId !== currentUser.businessId) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const business = await db.businesses.findByPk(review.businessId, { transaction });
|
||||||
|
if (business && business.owner_userId !== currentUser.id) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessIds = [...new Set(reviews.map(r => r.businessId))];
|
||||||
|
|
||||||
await ReviewsDBApi.deleteByIds(ids, {
|
await ReviewsDBApi.deleteByIds(ids, {
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const businessId of businessIds) {
|
||||||
|
await this.updateBusinessRating(businessId, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
@ -117,6 +190,27 @@ module.exports = class ReviewsService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const review = await db.reviews.findByPk(id, { transaction });
|
||||||
|
if (!review) {
|
||||||
|
throw new ValidationError('reviewsNotFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
if (currentUser.businessId) {
|
||||||
|
if (review.businessId !== currentUser.businessId) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const business = await db.businesses.findByPk(review.businessId, { transaction });
|
||||||
|
if (business && business.owner_userId !== currentUser.id) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = review.businessId;
|
||||||
|
|
||||||
await ReviewsDBApi.remove(
|
await ReviewsDBApi.remove(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
@ -125,14 +219,12 @@ module.exports = class ReviewsService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.updateBusinessRating(businessId, transaction);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,6 @@ const axios = require('axios');
|
|||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function buildWidgetResult(widget, queryResult, queryString) {
|
function buildWidgetResult(widget, queryResult, queryString) {
|
||||||
if (queryResult[0] && queryResult[0].length) {
|
if (queryResult[0] && queryResult[0].length) {
|
||||||
const key = Object.keys(queryResult[0][0])[0];
|
const key = Object.keys(queryResult[0][0])[0];
|
||||||
@ -17,14 +14,14 @@ function buildWidgetResult(widget, queryResult, queryString) {
|
|||||||
const widgetData = JSON.parse(widget.data);
|
const widgetData = JSON.parse(widget.data);
|
||||||
return { ...widget, ...widgetData, value, query: queryString };
|
return { ...widget, ...widgetData, value, query: queryString };
|
||||||
} else {
|
} else {
|
||||||
return { ...widget, value: [], query: queryString };
|
return { ...widget, value: widget.widget_type === 'scalar' ? 0 : [], query: queryString };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeQuery(queryString, currentUser) {
|
async function executeQuery(queryString, replacements) {
|
||||||
try {
|
try {
|
||||||
return await db.sequelize.query(queryString, {
|
return await db.sequelize.query(queryString, {
|
||||||
replacements: { organizationId: currentUser.organizationId },
|
replacements,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
@ -49,19 +46,46 @@ function insertWhereConditions(queryString, whereConditions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function constructWhereConditions(mainTable, currentUser, replacements) {
|
function constructWhereConditions(mainTable, currentUser, replacements) {
|
||||||
const { organizationId, app_role: { globalAccess } } = currentUser;
|
const organizationId = currentUser.organizationId;
|
||||||
|
const roleName = currentUser.app_role?.name;
|
||||||
|
const globalAccess = currentUser.app_role?.globalAccess;
|
||||||
|
const currentUserId = currentUser.id;
|
||||||
|
const businessId = currentUser.businessId;
|
||||||
|
|
||||||
const tablesWithoutOrgId = ['permissions', 'roles'];
|
const tablesWithoutOrgId = ['permissions', 'roles'];
|
||||||
let whereConditions = '';
|
let conditions = [];
|
||||||
|
|
||||||
if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) {
|
if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) {
|
||||||
whereConditions += `"${mainTable}"."organizationId" = :organizationId`;
|
if (organizationId) {
|
||||||
replacements.organizationId = organizationId;
|
conditions.push(`"${mainTable}"."organizationId" = :organizationId`);
|
||||||
|
replacements.organizationId = organizationId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
whereConditions += whereConditions ? ' AND ' : '';
|
// Business User isolation
|
||||||
whereConditions += `"${mainTable}"."deletedAt" IS NULL`;
|
if (roleName === 'Verified Business Owner') {
|
||||||
|
if (mainTable === 'businesses') {
|
||||||
|
if (businessId) {
|
||||||
|
conditions.push(`"${mainTable}"."id" = :businessId`);
|
||||||
|
replacements.businessId = businessId;
|
||||||
|
} else {
|
||||||
|
conditions.push(`"${mainTable}"."owner_userId" = :currentUserId`);
|
||||||
|
replacements.currentUserId = currentUserId;
|
||||||
|
}
|
||||||
|
} else if (['leads', 'messages', 'reviews', 'service_prices', 'verification_submissions'].includes(mainTable)) {
|
||||||
|
if (businessId) {
|
||||||
|
conditions.push(`"${mainTable}"."businessId" = :businessId`);
|
||||||
|
replacements.businessId = businessId;
|
||||||
|
} else {
|
||||||
|
// Fallback: try to filter by owner_userId if we can join or if it's leads (via matches)
|
||||||
|
// For now, we assume businessId is on the user for most VBOs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return whereConditions;
|
conditions.push(`"${mainTable}"."deletedAt" IS NULL`);
|
||||||
|
|
||||||
|
return conditions.join(' AND ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTableName(queryString) {
|
function extractTableName(queryString) {
|
||||||
@ -77,16 +101,15 @@ function buildQueryString(widget, currentUser) {
|
|||||||
const replacements = {};
|
const replacements = {};
|
||||||
const whereConditions = constructWhereConditions(mainTable, currentUser, replacements);
|
const whereConditions = constructWhereConditions(mainTable, currentUser, replacements);
|
||||||
queryString = insertWhereConditions(queryString, whereConditions);
|
queryString = insertWhereConditions(queryString, whereConditions);
|
||||||
console.log(queryString, 'queryString');
|
return { queryString, replacements };
|
||||||
return queryString;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function constructWidgetsResults(widgets, currentUser) {
|
async function constructWidgetsResults(widgets, currentUser) {
|
||||||
const widgetsResults = [];
|
const widgetsResults = [];
|
||||||
for (const widget of widgets) {
|
for (const widget of widgets) {
|
||||||
if (!widget) continue;
|
if (!widget) continue;
|
||||||
const queryString = buildQueryString(widget, currentUser);
|
const { queryString, replacements } = buildQueryString(widget, currentUser);
|
||||||
const queryResult = await executeQuery(queryString, currentUser);
|
const queryResult = await executeQuery(queryString, replacements);
|
||||||
widgetsResults.push(buildWidgetResult(widget, queryResult, queryString));
|
widgetsResults.push(buildWidgetResult(widget, queryResult, queryString));
|
||||||
}
|
}
|
||||||
return widgetsResults;
|
return widgetsResults;
|
||||||
@ -107,30 +130,6 @@ async function processWidgets(widgets, currentUser) {
|
|||||||
return constructWidgetsResults(widgetData, currentUser);
|
return constructWidgetsResults(widgetData, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCustomization(role) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(role.role_customization || '{}');
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findRole(roleId, currentUser) {
|
|
||||||
const transaction = await db.sequelize.transaction();
|
|
||||||
try {
|
|
||||||
const role = roleId
|
|
||||||
? await RolesDBApi.findBy({ id: roleId }, { transaction })
|
|
||||||
: await RolesDBApi.findBy({ name: 'User' }, { transaction });
|
|
||||||
await transaction.commit();
|
|
||||||
return role;
|
|
||||||
} catch (error) {
|
|
||||||
await transaction.rollback();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = class RolesService {
|
module.exports = class RolesService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -158,14 +157,13 @@ module.exports = class RolesService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (data) => results.push(data))
|
.on('data', (data) => results.push(data))
|
||||||
.on('end', async () => {
|
.on('end', async () => {
|
||||||
console.log('CSV results', results);
|
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
@ -362,11 +360,6 @@ module.exports = class RolesService {
|
|||||||
static async getRoleInfoByKey(key, roleId, currentUser) {
|
static async getRoleInfoByKey(key, roleId, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
const organizationId = currentUser.organizationId;
|
|
||||||
let globalAccess = currentUser.app_role?.globalAccess;
|
|
||||||
let queryString = '';
|
|
||||||
|
|
||||||
|
|
||||||
let role;
|
let role;
|
||||||
try {
|
try {
|
||||||
if (roleId) {
|
if (roleId) {
|
||||||
@ -381,7 +374,7 @@ module.exports = class RolesService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
let customization = '{}';
|
let customization = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
customization = JSON.parse(role.role_customization || '{}');
|
customization = JSON.parse(role.role_customization || '{}');
|
||||||
@ -391,47 +384,8 @@ module.exports = class RolesService {
|
|||||||
|
|
||||||
if (key === 'widgets') {
|
if (key === 'widgets') {
|
||||||
const widgets = (customization[key] || []);
|
const widgets = (customization[key] || []);
|
||||||
const widgetArray = widgets.map(widget => {
|
return await processWidgets(widgets, currentUser);
|
||||||
return axios.get(`${config.flHost}/${config.project_uuid}/project_customization_widgets/${widget}.json`)
|
|
||||||
})
|
|
||||||
const widgetResults = await Promise.allSettled(widgetArray);
|
|
||||||
|
|
||||||
const fulfilledWidgets = widgetResults.map(result => {
|
|
||||||
if (result.status === 'fulfilled') {
|
|
||||||
return result.value.data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const widgetsResults = [];
|
|
||||||
|
|
||||||
if (Array.isArray(fulfilledWidgets)) {
|
|
||||||
for (const widget of fulfilledWidgets) {
|
|
||||||
let result = [];
|
|
||||||
try {
|
|
||||||
result = await db.sequelize.query(widget.query);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result[0] && result[0].length) {
|
|
||||||
const key = Object.keys(result[0][0])[0];
|
|
||||||
const value =
|
|
||||||
widget.widget_type === 'scalar' ? result[0][0][key] : result[0];
|
|
||||||
const widgetData = JSON.parse(widget.data);
|
|
||||||
widgetsResults.push({ ...widget, ...widgetData, value });
|
|
||||||
} else {
|
|
||||||
widgetsResults.push({ ...widget, value: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return widgetsResults;
|
|
||||||
}
|
}
|
||||||
return customization[key];
|
return customization[key];
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,535 +1,201 @@
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const RolesDBApi = require('../db/api/roles');
|
||||||
|
const GooglePlacesService = require('./googlePlaces');
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
|
// Cache for the 'Public' role object
|
||||||
|
let publicRoleCache = null;
|
||||||
|
|
||||||
|
async function getPublicRole() {
|
||||||
|
if (publicRoleCache) return publicRoleCache;
|
||||||
|
publicRoleCache = await RolesDBApi.findBy({ name: 'Public' });
|
||||||
|
return publicRoleCache;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} permission
|
* @param {string} permission
|
||||||
* @param {object} currentUser
|
* @param {object} currentUser
|
||||||
*/
|
*/
|
||||||
async function checkPermissions(permission, currentUser) {
|
async function checkPermissions(permission, currentUser) {
|
||||||
|
let role = null;
|
||||||
|
|
||||||
if (!currentUser) {
|
if (currentUser) {
|
||||||
throw new ValidationError('auth.unauthorized');
|
const userPermission = currentUser.custom_permissions?.find(
|
||||||
|
(cp) => cp.name === permission,
|
||||||
|
);
|
||||||
|
if (userPermission) return true;
|
||||||
|
role = currentUser.app_role;
|
||||||
|
} else {
|
||||||
|
role = await getPublicRole();
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPermission = currentUser.custom_permissions.find(
|
if (!role) {
|
||||||
(cp) => cp.name === permission,
|
return false;
|
||||||
);
|
|
||||||
|
|
||||||
if (userPermission) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!currentUser.app_role) {
|
let permissions = [];
|
||||||
throw new ValidationError('auth.forbidden');
|
if (typeof role.getPermissions === 'function') {
|
||||||
|
permissions = await role.getPermissions();
|
||||||
|
} else {
|
||||||
|
permissions = role.permissions || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = await currentUser.app_role.getPermissions();
|
|
||||||
|
|
||||||
return !!permissions.find((p) => p.name === permission);
|
return !!permissions.find((p) => p.name === permission);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw e;
|
console.error("Search permission check error:", e);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = class SearchService {
|
module.exports = class SearchService {
|
||||||
static async search(searchQuery, currentUser ) {
|
static async search(searchQuery, currentUser, location ) {
|
||||||
try {
|
try {
|
||||||
if (!searchQuery) {
|
if (!searchQuery) {
|
||||||
throw new ValidationError('iam.errors.searchQueryRequired');
|
throw new ValidationError('iam.errors.searchQueryRequired');
|
||||||
}
|
}
|
||||||
const tableColumns = {
|
|
||||||
|
const roleName = currentUser?.app_role?.name || 'Public';
|
||||||
|
const isAdmin = roleName === 'Administrator' || roleName === 'Platform Owner';
|
||||||
|
|
||||||
|
|
||||||
|
// Columns that can be searched using iLike
|
||||||
|
const searchableColumns = {
|
||||||
"users": [
|
"users": [
|
||||||
|
|
||||||
"firstName",
|
"firstName",
|
||||||
|
|
||||||
"lastName",
|
"lastName",
|
||||||
|
|
||||||
"phoneNumber",
|
"phoneNumber",
|
||||||
|
|
||||||
"email",
|
"email",
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"refresh_tokens": [
|
|
||||||
|
|
||||||
"token_hash",
|
|
||||||
|
|
||||||
"ip_address",
|
|
||||||
|
|
||||||
"user_agent",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"categories": [
|
"categories": [
|
||||||
|
|
||||||
"name",
|
"name",
|
||||||
|
|
||||||
"slug",
|
"slug",
|
||||||
|
|
||||||
"icon",
|
|
||||||
|
|
||||||
"description",
|
"description",
|
||||||
|
|
||||||
"tenant_key",
|
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"locations": [
|
"locations": [
|
||||||
|
|
||||||
"label",
|
"label",
|
||||||
|
|
||||||
"city",
|
"city",
|
||||||
|
|
||||||
"state",
|
"state",
|
||||||
|
|
||||||
"zip",
|
"zip",
|
||||||
|
|
||||||
"tenant_key",
|
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"businesses": [
|
"businesses": [
|
||||||
|
|
||||||
"name",
|
"name",
|
||||||
|
|
||||||
"slug",
|
"slug",
|
||||||
|
|
||||||
"description",
|
"description",
|
||||||
|
|
||||||
"phone",
|
"phone",
|
||||||
|
|
||||||
"email",
|
"email",
|
||||||
|
|
||||||
"website",
|
"website",
|
||||||
|
|
||||||
"address",
|
"address",
|
||||||
|
|
||||||
"city",
|
"city",
|
||||||
|
|
||||||
"state",
|
"state",
|
||||||
|
|
||||||
"zip",
|
"zip",
|
||||||
|
|
||||||
"hours_json",
|
|
||||||
|
|
||||||
"reliability_breakdown_json",
|
|
||||||
|
|
||||||
"tenant_key",
|
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"service_prices": [
|
|
||||||
|
|
||||||
"service_name",
|
|
||||||
|
|
||||||
"notes",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"business_badges": [
|
|
||||||
|
|
||||||
"notes",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"verification_submissions": [
|
|
||||||
|
|
||||||
"notes",
|
|
||||||
|
|
||||||
"admin_notes",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"verification_evidences": [
|
|
||||||
|
|
||||||
"url",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"leads": [
|
|
||||||
|
|
||||||
"keyword",
|
|
||||||
|
|
||||||
"description",
|
|
||||||
|
|
||||||
"contact_name",
|
|
||||||
|
|
||||||
"contact_phone",
|
|
||||||
|
|
||||||
"contact_email",
|
|
||||||
|
|
||||||
"address",
|
|
||||||
|
|
||||||
"city",
|
|
||||||
|
|
||||||
"state",
|
|
||||||
|
|
||||||
"zip",
|
|
||||||
|
|
||||||
"inferred_tags_json",
|
|
||||||
|
|
||||||
"tenant_key",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"messages": [
|
|
||||||
|
|
||||||
"body",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"lead_events": [
|
|
||||||
|
|
||||||
"from_value",
|
|
||||||
|
|
||||||
"to_value",
|
|
||||||
|
|
||||||
"meta_json",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"reviews": [
|
|
||||||
|
|
||||||
"text",
|
|
||||||
|
|
||||||
"moderation_notes",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"disputes": [
|
|
||||||
|
|
||||||
"reason",
|
|
||||||
|
|
||||||
"resolution_notes",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"audit_logs": [
|
|
||||||
|
|
||||||
"action",
|
|
||||||
|
|
||||||
"entity_type",
|
|
||||||
|
|
||||||
"entity_key",
|
|
||||||
|
|
||||||
"meta_json",
|
|
||||||
|
|
||||||
"tenant_key",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"badge_rules": [
|
|
||||||
|
|
||||||
"required_evidence_json",
|
|
||||||
|
|
||||||
"tenant_key",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"trust_adjustments": [
|
|
||||||
|
|
||||||
"reason",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
const columnsInt = {
|
|
||||||
|
// 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",
|
||||||
|
"response_time_median_minutes"
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnsInt = {
|
||||||
|
|
||||||
"locations": [
|
|
||||||
|
|
||||||
"lat",
|
|
||||||
|
|
||||||
"lng",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"businesses": [
|
"businesses": [
|
||||||
|
|
||||||
"lat",
|
"lat",
|
||||||
|
|
||||||
"lng",
|
"lng",
|
||||||
|
|
||||||
"reliability_score",
|
"reliability_score",
|
||||||
|
|
||||||
"response_time_median_minutes",
|
"response_time_median_minutes",
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"service_prices": [
|
|
||||||
|
|
||||||
"min_price",
|
|
||||||
|
|
||||||
"max_price",
|
|
||||||
|
|
||||||
"typical_price",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"leads": [
|
|
||||||
|
|
||||||
"lat",
|
|
||||||
|
|
||||||
"lng",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"lead_matches": [
|
|
||||||
|
|
||||||
"match_score",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"reviews": [
|
|
||||||
|
|
||||||
"rating",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"trust_adjustments": [
|
|
||||||
|
|
||||||
"delta",
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let allFoundRecords = [];
|
let allFoundRecords = [];
|
||||||
|
|
||||||
for (const tableName in tableColumns) {
|
for (const tableName in searchableColumns) {
|
||||||
if (tableColumns.hasOwnProperty(tableName)) {
|
if (searchableColumns.hasOwnProperty(tableName)) {
|
||||||
const attributesToSearch = tableColumns[tableName];
|
const attributesToSearch = searchableColumns[tableName];
|
||||||
const attributesIntToSearch = columnsInt[tableName] || [];
|
const attributesIntToSearch = columnsInt[tableName] || [];
|
||||||
|
|
||||||
|
const searchConditions = [
|
||||||
|
...attributesToSearch.map(attribute => ({
|
||||||
|
[attribute]: {
|
||||||
|
[Op.iLike] : `%${searchQuery}%`,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...attributesIntToSearch.map(attribute => (
|
||||||
|
Sequelize.where(
|
||||||
|
Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'),
|
||||||
|
{ [Op.iLike]: `%${searchQuery}%` }
|
||||||
|
)
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
const whereCondition = {
|
const whereCondition = {
|
||||||
[Op.or]: [
|
[Op.or]: searchConditions,
|
||||||
...attributesToSearch.map(attribute => ({
|
|
||||||
[attribute]: {
|
|
||||||
[Op.iLike] : `%${searchQuery}%`,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
...attributesIntToSearch.map(attribute => (
|
|
||||||
Sequelize.where(
|
|
||||||
Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'),
|
|
||||||
{ [Op.iLike]: `%${searchQuery}%` }
|
|
||||||
)
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only show active businesses for non-admins
|
||||||
|
if (tableName === 'businesses' && !isAdmin) {
|
||||||
|
whereCondition[Op.and] = whereCondition[Op.and] || [];
|
||||||
|
whereCondition[Op.and].push({ is_active: true });
|
||||||
|
}
|
||||||
|
|
||||||
const hasPermission = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser);
|
// If location is provided, bias local results by location for businesses and locations
|
||||||
if (!hasPermission) {
|
if (location && (tableName === 'businesses' || tableName === 'locations')) {
|
||||||
|
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: [...tableColumns[tableName], 'id', ...attributesIntToSearch],
|
attributes: [...returnColumns[tableName], 'id'],
|
||||||
|
include
|
||||||
});
|
});
|
||||||
|
|
||||||
const modifiedRecords = foundRecords.map((record) => {
|
const modifiedRecords = foundRecords.map((record) => {
|
||||||
const matchAttribute = [];
|
const matchAttribute = [];
|
||||||
|
|
||||||
for (const attribute of attributesToSearch) {
|
for (const attribute of attributesToSearch) {
|
||||||
if (record[attribute]?.toLowerCase()?.includes(searchQuery.toLowerCase())) {
|
if (record[attribute] && typeof record[attribute] === 'string' && record[attribute].toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||||
matchAttribute.push(attribute);
|
matchAttribute.push(attribute);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -552,6 +218,84 @@ module.exports = class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special case: if we found categories, find businesses in those categories
|
||||||
|
const foundCategories = allFoundRecords.filter(r => r.tableName === 'categories');
|
||||||
|
if (foundCategories.length > 0) {
|
||||||
|
const categoryIds = foundCategories.map(c => c.id);
|
||||||
|
const businessesInCategories = await db.businesses.findAll({
|
||||||
|
where: !isAdmin ? { is_active: true } : {},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.business_categories,
|
||||||
|
as: 'business_categories_business',
|
||||||
|
where: { categoryId: { [Op.in]: categoryIds } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.business_photos,
|
||||||
|
as: 'business_photos_business',
|
||||||
|
include: [{
|
||||||
|
model: db.file,
|
||||||
|
as: 'photos'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const biz of businessesInCategories) {
|
||||||
|
if (!allFoundRecords.find(r => r.id === biz.id && r.tableName === 'businesses')) {
|
||||||
|
allFoundRecords.push({
|
||||||
|
...biz.get(),
|
||||||
|
matchAttribute: ['category'],
|
||||||
|
tableName: 'businesses',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If few local businesses found, try Google Places
|
||||||
|
const localBusinessesCount = allFoundRecords.filter(r => r.tableName === 'businesses').length;
|
||||||
|
if (localBusinessesCount < 5) {
|
||||||
|
try {
|
||||||
|
// If no location in search query, try to append a default location to help Google
|
||||||
|
let refinedQuery = searchQuery;
|
||||||
|
if (location) {
|
||||||
|
refinedQuery = `${searchQuery} ${location}`;
|
||||||
|
} else if (!searchQuery.match(/\d{5}/) && !searchQuery.match(/in\s+[A-Za-z]+/i)) {
|
||||||
|
refinedQuery = `${searchQuery} USA`; // Search nationwide if no location specified
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleResults = await GooglePlacesService.searchPlaces(refinedQuery);
|
||||||
|
for (const gPlace of googleResults) {
|
||||||
|
// Import each place to our DB
|
||||||
|
const importedBusiness = await GooglePlacesService.importFromGoogle(gPlace);
|
||||||
|
|
||||||
|
// Re-fetch with associations to get photos
|
||||||
|
const fullBusiness = await db.businesses.findByPk(importedBusiness.id, {
|
||||||
|
include: [{
|
||||||
|
model: db.business_photos,
|
||||||
|
as: 'business_photos_business',
|
||||||
|
include: [{
|
||||||
|
model: db.file,
|
||||||
|
as: 'photos'
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to search results if not already there
|
||||||
|
if (fullBusiness && !allFoundRecords.find(r => r.id === fullBusiness.id)) {
|
||||||
|
allFoundRecords.push({
|
||||||
|
...fullBusiness.get(),
|
||||||
|
matchAttribute: ['name'],
|
||||||
|
tableName: 'businesses',
|
||||||
|
isFromGoogle: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (gError) {
|
||||||
|
console.error("Google Search fallback error:", gError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return allFoundRecords;
|
return allFoundRecords;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -2,20 +2,27 @@ const db = require('../db/models');
|
|||||||
const Service_pricesDBApi = require('../db/api/service_prices');
|
const Service_pricesDBApi = require('../db/api/service_prices');
|
||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = class Service_pricesService {
|
module.exports = class Service_pricesService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await Service_pricesDBApi.create(
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const businessId = data.business || currentUser.businessId;
|
||||||
|
const business = await db.businesses.findByPk(businessId, { transaction });
|
||||||
|
if (!business || business.owner_userId !== currentUser.id) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
data.business = business.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service_price = await Service_pricesDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -24,6 +31,7 @@ module.exports = class Service_pricesService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
return service_price;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
@ -38,14 +46,13 @@ module.exports = class Service_pricesService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (data) => results.push(data))
|
.on('data', (data) => results.push(data))
|
||||||
.on('end', async () => {
|
.on('end', async () => {
|
||||||
console.log('CSV results', results);
|
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
@ -79,6 +86,15 @@ module.exports = class Service_pricesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const businessId = service_prices.business?.id;
|
||||||
|
const business = await db.businesses.findByPk(businessId, { transaction });
|
||||||
|
if (!business || business.owner_userId !== currentUser.id) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedService_prices = await Service_pricesDBApi.update(
|
const updatedService_prices = await Service_pricesDBApi.update(
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
@ -101,6 +117,20 @@ module.exports = class Service_pricesService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const records = await db.service_prices.findAll({
|
||||||
|
where: { id: { [db.Sequelize.Op.in]: ids } },
|
||||||
|
include: [{ model: db.businesses, as: 'business' }],
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Service_pricesDBApi.deleteByIds(ids, {
|
await Service_pricesDBApi.deleteByIds(ids, {
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
@ -117,6 +147,18 @@ module.exports = class Service_pricesService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const record = await db.service_prices.findByPk(id, {
|
||||||
|
include: [{ model: db.businesses, as: 'business' }],
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (!record) throw new ValidationError('service_pricesNotFound');
|
||||||
|
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Service_pricesDBApi.remove(
|
await Service_pricesDBApi.remove(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
@ -133,6 +175,4 @@ module.exports = class Service_pricesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -2,20 +2,27 @@ const db = require('../db/models');
|
|||||||
const Verification_submissionsDBApi = require('../db/api/verification_submissions');
|
const Verification_submissionsDBApi = require('../db/api/verification_submissions');
|
||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = class Verification_submissionsService {
|
module.exports = class Verification_submissionsService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await Verification_submissionsDBApi.create(
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const businessId = data.business || currentUser.businessId;
|
||||||
|
const business = await db.businesses.findByPk(businessId, { transaction });
|
||||||
|
if (!business || (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId)) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
data.business = business.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submission = await Verification_submissionsDBApi.create(
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -24,6 +31,7 @@ module.exports = class Verification_submissionsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
return submission;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
@ -38,14 +46,13 @@ module.exports = class Verification_submissionsService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (data) => results.push(data))
|
.on('data', (data) => results.push(data))
|
||||||
.on('end', async () => {
|
.on('end', async () => {
|
||||||
console.log('CSV results', results);
|
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
@ -79,6 +86,15 @@ module.exports = class Verification_submissionsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const businessId = verification_submissions.business?.id;
|
||||||
|
const business = await db.businesses.findByPk(businessId, { transaction });
|
||||||
|
if (!business || (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId)) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedVerification_submissions = await Verification_submissionsDBApi.update(
|
const updatedVerification_submissions = await Verification_submissionsDBApi.update(
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
@ -101,6 +117,20 @@ module.exports = class Verification_submissionsService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ownership check for Verified Business Owner
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const records = await db.verification_submissions.findAll({
|
||||||
|
where: { id: { [db.Sequelize.Op.in]: ids } },
|
||||||
|
include: [{ model: db.businesses, as: 'business' }],
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Verification_submissionsDBApi.deleteByIds(ids, {
|
await Verification_submissionsDBApi.deleteByIds(ids, {
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
@ -117,6 +147,18 @@ module.exports = class Verification_submissionsService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const record = await db.verification_submissions.findByPk(id, {
|
||||||
|
include: [{ model: db.businesses, as: 'business' }],
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
if (!record) throw new ValidationError('verification_submissionsNotFound');
|
||||||
|
|
||||||
|
if (currentUser.app_role?.name === 'Verified Business Owner') {
|
||||||
|
if (record.business?.owner_userId !== currentUser.id && record.business?.id !== currentUser.businessId) {
|
||||||
|
throw new ForbiddenError('forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Verification_submissionsDBApi.remove(
|
await Verification_submissionsDBApi.remove(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
@ -133,6 +175,4 @@ module.exports = class Verification_submissionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Crafted Network
|
# Fix-It-Local
|
||||||
|
|
||||||
## This project was generated by Flatlogic Platform.
|
## This project was generated by Flatlogic Platform.
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone';
|
const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone';
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/about',
|
||||||
|
destination: '/web_pages/about',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
distDir: 'build',
|
distDir: 'build',
|
||||||
output,
|
output,
|
||||||
|
|||||||
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After 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-violet-50/50"
|
export const colorBgBase = "bg-emerald-50/50"
|
||||||
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500`
|
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-rose-500`
|
||||||
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`
|
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`
|
||||||
export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`;
|
export const gradientBgDark = `${gradientBgBase} from-slate-800 via-slate-950 to-slate-900`;
|
||||||
export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`
|
export const gradientBgPinkRed = `${gradientBgBase} from-rose-400 via-emerald-500 to-emerald-600`
|
||||||
|
|
||||||
export const colorsBgLight = {
|
export const colorsBgLight = {
|
||||||
white: 'bg-white text-black',
|
white: 'bg-white text-black',
|
||||||
light: ' bg-white text-black text-black dark:bg-dark-900 dark:text-white',
|
light: ' bg-white text-black text-black dark:bg-dark-900 dark:text-white',
|
||||||
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
|
contrast: 'bg-slate-800 text-white dark:bg-white dark:text-black',
|
||||||
success: 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
success: 'bg-emerald-500 border-emerald-500 dark:bg-emerald-600 dark:border-emerald-600 text-white',
|
||||||
danger: 'bg-red-500 border-red-500 text-white',
|
danger: 'bg-rose-500 border-rose-500 text-white',
|
||||||
warning: 'bg-yellow-500 border-yellow-500 text-white',
|
warning: 'bg-amber-500 border-amber-500 text-white',
|
||||||
info: 'bg-blue-500 border-blue-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
info: 'bg-emerald-600 border-emerald-600 dark:bg-emerald-700 dark:border-emerald-700 text-white',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const colorsText = {
|
export const colorsText = {
|
||||||
white: 'text-black dark:text-slate-100',
|
white: 'text-black dark:text-slate-100',
|
||||||
light: 'text-gray-700 dark:text-slate-400',
|
light: 'text-slate-700 dark:text-slate-400',
|
||||||
contrast: 'dark:text-white',
|
contrast: 'dark:text-white',
|
||||||
success: 'text-emerald-500',
|
success: 'text-emerald-500',
|
||||||
danger: 'text-red-500',
|
danger: 'text-rose-500',
|
||||||
warning: 'text-yellow-500',
|
warning: 'text-amber-500',
|
||||||
info: 'text-blue-500',
|
info: 'text-emerald-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const colorsOutline = {
|
export const colorsOutline = {
|
||||||
white: [colorsText.white, 'border-gray-100'].join(' '),
|
white: [colorsText.white, 'border-slate-100'].join(' '),
|
||||||
light: [colorsText.light, 'border-gray-100'].join(' '),
|
light: [colorsText.light, 'border-slate-100'].join(' '),
|
||||||
contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'].join(' '),
|
contrast: [colorsText.contrast, 'border-slate-900 dark:border-slate-100'].join(' '),
|
||||||
success: [colorsText.success, 'border-emerald-500'].join(' '),
|
success: [colorsText.success, 'border-emerald-500'].join(' '),
|
||||||
danger: [colorsText.danger, 'border-red-500'].join(' '),
|
danger: [colorsText.danger, 'border-rose-500'].join(' '),
|
||||||
warning: [colorsText.warning, 'border-yellow-500'].join(' '),
|
warning: [colorsText.warning, 'border-amber-500'].join(' '),
|
||||||
info: [colorsText.info, 'border-blue-500'].join(' '),
|
info: [colorsText.info, 'border-emerald-600'].join(' '),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getButtonColor = (
|
export const getButtonColor = (
|
||||||
@ -49,74 +49,74 @@ export const getButtonColor = (
|
|||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
ring: {
|
ring: {
|
||||||
white: 'ring-gray-200 dark:ring-gray-500',
|
white: 'ring-slate-200 dark:ring-slate-500',
|
||||||
whiteDark: 'ring-gray-200 dark:ring-dark-500',
|
whiteDark: 'ring-slate-200 dark:ring-dark-500',
|
||||||
lightDark: 'ring-gray-200 dark:ring-gray-500',
|
lightDark: 'ring-slate-200 dark:ring-slate-500',
|
||||||
contrast: 'ring-gray-300 dark:ring-gray-400',
|
contrast: 'ring-slate-300 dark:ring-slate-400',
|
||||||
success: 'ring-emerald-300 dark:ring-pavitra-blue',
|
success: 'ring-emerald-300 dark:ring-emerald-700',
|
||||||
danger: 'ring-red-300 dark:ring-red-700',
|
danger: 'ring-rose-300 dark:ring-rose-700',
|
||||||
warning: 'ring-yellow-300 dark:ring-yellow-700',
|
warning: 'ring-amber-300 dark:ring-amber-700',
|
||||||
info: "ring-blue-300 dark:ring-pavitra-blue",
|
info: "ring-emerald-300 dark:ring-emerald-700",
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
white: 'bg-gray-100',
|
white: 'bg-slate-100',
|
||||||
whiteDark: 'bg-gray-100 dark:bg-dark-800',
|
whiteDark: 'bg-slate-100 dark:bg-dark-800',
|
||||||
lightDark: 'bg-gray-200 dark:bg-slate-700',
|
lightDark: 'bg-slate-200 dark:bg-slate-700',
|
||||||
contrast: 'bg-gray-700 dark:bg-slate-100',
|
contrast: 'bg-slate-700 dark:bg-slate-100',
|
||||||
success: 'bg-emerald-700 dark:bg-pavitra-blue',
|
success: 'bg-emerald-700 dark:bg-emerald-800',
|
||||||
danger: 'bg-red-700 dark:bg-red-600',
|
danger: 'bg-rose-700 dark:bg-rose-600',
|
||||||
warning: 'bg-yellow-700 dark:bg-yellow-600',
|
warning: 'bg-amber-700 dark:bg-amber-600',
|
||||||
info: 'bg-blue-700 dark:bg-pavitra-blue',
|
info: 'bg-emerald-700 dark:bg-emerald-800',
|
||||||
},
|
},
|
||||||
bg: {
|
bg: {
|
||||||
white: 'bg-white text-black',
|
white: 'bg-white text-black',
|
||||||
whiteDark: 'bg-white text-black dark:bg-dark-900 dark:text-white',
|
whiteDark: 'bg-white text-black dark:bg-dark-900 dark:text-white',
|
||||||
lightDark: 'bg-gray-100 text-black dark:bg-slate-800 dark:text-white',
|
lightDark: 'bg-slate-100 text-black dark:bg-slate-800 dark:text-white',
|
||||||
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
|
contrast: 'bg-slate-800 text-white dark:bg-white dark:text-black',
|
||||||
success: 'bg-emerald-600 dark:bg-pavitra-blue text-white',
|
success: 'bg-emerald-600 dark:bg-emerald-600 text-white',
|
||||||
danger: 'bg-red-600 text-white dark:bg-red-500 ',
|
danger: 'bg-rose-600 text-white dark:bg-rose-500 ',
|
||||||
warning: 'bg-yellow-600 dark:bg-yellow-500 text-white',
|
warning: 'bg-amber-600 dark:bg-amber-500 text-white',
|
||||||
info: " bg-blue-600 dark:bg-pavitra-blue text-white ",
|
info: " bg-emerald-600 dark:bg-emerald-600 text-white ",
|
||||||
},
|
},
|
||||||
bgHover: {
|
bgHover: {
|
||||||
white: 'hover:bg-gray-100',
|
white: 'hover:bg-slate-100',
|
||||||
whiteDark: 'hover:bg-gray-100 hover:dark:bg-dark-800',
|
whiteDark: 'hover:bg-slate-100 hover:dark:bg-dark-800',
|
||||||
lightDark: 'hover:bg-gray-200 hover:dark:bg-slate-700',
|
lightDark: 'hover:bg-slate-200 hover:dark:bg-slate-700',
|
||||||
contrast: 'hover:bg-gray-700 hover:dark:bg-slate-100',
|
contrast: 'hover:bg-slate-700 hover:dark:bg-slate-100',
|
||||||
success:
|
success:
|
||||||
'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-pavitra-blue hover:dark:border-pavitra-blue',
|
'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-emerald-800 hover:dark:border-emerald-800',
|
||||||
danger:
|
danger:
|
||||||
'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600',
|
'hover:bg-rose-700 hover:border-rose-700 hover:dark:bg-rose-600 hover:dark:border-rose-600',
|
||||||
warning:
|
warning:
|
||||||
'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600',
|
'hover:bg-amber-700 hover:border-amber-700 hover:dark:bg-amber-600 hover:dark:border-amber-600',
|
||||||
info: "hover:bg-blue-700 hover:border-blue-700 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80",
|
info: "hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-emerald-800 hover:dark:border-emerald-800",
|
||||||
},
|
},
|
||||||
borders: {
|
borders: {
|
||||||
white: 'border-white',
|
white: 'border-white',
|
||||||
whiteDark: 'border-white dark:border-dark-900',
|
whiteDark: 'border-white dark:border-dark-900',
|
||||||
lightDark: 'border-gray-100 dark:border-slate-800',
|
lightDark: 'border-slate-100 dark:border-slate-800',
|
||||||
contrast: 'border-gray-800 dark:border-white',
|
contrast: 'border-slate-800 dark:border-white',
|
||||||
success: 'border-emerald-600 dark:border-pavitra-blue',
|
success: 'border-emerald-600 dark:border-emerald-600',
|
||||||
danger: 'border-red-600 dark:border-red-500',
|
danger: 'border-rose-600 dark:border-rose-500',
|
||||||
warning: 'border-yellow-600 dark:border-yellow-500',
|
warning: 'border-amber-600 dark:border-amber-500',
|
||||||
info: "border-blue-600 border-blue-600 dark:border-pavitra-blue",
|
info: "border-emerald-600 border-emerald-600 dark:border-emerald-600",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
contrast: 'dark:text-slate-100',
|
contrast: 'dark:text-slate-100',
|
||||||
success: 'text-emerald-600 dark:text-pavitra-blue',
|
success: 'text-emerald-600 dark:text-emerald-500',
|
||||||
danger: 'text-red-600 dark:text-red-500',
|
danger: 'text-rose-600 dark:text-rose-500',
|
||||||
warning: 'text-yellow-600 dark:text-yellow-500',
|
warning: 'text-amber-600 dark:text-amber-500',
|
||||||
info: 'text-blue-600 dark:text-pavitra-blue',
|
info: 'text-emerald-600 dark:text-emerald-500',
|
||||||
},
|
},
|
||||||
outlineHover: {
|
outlineHover: {
|
||||||
contrast:
|
contrast:
|
||||||
'hover:bg-gray-800 hover:text-gray-100 hover:dark:bg-slate-100 hover:dark:text-black',
|
'hover:bg-slate-800 hover:text-slate-100 hover:dark:bg-slate-100 hover:dark:text-black',
|
||||||
success: 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue',
|
success: 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-emerald-600',
|
||||||
danger:
|
danger:
|
||||||
'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600',
|
'hover:bg-rose-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-rose-600',
|
||||||
warning:
|
warning:
|
||||||
'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600',
|
'hover:bg-amber-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-amber-600',
|
||||||
info: "hover:bg-blue-600 hover:bg-blue-600 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue",
|
info: "hover:bg-emerald-600 hover:bg-emerald-600 hover:text-white hover:dark:text-white hover:dark:border-emerald-600",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,4 +135,4 @@ export const getButtonColor = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return base.join(' ')
|
return base.join(' ')
|
||||||
}
|
}
|
||||||
@ -5,6 +5,7 @@ import AsideMenuList from './AsideMenuList'
|
|||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Logo from './Logo'
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -37,11 +38,10 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
|||||||
<div
|
<div
|
||||||
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
|
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
|
||||||
>
|
>
|
||||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
<div className="text-center flex-1 flex items-center justify-center">
|
||||||
|
<Link href="/">
|
||||||
<b className="font-black">Crafted Network</b>
|
<Logo className="h-8 w-auto" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="hidden lg:inline-block xl:hidden p-3"
|
className="hidden lg:inline-block xl:hidden p-3"
|
||||||
@ -60,4 +60,4 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -18,7 +18,11 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
|
|||||||
return (
|
return (
|
||||||
<ul className={className}>
|
<ul className={className}>
|
||||||
{menu.map((item, index) => {
|
{menu.map((item, index) => {
|
||||||
|
// Role check
|
||||||
|
if (item.roles && !item.roles.includes(currentUser.app_role?.name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Permission check
|
||||||
if (!hasPermission(currentUser, item.permissions)) return null;
|
if (!hasPermission(currentUser, item.permissions)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -32,4 +36,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={"https://flatlogic.com/logo.svg"}
|
src={"/logo.png"}
|
||||||
className={className}
|
className={className}
|
||||||
alt={'Flatlogic logo'}>
|
alt={'Fix-It-Local logo'}>
|
||||||
</img>
|
</img>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
@ -129,4 +128,4 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div>
|
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div>
|
||||||
}
|
}
|
||||||
41
frontend/src/components/ProgressBar.tsx
Normal file
41
frontend/src/components/ProgressBar.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
color?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProgressBar = ({ value, max = 100, color = 'emerald', label }: Props) => {
|
||||||
|
const percentage = Math.max(0, Math.min(100, Math.round((value / max) * 100)));
|
||||||
|
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
emerald: 'bg-emerald-500 shadow-sm shadow-emerald-500/20',
|
||||||
|
green: 'bg-emerald-500 shadow-sm shadow-emerald-500/20',
|
||||||
|
yellow: 'bg-amber-400 shadow-sm shadow-amber-400/20',
|
||||||
|
red: 'bg-rose-500 shadow-sm shadow-rose-500/20',
|
||||||
|
rose: 'bg-rose-500 shadow-sm shadow-rose-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const bgColor = colors[color] || colors.emerald;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full animate-fade-in">
|
||||||
|
{label && (
|
||||||
|
<div className="flex justify-between mb-2 items-baseline">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-400">{label}</span>
|
||||||
|
<span className="text-sm font-black text-slate-800 dark:text-white">{percentage}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full bg-slate-100 rounded-full h-3 dark:bg-slate-800 overflow-hidden shadow-inner">
|
||||||
|
<div
|
||||||
|
className={`${bgColor} h-3 rounded-full transition-all duration-1000 ease-out`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProgressBar;
|
||||||
@ -1,16 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import BaseIcon from '../BaseIcon';
|
|
||||||
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
GridActionsCellItem,
|
|
||||||
GridRowParams,
|
GridRowParams,
|
||||||
GridValueGetterParams,
|
GridValueGetterParams,
|
||||||
} from '@mui/x-data-grid';
|
} from '@mui/x-data-grid';
|
||||||
import ImageField from '../ImageField';
|
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter'
|
|
||||||
import DataGridMultiSelect from "../DataGridMultiSelect";
|
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
import ListActionsPopover from '../ListActionsPopover';
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import {hasPermission} from "../../helpers/userPermissions";
|
||||||
@ -38,9 +31,9 @@ export const loadColumns = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_REVIEWS')
|
const hasUpdatePermission = hasPermission(user, 'UPDATE_REVIEWS')
|
||||||
|
const isBusinessOwner = user?.app_role?.name === 'Verified Business Owner';
|
||||||
|
|
||||||
return [
|
const columns: any[] = [
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'business',
|
field: 'business',
|
||||||
headerName: 'Business',
|
headerName: 'Business',
|
||||||
@ -49,10 +42,7 @@ export const loadColumns = async (
|
|||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
editable: hasUpdatePermission && !isBusinessOwner,
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
sortable: false,
|
||||||
type: 'singleSelect',
|
type: 'singleSelect',
|
||||||
getOptionValue: (value: any) => value?.id,
|
getOptionValue: (value: any) => value?.id,
|
||||||
@ -60,9 +50,7 @@ export const loadColumns = async (
|
|||||||
valueOptions: await callOptionsApi('businesses'),
|
valueOptions: await callOptionsApi('businesses'),
|
||||||
valueGetter: (params: GridValueGetterParams) =>
|
valueGetter: (params: GridValueGetterParams) =>
|
||||||
params?.value?.id ?? params?.value,
|
params?.value?.id ?? params?.value,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'user',
|
field: 'user',
|
||||||
headerName: 'User',
|
headerName: 'User',
|
||||||
@ -71,10 +59,7 @@ export const loadColumns = async (
|
|||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
editable: hasUpdatePermission && !isBusinessOwner,
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
sortable: false,
|
||||||
type: 'singleSelect',
|
type: 'singleSelect',
|
||||||
getOptionValue: (value: any) => value?.id,
|
getOptionValue: (value: any) => value?.id,
|
||||||
@ -82,144 +67,89 @@ export const loadColumns = async (
|
|||||||
valueOptions: await callOptionsApi('users'),
|
valueOptions: await callOptionsApi('users'),
|
||||||
valueGetter: (params: GridValueGetterParams) =>
|
valueGetter: (params: GridValueGetterParams) =>
|
||||||
params?.value?.id ?? params?.value,
|
params?.value?.id ?? params?.value,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
field: 'lead',
|
|
||||||
headerName: 'Lead',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect',
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('leads'),
|
|
||||||
valueGetter: (params: GridValueGetterParams) =>
|
|
||||||
params?.value?.id ?? params?.value,
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'rating',
|
field: 'rating',
|
||||||
headerName: 'Rating',
|
headerName: 'Rating',
|
||||||
flex: 1,
|
flex: 0.5,
|
||||||
minWidth: 120,
|
minWidth: 80,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
editable: hasUpdatePermission && !isBusinessOwner,
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'text',
|
field: 'text',
|
||||||
headerName: 'Text',
|
headerName: 'Review',
|
||||||
flex: 1,
|
flex: 2,
|
||||||
minWidth: 120,
|
minWidth: 200,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
editable: hasUpdatePermission && !isBusinessOwner,
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'is_verified_job',
|
field: 'response',
|
||||||
headerName: 'IsVerifiedJob',
|
headerName: 'Your Response',
|
||||||
flex: 1,
|
flex: 2,
|
||||||
minWidth: 120,
|
minWidth: 200,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'status',
|
field: 'status',
|
||||||
headerName: 'Status',
|
headerName: 'Status',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 100,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
editable: hasUpdatePermission && !isBusinessOwner,
|
||||||
|
}
|
||||||
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: 'moderation_notes',
|
field: 'createdAt',
|
||||||
headerName: 'ModerationNotes',
|
headerName: 'Date',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 150,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'created_at_ts',
|
|
||||||
headerName: 'CreatedAt',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime',
|
type: 'dateTime',
|
||||||
valueGetter: (params: GridValueGetterParams) =>
|
valueGetter: (params: GridValueGetterParams) =>
|
||||||
new Date(params.row.created_at_ts),
|
new Date(params.row.createdAt),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
field: 'updated_at_ts',
|
|
||||||
headerName: 'UpdatedAt',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime',
|
|
||||||
valueGetter: (params: GridValueGetterParams) =>
|
|
||||||
new Date(params.row.updated_at_ts),
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'actions',
|
field: 'actions',
|
||||||
type: 'actions',
|
type: 'actions',
|
||||||
@ -227,7 +157,6 @@ export const loadColumns = async (
|
|||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
getActions: (params: GridRowParams) => {
|
getActions: (params: GridRowParams) => {
|
||||||
|
|
||||||
return [
|
return [
|
||||||
<div key={params?.row?.id}>
|
<div key={params?.row?.id}>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
@ -235,13 +164,13 @@ export const loadColumns = async (
|
|||||||
itemId={params?.row?.id}
|
itemId={params?.row?.id}
|
||||||
pathEdit={`/reviews/reviews-edit/?id=${params?.row?.id}`}
|
pathEdit={`/reviews/reviews-edit/?id=${params?.row?.id}`}
|
||||||
pathView={`/reviews/reviews-view/?id=${params?.row?.id}`}
|
pathView={`/reviews/reviews-view/?id=${params?.row?.id}`}
|
||||||
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
];
|
);
|
||||||
};
|
|
||||||
|
return columns;
|
||||||
|
};
|
||||||
@ -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 = 'created by Flatlogic generator!'
|
export const appTitle = 'Fix-It-Local'
|
||||||
|
|
||||||
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,6 +14,7 @@ export type MenuAsideItem = {
|
|||||||
withDevider?: boolean;
|
withDevider?: boolean;
|
||||||
menu?: MenuAsideItem[]
|
menu?: MenuAsideItem[]
|
||||||
permissions?: string | string[]
|
permissions?: string | string[]
|
||||||
|
roles?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MenuNavBarItem = {
|
export type MenuNavBarItem = {
|
||||||
@ -99,6 +100,10 @@ export interface User {
|
|||||||
updatedById?: any;
|
updatedById?: any;
|
||||||
avatar: any[];
|
avatar: any[];
|
||||||
notes: any[];
|
notes: any[];
|
||||||
|
businessId?: string;
|
||||||
|
app_role?: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StyleKey = 'white' | 'basic'
|
export type StyleKey = 'white' | 'basic'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
@ -88,6 +87,30 @@ export default function LayoutAuthenticated({
|
|||||||
|
|
||||||
const layoutAsidePadding = 'xl:pl-60'
|
const layoutAsidePadding = 'xl:pl-60'
|
||||||
|
|
||||||
|
// Filter and customize menu for Verified Business Owner
|
||||||
|
const filteredMenu = menuAside.filter(item => {
|
||||||
|
if (currentUser?.app_role?.name === 'Verified Business Owner') {
|
||||||
|
const allowedPaths = [
|
||||||
|
'/dashboard',
|
||||||
|
'/my-listing',
|
||||||
|
'/leads/leads-list',
|
||||||
|
'/reviews/reviews-list',
|
||||||
|
'/messages/messages-list',
|
||||||
|
'/profile',
|
||||||
|
'/billing',
|
||||||
|
];
|
||||||
|
return allowedPaths.includes(item.href);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).map(item => {
|
||||||
|
if (currentUser?.app_role?.name === 'Verified Business Owner') {
|
||||||
|
if (item.href === '/leads/leads-list') {
|
||||||
|
return { ...item, label: 'Service Requests' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||||
<div
|
<div
|
||||||
@ -118,7 +141,7 @@ export default function LayoutAuthenticated({
|
|||||||
<AsideMenu
|
<AsideMenu
|
||||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||||
isAsideLgActive={isAsideLgActive}
|
isAsideLgActive={isAsideLgActive}
|
||||||
menu={menuAside}
|
menu={filteredMenu}
|
||||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
@ -126,4 +149,4 @@ export default function LayoutAuthenticated({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,17 +1,111 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react';
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { mdiShieldCheck, mdiMenu, mdiClose, mdiMagnify } from '@mdi/js';
|
||||||
|
import { useAppSelector } from '../stores/hooks';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
import Logo from '../components/Logo';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LayoutGuest({ children }: Props) {
|
export default function LayoutGuest({ children }: Props) {
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: '/search', label: 'Find Services' },
|
||||||
|
{ href: '/register', label: 'List Business' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={darkMode ? 'dark' : ''}>
|
<div className={`${darkMode ? 'dark' : ''} min-h-screen flex flex-col`}>
|
||||||
<div className={`${bgColor} dark:bg-slate-800 dark:text-slate-100`}>{children}</div>
|
{/* Dynamic Header */}
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-lg border-b border-slate-200/60 dark:bg-slate-900/80 dark:border-slate-800">
|
||||||
|
<div className="container mx-auto px-6 h-20 flex items-center justify-between">
|
||||||
|
<Link href="/" className="flex items-center gap-3 group">
|
||||||
|
<Logo className="h-10 w-auto" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center gap-10">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`text-sm font-bold uppercase tracking-widest hover:text-emerald-500 transition-colors ${router.pathname === link.href ? 'text-emerald-500' : 'text-slate-600 dark:text-slate-400'}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="h-6 w-px bg-slate-200 dark:bg-slate-800"></div>
|
||||||
|
{currentUser ? (
|
||||||
|
<Link href="/dashboard" className="bg-slate-900 text-white dark:bg-white dark:text-slate-900 px-6 py-3 rounded-xl text-sm font-bold hover:shadow-xl transition-all">
|
||||||
|
Go to Dashboard
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/login" className="text-sm font-bold text-slate-600 hover:text-emerald-500 transition-colors">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link href="/register" className="bg-emerald-500 text-white px-6 py-3 rounded-xl text-sm font-bold hover:bg-emerald-600 shadow-lg shadow-emerald-500/20 transition-all">
|
||||||
|
Join Now
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button className="md:hidden p-2 text-slate-600 dark:text-slate-400" onClick={() => setIsMenuOpen(!isMenuOpen)}>
|
||||||
|
<BaseIcon path={isMenuOpen ? mdiClose : mdiMenu} size={28} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div className="md:hidden bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 p-6 space-y-6 animate-fade-in">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link key={link.href} href={link.href} className="block text-lg font-bold text-slate-700 dark:text-slate-300" onClick={() => setIsMenuOpen(false)}>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="pt-6 border-t border-slate-100 dark:border-slate-800 flex flex-col gap-4">
|
||||||
|
{currentUser ? (
|
||||||
|
<Link href="/dashboard" className="w-full bg-slate-900 text-white py-4 rounded-xl text-center font-bold" onClick={() => setIsMenuOpen(false)}>Dashboard</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/login" className="w-full text-center py-4 font-bold text-slate-600" onClick={() => setIsMenuOpen(false)}>Login</Link>
|
||||||
|
<Link href="/register" className="w-full bg-emerald-500 text-white py-4 rounded-xl text-center font-bold" onClick={() => setIsMenuOpen(false)}>Join Now</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={`flex-grow ${bgColor} dark:bg-slate-800 dark:text-slate-100`}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="bg-white border-t border-slate-200 py-12 dark:bg-slate-900 dark:border-slate-800">
|
||||||
|
<div className="container mx-auto px-6">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||||
|
<div className="flex items-center mb-6 md:mb-0">
|
||||||
|
<Logo className="h-10 w-auto" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8 text-slate-500 font-medium dark:text-slate-400">
|
||||||
|
<Link href="/search" className="hover:text-emerald-500">Find Help</Link>
|
||||||
|
<Link href="/register" className="hover:text-emerald-500">List Business</Link>
|
||||||
|
<Link href="/privacy-policy" className="hover:text-emerald-500">Privacy</Link>
|
||||||
|
<Link href="/terms-of-use" className="hover:text-emerald-500">Terms</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12 pt-8 border-t border-slate-100 dark:border-slate-800 text-center text-slate-400 text-sm">
|
||||||
|
© 2026 Fix-It-Local™. Built with Trust & Transparency.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -2,210 +2,143 @@ import * as icon from '@mdi/js';
|
|||||||
import { MenuAsideItem } from './interfaces'
|
import { MenuAsideItem } from './interfaces'
|
||||||
|
|
||||||
const menuAside: MenuAsideItem[] = [
|
const menuAside: MenuAsideItem[] = [
|
||||||
|
// Common
|
||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiStarFourPoints,
|
||||||
label: 'Dashboard',
|
label: 'Studio Hub',
|
||||||
|
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/dashboard',
|
||||||
|
icon: icon.mdiStarFourPoints,
|
||||||
|
label: 'Studio Hub',
|
||||||
|
roles: ['Verified Business Owner']
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin Only
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Clients',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiAccountHeart,
|
||||||
// @ts-ignore
|
permissions: 'READ_USERS',
|
||||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
roles: ['Administrator', 'Platform Owner']
|
||||||
permissions: 'READ_USERS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/roles/roles-list',
|
|
||||||
label: 'Roles',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_ROLES'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/permissions/permissions-list',
|
|
||||||
label: 'Permissions',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_PERMISSIONS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/refresh_tokens/refresh_tokens-list',
|
|
||||||
label: 'Refresh tokens',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiLock' in icon ? icon['mdiLock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_REFRESH_TOKENS'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/categories/categories-list',
|
href: '/categories/categories-list',
|
||||||
label: 'Categories',
|
label: 'Beauty Categories',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiLipstick,
|
||||||
// @ts-ignore
|
permissions: 'READ_CATEGORIES',
|
||||||
icon: 'mdiShape' in icon ? icon['mdiShape' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
roles: ['Administrator', 'Platform Owner']
|
||||||
permissions: 'READ_CATEGORIES'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/locations/locations-list',
|
href: '/locations/locations-list',
|
||||||
label: 'Locations',
|
label: 'Regions',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiMapMarkerRadius,
|
||||||
// @ts-ignore
|
permissions: 'READ_LOCATIONS',
|
||||||
icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
roles: ['Administrator', 'Platform Owner']
|
||||||
permissions: 'READ_LOCATIONS'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Admin and Platform Owner see all service listings
|
||||||
{
|
{
|
||||||
href: '/businesses/businesses-list',
|
href: '/businesses/businesses-list',
|
||||||
label: 'Businesses',
|
label: 'Service Listings',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiStorefront,
|
||||||
// @ts-ignore
|
permissions: 'READ_BUSINESSES',
|
||||||
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
roles: ['Administrator', 'Platform Owner']
|
||||||
permissions: 'READ_BUSINESSES'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Claim Requests (Admin Only)
|
||||||
{
|
{
|
||||||
href: '/business_photos/business_photos-list',
|
href: '/claim_requests/claim_requests-list',
|
||||||
label: 'Business photos',
|
label: 'Claim Requests',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiShieldCheck,
|
||||||
// @ts-ignore
|
permissions: 'READ_CLAIM_REQUESTS',
|
||||||
icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
roles: ['Administrator', 'Platform Owner']
|
||||||
permissions: 'READ_BUSINESS_PHOTOS'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Business Owner sees their My Listing page
|
||||||
{
|
{
|
||||||
href: '/business_categories/business_categories-list',
|
href: '/my-listing',
|
||||||
label: 'Business categories',
|
label: 'My Listing',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiStorefront,
|
||||||
// @ts-ignore
|
roles: ['Verified Business Owner']
|
||||||
icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_BUSINESS_CATEGORIES'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/service_prices/service_prices-list',
|
href: '/leads/leads-list',
|
||||||
label: 'Service prices',
|
label: 'Service Requests',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiCalendarHeart,
|
||||||
// @ts-ignore
|
permissions: 'READ_LEADS',
|
||||||
icon: 'mdiCurrencyUsd' in icon ? icon['mdiCurrencyUsd' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
roles: ['Verified Business Owner']
|
||||||
permissions: 'READ_SERVICE_PRICES'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/business_badges/business_badges-list',
|
|
||||||
label: 'Business badges',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiShieldCheck' in icon ? icon['mdiShieldCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_BUSINESS_BADGES'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/verification_submissions/verification_submissions-list',
|
|
||||||
label: 'Verification submissions',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiFileCheck' in icon ? icon['mdiFileCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_VERIFICATION_SUBMISSIONS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/verification_evidences/verification_evidences-list',
|
|
||||||
label: 'Verification evidences',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiFileUpload' in icon ? icon['mdiFileUpload' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_VERIFICATION_EVIDENCES'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/leads/leads-list',
|
href: '/leads/leads-list',
|
||||||
label: 'Leads',
|
label: 'Leads',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiCalendarHeart,
|
||||||
// @ts-ignore
|
permissions: 'READ_LEADS',
|
||||||
icon: 'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
roles: ['Administrator', 'Platform Owner']
|
||||||
permissions: 'READ_LEADS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/lead_photos/lead_photos-list',
|
|
||||||
label: 'Lead photos',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiCamera' in icon ? icon['mdiCamera' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_LEAD_PHOTOS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/lead_matches/lead_matches-list',
|
|
||||||
label: 'Lead matches',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiLinkVariant' in icon ? icon['mdiLinkVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_LEAD_MATCHES'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/messages/messages-list',
|
href: '/messages/messages-list',
|
||||||
label: 'Messages',
|
label: 'Consultations',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiMessageProcessing,
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiMessageText' in icon ? icon['mdiMessageText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_MESSAGES'
|
permissions: 'READ_MESSAGES'
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/lead_events/lead_events-list',
|
href: '/reviews/reviews-list',
|
||||||
label: 'Lead events',
|
label: 'Love Letters',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiStarFace,
|
||||||
// @ts-ignore
|
permissions: 'READ_REVIEWS',
|
||||||
icon: 'mdiTimelineText' in icon ? icon['mdiTimelineText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
|
||||||
permissions: 'READ_LEAD_EVENTS'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/reviews/reviews-list',
|
href: '/reviews/reviews-list',
|
||||||
label: 'Reviews',
|
label: 'Client Love',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiStarFace,
|
||||||
// @ts-ignore
|
permissions: 'READ_REVIEWS',
|
||||||
icon: 'mdiStar' in icon ? icon['mdiStar' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
roles: ['Verified Business Owner']
|
||||||
permissions: 'READ_REVIEWS'
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/verification_submissions/verification_submissions-list',
|
||||||
|
label: 'Verification',
|
||||||
|
icon: icon.mdiShieldCheck,
|
||||||
|
permissions: 'READ_VERIFICATION_SUBMISSIONS',
|
||||||
|
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
|
||||||
|
},
|
||||||
|
|
||||||
|
// Placeholder for Billing and Team
|
||||||
|
{
|
||||||
|
href: '/billing',
|
||||||
|
label: 'Earnings',
|
||||||
|
icon: icon.mdiWallet,
|
||||||
|
roles: ['Verified Business Owner']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/disputes/disputes-list',
|
href: '/billing-settings',
|
||||||
label: 'Disputes',
|
label: 'Global Billing',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiFinance,
|
||||||
// @ts-ignore
|
roles: ['Administrator', 'Platform Owner']
|
||||||
icon: 'mdiAlertOctagon' in icon ? icon['mdiAlertOctagon' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_DISPUTES'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Moderator
|
||||||
{
|
{
|
||||||
href: '/audit_logs/audit_logs-list',
|
href: '/audit_logs/audit_logs-list',
|
||||||
label: 'Audit logs',
|
label: 'Audit logs',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiClipboardListOutline,
|
||||||
// @ts-ignore
|
permissions: 'READ_AUDIT_LOGS',
|
||||||
icon: 'mdiClipboardList' in icon ? icon['mdiClipboardList' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
roles: ['Administrator', 'Platform Owner']
|
||||||
permissions: 'READ_AUDIT_LOGS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/badge_rules/badge_rules-list',
|
|
||||||
label: 'Badge rules',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiShieldCrown' in icon ? icon['mdiShieldCrown' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_BADGE_RULES'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/trust_adjustments/trust_adjustments-list',
|
|
||||||
label: 'Trust adjustments',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiTuneVariant' in icon ? icon['mdiTuneVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_TRUST_ADJUSTMENTS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/profile',
|
|
||||||
label: 'Profile',
|
|
||||||
icon: icon.mdiAccountCircle,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Profile/Settings
|
||||||
{
|
{
|
||||||
href: '/api-docs',
|
href: '/profile',
|
||||||
target: '_blank',
|
label: 'My Profile',
|
||||||
label: 'Swagger API',
|
icon: icon.mdiAccountSettings,
|
||||||
icon: icon.mdiFileCode,
|
|
||||||
permissions: 'READ_API_DOCS'
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default menuAside
|
export default menuAside
|
||||||
@ -47,7 +47,9 @@ const menuNavBar: MenuNavBarItem[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const webPagesNavBar = [
|
export const webPagesNavBar = [
|
||||||
|
{ href: '/search', label: 'Find Services' },
|
||||||
|
{ href: '/register', label: 'List Business' },
|
||||||
|
{ href: '/about', label: 'About Us' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export default menuNavBar
|
export default menuNavBar
|
||||||
@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
|||||||
setStepsEnabled(false);
|
setStepsEnabled(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = 'Crafted Network'
|
const title = 'Fix-It-Local'
|
||||||
const description = "Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking."
|
const description = "Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking."
|
||||||
const url = "https://flatlogic.com/"
|
const url = "https://flatlogic.com/"
|
||||||
const image = "https://project-screens.s3.amazonaws.com/screenshots/38501/app-hero-20260217-010030.png"
|
const image = "https://project-screens.s3.amazonaws.com/screenshots/38501/app-hero-20260217-010030.png"
|
||||||
|
|||||||
171
frontend/src/pages/billing.tsx
Normal file
171
frontend/src/pages/billing.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import * as icon from '@mdi/js';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
import SectionMain from '../components/SectionMain';
|
||||||
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
|
import CardBox from '../components/CardBox';
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
import { useAppSelector } from '../stores/hooks';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
const PlanCard = ({ plan, currentPlanId, onUpgrade }: any) => {
|
||||||
|
const isCurrent = plan.id === currentPlanId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBox className={`h-full flex flex-col ${isCurrent ? 'border-2 border-blue-500 ring-2 ring-blue-500 ring-opacity-10' : ''}`}>
|
||||||
|
<div className="flex-grow">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-xl font-bold">{plan.name}</h3>
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded-full font-bold">CURRENT PLAN</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<span className="text-4xl font-black">${plan.price}</span>
|
||||||
|
<span className="text-gray-500">/mo</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3 mb-8">
|
||||||
|
{plan.features?.map((feature: string, i: number) => (
|
||||||
|
<li key={i} className="flex items-center text-sm">
|
||||||
|
<BaseIcon path={icon.mdiCheckCircle} size={18} className="text-emerald-500 mr-2" />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<BaseButton
|
||||||
|
label={isCurrent ? 'Current Plan' : `Upgrade to ${plan.name}`}
|
||||||
|
color={isCurrent ? 'whiteDark' : 'info'}
|
||||||
|
disabled={isCurrent}
|
||||||
|
className="w-full mt-auto"
|
||||||
|
onClick={() => onUpgrade(plan)}
|
||||||
|
/>
|
||||||
|
</CardBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BillingPage = () => {
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const [plans, setPlans] = useState([]);
|
||||||
|
const [business, setBusiness] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [plansRes, metricsRes] = await Promise.all([
|
||||||
|
axios.get('/dashboard/business-metrics'), // reusing for business data
|
||||||
|
// We need a plans endpoint, but since I seeded them, I can mock or create one
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mocking plans for now based on migration seeds if no endpoint exists
|
||||||
|
setPlans([
|
||||||
|
{
|
||||||
|
id: '00000000-0000-0000-0000-000000000001',
|
||||||
|
name: 'Basic (Free)',
|
||||||
|
price: 0,
|
||||||
|
features: ['Limited Leads', 'Standard Listing'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-0000-0000-000000000002',
|
||||||
|
name: 'Professional',
|
||||||
|
price: 49.99,
|
||||||
|
features: ['Unlimited Leads', 'Priority Support', 'Enhanced Profile'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-0000-0000-000000000003',
|
||||||
|
name: 'Enterprise',
|
||||||
|
price: 199.99,
|
||||||
|
features: ['Custom Branding', 'API Access', 'Dedicated Manager'],
|
||||||
|
},
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
if (metricsRes.data.businesses?.length) {
|
||||||
|
setBusiness(metricsRes.data.businesses[0]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch billing data', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpgrade = (plan: any) => {
|
||||||
|
alert(`Upgrading to ${plan.name}. This would normally trigger a payment flow.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SectionMain>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<BaseIcon path={icon.mdiLoading} size={48} className="animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Billing & Plans')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton icon={icon.mdiCreditCardOutline} title="Billing & Plans" main />
|
||||||
|
|
||||||
|
{business && (
|
||||||
|
<div className="mb-8 p-6 bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-gray-100 dark:border-slate-800 flex flex-col md:flex-row justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-sm mb-1 uppercase tracking-wider font-bold">Current Plan</div>
|
||||||
|
<div className="text-2xl font-black">{business.plan?.name || 'Basic'}</div>
|
||||||
|
<div className="text-gray-500 text-sm mt-2">
|
||||||
|
Your plan renews on <span className="font-bold text-gray-900 dark:text-white">{moment(business.renewal_date).format('MMMM D, YYYY')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 md:mt-0 flex space-x-2">
|
||||||
|
<BaseButton label="View Invoices" color="white" small />
|
||||||
|
<BaseButton label="Payment Method" color="white" small />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{plans.map((plan: any) => (
|
||||||
|
<PlanCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
currentPlanId={business?.planId}
|
||||||
|
onUpgrade={handleUpgrade}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardBox className="mt-12 bg-gray-50 dark:bg-slate-800 border-dashed">
|
||||||
|
<div className="flex items-center p-4">
|
||||||
|
<div className="bg-blue-100 p-3 rounded-full mr-4">
|
||||||
|
<BaseIcon path={icon.mdiInformation} size={24} className="text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold">Need a custom plan?</h4>
|
||||||
|
<p className="text-sm text-gray-500">Contact our sales team for enterprise solutions tailored to your specific needs.</p>
|
||||||
|
</div>
|
||||||
|
<BaseButton label="Contact Sales" color="light" className="ml-auto" />
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
BillingPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingPage;
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
|||||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
import { mdiChartTimelineVariant, mdiPlus, mdiEye, mdiPencil, mdiShieldCheck, mdiCheckDecagram } from '@mdi/js'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import { uniqueId } from 'lodash';
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
import React, { ReactElement, useState } from 'react'
|
|
||||||
import CardBox from '../../components/CardBox'
|
import CardBox from '../../components/CardBox'
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||||
import SectionMain from '../../components/SectionMain'
|
import SectionMain from '../../components/SectionMain'
|
||||||
@ -10,147 +9,275 @@ import { getPageTitle } from '../../config'
|
|||||||
import TableBusinesses from '../../components/Businesses/TableBusinesses'
|
import TableBusinesses from '../../components/Businesses/TableBusinesses'
|
||||||
import BaseButton from '../../components/BaseButton'
|
import BaseButton from '../../components/BaseButton'
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Link from "next/link";
|
|
||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||||
import CardBoxModal from "../../components/CardBoxModal";
|
import {fetch} from '../../stores/businesses/businessesSlice';
|
||||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
import { useRouter } from 'next/router';
|
||||||
import {setRefetch, uploadCsv} from '../../stores/businesses/businessesSlice';
|
import IconRounded from '../../components/IconRounded';
|
||||||
|
import BaseButtons from '../../components/BaseButtons';
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const BusinessesTablesPage = () => {
|
const BusinessesTablesPage = () => {
|
||||||
const [filterItems, setFilterItems] = useState([]);
|
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
|
||||||
const [isModalActive, setIsModalActive] = useState(false);
|
|
||||||
const [showTableView, setShowTableView] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const { businesses, count, loading } = useAppSelector((state) => state.businesses);
|
||||||
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetch({ limit: 50, page: 0 }));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
const [filters] = useState([{label: 'Name', title: 'name'},{label: 'Slug', title: 'slug'},{label: 'Description', title: 'description'},{label: 'Phone', title: 'phone'},{label: 'Email', title: 'email'},{label: 'Website', title: 'website'},{label: 'Address', title: 'address'},{label: 'City', title: 'city'},{label: 'State', title: 'state'},{label: 'ZIP', title: 'zip'},{label: 'HoursJSON', title: 'hours_json'},{label: 'ReliabilityBreakdownJSON', title: 'reliability_breakdown_json'},{label: 'TenantKey', title: 'tenant_key'},
|
const isVBO = currentUser?.app_role?.name === 'Verified Business Owner';
|
||||||
{label: 'ReliabilityScore', title: 'reliability_score', number: 'true'},{label: 'ResponseTimeMedianMinutes', title: 'response_time_median_minutes', number: 'true'},
|
|
||||||
{label: 'Latitude', title: 'lat', number: 'true'},{label: 'Longitude', title: 'lng', number: 'true'},
|
// Completion calculation helper
|
||||||
{label: 'CreatedAt', title: 'created_at_ts', date: 'true'},{label: 'UpdatedAt', title: 'updated_at_ts', date: 'true'},
|
const calculateCompletion = (business) => {
|
||||||
|
const fields = ['name', 'description', 'phone', 'email', 'website', 'address', 'city', 'state', 'zip'];
|
||||||
|
let filled = 0;
|
||||||
{label: 'OwnerUser', title: 'owner_user'},
|
fields.forEach(f => {
|
||||||
|
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>
|
||||||
|
|
||||||
{label: 'AvailabilityStatus', title: 'availability_status', type: 'enum', options: ['AVAILABLE_TODAY','THIS_WEEK','BOOKED_OUT']},
|
<div className="flex flex-col items-center justify-center mt-12">
|
||||||
]);
|
<CardBox className="max-w-2xl w-full text-center py-12">
|
||||||
|
<IconRounded icon={mdiPlus} color="info" className="mb-6 mx-auto" />
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BUSINESSES');
|
<h1 className="text-3xl font-bold mb-4">Create your business listing</h1>
|
||||||
|
<p className="text-gray-500 mb-8 px-6 text-lg">
|
||||||
|
This is what customers see in search results. A complete profile helps you get more leads and builds trust with potential clients.
|
||||||
|
</p>
|
||||||
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
label="Create Listing"
|
||||||
|
icon={mdiPlus}
|
||||||
|
onClick={() => router.push('/businesses/businesses-new')}
|
||||||
|
className="px-8 py-3 text-lg"
|
||||||
|
/>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const addFilter = () => {
|
// State B: Exactly 1 listing exists
|
||||||
const newItem = {
|
if (isVBO && count === 1) {
|
||||||
id: uniqueId(),
|
const business = businesses[0];
|
||||||
fields: {
|
const completion = calculateCompletion(business);
|
||||||
filterValue: '',
|
|
||||||
filterValueFrom: '',
|
|
||||||
filterValueTo: '',
|
|
||||||
selectedField: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
newItem.fields.selectedField = filters[0].title;
|
|
||||||
setFilterItems([...filterItems, newItem]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBusinessesCSV = async () => {
|
return (
|
||||||
const response = await axios({url: '/businesses?filetype=csv', method: 'GET',responseType: 'blob'});
|
<>
|
||||||
const type = response.headers['content-type']
|
<Head>
|
||||||
const blob = new Blob([response.data], { type: type })
|
<title>{getPageTitle('My Listing')}</title>
|
||||||
const link = document.createElement('a')
|
</Head>
|
||||||
link.href = window.URL.createObjectURL(blob)
|
<SectionMain>
|
||||||
link.download = 'businessesCSV.csv'
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Listing Profile" main>
|
||||||
link.click()
|
<BaseButtons>
|
||||||
};
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
label="Preview Public Profile"
|
||||||
|
icon={mdiEye}
|
||||||
|
outline
|
||||||
|
onClick={() => window.open(`/public/businesses-details/?id=${business.id}`, '_blank')}
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
color="warning"
|
||||||
|
label="Edit"
|
||||||
|
icon={mdiPencil}
|
||||||
|
onClick={() => router.push(`/businesses/${business.id}`)}
|
||||||
|
/>
|
||||||
|
</BaseButtons>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
const onModalConfirm = async () => {
|
<CardBox className="mb-6">
|
||||||
if (!csvFile) return;
|
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
||||||
await dispatch(uploadCsv(csvFile));
|
<div>
|
||||||
dispatch(setRefetch(true));
|
<h2 className="text-2xl font-bold mb-1">{business.name}</h2>
|
||||||
setCsvFile(null);
|
<div className="flex items-center space-x-2">
|
||||||
setIsModalActive(false);
|
<span className={`px-2 py-1 rounded text-xs font-bold ${business.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
};
|
{business.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
{business.is_claimed && (
|
||||||
|
<span className="flex items-center text-blue-600 text-xs font-bold">
|
||||||
|
<BaseIcon path={mdiCheckDecagram} size={16} className="mr-1" />
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!business.is_claimed && (
|
||||||
|
<BaseButton
|
||||||
|
color="success"
|
||||||
|
label="Request Verification"
|
||||||
|
icon={mdiShieldCheck}
|
||||||
|
className="mt-4 md:mt-0"
|
||||||
|
onClick={() => router.push('/verification_submissions/verification_submissions-new')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
const onModalCancel = () => {
|
<div className="mt-8">
|
||||||
setCsvFile(null);
|
<div className="flex justify-between items-center mb-2">
|
||||||
setIsModalActive(false);
|
<span className="text-sm font-medium text-gray-700 font-bold">Profile completeness</span>
|
||||||
};
|
<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="Businesses" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={isVBO ? "My Locations" : "Service Listings"} main>
|
||||||
{''}
|
<BaseButton
|
||||||
|
color="info"
|
||||||
|
label={isVBO ? "Add another location" : "New Item"}
|
||||||
|
icon={mdiPlus}
|
||||||
|
onClick={() => router.push('/businesses/businesses-new')}
|
||||||
|
/>
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
|
||||||
|
{isVBO ? (
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/businesses/businesses-new'} color='info' label='New Item'/>}
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{businesses.map((business) => {
|
||||||
<BaseButton
|
const completion = calculateCompletion(business);
|
||||||
className={'mr-3'}
|
return (
|
||||||
color='info'
|
<CardBox key={business.id} className="hover:shadow-lg transition-shadow">
|
||||||
label='Filter'
|
<div className="flex justify-between items-start mb-4">
|
||||||
onClick={addFilter}
|
<h3 className="text-xl font-bold truncate pr-2">{business.name}</h3>
|
||||||
/>
|
<span className={`px-2 py-1 rounded text-xs font-bold ${business.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getBusinessesCSV} />
|
{business.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
{hasCreatePermission && (
|
</div>
|
||||||
<BaseButton
|
|
||||||
color='info'
|
<p className="text-sm text-gray-500 mb-4 h-10 overflow-hidden line-clamp-2">
|
||||||
label='Upload CSV'
|
{business.city ? `${business.city}, ${business.state}` : 'Location not set'}
|
||||||
onClick={() => setIsModalActive(true)}
|
</p>
|
||||||
/>
|
|
||||||
)}
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
<span className="text-xs font-bold">Completion</span>
|
||||||
<div id='delete-rows-button'></div>
|
<span className="text-xs font-bold">{completion}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
<div
|
||||||
<Link href={'/businesses/businesses-table'}>Switch to Table</Link>
|
className="bg-info h-2 rounded-full"
|
||||||
|
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>
|
<CardBox className="mb-6" hasTable>
|
||||||
|
<TableBusinesses
|
||||||
<CardBox className="mb-6" hasTable>
|
filterItems={[]}
|
||||||
<TableBusinesses
|
setFilterItems={() => { /* nothing to do */ }}
|
||||||
filterItems={filterItems}
|
filters={[]}
|
||||||
setFilterItems={setFilterItems}
|
showGrid={false}
|
||||||
filters={filters}
|
/>
|
||||||
showGrid={false}
|
</CardBox>
|
||||||
/>
|
)}
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
<CardBoxModal
|
|
||||||
title='Upload CSV'
|
|
||||||
buttonColor='info'
|
|
||||||
buttonLabel={'Confirm'}
|
|
||||||
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
|
|
||||||
isActive={isModalActive}
|
|
||||||
onConfirm={onModalConfirm}
|
|
||||||
onCancel={onModalCancel}
|
|
||||||
>
|
|
||||||
<DragDropFilePicker
|
|
||||||
file={csvFile}
|
|
||||||
setFile={setCsvFile}
|
|
||||||
formats={'.csv'}
|
|
||||||
/>
|
|
||||||
</CardBoxModal>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -158,13 +285,11 @@ 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
142
frontend/src/pages/claim_requests/claim_requests-list.tsx
Normal file
142
frontend/src/pages/claim_requests/claim_requests-list.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { mdiShieldCheck, mdiCheck, mdiClose, mdiEye } from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||||
|
import CardBox from '../../components/CardBox';
|
||||||
|
import BaseButton from '../../components/BaseButton';
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
|
||||||
|
const ClaimRequestsListPage = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [requests, setRequests] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRequests();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/claim_requests');
|
||||||
|
setRequests(response.data.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching claim requests:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (id: string, status: string) => {
|
||||||
|
let rejectionReason = '';
|
||||||
|
if (status === 'REJECTED') {
|
||||||
|
rejectionReason = prompt('Please enter rejection reason:') || 'Documentation insufficient';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await axios.put(`/claim_requests/${id}`, { data: { status, rejectionReason } });
|
||||||
|
fetchRequests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating claim request:', error);
|
||||||
|
alert('Failed to update request.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <SectionMain><LoadingSpinner /></SectionMain>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Claim Requests')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton icon={mdiShieldCheck} title="Claim Requests" main />
|
||||||
|
|
||||||
|
<CardBox className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-100">
|
||||||
|
<th className="p-4 font-bold text-slate-500 uppercase text-xs">Business</th>
|
||||||
|
<th className="p-4 font-bold text-slate-500 uppercase text-xs">User</th>
|
||||||
|
<th className="p-4 font-bold text-slate-500 uppercase text-xs">Status</th>
|
||||||
|
<th className="p-4 font-bold text-slate-500 uppercase text-xs">Date</th>
|
||||||
|
<th className="p-4 font-bold text-slate-500 uppercase text-xs text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requests.map((request) => (
|
||||||
|
<tr key={request.id} className="border-b border-slate-50 hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="font-bold">{request.business?.name}</div>
|
||||||
|
<div className="text-xs text-slate-400">{request.business?.city}, {request.business?.state}</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="font-medium">{request.user?.firstName} {request.user?.lastName}</div>
|
||||||
|
<div className="text-xs text-slate-400">{request.user?.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase ${
|
||||||
|
request.status === 'APPROVED' ? 'bg-emerald-50 text-emerald-600' :
|
||||||
|
request.status === 'REJECTED' ? 'bg-rose-50 text-rose-600' :
|
||||||
|
'bg-amber-50 text-amber-600'
|
||||||
|
}`}>
|
||||||
|
{request.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-sm text-slate-500">
|
||||||
|
{new Date(request.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<BaseButton
|
||||||
|
icon={mdiEye}
|
||||||
|
color="info"
|
||||||
|
small
|
||||||
|
href={`/public/businesses-details?id=${request.businessId}`}
|
||||||
|
target="_blank"
|
||||||
|
/>
|
||||||
|
{request.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<BaseButton
|
||||||
|
icon={mdiCheck}
|
||||||
|
color="success"
|
||||||
|
small
|
||||||
|
onClick={() => handleAction(request.id, 'APPROVED')}
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
icon={mdiClose}
|
||||||
|
color="danger"
|
||||||
|
small
|
||||||
|
onClick={() => handleAction(request.id, 'REJECTED')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{requests.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="p-10 text-center text-slate-400 italic">No claim requests found.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardBox>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ClaimRequestsListPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return (
|
||||||
|
<LayoutAuthenticated permission="READ_CLAIM_REQUESTS">
|
||||||
|
{page}
|
||||||
|
</LayoutAuthenticated>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClaimRequestsListPage;
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,79 +1,151 @@
|
|||||||
import React from 'react';
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { ToastContainer, toast } from 'react-toastify';
|
import { ToastContainer, toast } from 'react-toastify';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import BaseIcon from "../components/BaseIcon";
|
||||||
|
import { mdiShieldCheck } from '@mdi/js';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import FormField from '../components/FormField';
|
import FormField from '../components/FormField';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
import Link from 'next/link';
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||||
|
|
||||||
export default function Forgot() {
|
export default function Forgot() {
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const notify = (type, msg) => toast( msg, {type});
|
const notify = (type, msg) => toast(msg, { type });
|
||||||
|
|
||||||
|
const [illustrationImage, setIllustrationImage] = useState({
|
||||||
|
src: undefined,
|
||||||
|
photographer: undefined,
|
||||||
|
photographer_url: undefined,
|
||||||
|
})
|
||||||
|
const [illustrationVideo, setIllustrationVideo] = useState({ video_files: [] })
|
||||||
|
const [contentType, setContentType] = useState('image');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
const image = await getPexelsImage()
|
||||||
|
const video = await getPexelsVideo()
|
||||||
|
setIllustrationImage(image);
|
||||||
|
setIllustrationVideo(video);
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (value) => {
|
const handleSubmit = async (value) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const { data: response } = await axios.post('/auth/send-password-reset-email', value);
|
await axios.post('/auth/send-password-reset-email', value);
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
notify('success', 'Please check your email for verification link');
|
notify('success', 'Please check your email for reset link');
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await router.push('/login')
|
await router.push('/login')
|
||||||
}, 3000)
|
}, 3000)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
console.log('error: ', error)
|
console.log('error: ', error)
|
||||||
notify('error', 'Something was wrong. Try again')
|
notify('error', 'Something went wrong. Try again')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const imageBlock = (image) => (
|
||||||
|
<div className="hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(16, 185, 129, 0.1), rgba(6, 78, 59, 0.2))'}`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}}>
|
||||||
|
<div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
|
||||||
|
<div className="relative z-10 p-12 text-white">
|
||||||
|
<h1 className="text-4xl font-black mb-4">Secure Your Account.</h1>
|
||||||
|
<p className="text-lg text-emerald-50/80 max-w-md leading-relaxed">
|
||||||
|
We'll help you get back into your account safely and securely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center w-full bg-black/40 py-2 relative z-10">
|
||||||
|
<a className="text-[10px] text-white/60 hover:text-white transition-colors" href={image?.photographer_url} target="_blank" rel="noreferrer">
|
||||||
|
Photo by {image?.photographer} on Pexels
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-slate-50 font-sans">
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Login')}</title>
|
<title>{getPageTitle('Forgot Password')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="flex flex-row min-h-screen">
|
||||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
{imageBlock(illustrationImage)}
|
||||||
<Formik
|
|
||||||
initialValues={{
|
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
|
||||||
email: '',
|
<div className="w-full max-w-md space-y-8">
|
||||||
}}
|
{/* Branding */}
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
<div className="flex flex-col items-center mb-8">
|
||||||
>
|
<Link href="/" className="flex items-center gap-3 group mb-6">
|
||||||
<Form>
|
<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">
|
||||||
<FormField label='Email' help='Please enter your email'>
|
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
|
||||||
<Field name='email' />
|
</div>
|
||||||
</FormField>
|
<span className="text-2xl font-black tracking-tight text-slate-900">
|
||||||
|
Fix-It-Local<span className="text-emerald-500 italic">™</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 text-center">Forgot Password?</h2>
|
||||||
|
<p className="text-slate-500 mt-2 text-center">Enter your email and we'll send you a link to reset your password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<BaseDivider />
|
<CardBox className="shadow-2xl border-none rounded-[2rem] p-4 lg:p-6">
|
||||||
|
<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>
|
||||||
|
|
||||||
<BaseButtons>
|
<div className="pt-2">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
type='submit'
|
className={'w-full py-4 rounded-xl font-bold text-lg shadow-lg shadow-emerald-500/20'}
|
||||||
label={loading ? 'Loading...' : 'Submit' }
|
type='submit'
|
||||||
color='info'
|
label={loading ? 'Sending link...' : 'Send Reset Link'}
|
||||||
/>
|
color='success'
|
||||||
<BaseButton
|
disabled={loading}
|
||||||
href={'/login'}
|
/>
|
||||||
label={'Login'}
|
</div>
|
||||||
color='info'
|
|
||||||
/>
|
<div className="text-center pt-2">
|
||||||
</BaseButtons>
|
<Link className="font-bold text-emerald-600 hover:text-emerald-700 text-sm" href={'/login'}>
|
||||||
</Form>
|
Back to Login
|
||||||
</Formik>
|
</Link>
|
||||||
</CardBox>
|
</div>
|
||||||
</SectionFullScreen>
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<div className="text-center text-slate-400 text-xs pt-8">
|
||||||
|
© 2026 Fix-It-Local™. All rights reserved. <br/>
|
||||||
|
<Link href='/privacy-policy/' className="hover:text-slate-600 mt-2 inline-block">Privacy Policy</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,166 +1,250 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
import { useRouter } from 'next/router';
|
||||||
import CardBox from '../components/CardBox';
|
import {
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
mdiMagnify,
|
||||||
|
mdiMapMarker,
|
||||||
|
mdiShieldCheck,
|
||||||
|
mdiCurrencyUsd,
|
||||||
|
mdiFlash,
|
||||||
|
mdiTools,
|
||||||
|
mdiPowerPlug,
|
||||||
|
mdiAirConditioner,
|
||||||
|
mdiBrush,
|
||||||
|
mdiFormatPaint,
|
||||||
|
mdiClipboardTextOutline,
|
||||||
|
mdiCheckCircleOutline
|
||||||
|
} from '@mdi/js';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import BaseButtons from '../components/BaseButtons';
|
import { fetch as fetchCategories } from '../stores/categories/categoriesSlice';
|
||||||
import { getPageTitle } from '../config';
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { categories } = useAppSelector((state) => state.categories);
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
export default function Starter() {
|
useEffect(() => {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
dispatch(fetchCategories({ query: '?limit=8' }));
|
||||||
src: undefined,
|
}, [dispatch]);
|
||||||
photographer: undefined,
|
|
||||||
photographer_url: undefined,
|
|
||||||
})
|
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
|
||||||
const [contentType, setContentType] = useState('video');
|
|
||||||
const [contentPosition, setContentPosition] = useState('left');
|
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
|
||||||
|
|
||||||
const title = 'Crafted Network'
|
const featuredCategories = [
|
||||||
|
{ name: 'Plumbing', icon: mdiTools, color: 'text-blue-500' },
|
||||||
|
{ name: 'Electrical', icon: mdiPowerPlug, color: 'text-yellow-500' },
|
||||||
|
{ name: 'HVAC', icon: mdiAirConditioner, color: 'text-emerald-500' },
|
||||||
|
{ name: 'Cleaning', icon: mdiBrush, color: 'text-purple-500' },
|
||||||
|
{ name: 'Painting', icon: mdiFormatPaint, color: 'text-orange-500' },
|
||||||
|
{ name: 'General', icon: mdiTools, color: 'text-slate-500' },
|
||||||
|
];
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
useEffect(() => {
|
e.preventDefault();
|
||||||
async function fetchData() {
|
const formData = new FormData(e.target as HTMLFormElement);
|
||||||
const image = await getPexelsImage();
|
const query = formData.get('query');
|
||||||
const video = await getPexelsVideo();
|
const location = formData.get('location');
|
||||||
setIllustrationImage(image);
|
router.push({
|
||||||
setIllustrationVideo(video);
|
pathname: '/search',
|
||||||
}
|
query: { query, location },
|
||||||
fetchData();
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>Fix-It-Local™ | 21st Century Service Directory</title>
|
||||||
|
<meta name="description" content="Connect with verified service professionals. Trust, transparency, and AI-powered matching." />
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
{/* Hero Section */}
|
||||||
<div
|
<section className="relative bg-slate-900 text-white overflow-hidden py-32 lg:py-48">
|
||||||
className={`flex ${
|
<div className="absolute inset-0 opacity-20">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div className="absolute top-0 -left-4 w-72 h-72 bg-emerald-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
|
||||||
} min-h-screen w-full`}
|
<div className="absolute top-0 -right-4 w-72 h-72 bg-blue-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
|
||||||
>
|
<div className="absolute -bottom-8 left-20 w-72 h-72 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-4000"></div>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
|
||||||
? imageBlock(illustrationImage)
|
|
||||||
: null}
|
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
|
||||||
? videoBlock(illustrationVideo)
|
|
||||||
: null}
|
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<CardBoxComponentTitle title="Welcome to your Crafted Network app!"/>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
|
||||||
</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 className="container mx-auto px-6 relative z-10">
|
||||||
|
<div className="text-center max-w-4xl mx-auto">
|
||||||
|
<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">
|
||||||
|
<BaseIcon path={mdiShieldCheck} size={18} className="mr-2" />
|
||||||
|
Verified Professionals & AI-Powered Matching
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl lg:text-7xl font-bold tracking-tight mb-8">
|
||||||
|
The <span className="text-emerald-400">Fix-It-Local</span> Service Network
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-slate-400 mb-12 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
Find reliable, verified experts for your home or business. Real-time availability, transparent pricing, and zero spam.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4 p-2 bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 shadow-2xl max-w-3xl mx-auto">
|
||||||
|
<div className="flex-grow relative">
|
||||||
|
<BaseIcon path={mdiMagnify} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="query"
|
||||||
|
placeholder="What service do you need?"
|
||||||
|
className="w-full bg-transparent border-none focus:ring-0 py-4 pl-12 pr-4 text-white placeholder-slate-500 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-px h-8 bg-white/10 my-auto"></div>
|
||||||
|
<div className="flex-grow relative">
|
||||||
|
<BaseIcon path={mdiMapMarker} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="location"
|
||||||
|
placeholder="Location"
|
||||||
|
className="w-full bg-transparent border-none focus:ring-0 py-4 pl-12 pr-4 text-white placeholder-slate-500 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-4 px-8 rounded-xl transition-all shadow-lg shadow-emerald-500/25">
|
||||||
|
Find Help
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Featured Categories */}
|
||||||
|
<section className="py-24 container mx-auto px-6">
|
||||||
|
<div className="flex items-end justify-between mb-12">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold mb-4">Popular Services</h2>
|
||||||
|
<p className="text-slate-500">Explore our most requested categories from verified pros.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/categories/categories-list" className="text-emerald-500 font-semibold hover:underline flex items-center">
|
||||||
|
View All Categories
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||||
|
{(categories?.length > 0 ? categories.slice(0, 6) : featuredCategories).map((cat: any, i: number) => (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
href={`/search?query=${cat.name}`}
|
||||||
|
className="group bg-white p-8 rounded-3xl border border-slate-200 hover:border-emerald-500 hover:shadow-xl hover:shadow-emerald-500/10 transition-all text-center"
|
||||||
|
>
|
||||||
|
<div className={`mb-4 w-16 h-16 mx-auto rounded-2xl flex items-center justify-center bg-slate-50 group-hover:bg-emerald-50 transition-colors`}>
|
||||||
|
<BaseIcon path={cat.icon || mdiTools} size={32} className={cat.color || 'text-emerald-500'} />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-slate-700 group-hover:text-emerald-600 transition-colors">{cat.name}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* How it Works Section */}
|
||||||
|
<section className="py-24 bg-white border-y border-slate-200 overflow-hidden">
|
||||||
|
<div className="container mx-auto px-6">
|
||||||
|
<div className="text-center max-w-3xl mx-auto mb-20">
|
||||||
|
<h2 className="text-4xl font-bold mb-6 text-slate-900 leading-tight">Simplified Discovery. Trusted Connections.</h2>
|
||||||
|
<p className="text-lg text-slate-500 leading-relaxed">
|
||||||
|
Fix-It-Local connects homeowners and businesses with verified professionals through a transparent, AI-powered process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-4 gap-12 relative">
|
||||||
|
{/* Connector line for desktop */}
|
||||||
|
<div className="hidden lg:block absolute top-1/4 left-[10%] right-[10%] h-px bg-slate-200 -z-0"></div>
|
||||||
|
|
||||||
|
<div className="relative z-10 text-center flex flex-col items-center">
|
||||||
|
<div className="w-20 h-20 bg-emerald-500 text-white rounded-3xl flex items-center justify-center mb-8 shadow-xl shadow-emerald-500/20 transform hover:scale-110 transition-transform">
|
||||||
|
<BaseIcon path={mdiMagnify} size={36} />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold text-xl mb-4">1. Find a Pro</h4>
|
||||||
|
<p className="text-slate-500 text-sm leading-relaxed max-w-[200px]">Browse categories or search for specific verified services near you.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
LandingPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -52,7 +52,8 @@ const LeadsTablesPage = () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_LEADS');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_LEADS');
|
||||||
|
const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner';
|
||||||
|
const pageTitle = isBusinessOwner ? 'Service Requests' : 'Leads';
|
||||||
|
|
||||||
const addFilter = () => {
|
const addFilter = () => {
|
||||||
const newItem = {
|
const newItem = {
|
||||||
@ -94,10 +95,10 @@ const LeadsTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Leads')}</title>
|
<title>{getPageTitle(pageTitle)}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Leads" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={pageTitle} main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
@ -169,4 +170,4 @@ LeadsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LeadsTablesPage
|
export default LeadsTablesPage
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,10 @@
|
|||||||
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import BaseIcon from "../components/BaseIcon";
|
import BaseIcon from "../components/BaseIcon";
|
||||||
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
import { mdiInformation, mdiEye, mdiEyeOff, mdiShieldCheck } from '@mdi/js';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
@ -21,12 +19,13 @@ import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||||
|
import Logo from '../components/Logo'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
const textColor = 'text-emerald-600';
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
const iconsColor = 'text-emerald-500';
|
||||||
const notify = (type, msg) => toast(msg, { type });
|
const notify = (type, msg) => toast(msg, { type });
|
||||||
const [ illustrationImage, setIllustrationImage ] = useState({
|
const [ illustrationImage, setIllustrationImage ] = useState({
|
||||||
src: undefined,
|
src: undefined,
|
||||||
@ -34,7 +33,7 @@ export default function Login() {
|
|||||||
photographer_url: undefined,
|
photographer_url: undefined,
|
||||||
})
|
})
|
||||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
||||||
const [contentType, setContentType] = useState('video');
|
const [contentType, setContentType] = useState('image');
|
||||||
const [contentPosition, setContentPosition] = useState('left');
|
const [contentPosition, setContentPosition] = useState('left');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||||
@ -44,7 +43,7 @@ export default function Login() {
|
|||||||
password: 'b2096650',
|
password: 'b2096650',
|
||||||
remember: true })
|
remember: true })
|
||||||
|
|
||||||
const title = 'Crafted Network'
|
const title = 'Fix-It-Local'
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
// Fetch Pexels image/video
|
||||||
useEffect( () => {
|
useEffect( () => {
|
||||||
@ -101,16 +100,24 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
const imageBlock = (image) => (
|
||||||
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
|
<div className="hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
|
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(16, 185, 129, 0.1), rgba(6, 78, 59, 0.2))'}`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'left center',
|
backgroundPosition: 'center',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
}}>
|
}}>
|
||||||
<div className="flex justify-center w-full bg-blue-300/20">
|
<div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
|
||||||
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
|
<div className="relative z-10 p-12 text-white">
|
||||||
by {image?.photographer} on Pexels</a>
|
<h1 className="text-4xl font-black mb-4">Welcome Back to the Network.</h1>
|
||||||
|
<p className="text-lg text-emerald-50/80 max-w-md leading-relaxed">
|
||||||
|
Join thousands of verified professionals and clients in the most transparent service ecosystem.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center w-full bg-black/40 py-2 relative z-10">
|
||||||
|
<a className="text-[10px] text-white/60 hover:text-white transition-colors" href={image?.photographer_url} target="_blank" rel="noreferrer">
|
||||||
|
Photo by {image?.photographer} on Pexels
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -118,7 +125,7 @@ export default function Login() {
|
|||||||
const videoBlock = (video) => {
|
const videoBlock = (video) => {
|
||||||
if (video?.video_files?.length > 0) {
|
if (video?.video_files?.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
<div className='hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden'>
|
||||||
<video
|
<video
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||||
autoPlay
|
autoPlay
|
||||||
@ -128,9 +135,16 @@ export default function Login() {
|
|||||||
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
<div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
|
||||||
|
<div className="relative z-10 p-12 text-white">
|
||||||
|
<h1 className="text-4xl font-black mb-4">Welcome Back to the Network.</h1>
|
||||||
|
<p className="text-lg text-emerald-50/80 max-w-md leading-relaxed">
|
||||||
|
Join thousands of verified professionals and clients in the most transparent service ecosystem.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-center w-full bg-black/40 py-2 relative z-10'>
|
||||||
<a
|
<a
|
||||||
className='text-[8px]'
|
className='text-[10px] text-white/60 hover:text-white transition-colors'
|
||||||
href={video.user.url}
|
href={video.user.url}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noreferrer'
|
rel='noreferrer'
|
||||||
@ -140,131 +154,122 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
return imageBlock(illustrationImage);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={contentPosition === 'background' ? {
|
<div className="min-h-screen bg-slate-50 font-sans">
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
} : {}}>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Login')}</title>
|
<title>{getPageTitle('Login')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="flex flex-row min-h-screen">
|
||||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
{contentType === 'video' ? videoBlock(illustrationVideo) : imageBlock(illustrationImage)}
|
||||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
|
||||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
{/* Branding */}
|
||||||
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
<div className="flex flex-col items-center mb-8">
|
||||||
|
<Link href="/" className="flex items-center gap-3 group mb-6">
|
||||||
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
<Logo className="h-12 w-auto" />
|
||||||
|
</Link>
|
||||||
<div className='flex flex-row text-gray-500 justify-between'>
|
<h2 className="text-3xl font-bold text-slate-900">Account Login</h2>
|
||||||
<div>
|
<p className="text-slate-500 mt-2">Enter your credentials to access your dashboard</p>
|
||||||
|
|
||||||
<p className='mb-2'>Use{' '}
|
|
||||||
<code className={`cursor-pointer ${textColor} `}
|
|
||||||
data-password="b2096650"
|
|
||||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
|
||||||
<code className={`${textColor}`}>b2096650</code>{' / '}
|
|
||||||
to login as Admin</p>
|
|
||||||
<p>Use <code
|
|
||||||
className={`cursor-pointer ${textColor} `}
|
|
||||||
data-password="7302e7d1c0fe"
|
|
||||||
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
|
||||||
<code className={`${textColor}`}>7302e7d1c0fe</code>{' / '}
|
|
||||||
to login as User</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={mdiInformation}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<Formik
|
|
||||||
initialValues={initialValues}
|
|
||||||
enableReinitialize
|
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
<Form>
|
|
||||||
<FormField
|
|
||||||
label='Login'
|
|
||||||
help='Please enter your login'>
|
|
||||||
<Field name='email' />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className='relative'>
|
<CardBox className="shadow-2xl border-none rounded-[2rem] p-4 lg:p-6">
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
enableReinitialize
|
||||||
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
|
>
|
||||||
|
<Form className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
label='Password'
|
label='Email Address'
|
||||||
help='Please enter your password'>
|
labelColor="text-slate-700 font-bold"
|
||||||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
|
||||||
</FormField>
|
|
||||||
<div
|
|
||||||
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
|
|
||||||
onClick={togglePasswordVisibility}
|
|
||||||
>
|
>
|
||||||
<BaseIcon
|
<Field name='email' placeholder="name@company.com" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
|
||||||
className='text-gray-500 hover:text-gray-700'
|
</FormField>
|
||||||
size={20}
|
|
||||||
path={showPassword ? mdiEyeOff : mdiEye}
|
<div className='relative'>
|
||||||
|
<FormField
|
||||||
|
label='Password'
|
||||||
|
labelColor="text-slate-700 font-bold"
|
||||||
|
>
|
||||||
|
<Field name='password' type={showPassword ? 'text' : 'password'} placeholder="••••••••" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
|
||||||
|
</FormField>
|
||||||
|
<div
|
||||||
|
className='absolute top-[42px] right-0 pr-4 flex items-center cursor-pointer'
|
||||||
|
onClick={togglePasswordVisibility}
|
||||||
|
>
|
||||||
|
<BaseIcon
|
||||||
|
className='text-slate-400 hover:text-emerald-500'
|
||||||
|
size={20}
|
||||||
|
path={showPassword ? mdiEyeOff : mdiEye}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex justify-between items-center text-sm'}>
|
||||||
|
<FormCheckRadio type='checkbox' label='Keep me logged in'>
|
||||||
|
<Field type='checkbox' name='remember' />
|
||||||
|
</FormCheckRadio>
|
||||||
|
|
||||||
|
<Link className="font-semibold text-emerald-600 hover:text-emerald-700" href={'/forgot'}>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<BaseButton
|
||||||
|
className={'w-full py-4 rounded-xl font-bold text-lg shadow-lg shadow-emerald-500/20'}
|
||||||
|
type='submit'
|
||||||
|
label={isFetching ? 'Signing in...' : 'Sign In'}
|
||||||
|
color='success'
|
||||||
|
disabled={isFetching}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center pt-4">
|
||||||
|
<p className="text-slate-500 text-sm">
|
||||||
|
Don't have an account yet?{' '}
|
||||||
|
<Link className="font-bold text-emerald-600 hover:text-emerald-700" href={'/register'}>
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
{/* Demo Access Card - Styled more like a hint/badge */}
|
||||||
|
<div className="bg-slate-100/80 backdrop-blur rounded-2xl p-6 border border-slate-200">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center shrink-0 shadow-sm">
|
||||||
|
<BaseIcon path={mdiInformation} size={20} className="text-emerald-500" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div className={'flex justify-between'}>
|
<h4 className="font-bold text-slate-900 text-sm mb-2">Demo Access</h4>
|
||||||
<FormCheckRadio type='checkbox' label='Remember'>
|
<div className="space-y-2 text-xs text-slate-600">
|
||||||
<Field type='checkbox' name='remember' />
|
<p>
|
||||||
</FormCheckRadio>
|
<span className="font-semibold">Admin:</span>{' '}
|
||||||
|
<code className="cursor-pointer text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded" data-password="b2096650" onClick={(e) => setLogin(e.target as HTMLElement)}>admin@flatlogic.com</code>
|
||||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
</p>
|
||||||
Forgot password?
|
<p>
|
||||||
</Link>
|
<span className="font-semibold">Client:</span>{' '}
|
||||||
|
<code className="cursor-pointer text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded" data-password="7302e7d1c0fe" onClick={(e) => setLogin(e.target as HTMLElement)}>client@hello.com</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<BaseDivider />
|
<div className="text-center text-slate-400 text-xs pt-8">
|
||||||
|
© 2026 Fix-It-Local™. All rights reserved. <br/>
|
||||||
<BaseButtons>
|
<Link href='/privacy-policy/' className="hover:text-slate-600 mt-2 inline-block">Privacy Policy</Link>
|
||||||
<BaseButton
|
</div>
|
||||||
className={'w-full'}
|
</div>
|
||||||
type='submit'
|
|
||||||
label={isFetching ? 'Loading...' : 'Login'}
|
|
||||||
color='info'
|
|
||||||
disabled={isFetching}
|
|
||||||
/>
|
|
||||||
</BaseButtons>
|
|
||||||
<br />
|
|
||||||
<p className={'text-center'}>
|
|
||||||
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>
|
||||||
@ -273,4 +278,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 { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
|
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
import CardBox from '../../components/CardBox'
|
import CardBox from '../../components/CardBox'
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||||
import SectionMain from '../../components/SectionMain'
|
import SectionMain from '../../components/SectionMain'
|
||||||
@ -12,128 +12,42 @@ import FormField from '../../components/FormField'
|
|||||||
import BaseDivider from '../../components/BaseDivider'
|
import BaseDivider from '../../components/BaseDivider'
|
||||||
import BaseButtons from '../../components/BaseButtons'
|
import BaseButtons from '../../components/BaseButtons'
|
||||||
import BaseButton from '../../components/BaseButton'
|
import BaseButton from '../../components/BaseButton'
|
||||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
|
||||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
|
||||||
import FormFilePicker from '../../components/FormFilePicker'
|
|
||||||
import FormImagePicker from '../../components/FormImagePicker'
|
|
||||||
import { SwitchField } from '../../components/SwitchField'
|
|
||||||
|
|
||||||
import { SelectField } from '../../components/SelectField'
|
import { SelectField } from '../../components/SelectField'
|
||||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
|
||||||
import {RichTextField} from "../../components/RichTextField";
|
|
||||||
|
|
||||||
import { create } from '../../stores/messages/messagesSlice'
|
import { create } from '../../stores/messages/messagesSlice'
|
||||||
import { useAppDispatch } from '../../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import moment from 'moment';
|
|
||||||
|
|
||||||
const initialValues = {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
lead: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
sender_user: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
receiver_user: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
body: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
read_at: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
created_at_ts: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const MessagesNew = () => {
|
const MessagesNew = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const { leadId, receiverId } = router.query;
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
lead: leadId || '',
|
||||||
|
sender_user: currentUser?.id || '',
|
||||||
|
receiver_user: receiverId || '',
|
||||||
|
body: '',
|
||||||
|
read_at: null,
|
||||||
|
created_at_ts: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const [formInitialValues, setFormInitialValues] = useState(initialValues);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (leadId || receiverId) {
|
||||||
|
setFormInitialValues({
|
||||||
|
...initialValues,
|
||||||
|
lead: leadId || '',
|
||||||
|
sender_user: currentUser?.id || '',
|
||||||
|
receiver_user: receiverId || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [leadId, receiverId, currentUser]);
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
const handleSubmit = async (data) => {
|
||||||
await dispatch(create(data))
|
await dispatch(create(data))
|
||||||
@ -142,219 +56,38 @@ const MessagesNew = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('New Item')}</title>
|
<title>{getPageTitle('Send Message')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Send Message" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox>
|
<CardBox>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={
|
enableReinitialize
|
||||||
|
initialValues={formInitialValues}
|
||||||
initialValues
|
|
||||||
|
|
||||||
}
|
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
>
|
>
|
||||||
<Form>
|
<Form>
|
||||||
|
<FormField label="Lead" labelFor="lead">
|
||||||
|
<Field name="lead" id="lead" component={SelectField} options={formInitialValues.lead ? [formInitialValues.lead] : []} itemRef={'leads'}></Field>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Sender" labelFor="sender_user">
|
||||||
|
<Field name="sender_user" id="sender_user" component={SelectField} options={formInitialValues.sender_user ? [formInitialValues.sender_user] : []} itemRef={'users'} showField={'firstName'}></Field>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="To" labelFor="receiver_user">
|
||||||
|
<Field name="receiver_user" id="receiver_user" component={SelectField} options={formInitialValues.receiver_user ? [formInitialValues.receiver_user] : []} itemRef={'users'} showField={'firstName'}></Field>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Message Content" hasTextareaHeight>
|
||||||
|
<Field name="body" as="textarea" placeholder="Type your message here..." />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Lead" labelFor="lead">
|
|
||||||
<Field name="lead" id="lead" component={SelectField} options={[]} itemRef={'leads'}></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="SenderUser" labelFor="sender_user">
|
|
||||||
<Field name="sender_user" id="sender_user" component={SelectField} options={[]} itemRef={'users'}></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="ReceiverUser" labelFor="receiver_user">
|
|
||||||
<Field name="receiver_user" id="receiver_user" component={SelectField} options={[]} itemRef={'users'}></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Body" hasTextareaHeight>
|
|
||||||
<Field name="body" as="textarea" placeholder="Body" />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="ReadAt"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="datetime-local"
|
|
||||||
name="read_at"
|
|
||||||
placeholder="ReadAt"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="CreatedAt"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="datetime-local"
|
|
||||||
name="created_at_ts"
|
|
||||||
placeholder="CreatedAt"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
<BaseDivider />
|
||||||
<BaseButtons>
|
<BaseButtons>
|
||||||
<BaseButton type="submit" color="info" label="Submit" />
|
<BaseButton type="submit" color="info" label="Send Message" />
|
||||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
|
||||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/messages/messages-list')}/>
|
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/messages/messages-list')}/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</Form>
|
</Form>
|
||||||
@ -368,13 +101,11 @@ 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
|
||||||
396
frontend/src/pages/my-listing.tsx
Normal file
396
frontend/src/pages/my-listing.tsx
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import {
|
||||||
|
mdiStorefront,
|
||||||
|
mdiShieldCheck,
|
||||||
|
mdiAlertCircle,
|
||||||
|
mdiCheckCircle,
|
||||||
|
mdiPlus,
|
||||||
|
mdiMagnify,
|
||||||
|
mdiPencil,
|
||||||
|
mdiCamera,
|
||||||
|
mdiDelete,
|
||||||
|
mdiUpload
|
||||||
|
} from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
import SectionMain from '../components/SectionMain';
|
||||||
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
|
import CardBox from '../components/CardBox';
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import { useAppSelector } from '../stores/hooks';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
import { Form, Formik, Field } from 'formik';
|
||||||
|
import FormField from '../components/FormField';
|
||||||
|
import FormImagePicker from '../components/FormImagePicker';
|
||||||
|
|
||||||
|
const MyListingPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [myBusiness, setMyBusiness] = useState<any>(null);
|
||||||
|
const [pendingClaim, setPendingClaim] = useState<any>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentUser) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 1. Fetch owned business
|
||||||
|
let business = null;
|
||||||
|
if (currentUser.businessId) {
|
||||||
|
const res = await axios.get(`/businesses/${currentUser.businessId}`);
|
||||||
|
business = res.data;
|
||||||
|
} else {
|
||||||
|
// Search by owner_userId if businessId is not set on user record yet
|
||||||
|
const res = await axios.get('/businesses', { params: { owner_userId: currentUser.id } });
|
||||||
|
if (res.data.rows && res.data.rows.length > 0) {
|
||||||
|
const resById = await axios.get(`/businesses/${res.data.rows[0].id}`);
|
||||||
|
business = resById.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMyBusiness(business);
|
||||||
|
|
||||||
|
// 2. If no business, fetch pending claim
|
||||||
|
if (!business) {
|
||||||
|
const res = await axios.get('/claim_requests', { params: { userId: currentUser.id, status: 'PENDING' } });
|
||||||
|
if (res.data.rows && res.data.rows.length > 0) {
|
||||||
|
setPendingClaim(res.data.rows[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoUpload = async (values: any, { resetForm }: any) => {
|
||||||
|
if (!myBusiness) return;
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
// Check if we already have a business_photos record
|
||||||
|
const existingPhotosRecord = myBusiness.business_photos_business && myBusiness.business_photos_business[0];
|
||||||
|
|
||||||
|
if (existingPhotosRecord) {
|
||||||
|
// Update existing record with NEW photos (append)
|
||||||
|
await axios.put(`/business_photos/${existingPhotosRecord.id}`, {
|
||||||
|
id: existingPhotosRecord.id,
|
||||||
|
data: {
|
||||||
|
photos: [...(existingPhotosRecord.photos || []), ...values.photos]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new record
|
||||||
|
await axios.post('/business_photos', {
|
||||||
|
data: {
|
||||||
|
business: myBusiness.id,
|
||||||
|
photos: values.photos
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Refresh data
|
||||||
|
await fetchData();
|
||||||
|
resetForm();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading photos:', error);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePhoto = async (photoId: string, businessPhotosRecordId: string) => {
|
||||||
|
if (!window.confirm('Are you sure you want to remove this photo?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = myBusiness.business_photos_business.find((r: any) => r.id === businessPhotosRecordId);
|
||||||
|
if (!record) return;
|
||||||
|
|
||||||
|
const newPhotos = record.photos.filter((p: any) => p.id !== photoId);
|
||||||
|
|
||||||
|
await axios.put(`/business_photos/${businessPhotosRecordId}`, {
|
||||||
|
id: businessPhotosRecordId,
|
||||||
|
data: {
|
||||||
|
photos: newPhotos
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing photo:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatImageUrl = (url: string) => {
|
||||||
|
if (!url) return null;
|
||||||
|
if (url.startsWith('http') || url.startsWith('/')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return `${axios.defaults.baseURL}/file/download?privateUrl=${url}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <SectionMain><LoadingSpinner /></SectionMain>;
|
||||||
|
|
||||||
|
// STATE 1: Owns a business
|
||||||
|
if (myBusiness) {
|
||||||
|
const allPhotos = myBusiness.business_photos_business?.flatMap((bp: any) =>
|
||||||
|
bp.photos?.map((p: any) => ({ ...p, bpId: bp.id }))
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionMain>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('My Listing')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionTitleLineWithButton icon={mdiStorefront} title="My Listing" main>
|
||||||
|
<BaseButton
|
||||||
|
href={`/businesses/businesses-edit/?id=${myBusiness.id}`}
|
||||||
|
icon={mdiPencil}
|
||||||
|
label="Edit Listing"
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<CardBox className="mb-6">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-8 p-4">
|
||||||
|
<div className="w-32 h-32 bg-slate-100 rounded-3xl overflow-hidden flex items-center justify-center text-slate-400">
|
||||||
|
{allPhotos.length > 0 ? (
|
||||||
|
<img
|
||||||
|
src={formatImageUrl(allPhotos[0].publicUrl)!}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
alt="Business"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BaseIcon path={mdiStorefront} size={48} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow text-center md:text-left">
|
||||||
|
<h2 className="text-3xl font-bold mb-2">{myBusiness.name}</h2>
|
||||||
|
<p className="text-slate-500 mb-4">{myBusiness.address}, {myBusiness.city}, {myBusiness.state}</p>
|
||||||
|
<div className="flex flex-wrap justify-center md:justify-start gap-4">
|
||||||
|
<span className="bg-emerald-50 text-emerald-600 px-3 py-1 rounded-full text-xs font-bold uppercase">Active Listing</span>
|
||||||
|
<span className="bg-blue-50 text-blue-600 px-3 py-1 rounded-full text-xs font-bold uppercase">Verified Owner</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<BaseButton
|
||||||
|
href={`/public/businesses-details?id=${myBusiness.id}`}
|
||||||
|
label="View Public Profile"
|
||||||
|
outline
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
{/* Performance & Gallery Section */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="lg:col-span-1 space-y-6">
|
||||||
|
<CardBox className="p-6 text-center">
|
||||||
|
<div className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-2">Total Love Letters</div>
|
||||||
|
<div className="text-3xl font-bold">{myBusiness.reviews_business?.length || 0}</div>
|
||||||
|
</CardBox>
|
||||||
|
<CardBox className="p-6 text-center">
|
||||||
|
<div className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-2">Avg Rating</div>
|
||||||
|
<div className="text-3xl font-bold">{myBusiness.rating ? Number(myBusiness.rating).toFixed(1) : 'New'}</div>
|
||||||
|
</CardBox>
|
||||||
|
<CardBox className="p-10 bg-slate-900 text-white flex flex-col items-center justify-center text-center rounded-[2.5rem]">
|
||||||
|
<div className="w-12 h-12 bg-emerald-500 rounded-2xl flex items-center justify-center mb-4">
|
||||||
|
<BaseIcon path={mdiShieldCheck} size={24} />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold mb-1">Reliability Score</h4>
|
||||||
|
<div className="text-4xl font-black text-emerald-400">{myBusiness.reliability_score || 0}%</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gallery Management */}
|
||||||
|
<CardBox className="lg:col-span-2 p-8 rounded-[3rem]">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-2xl font-bold flex items-center">
|
||||||
|
<BaseIcon path={mdiCamera} size={28} className="mr-3 text-emerald-500" />
|
||||||
|
Portfolio Gallery
|
||||||
|
</h3>
|
||||||
|
<span className="text-slate-400 text-sm font-medium">{allPhotos.length} Pictures</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Form */}
|
||||||
|
<div className="mb-8 p-6 bg-slate-50 rounded-3xl border border-dashed border-slate-200">
|
||||||
|
<Formik
|
||||||
|
initialValues={{ photos: [] }}
|
||||||
|
onSubmit={handlePhotoUpload}
|
||||||
|
>
|
||||||
|
<Form className="flex flex-col md:flex-row items-end gap-4">
|
||||||
|
<div className="flex-grow w-full">
|
||||||
|
<FormField label="Add new pictures to your listing" help="Show clients your best work. High-quality photos increase bookings.">
|
||||||
|
<Field
|
||||||
|
label='Choose Photos'
|
||||||
|
color='info'
|
||||||
|
icon={mdiUpload}
|
||||||
|
path={'business_photos/photos'}
|
||||||
|
name='photos'
|
||||||
|
id='photos'
|
||||||
|
schema={{
|
||||||
|
size: undefined,
|
||||||
|
formats: undefined,
|
||||||
|
}}
|
||||||
|
component={FormImagePicker}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
color="info"
|
||||||
|
label={isUploading ? "Uploading..." : "Add to Gallery"}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Photos Grid */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||||
|
{allPhotos.map((photo: any) => (
|
||||||
|
<div key={photo.id} className="group relative aspect-square rounded-2xl overflow-hidden bg-slate-100 border border-slate-200">
|
||||||
|
<img
|
||||||
|
src={formatImageUrl(photo.publicUrl)!}
|
||||||
|
alt="Business"
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => removePhoto(photo.id, photo.bpId)}
|
||||||
|
className="bg-white/20 hover:bg-red-500 text-white p-3 rounded-xl backdrop-blur-md transition-all"
|
||||||
|
title="Remove Photo"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiDelete} size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{allPhotos.length === 0 && (
|
||||||
|
<div className="col-span-full py-12 text-center text-slate-400 italic">
|
||||||
|
No photos in your gallery yet. Add some to stand out!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATE 2: Pending claim
|
||||||
|
if (pendingClaim) {
|
||||||
|
return (
|
||||||
|
<SectionMain>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Claim Pending')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionTitleLineWithButton icon={mdiShieldCheck} title="Claim Verification" main />
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<CardBox className="p-10 text-center space-y-6">
|
||||||
|
<div className="w-20 h-20 bg-amber-100 text-amber-600 rounded-full flex items-center justify-center mx-auto">
|
||||||
|
<BaseIcon path={mdiAlertCircle} size={48} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold mb-2">Claim Request Pending</h2>
|
||||||
|
<p className="text-slate-500 max-w-md mx-auto">
|
||||||
|
We'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 = 'Crafted Network'
|
const title = 'Fix-It-Local'
|
||||||
const [projectUrl, setProjectUrl] = useState('');
|
const [projectUrl, setProjectUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
456
frontend/src/pages/public/businesses-details.tsx
Normal file
456
frontend/src/pages/public/businesses-details.tsx
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import {
|
||||||
|
mdiStar,
|
||||||
|
mdiShieldCheck,
|
||||||
|
mdiClockOutline,
|
||||||
|
mdiMapMarker,
|
||||||
|
mdiPhone,
|
||||||
|
mdiWeb,
|
||||||
|
mdiEmail,
|
||||||
|
mdiCurrencyUsd,
|
||||||
|
mdiCheckDecagram,
|
||||||
|
mdiMessageDraw,
|
||||||
|
mdiAccount,
|
||||||
|
mdiReply
|
||||||
|
} from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import LayoutGuest from '../../layouts/Guest';
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
import dataFormatter from '../../helpers/dataFormatter';
|
||||||
|
import { useAppSelector } from '../../stores/hooks';
|
||||||
|
|
||||||
|
const BusinessDetailsPublic = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = router.query;
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [business, setBusiness] = useState<any>(null);
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchBusiness();
|
||||||
|
recordEvent('VIEW');
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchBusiness = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/businesses/${id}`);
|
||||||
|
setBusiness(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching business:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordEvent = async (type: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await axios.post('/dashboard/record-event', {
|
||||||
|
businessId: id,
|
||||||
|
event_type: type,
|
||||||
|
metadata: { path: router.asPath }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail for analytics
|
||||||
|
console.error('Failed to record event', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const claimListing = async () => {
|
||||||
|
if (!currentUser) {
|
||||||
|
// Redirect to login with original destination
|
||||||
|
router.push(`/login?redirect=${encodeURIComponent(router.asPath)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await axios.post(`/businesses/${id}/claim`);
|
||||||
|
// After claiming, redirect to my-listing as requested
|
||||||
|
router.push('/my-listing');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error claiming business:', error);
|
||||||
|
alert('Failed to claim business. It might already be claimed or you have a pending request.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatImageUrl = (url: string) => {
|
||||||
|
if (!url) return null;
|
||||||
|
if (url.startsWith('http') || url.startsWith('/')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return `${axios.defaults.baseURL}/file/download?privateUrl=${url}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBusinessImage = () => {
|
||||||
|
if (business && business.business_photos_business && business.business_photos_business.length > 0) {
|
||||||
|
const photo = business.business_photos_business[0].photos && business.business_photos_business[0].photos[0];
|
||||||
|
if (photo && photo.publicUrl) {
|
||||||
|
return formatImageUrl(photo.publicUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOpeningHours = () => {
|
||||||
|
if (!business?.hours_json) return null;
|
||||||
|
try {
|
||||||
|
const hours = JSON.parse(business.hours_json);
|
||||||
|
return hours.weekday_text || null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="min-h-screen flex items-center justify-center bg-slate-50"><LoadingSpinner /></div>;
|
||||||
|
if (!business) return <div className="min-h-screen flex items-center justify-center bg-slate-50">Business not found.</div>;
|
||||||
|
|
||||||
|
const displayRating = business.rating ? Number(business.rating).toFixed(1) : 'New';
|
||||||
|
const weekdayText = getOpeningHours();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
|
||||||
|
<Head>
|
||||||
|
<title>{business.name} | Fix-It-Local™</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
{/* Hero Header */}
|
||||||
|
<section className="bg-white border-b border-slate-200 pt-16 pb-12">
|
||||||
|
<div className="container mx-auto px-6">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-12 items-start">
|
||||||
|
{/* Business Photo */}
|
||||||
|
<div className="w-32 h-32 lg:w-48 lg:h-48 bg-slate-100 rounded-[2.5rem] overflow-hidden flex items-center justify-center shadow-inner relative flex-shrink-0">
|
||||||
|
{getBusinessImage() ? (
|
||||||
|
<img
|
||||||
|
src={getBusinessImage()!}
|
||||||
|
alt={business.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BaseIcon path={mdiShieldCheck} size={64} className="text-slate-300" />
|
||||||
|
)}
|
||||||
|
{(business.reliability_score >= 80 || business.owner_userId) && (
|
||||||
|
<div className="absolute -top-2 -right-2 bg-emerald-500 text-white p-2 rounded-full shadow-lg">
|
||||||
|
<BaseIcon path={mdiCheckDecagram} size={24} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-grow w-full">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl lg:text-5xl font-bold mb-3">{business.name}</h1>
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-slate-500 font-medium">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<BaseIcon path={mdiMapMarker} size={18} className="mr-1 text-emerald-500" />
|
||||||
|
{business.city}, {business.state}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<BaseIcon path={mdiStar} size={18} className="mr-1 text-amber-400" />
|
||||||
|
{displayRating} Rating
|
||||||
|
</span>
|
||||||
|
{business.owner_userId ? (
|
||||||
|
<span className="bg-emerald-50 text-emerald-600 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider">
|
||||||
|
Verified Pro
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="bg-slate-100 text-slate-500 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider flex items-center">
|
||||||
|
Unclaimed Listing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/public/request-service?businessId=${business.id}`)}
|
||||||
|
className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-xl shadow-emerald-500/20"
|
||||||
|
>
|
||||||
|
Request Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 py-6 border-t border-slate-100">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Avg Rating</div>
|
||||||
|
<div className="text-2xl font-bold text-slate-900">{displayRating} / 5.0</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Response Time</div>
|
||||||
|
<div className="text-2xl font-bold text-slate-900">~{business.response_time_median_minutes || 30}m</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Status</div>
|
||||||
|
<div className="flex items-center text-emerald-500 font-bold">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2 animate-pulse"></div>
|
||||||
|
Available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Total Reviews</div>
|
||||||
|
<div className="text-2xl font-bold text-slate-900">{business.reviews_business?.length || 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="container mx-auto px-6 py-12">
|
||||||
|
<div className="grid lg:grid-cols-3 gap-12">
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-12">
|
||||||
|
|
||||||
|
{!business.owner_userId && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 p-8 rounded-[2rem] flex flex-col md:flex-row items-center justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xl font-bold text-amber-900 mb-2">Is this your business?</h4>
|
||||||
|
<p className="text-amber-700">Claim your listing to respond to reviews, update your profile, and get more leads.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={claimListing}
|
||||||
|
className="bg-amber-500 hover:bg-amber-600 text-white font-bold py-3 px-8 rounded-xl transition-all flex-shrink-0"
|
||||||
|
>
|
||||||
|
Claim Listing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photos Gallery */}
|
||||||
|
{business.business_photos_business?.length > 0 && (
|
||||||
|
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
||||||
|
<h3 className="text-2xl font-bold mb-6">Photos</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{business.business_photos_business.map((bp: any) => (
|
||||||
|
bp.photos?.map((p: any) => (
|
||||||
|
<div key={p.id} className="aspect-square rounded-2xl overflow-hidden bg-slate-100">
|
||||||
|
<img
|
||||||
|
src={formatImageUrl(p.publicUrl)!}
|
||||||
|
alt="Business"
|
||||||
|
className="w-full h-full object-cover hover:scale-110 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* About */}
|
||||||
|
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
||||||
|
<h3 className="text-2xl font-bold mb-6">About the Business</h3>
|
||||||
|
<div className="text-slate-600 leading-relaxed text-lg"
|
||||||
|
dangerouslySetInnerHTML={{ __html: business.description || 'No description provided.' }} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
|
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
||||||
|
<h3 className="text-2xl font-bold mb-6">Service Pricing Range</h3>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{business.service_prices_business?.map((price: any) => (
|
||||||
|
<div key={price.id} className="flex items-center justify-between p-6 rounded-2xl bg-slate-50 hover:bg-emerald-50 transition-colors group">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-slate-800 text-lg group-hover:text-emerald-700">{price.service_name}</h4>
|
||||||
|
<p className="text-slate-500 text-sm">{price.notes || 'Standard professional service.'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-emerald-600 font-bold text-xl">${price.typical_price}</div>
|
||||||
|
<div className="text-xs text-slate-400 font-medium">Typical Price</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!business.service_prices_business?.length && <p className="text-slate-500">No pricing information available.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Reviews */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-2xl font-bold">Customer Reviews</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/reviews/reviews-new?businessId=${business.id}`)}
|
||||||
|
className="flex items-center gap-2 bg-white border border-slate-200 px-6 py-3 rounded-2xl text-emerald-600 font-bold hover:bg-slate-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiMessageDraw} size={20} />
|
||||||
|
Write a Review
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{business.reviews_business?.map((review: any) => (
|
||||||
|
<div key={review.id} className="bg-white p-8 rounded-3xl border border-slate-200 shadow-sm hover:shadow-md transition-all">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<BaseIcon key={i} path={mdiStar} size={18} className={i < review.rating ? 'text-amber-400' : 'text-slate-200'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 font-medium">
|
||||||
|
{dataFormatter.dateFormatter(review.created_at_ts || review.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-700 leading-relaxed mb-4 italic text-lg">"{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;
|
||||||
211
frontend/src/pages/public/request-service.tsx
Normal file
211
frontend/src/pages/public/request-service.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import {
|
||||||
|
mdiShieldCheck,
|
||||||
|
mdiClockOutline,
|
||||||
|
mdiMapMarker,
|
||||||
|
mdiEmail,
|
||||||
|
mdiAccount,
|
||||||
|
mdiPhone,
|
||||||
|
mdiAlertDecagram
|
||||||
|
} from '@mdi/js';
|
||||||
|
import { Formik, Form, Field } from 'formik';
|
||||||
|
import axios from 'axios';
|
||||||
|
import LayoutGuest from '../../layouts/Guest';
|
||||||
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
import FormField from '../../components/FormField';
|
||||||
|
import BaseButton from '../../components/BaseButton';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import { create as createLead } from '../../stores/leads/leadsSlice';
|
||||||
|
|
||||||
|
const RequestServicePage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { businessId } = router.query;
|
||||||
|
const [business, setBusiness] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { currentUser } = useAppSelector(state => state.auth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (businessId) {
|
||||||
|
fetchBusiness();
|
||||||
|
}
|
||||||
|
}, [businessId]);
|
||||||
|
|
||||||
|
const fetchBusiness = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/businesses/${businessId}`);
|
||||||
|
setBusiness(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching business:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
businessId,
|
||||||
|
user: currentUser?.id || null
|
||||||
|
};
|
||||||
|
await dispatch(createLead(payload)).unwrap();
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
router.push('/leads/leads-list'); // Redirect to their leads tracker if logged in
|
||||||
|
} else {
|
||||||
|
alert('Your request has been sent! The professional will contact you soon.');
|
||||||
|
router.push(`/public/businesses-details?id=${businessId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lead creation error:', error);
|
||||||
|
alert('There was an error sending your request. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!business && businessId) return <div className="min-h-screen flex items-center justify-center bg-slate-50"><LoadingSpinner /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 pb-20 pt-28">
|
||||||
|
<Head>
|
||||||
|
<title>Request Service | Fix-It-Local™</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<div className="container mx-auto px-6 max-w-4xl">
|
||||||
|
<div className="bg-white rounded-[3rem] shadow-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="bg-slate-900 p-12 text-white relative">
|
||||||
|
<div className="absolute top-0 right-0 p-12 opacity-10">
|
||||||
|
<BaseIcon path={mdiShieldCheck} size={120} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold mb-4">Request Service</h1>
|
||||||
|
<p className="text-slate-400 text-lg max-w-xl">
|
||||||
|
You are requesting a service from <span className="text-emerald-400 font-bold">{business?.name || 'a professional'}</span>.
|
||||||
|
Our smart matching system ensures your request is handled with priority.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-12">
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
keyword: '',
|
||||||
|
description: '',
|
||||||
|
urgency: 'TODAY',
|
||||||
|
contact_name: currentUser ? `${currentUser.firstName} ${currentUser.lastName}` : '',
|
||||||
|
contact_email: currentUser?.email || '',
|
||||||
|
contact_phone: currentUser?.phoneNumber || '',
|
||||||
|
address: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zip: ''
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{({ values }) => (
|
||||||
|
<Form className="space-y-8">
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
<FormField label="What do you need help with?" labelFor="keyword">
|
||||||
|
<Field
|
||||||
|
name="keyword"
|
||||||
|
placeholder="e.g. Leaking faucet in kitchen"
|
||||||
|
className="w-full bg-slate-50 border-slate-200 rounded-2xl py-4 px-6 focus:ring-emerald-500 focus:border-emerald-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Urgency" labelFor="urgency">
|
||||||
|
<Field
|
||||||
|
name="urgency"
|
||||||
|
as="select"
|
||||||
|
className="w-full bg-slate-50 border-slate-200 rounded-2xl py-4 px-6 focus:ring-emerald-500 focus:border-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="EMERGENCY">🚨 Emergency (Immediate)</option>
|
||||||
|
<option value="TODAY">📅 Today</option>
|
||||||
|
<option value="THIS_WEEK">🗓️ This Week</option>
|
||||||
|
<option value="FLEXIBLE">🍃 Flexible</option>
|
||||||
|
</Field>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Details of the issue" labelFor="description">
|
||||||
|
<Field
|
||||||
|
name="description"
|
||||||
|
as="textarea"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Please describe the problem in detail so the professional can give you an accurate estimate."
|
||||||
|
className="w-full bg-slate-50 border-slate-200 rounded-2xl py-4 px-6 focus:ring-emerald-500 focus:border-emerald-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 p-8 rounded-3xl space-y-8">
|
||||||
|
<h3 className="text-xl font-bold flex items-center">
|
||||||
|
<BaseIcon path={mdiAccount} size={24} className="mr-3 text-emerald-500" />
|
||||||
|
Contact Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
<FormField label="Your Name" labelFor="contact_name">
|
||||||
|
<Field name="contact_name" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Email" labelFor="contact_email">
|
||||||
|
<Field name="contact_email" type="email" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Phone" labelFor="contact_phone">
|
||||||
|
<Field name="contact_phone" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<FormField label="Service Address" labelFor="address">
|
||||||
|
<Field name="address" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
|
||||||
|
</FormField>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<FormField label="City" labelFor="city">
|
||||||
|
<Field name="city" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="State" labelFor="state">
|
||||||
|
<Field name="state" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="ZIP" labelFor="zip">
|
||||||
|
<Field name="zip" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-8 border-t border-slate-100">
|
||||||
|
<div className="text-slate-500 text-sm flex items-center max-w-sm">
|
||||||
|
<BaseIcon path={mdiShieldCheck} size={20} className="mr-2 text-emerald-500" />
|
||||||
|
Your data is protected and will only be shared with the professional you request.
|
||||||
|
</div>
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
color="emerald"
|
||||||
|
label={loading ? 'Submitting...' : 'Send Request'}
|
||||||
|
className="py-5 px-12 rounded-2xl text-lg font-bold shadow-2xl shadow-emerald-500/30"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
RequestServicePage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return (
|
||||||
|
<LayoutGuest>
|
||||||
|
{page}
|
||||||
|
</LayoutGuest>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RequestServicePage;
|
||||||
@ -1,92 +1,224 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { ToastContainer, toast } from 'react-toastify';
|
import { ToastContainer, toast } from 'react-toastify';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import BaseIcon from "../components/BaseIcon";
|
||||||
|
import { mdiShieldCheck, mdiEye, mdiEyeOff } from '@mdi/js';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import FormField from '../components/FormField';
|
import FormField from '../components/FormField';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
import Link from 'next/link';
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||||
|
import Logo from '../components/Logo'
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
const notify = (type, msg) => toast(msg, { type, position: "bottom-center" });
|
||||||
|
|
||||||
|
const [illustrationImage, setIllustrationImage] = useState({
|
||||||
|
src: undefined,
|
||||||
|
photographer: undefined,
|
||||||
|
photographer_url: undefined,
|
||||||
|
})
|
||||||
|
const [illustrationVideo, setIllustrationVideo] = useState({ video_files: [] })
|
||||||
|
const [contentType, setContentType] = useState('video');
|
||||||
|
|
||||||
const handleSubmit = async (value) => {
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
const image = await getPexelsImage()
|
||||||
|
const video = await getPexelsVideo()
|
||||||
|
setIllustrationImage(image);
|
||||||
|
setIllustrationVideo(video);
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePasswordVisibility = () => {
|
||||||
|
setShowPassword(!showPassword);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values) => {
|
||||||
|
if (values.password !== values.confirm) {
|
||||||
|
notify('error', 'Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
await axios.post('/auth/signup', values);
|
||||||
const { data: response } = await axios.post('/auth/signup',value);
|
|
||||||
await router.push('/login')
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
notify('success', 'Please check your email for verification link')
|
notify('success', 'Please check your email for verification link')
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login')
|
||||||
|
}, 2000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
console.log('error: ', error)
|
console.log('error: ', error)
|
||||||
notify('error', 'Something was wrong. Try again')
|
notify('error', error.response?.data?.message || 'Something went wrong. Try again')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const imageBlock = (image) => (
|
||||||
|
<div className="hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(16, 185, 129, 0.1), rgba(6, 78, 59, 0.2))'}`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}}>
|
||||||
|
<div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
|
||||||
|
<div className="relative z-10 p-12 text-white">
|
||||||
|
<h1 className="text-4xl font-black mb-4">Start Your Professional Journey.</h1>
|
||||||
|
<p className="text-lg text-emerald-50/80 max-w-md leading-relaxed">
|
||||||
|
Get listed, get verified, and connect with clients looking for high-quality, trusted services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center w-full bg-black/40 py-2 relative z-10">
|
||||||
|
<a className="text-[10px] text-white/60 hover:text-white transition-colors" href={image?.photographer_url} target="_blank" rel="noreferrer">
|
||||||
|
Photo by {image?.photographer} on Pexels
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const videoBlock = (video) => {
|
||||||
|
if (video?.video_files?.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className='hidden lg:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/2 overflow-hidden'>
|
||||||
|
<video
|
||||||
|
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={video.video_files[0]?.link} type='video/mp4' />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
<div className="absolute inset-0 bg-emerald-900/20 backdrop-brightness-75"></div>
|
||||||
|
<div className="relative z-10 p-12 text-white">
|
||||||
|
<h1 className="text-4xl font-black mb-4">Start Your Professional Journey.</h1>
|
||||||
|
<p className="text-lg text-emerald-50/80 max-w-md leading-relaxed">
|
||||||
|
Get listed, get verified, and connect with clients looking for high-quality, trusted services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-center w-full bg-black/40 py-2 relative z-10'>
|
||||||
|
<a
|
||||||
|
className='text-[10px] text-white/60 hover:text-white transition-colors'
|
||||||
|
href={video.user.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
Video by {video.user.name} on Pexels
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
return imageBlock(illustrationImage);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-slate-50 font-sans">
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Login')}</title>
|
<title>{getPageTitle('Register')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="flex flex-row min-h-screen">
|
||||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
{contentType === 'video' ? videoBlock(illustrationVideo) : imageBlock(illustrationImage)}
|
||||||
<Formik
|
|
||||||
initialValues={{
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
confirm: ''
|
|
||||||
}}
|
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
<Form>
|
|
||||||
|
|
||||||
<FormField label='Email' help='Please enter your email'>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<BaseDivider />
|
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-16">
|
||||||
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
{/* Branding */}
|
||||||
|
<div className="flex flex-col items-center mb-8">
|
||||||
|
<Link href="/" className="flex items-center gap-3 group mb-6">
|
||||||
|
<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>
|
||||||
|
|
||||||
<BaseButtons>
|
<CardBox className="shadow-2xl border-none rounded-[2rem] p-4 lg:p-6">
|
||||||
<BaseButton
|
<Formik
|
||||||
type='submit'
|
initialValues={{
|
||||||
label={loading ? 'Loading...' : 'Register' }
|
email: '',
|
||||||
color='info'
|
password: '',
|
||||||
/>
|
confirm: ''
|
||||||
<BaseButton
|
}}
|
||||||
href={'/login'}
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
label={'Login'}
|
>
|
||||||
color='info'
|
<Form className="space-y-4">
|
||||||
/>
|
<FormField
|
||||||
</BaseButtons>
|
label='Email Address'
|
||||||
</Form>
|
labelColor="text-slate-700 font-bold"
|
||||||
</Formik>
|
>
|
||||||
</CardBox>
|
<Field name='email' type='email' placeholder="name@company.com" className="rounded-xl border-slate-200 focus:border-emerald-500 focus:ring-emerald-500" />
|
||||||
</SectionFullScreen>
|
</FormField>
|
||||||
|
|
||||||
|
<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, mdiUpload } from '@mdi/js'
|
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
import DatePicker from "react-datepicker";
|
import DatePicker from "react-datepicker";
|
||||||
@ -16,310 +16,34 @@ import FormField from '../../components/FormField'
|
|||||||
import BaseDivider from '../../components/BaseDivider'
|
import BaseDivider from '../../components/BaseDivider'
|
||||||
import BaseButtons from '../../components/BaseButtons'
|
import BaseButtons from '../../components/BaseButtons'
|
||||||
import BaseButton from '../../components/BaseButton'
|
import BaseButton from '../../components/BaseButton'
|
||||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
|
||||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
|
||||||
import FormFilePicker from '../../components/FormFilePicker'
|
|
||||||
import FormImagePicker from '../../components/FormImagePicker'
|
|
||||||
import { SelectField } from "../../components/SelectField";
|
import { SelectField } from "../../components/SelectField";
|
||||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
|
||||||
import { SwitchField } from '../../components/SwitchField'
|
import { SwitchField } from '../../components/SwitchField'
|
||||||
import {RichTextField} from "../../components/RichTextField";
|
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/reviews/reviewsSlice'
|
import { update, fetch } from '../../stores/reviews/reviewsSlice'
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
|
||||||
import ImageField from "../../components/ImageField";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const EditReviewsPage = () => {
|
const EditReviewsPage = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
business: null,
|
business: null,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
user: null,
|
user: null,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
lead: null,
|
lead: null,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
rating: '',
|
rating: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
text: '',
|
text: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
is_verified_job: false,
|
is_verified_job: false,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
status: '',
|
status: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
moderation_notes: '',
|
moderation_notes: '',
|
||||||
|
response: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
created_at_ts: new Date(),
|
created_at_ts: new Date(),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
updated_at_ts: new Date(),
|
updated_at_ts: new Date(),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
const [initialValues, setInitialValues] = useState(initVals)
|
const [initialValues, setInitialValues] = useState(initVals)
|
||||||
|
|
||||||
@ -330,22 +54,20 @@ const EditReviewsPage = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetch({ id: id }))
|
dispatch(fetch({ id: id }))
|
||||||
}, [id])
|
}, [id, dispatch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof reviews === 'object') {
|
if (typeof reviews === 'object' && reviews !== null) {
|
||||||
setInitialValues(reviews)
|
const newInitialVal = {...initVals};
|
||||||
|
Object.keys(initVals).forEach(el => {
|
||||||
|
if (reviews[el] !== undefined) {
|
||||||
|
newInitialVal[el] = reviews[el]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setInitialValues(newInitialVal);
|
||||||
}
|
}
|
||||||
}, [reviews])
|
}, [reviews])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof reviews === 'object') {
|
|
||||||
const newInitialVal = {...initVals};
|
|
||||||
Object.keys(initVals).forEach(el => newInitialVal[el] = (reviews)[el])
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [reviews])
|
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
const handleSubmit = async (data) => {
|
||||||
await dispatch(update({ id: id, data }))
|
await dispatch(update({ id: id, data }))
|
||||||
await router.push('/reviews/reviews-list')
|
await router.push('/reviews/reviews-list')
|
||||||
@ -367,28 +89,8 @@ const EditReviewsPage = () => {
|
|||||||
onSubmit={(values) => handleSubmit(values)}
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
>
|
>
|
||||||
<Form>
|
<Form>
|
||||||
|
{!isBusinessOwner && (
|
||||||
|
<>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Business' labelFor='business'>
|
<FormField label='Business' labelFor='business'>
|
||||||
<Field
|
<Field
|
||||||
name='business'
|
name='business'
|
||||||
@ -396,89 +98,10 @@ const EditReviewsPage = () => {
|
|||||||
component={SelectField}
|
component={SelectField}
|
||||||
options={initialValues.business}
|
options={initialValues.business}
|
||||||
itemRef={'businesses'}
|
itemRef={'businesses'}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
showField={'name'}
|
showField={'name'}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
></Field>
|
></Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='User' labelFor='user'>
|
<FormField label='User' labelFor='user'>
|
||||||
<Field
|
<Field
|
||||||
name='user'
|
name='user'
|
||||||
@ -486,89 +109,10 @@ const EditReviewsPage = () => {
|
|||||||
component={SelectField}
|
component={SelectField}
|
||||||
options={initialValues.user}
|
options={initialValues.user}
|
||||||
itemRef={'users'}
|
itemRef={'users'}
|
||||||
|
|
||||||
|
|
||||||
showField={'firstName'}
|
showField={'firstName'}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
></Field>
|
></Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='Lead' labelFor='lead'>
|
<FormField label='Lead' labelFor='lead'>
|
||||||
<Field
|
<Field
|
||||||
name='lead'
|
name='lead'
|
||||||
@ -576,76 +120,10 @@ const EditReviewsPage = () => {
|
|||||||
component={SelectField}
|
component={SelectField}
|
||||||
options={initialValues.lead}
|
options={initialValues.lead}
|
||||||
itemRef={'leads'}
|
itemRef={'leads'}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
showField={'keyword'}
|
showField={'keyword'}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
></Field>
|
></Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Rating"
|
label="Rating"
|
||||||
>
|
>
|
||||||
@ -655,77 +133,15 @@ const EditReviewsPage = () => {
|
|||||||
placeholder="Rating"
|
placeholder="Rating"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField label="Review Text" hasTextareaHeight>
|
||||||
|
<Field name="text" as="textarea" placeholder="Text" disabled={isBusinessOwner} />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Text" hasTextareaHeight>
|
|
||||||
<Field name="text" as="textarea" placeholder="Text" />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
{!isBusinessOwner && (
|
||||||
|
<>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='IsVerifiedJob' labelFor='is_verified_job'>
|
<FormField label='IsVerifiedJob' labelFor='is_verified_job'>
|
||||||
<Field
|
<Field
|
||||||
name='is_verified_job'
|
name='is_verified_job'
|
||||||
@ -733,103 +149,28 @@ 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"
|
||||||
>
|
>
|
||||||
@ -837,42 +178,12 @@ const EditReviewsPage = () => {
|
|||||||
dateFormat="yyyy-MM-dd hh:mm"
|
dateFormat="yyyy-MM-dd hh:mm"
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
selected={initialValues.created_at_ts ?
|
selected={initialValues.created_at_ts ?
|
||||||
new Date(
|
new Date(initialValues.created_at_ts) : null
|
||||||
dayjs(initialValues.created_at_ts).format('YYYY-MM-DD hh:mm'),
|
|
||||||
) : null
|
|
||||||
}
|
}
|
||||||
onChange={(date) => setInitialValues({...initialValues, 'created_at_ts': date})}
|
onChange={(date) => setInitialValues({...initialValues, 'created_at_ts': date})}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="UpdatedAt"
|
label="UpdatedAt"
|
||||||
>
|
>
|
||||||
@ -880,31 +191,13 @@ const EditReviewsPage = () => {
|
|||||||
dateFormat="yyyy-MM-dd hh:mm"
|
dateFormat="yyyy-MM-dd hh:mm"
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
selected={initialValues.updated_at_ts ?
|
selected={initialValues.updated_at_ts ?
|
||||||
new Date(
|
new Date(initialValues.updated_at_ts) : null
|
||||||
dayjs(initialValues.updated_at_ts).format('YYYY-MM-DD hh:mm'),
|
|
||||||
) : null
|
|
||||||
}
|
}
|
||||||
onChange={(date) => setInitialValues({...initialValues, 'updated_at_ts': date})}
|
onChange={(date) => setInitialValues({...initialValues, 'updated_at_ts': date})}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
<BaseDivider />
|
||||||
<BaseButtons>
|
<BaseButtons>
|
||||||
@ -923,13 +216,11 @@ 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 } from '@mdi/js'
|
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload, mdiStar, mdiMessageDraw } from '@mdi/js'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
import CardBox from '../../components/CardBox'
|
import CardBox from '../../components/CardBox'
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
import LayoutGuest from '../../layouts/Guest'
|
||||||
import SectionMain from '../../components/SectionMain'
|
import SectionMain from '../../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||||
import { getPageTitle } from '../../config'
|
import { getPageTitle } from '../../config'
|
||||||
@ -12,574 +12,171 @@ import FormField from '../../components/FormField'
|
|||||||
import BaseDivider from '../../components/BaseDivider'
|
import BaseDivider from '../../components/BaseDivider'
|
||||||
import BaseButtons from '../../components/BaseButtons'
|
import BaseButtons from '../../components/BaseButtons'
|
||||||
import BaseButton from '../../components/BaseButton'
|
import BaseButton from '../../components/BaseButton'
|
||||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
|
||||||
import FormFilePicker from '../../components/FormFilePicker'
|
|
||||||
import FormImagePicker from '../../components/FormImagePicker'
|
|
||||||
import { SwitchField } from '../../components/SwitchField'
|
|
||||||
|
|
||||||
import { SelectField } from '../../components/SelectField'
|
|
||||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
|
||||||
import {RichTextField} from "../../components/RichTextField";
|
|
||||||
|
|
||||||
import { create } from '../../stores/reviews/reviewsSlice'
|
|
||||||
import { useAppDispatch } from '../../stores/hooks'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import moment from 'moment';
|
import { create } from '../../stores/reviews/reviewsSlice'
|
||||||
|
import BaseIcon from '../../components/BaseIcon'
|
||||||
const initialValues = {
|
import axios from 'axios'
|
||||||
|
import { ToastContainer, toast } from 'react-toastify'
|
||||||
|
import 'react-toastify/dist/ReactToastify.css'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
business: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
user: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
lead: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
rating: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
text: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
is_verified_job: false,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
status: 'PUBLISHED',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
moderation_notes: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
created_at_ts: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
updated_at_ts: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const ReviewsNew = () => {
|
const ReviewsNew = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { businessId } = router.query
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth)
|
||||||
|
const [businessName, setBusinessName] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (businessId) {
|
||||||
|
fetchBusinessName()
|
||||||
|
}
|
||||||
|
}, [businessId])
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
const fetchBusinessName = async () => {
|
||||||
await dispatch(create(data))
|
try {
|
||||||
await router.push('/reviews/reviews-list')
|
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 {
|
||||||
|
await dispatch(create(data)).unwrap()
|
||||||
|
toast.success('Thank you for your review!')
|
||||||
|
setTimeout(() => {
|
||||||
|
if (businessId) {
|
||||||
|
router.push(`/public/businesses-details?id=${businessId}`)
|
||||||
|
} else {
|
||||||
|
router.push('/reviews/reviews-list')
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to submit review:', e)
|
||||||
|
toast.error('Failed to submit review. Please try again.')
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('New Item')}</title>
|
<title>{getPageTitle('Write a Review')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<div className="pt-24 pb-12">
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
<SectionMain>
|
||||||
{''}
|
<SectionTitleLineWithButton icon={mdiMessageDraw} title={businessName ? `Review for ${businessName}` : "Write a Review"} main>
|
||||||
</SectionTitleLineWithButton>
|
{''}
|
||||||
<CardBox>
|
</SectionTitleLineWithButton>
|
||||||
<Formik
|
|
||||||
initialValues={
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<CardBox>
|
||||||
initialValues
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
}
|
enableReinitialize={true}
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
>
|
>
|
||||||
<Form>
|
{({ values, setFieldValue }) => (
|
||||||
|
<Form>
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<p className="text-slate-500 mb-4 font-medium uppercase tracking-widest text-xs">Overall Experience</p>
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFieldValue('rating', star)}
|
||||||
|
className={`p-2 transition-all transform hover:scale-110 ${values.rating >= star ? 'text-amber-400' : 'text-slate-200'}`}
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiStar} size={48} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-amber-500 font-black text-xl mt-2">
|
||||||
|
{values.rating === 1 && 'Poor'}
|
||||||
|
{values.rating === 2 && 'Fair'}
|
||||||
|
{values.rating === 3 && 'Good'}
|
||||||
|
{values.rating === 4 && 'Very Good'}
|
||||||
|
{values.rating === 5 && 'Excellent!'}
|
||||||
<FormField label="Business" labelFor="business">
|
</div>
|
||||||
<Field name="business" id="business" component={SelectField} options={[]} itemRef={'businesses'}></Field>
|
</div>
|
||||||
</FormField>
|
|
||||||
|
<FormField label="Your Review" help="Share details of your experience with this professional.">
|
||||||
|
<Field
|
||||||
|
name="text"
|
||||||
|
as="textarea"
|
||||||
|
placeholder="What was it like working with them?"
|
||||||
|
className="w-full rounded-2xl border-slate-200 focus:ring-emerald-500 focus:border-emerald-500"
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<BaseDivider />
|
||||||
|
|
||||||
|
<BaseButtons>
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
color="emerald"
|
||||||
|
label={isSubmitting ? "Submitting..." : "Submit Review"}
|
||||||
|
className="w-full md:w-auto px-12 py-4 rounded-2xl"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
color="info"
|
||||||
|
outline
|
||||||
|
label="Cancel"
|
||||||
|
onClick={() => businessId ? router.push(`/public/businesses-details?id=${businessId}`) : router.push('/reviews/reviews-list')}
|
||||||
|
className="w-full md:w-auto px-12 py-4 rounded-2xl"
|
||||||
<FormField label="User" labelFor="user">
|
disabled={isSubmitting}
|
||||||
<Field name="user" id="user" component={SelectField} options={[]} itemRef={'users'}></Field>
|
/>
|
||||||
</FormField>
|
</BaseButtons>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</div>
|
||||||
|
<ToastContainer />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Lead" labelFor="lead">
|
|
||||||
<Field name="lead" id="lead" component={SelectField} options={[]} itemRef={'leads'}></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Rating"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="number"
|
|
||||||
name="rating"
|
|
||||||
placeholder="Rating"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Text" hasTextareaHeight>
|
|
||||||
<Field name="text" as="textarea" placeholder="Text" />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='IsVerifiedJob' labelFor='is_verified_job'>
|
|
||||||
<Field
|
|
||||||
name='is_verified_job'
|
|
||||||
id='is_verified_job'
|
|
||||||
component={SwitchField}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Status" labelFor="status">
|
|
||||||
<Field name="status" id="status" component="select">
|
|
||||||
|
|
||||||
<option value="PUBLISHED">PUBLISHED</option>
|
|
||||||
|
|
||||||
<option value="PENDING">PENDING</option>
|
|
||||||
|
|
||||||
<option value="HIDDEN">HIDDEN</option>
|
|
||||||
|
|
||||||
<option value="REJECTED">REJECTED</option>
|
|
||||||
|
|
||||||
</Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="ModerationNotes" hasTextareaHeight>
|
|
||||||
<Field name="moderation_notes" as="textarea" placeholder="ModerationNotes" />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="CreatedAt"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="datetime-local"
|
|
||||||
name="created_at_ts"
|
|
||||||
placeholder="CreatedAt"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="UpdatedAt"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="datetime-local"
|
|
||||||
name="updated_at_ts"
|
|
||||||
placeholder="UpdatedAt"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton type="submit" color="info" label="Submit" />
|
|
||||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
|
||||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/reviews/reviews-list')}/>
|
|
||||||
</BaseButtons>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ReviewsNew.getLayout = function getLayout(page: ReactElement) {
|
ReviewsNew.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutGuest>
|
||||||
|
{page}
|
||||||
permission={'CREATE_REVIEWS'}
|
</LayoutGuest>
|
||||||
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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