Autosave: 20260218-134704

This commit is contained in:
Flatlogic Bot 2026-02-18 13:47:05 +00:00
parent b03b911e99
commit 111693ae6e
18 changed files with 885 additions and 373 deletions

View File

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

View File

@ -90,6 +90,7 @@ module.exports = class LeadsDBApi {
where,
include,
distinct: true,
subQuery: false,
limit: options?.countOnly ? undefined : (limit ? Number(limit) : undefined),
offset: options?.countOnly ? undefined : (offset ? Number(offset) : undefined),
order: [['createdAt', 'desc']],

View File

@ -72,6 +72,7 @@ module.exports = class MessagesDBApi {
{
model: db.leads,
as: 'lead',
required: false,
include: [{
model: db.lead_matches,
as: 'lead_matches_lead',
@ -82,27 +83,29 @@ module.exports = class MessagesDBApi {
{ 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,
},
];

View File

@ -373,7 +373,7 @@ module.exports = class ReviewsDBApi {
{
model: db.businesses,
as: 'business',
required: false,
where: filter.business ? {
[Op.or]: [
{ id: { [Op.in]: filter.business.split('|').map(term => Utils.uuid(term)) } },
@ -383,14 +383,14 @@ module.exports = class ReviewsDBApi {
}
},
]
} : {},
} : undefined,
},
{
model: db.users,
as: 'user',
required: false,
where: filter.user ? {
[Op.or]: [
{ id: { [Op.in]: filter.user.split('|').map(term => Utils.uuid(term)) } },
@ -400,14 +400,14 @@ module.exports = class ReviewsDBApi {
}
},
]
} : {},
} : undefined,
},
{
model: db.leads,
as: 'lead',
required: false,
where: filter.lead ? {
[Op.or]: [
{ id: { [Op.in]: filter.lead.split('|').map(term => Utils.uuid(term)) } },
@ -417,7 +417,7 @@ module.exports = class ReviewsDBApi {
}
},
]
} : {},
} : undefined,
},
@ -598,6 +598,7 @@ module.exports = class ReviewsDBApi {
where,
include,
distinct: true,
subQuery: false,
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],

View File

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

View File

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

View File

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

View File

@ -46,6 +46,8 @@ const verification_submissionsRoutes = require('./routes/verification_submission
const verification_evidencesRoutes = require('./routes/verification_evidences');
const claim_requestsRoutes = require('./routes/claim_requests');
const leadsRoutes = require('./routes/leads');
const lead_photosRoutes = require('./routes/lead_photos');
@ -159,6 +161,8 @@ app.use('/api/verification_submissions', passport.authenticate('jwt', {session:
app.use('/api/verification_evidences', passport.authenticate('jwt', {session: false}), verification_evidencesRoutes);
app.use('/api/claim_requests', passport.authenticate('jwt', {session: false}), claim_requestsRoutes);
app.use('/api/leads', passport.authenticate('jwt', {session: false}), leadsRoutes);
app.use('/api/lead_photos', passport.authenticate('jwt', {session: false}), lead_photosRoutes);

View File

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

View File

@ -10,13 +10,35 @@ const stream = require('stream');
const { v4: uuidv4 } = require('uuid');
module.exports = class BusinessesService {
static _sanitize(data) {
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;
}
@ -29,9 +51,9 @@ module.exports = class BusinessesService {
// 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');
}
// 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;
@ -40,11 +62,11 @@ module.exports = class BusinessesService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
data = this._sanitize(data);
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_user = currentUser.id;
data.owner_userId = currentUser.id;
data.is_active = true; // Ensure new business owner listings are active
// Auto-generate internal fields if missing
@ -87,25 +109,33 @@ module.exports = class BusinessesService {
if (!business) {
throw new ValidationError('businessNotFound');
}
if (business.is_claimed) {
if (business.owner_userId) {
throw new ValidationError('businessAlreadyClaimed');
}
await business.update({
owner_userId: currentUser.id,
is_claimed: true,
}, { transaction });
// Link business to user if they don't have one set yet
if (currentUser && !currentUser.businessId) {
await db.users.update({ businessId: business.id }, {
where: { id: currentUser.id },
transaction
});
// 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 business;
return claim_request;
} catch (error) {
await transaction.rollback();
throw error;
@ -150,7 +180,7 @@ module.exports = class BusinessesService {
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
data = this._sanitize(data);
data = this._sanitize(data, currentUser);
let business = await BusinessesDBApi.findBy(
{id},
@ -168,11 +198,13 @@ module.exports = class BusinessesService {
if (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
// Prevent transferring ownership
// 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(

View File

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

View File

@ -92,11 +92,14 @@ export default function LayoutAuthenticated({
if (currentUser?.app_role?.name === 'Verified Business Owner') {
const allowedPaths = [
'/dashboard',
'/businesses/businesses-list',
'/my-listing',
'/leads/leads-list',
'/reviews/reviews-list',
'/messages/messages-list',
'/profile'
'/verification_submissions/verification_submissions-list',
'/profile',
'/billing',
'/team'
];
return allowedPaths.includes(item.href);
}
@ -106,9 +109,6 @@ export default function LayoutAuthenticated({
if (item.href === '/leads/leads-list') {
return { ...item, label: 'Service Requests' };
}
if (item.href === '/businesses/businesses-list') {
return { ...item, label: 'My Listing' };
}
}
return item;
});
@ -151,4 +151,4 @@ export default function LayoutAuthenticated({
</div>
</div>
)
}
}

View File

@ -39,7 +39,7 @@ const menuAside: MenuAsideItem[] = [
roles: ['Administrator', 'Platform Owner']
},
// Shared but labeled differently or scoped
// Admin and Platform Owner see all service listings
{
href: '/businesses/businesses-list',
label: 'Service Listings',
@ -47,11 +47,21 @@ const menuAside: MenuAsideItem[] = [
permissions: 'READ_BUSINESSES',
roles: ['Administrator', 'Platform Owner']
},
// Claim Requests (Admin Only)
{
href: '/businesses/businesses-list',
label: 'My Studio',
href: '/claim_requests/claim_requests-list',
label: 'Claim Requests',
icon: icon.mdiShieldCheck,
permissions: 'READ_CLAIM_REQUESTS',
roles: ['Administrator', 'Platform Owner']
},
// Business Owner sees their My Listing page
{
href: '/my-listing',
label: 'My Listing',
icon: icon.mdiStorefront,
permissions: 'READ_BUSINESSES',
roles: ['Verified Business Owner']
},

View File

@ -55,7 +55,7 @@ const BusinessesNew = () => {
data.slug = data.name.toLowerCase().replace(/ /g, '-').replace(/[^\w-]+/g, '');
}
await dispatch(create(data))
await router.push('/businesses/businesses-list')
await router.push(isVBO ? '/my-listing' : '/businesses/businesses-list')
}
return (
@ -133,7 +133,7 @@ const BusinessesNew = () => {
<BaseButtons>
<BaseButton type="submit" color="info" label={isVBO ? "Create Listing" : "Submit"} />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/businesses/businesses-list')}/>
<BaseButton type='button' color='danger' outline label='Cancel' onClick={() => router.push(isVBO ? '/my-listing' : '/businesses/businesses-list')}/>
</BaseButtons>
</Form>
</Formik>

View File

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

View File

@ -0,0 +1,234 @@
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
} 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';
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);
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) {
business = res.data.rows[0];
}
}
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);
}
};
if (loading) return <SectionMain><LoadingSpinner /></SectionMain>;
// STATE 1: Owns a business
if (myBusiness) {
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 flex items-center justify-center text-slate-400">
<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>
{/* Placeholder for stats or recent bookings */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<CardBox className="p-6 text-center">
<div className="text-slate-400 text-xs font-bold 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-xs font-bold uppercase tracking-widest mb-2">Avg Rating</div>
<div className="text-3xl font-bold">{myBusiness.rating || 'New'}</div>
</CardBox>
<CardBox className="p-6 text-center">
<div className="text-slate-400 text-xs font-bold uppercase tracking-widest mb-2">Service Bookings</div>
<div className="text-3xl font-bold">0</div>
</CardBox>
</div>
</SectionMain>
);
}
// STATE 2: Pending claim
if (pendingClaim) {
return (
<SectionMain>
<Head>
<title>{getPageTitle('Claim Pending')}</title>
</Head>
<SectionTitleLineWithButton icon={mdiShieldCheck} title="Claim Verification" main />
<div className="max-w-3xl mx-auto">
<CardBox className="p-10 text-center space-y-6">
<div className="w-20 h-20 bg-amber-100 text-amber-600 rounded-full flex items-center justify-center mx-auto">
<BaseIcon path={mdiAlertCircle} size={48} />
</div>
<div>
<h2 className="text-3xl font-bold mb-2">Claim Request Pending</h2>
<p className="text-slate-500 max-w-md mx-auto">
We&apos;ve received your request to claim <strong>{pendingClaim.business?.name}</strong>.
Our team is currently reviewing your application.
</p>
</div>
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 inline-block text-left w-full max-w-md">
<h4 className="font-bold mb-4 flex items-center">
<BaseIcon path={mdiShieldCheck} size={20} className="mr-2 text-emerald-500" />
Next Steps: Verification
</h4>
<p className="text-sm text-slate-600 mb-6">
To approve your claim, we need to verify your association with this business. Please upload a business license or utility bill.
</p>
<BaseButton
href={`/verification_submissions/verification_submissions-new?businessId=${pendingClaim.businessId}`}
label="Upload Verification Documents"
color="info"
className="w-full"
/>
</div>
<div className="pt-6 border-t border-slate-100">
<p className="text-xs text-slate-400">Request ID: {pendingClaim.id} Submitted on {new Date(pendingClaim.createdAt).toLocaleDateString()}</p>
</div>
</CardBox>
</div>
</SectionMain>
);
}
// STATE 3: Neither
return (
<SectionMain>
<Head>
<title>{getPageTitle('My Listing')}</title>
</Head>
<SectionTitleLineWithButton icon={mdiStorefront} title="My Listing" main />
<div className="grid md:grid-cols-2 gap-8 max-w-5xl mx-auto pt-10">
{/* Path 1: Create New */}
<CardBox className="p-10 flex flex-col items-center text-center space-y-6 hover:shadow-xl transition-all border-2 border-transparent hover:border-emerald-100">
<div className="w-16 h-16 bg-emerald-100 text-emerald-600 rounded-2xl flex items-center justify-center">
<BaseIcon path={mdiPlus} size={40} />
</div>
<div>
<h3 className="text-2xl font-bold mb-2">Create New Listing</h3>
<p className="text-slate-500 text-sm">
Your business isn&apos;t on Fix-It-Local yet? Create a fresh listing and start attracting clients immediately.
</p>
</div>
<BaseButton
href="/businesses/businesses-new"
label="Start Fresh Listing"
color="info"
className="w-full"
/>
</CardBox>
{/* Path 2: Claim Existing */}
<CardBox className="p-10 flex flex-col items-center text-center space-y-6 hover:shadow-xl transition-all border-2 border-transparent hover:border-blue-100">
<div className="w-16 h-16 bg-blue-100 text-blue-600 rounded-2xl flex items-center justify-center">
<BaseIcon path={mdiMagnify} size={40} />
</div>
<div>
<h3 className="text-2xl font-bold mb-2">Claim Existing Business</h3>
<p className="text-slate-500 text-sm">
Search for your business in our directory. If it exists but is unowned, you can claim it for free.
</p>
</div>
<BaseButton
href="/search"
label="Search Directory"
outline
color="info"
className="w-full"
/>
</CardBox>
</div>
</SectionMain>
);
};
MyListingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default MyListingPage;

View File

@ -63,16 +63,17 @@ const BusinessDetailsPublic = () => {
const claimListing = async () => {
if (!currentUser) {
router.push('/login');
// 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 dashboard as requested by user
router.push('/dashboard');
// 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. Please try again.');
alert('Failed to claim business. It might already be claimed or you have a pending request.');
}
};
@ -123,7 +124,7 @@ const BusinessDetailsPublic = () => {
) : (
<BaseIcon path={mdiShieldCheck} size={64} className="text-slate-300" />
)}
{(business.reliability_score >= 80 || business.is_claimed) && (
{(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>
@ -143,7 +144,7 @@ const BusinessDetailsPublic = () => {
<BaseIcon path={mdiStar} size={18} className="mr-1 text-amber-400" />
{displayRating} Rating
</span>
{business.is_claimed ? (
{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>
@ -196,7 +197,7 @@ const BusinessDetailsPublic = () => {
{/* Main Content */}
<div className="lg:col-span-2 space-y-12">
{!business.is_claimed && (
{!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>
@ -401,7 +402,7 @@ const BusinessDetailsPublic = () => {
</div>
</div>
))}
{business.is_claimed && (
{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} />
@ -412,7 +413,7 @@ const BusinessDetailsPublic = () => {
</div>
</div>
)}
{!business.business_badges_business?.length && !business.is_claimed && <p className="text-slate-400 text-sm italic">Pending verification...</p>}
{!business.business_badges_business?.length && !business.owner_userId && <p className="text-slate-400 text-sm italic">Pending verification...</p>}
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement } from 'react'
import React, { ReactElement, useEffect, useState } from 'react'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
@ -27,206 +27,66 @@ import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = {
const initialValuesDefault = {
business: '',
badge_type: 'VERIFIED_BUSINESS',
status: 'PENDING',
notes: '',
admin_notes: '',
created_at_ts: '',
updated_at_ts: '',
}
const Verification_submissionsNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const { businessId } = router.query
const [initialValues, setInitialValues] = useState(initialValuesDefault)
useEffect(() => {
if (businessId) {
setInitialValues({
...initialValuesDefault,
business: businessId as string,
badge_type: 'VERIFIED_BUSINESS'
})
}
}, [businessId])
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/verification_submissions/verification_submissions-list')
if (businessId) {
router.push('/my-listing')
} else {
await router.push('/verification_submissions/verification_submissions-list')
}
}
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
<title>{getPageTitle('Verification Submission')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Verification Submission" main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={
initialValues
}
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label="Business" labelFor="business">
<Field name="business" id="business" component={SelectField} options={[]} itemRef={'businesses'}></Field>
</FormField>
<FormField label="BadgeType" labelFor="badge_type">
<FormField label="Badge Type" labelFor="badge_type">
<Field name="badge_type" id="badge_type" component="select">
<option value="VERIFIED_BUSINESS">VERIFIED_BUSINESS</option>
<option value="VERIFIED_BUSINESS">VERIFIED_BUSINESS (Ownership Claim)</option>
<option value="VERIFIED_LICENSE">VERIFIED_LICENSE</option>
@ -241,32 +101,6 @@ const Verification_submissionsNew = () => {
</Field>
</FormField>
<FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select">
@ -279,90 +113,16 @@ const Verification_submissionsNew = () => {
</Field>
</FormField>
<FormField label="Notes" hasTextareaHeight>
<Field name="notes" as="textarea" placeholder="Notes" />
<FormField label="Notes / Verification Proof Description" hasTextareaHeight>
<Field name="notes" as="textarea" placeholder="Enter details about your verification proof..." />
</FormField>
<FormField label="AdminNotes" hasTextareaHeight>
<Field name="admin_notes" as="textarea" placeholder="AdminNotes" />
<FormField label="Admin Notes" hasTextareaHeight>
<Field name="admin_notes" as="textarea" placeholder="Admin notes..." />
</FormField>
<FormField
label="CreatedAt"
label="Created At"
>
<Field
type="datetime-local"
@ -371,61 +131,11 @@ const Verification_submissionsNew = () => {
/>
</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="submit" color="info" label="Submit Verification" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/verification_submissions/verification_submissions-list')}/>
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.back()}/>
</BaseButtons>
</Form>
</Formik>
@ -447,4 +157,4 @@ Verification_submissionsNew.getLayout = function getLayout(page: ReactElement) {
)
}
export default Verification_submissionsNew
export default Verification_submissionsNew