diff --git a/backend/src/db/api/claim_requests.js b/backend/src/db/api/claim_requests.js new file mode 100644 index 0000000..d7ae188 --- /dev/null +++ b/backend/src/db/api/claim_requests.js @@ -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 }; + } +}; \ No newline at end of file diff --git a/backend/src/db/api/leads.js b/backend/src/db/api/leads.js index 5cc1742..64c542e 100644 --- a/backend/src/db/api/leads.js +++ b/backend/src/db/api/leads.js @@ -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']], diff --git a/backend/src/db/api/messages.js b/backend/src/db/api/messages.js index 65758c8..d6deac3 100644 --- a/backend/src/db/api/messages.js +++ b/backend/src/db/api/messages.js @@ -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, }, ]; diff --git a/backend/src/db/api/reviews.js b/backend/src/db/api/reviews.js index 905415d..00b3af2 100644 --- a/backend/src/db/api/reviews.js +++ b/backend/src/db/api/reviews.js @@ -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']], diff --git a/backend/src/db/migrations/20260218041000-create-claim-requests.js b/backend/src/db/migrations/20260218041000-create-claim-requests.js new file mode 100644 index 0000000..e20f237 --- /dev/null +++ b/backend/src/db/migrations/20260218041000-create-claim-requests.js @@ -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; + } + }, +}; diff --git a/backend/src/db/migrations/20260218041500-add-claim-requests-permissions.js b/backend/src/db/migrations/20260218041500-add-claim-requests-permissions.js new file mode 100644 index 0000000..b491cdc --- /dev/null +++ b/backend/src/db/migrations/20260218041500-add-claim-requests-permissions.js @@ -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 + } +}; diff --git a/backend/src/db/models/claim_requests.js b/backend/src/db/models/claim_requests.js new file mode 100644 index 0000000..9a49db6 --- /dev/null +++ b/backend/src/db/models/claim_requests.js @@ -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; +}; diff --git a/backend/src/index.js b/backend/src/index.js index 5bb8609..0187e55 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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); diff --git a/backend/src/routes/claim_requests.js b/backend/src/routes/claim_requests.js new file mode 100644 index 0000000..702c4e5 --- /dev/null +++ b/backend/src/routes/claim_requests.js @@ -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; diff --git a/backend/src/services/businesses.js b/backend/src/services/businesses.js index ccc4ba3..acf3841 100644 --- a/backend/src/services/businesses.js +++ b/backend/src/services/businesses.js @@ -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( diff --git a/backend/src/services/claim_requests.js b/backend/src/services/claim_requests.js new file mode 100644 index 0000000..a0e4008 --- /dev/null +++ b/backend/src/services/claim_requests.js @@ -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 }); + } +}; diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 200e8b6..64ac999 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -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({ ) -} +} \ No newline at end of file diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 87afed6..999f12c 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -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'] }, diff --git a/frontend/src/pages/businesses/businesses-new.tsx b/frontend/src/pages/businesses/businesses-new.tsx index 0021319..b7adf54 100644 --- a/frontend/src/pages/businesses/businesses-new.tsx +++ b/frontend/src/pages/businesses/businesses-new.tsx @@ -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 = () => { - router.push('/businesses/businesses-list')}/> + router.push(isVBO ? '/my-listing' : '/businesses/businesses-list')}/> diff --git a/frontend/src/pages/claim_requests/claim_requests-list.tsx b/frontend/src/pages/claim_requests/claim_requests-list.tsx new file mode 100644 index 0000000..3eeb9ab --- /dev/null +++ b/frontend/src/pages/claim_requests/claim_requests-list.tsx @@ -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([]); + + 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 ; + + return ( + <> + + {getPageTitle('Claim Requests')} + + + + + + + + + + + + + + + + + {requests.map((request) => ( + + + + + + + + ))} + {requests.length === 0 && ( + + + + )} + +
BusinessUserStatusDateActions
+
{request.business?.name}
+
{request.business?.city}, {request.business?.state}
+
+
{request.user?.firstName} {request.user?.lastName}
+
{request.user?.email}
+
+ + {request.status} + + + {new Date(request.createdAt).toLocaleDateString()} + +
+ + {request.status === 'PENDING' && ( + <> + handleAction(request.id, 'APPROVED')} + /> + handleAction(request.id, 'REJECTED')} + /> + + )} +
+
No claim requests found.
+
+
+ + ); +}; + +ClaimRequestsListPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ClaimRequestsListPage; diff --git a/frontend/src/pages/my-listing.tsx b/frontend/src/pages/my-listing.tsx new file mode 100644 index 0000000..3f5f997 --- /dev/null +++ b/frontend/src/pages/my-listing.tsx @@ -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(null); + const [pendingClaim, setPendingClaim] = useState(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 ; + + // STATE 1: Owns a business + if (myBusiness) { + return ( + + + {getPageTitle('My Listing')} + + + + + + +
+
+ +
+
+

{myBusiness.name}

+

{myBusiness.address}, {myBusiness.city}, {myBusiness.state}

+
+ Active Listing + Verified Owner +
+
+
+ +
+
+
+ + {/* Placeholder for stats or recent bookings */} +
+ +
Total Love Letters
+
{myBusiness.reviews_business?.length || 0}
+
+ +
Avg Rating
+
{myBusiness.rating || 'New'}
+
+ +
Service Bookings
+
0
+
+
+
+ ); + } + + // STATE 2: Pending claim + if (pendingClaim) { + return ( + + + {getPageTitle('Claim Pending')} + + + +
+ +
+ +
+
+

Claim Request Pending

+

+ We've received your request to claim {pendingClaim.business?.name}. + Our team is currently reviewing your application. +

+
+ +
+

+ + Next Steps: Verification +

+

+ To approve your claim, we need to verify your association with this business. Please upload a business license or utility bill. +

+ +
+ +
+

Request ID: {pendingClaim.id} • Submitted on {new Date(pendingClaim.createdAt).toLocaleDateString()}

+
+
+
+
+ ); + } + + // STATE 3: Neither + return ( + + + {getPageTitle('My Listing')} + + + +
+ + {/* Path 1: Create New */} + +
+ +
+
+

Create New Listing

+

+ Your business isn't on Fix-It-Local yet? Create a fresh listing and start attracting clients immediately. +

+
+ +
+ + {/* Path 2: Claim Existing */} + +
+ +
+
+

Claim Existing Business

+

+ Search for your business in our directory. If it exists but is unowned, you can claim it for free. +

+
+ +
+ +
+
+ ); +}; + +MyListingPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default MyListingPage; \ No newline at end of file diff --git a/frontend/src/pages/public/businesses-details.tsx b/frontend/src/pages/public/businesses-details.tsx index 1fc0927..17ba4cd 100644 --- a/frontend/src/pages/public/businesses-details.tsx +++ b/frontend/src/pages/public/businesses-details.tsx @@ -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 = () => { ) : ( )} - {(business.reliability_score >= 80 || business.is_claimed) && ( + {(business.reliability_score >= 80 || business.owner_userId) && (
@@ -143,7 +144,7 @@ const BusinessDetailsPublic = () => { {displayRating} Rating - {business.is_claimed ? ( + {business.owner_userId ? ( Verified Pro @@ -196,7 +197,7 @@ const BusinessDetailsPublic = () => { {/* Main Content */}
- {!business.is_claimed && ( + {!business.owner_userId && (

Is this your business?

@@ -401,7 +402,7 @@ const BusinessDetailsPublic = () => {
))} - {business.is_claimed && ( + {business.owner_userId && (
@@ -412,7 +413,7 @@ const BusinessDetailsPublic = () => {
)} - {!business.business_badges_business?.length && !business.is_claimed &&

Pending verification...

} + {!business.business_badges_business?.length && !business.owner_userId &&

Pending verification...

}
diff --git a/frontend/src/pages/verification_submissions/verification_submissions-new.tsx b/frontend/src/pages/verification_submissions/verification_submissions-new.tsx index 8f5a495..9afc2cf 100644 --- a/frontend/src/pages/verification_submissions/verification_submissions-new.tsx +++ b/frontend/src/pages/verification_submissions/verification_submissions-new.tsx @@ -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 ( <> - {getPageTitle('New Item')} + {getPageTitle('Verification Submission')} - + {''} handleSubmit(values)} >
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + @@ -241,32 +101,6 @@ const Verification_submissionsNew = () => { - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -279,90 +113,16 @@ const Verification_submissionsNew = () => { - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - router.push('/verification_submissions/verification_submissions-list')}/> + router.back()}/>
@@ -447,4 +157,4 @@ Verification_submissionsNew.getLayout = function getLayout(page: ReactElement) { ) } -export default Verification_submissionsNew +export default Verification_submissionsNew \ No newline at end of file