This commit is contained in:
Flatlogic Bot 2026-02-18 13:28:04 +00:00
parent f7df7e331e
commit b03b911e99
33 changed files with 866 additions and 3221 deletions

View File

@ -129,7 +129,7 @@
<p class="tip">The application is currently launching. The page will automatically refresh once site is
available.</p>
<div class="project-info">
<h2>Fix It Local</h2>
<h2>Fix-It-Local</h2>
<p>Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking.</p>
</div>
<div class="loader-container">

View File

@ -1,6 +1,6 @@
# Fix It Local
# Fix-It-Local
## This project was generated by [Flatlogic Platform](https://flatlogic.com).

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

View File

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

View File

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

View File

@ -37,7 +37,7 @@ const config = {
},
uploadDir: os.tmpdir(),
email: {
from: 'Fix It Local <app@flatlogic.app>',
from: 'Fix-It-Local <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
@ -67,11 +67,11 @@ const config = {
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.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`;
config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`;
config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`;
config.backUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}`;
module.exports = config;
module.exports = config;

View File

@ -37,11 +37,11 @@ module.exports = class BusinessesDBApi {
if (!isAdmin && !isPublicOrConsumer) {
// This is a "client" (e.g. Verified Business Owner)
if (currentUser.businessId) {
where.id = currentUser.businessId;
} else {
where.owner_userId = currentUser.id;
}
// Show businesses they own OR their primary businessId
where[Op.or] = [
{ owner_userId: currentUser.id },
{ id: currentUser.businessId || null }
];
} else if (isPublicOrConsumer) {
where.is_active = true;
}

View File

@ -17,7 +17,7 @@ module.exports = class Lead_matchesDBApi {
const currentUser = options?.currentUser;
const transaction = (options && options.transaction) || undefined;
// Data Isolation for Fix It Local™
// Data Isolation for Fix-It-Local™
if (currentUser && currentUser.app_role) {
const roleName = currentUser.app_role.name;
if (roleName === 'Verified Business Owner') {

View File

@ -77,8 +77,8 @@ const options = {
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "Fix It Local",
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.",
title: "Fix-It-Local",
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: [
{

View File

@ -480,11 +480,10 @@ router.get('/autocomplete', async (req, res) => {
* description: Some server error
*/
router.get('/:id', wrapAsync(async (req, res) => {
const payload = await BusinessesDBApi.findBy(
{ id: req.params.id },
const payload = await BusinessesService.findBy(
req.params.id,
req.currentUser
);
res.status(200).send(payload);
}));

View File

@ -7,14 +7,53 @@ const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const { v4: uuidv4 } = require('uuid');
module.exports = class BusinessesService {
static _sanitize(data) {
const numericFields = ['lat', 'lng', 'reliability_score', 'response_time_median_minutes', 'rating'];
numericFields.forEach(field => {
if (data[field] === '') {
data[field] = null;
}
});
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') {
if (business.owner_userId !== currentUser.id && business.id !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
}
return business;
}
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
data = this._sanitize(data);
// For VBOs, force the owner to be the current user
if (currentUser.app_role?.name === 'Verified Business Owner') {
if (currentUser?.app_role?.name === 'Verified Business Owner') {
data.owner_user = 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(
@ -26,7 +65,7 @@ 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) {
if (currentUser?.app_role?.name === 'Verified Business Owner' && !currentUser.businessId) {
await db.users.update({ businessId: business.id }, {
where: { id: currentUser.id },
transaction
@ -58,7 +97,7 @@ module.exports = class BusinessesService {
}, { transaction });
// Link business to user if they don't have one set yet
if (!currentUser.businessId) {
if (currentUser && !currentUser.businessId) {
await db.users.update({ businessId: business.id }, {
where: { id: currentUser.id },
transaction
@ -111,6 +150,8 @@ module.exports = class BusinessesService {
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
data = this._sanitize(data);
let business = await BusinessesDBApi.findBy(
{id},
{transaction},
@ -123,13 +164,15 @@ module.exports = class BusinessesService {
}
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === '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
delete data.owner_user;
delete data.owner_userId;
delete data.slug;
delete data.tenant_key;
}
const updatedBusinesses = await BusinessesDBApi.update(
@ -155,7 +198,7 @@ module.exports = class BusinessesService {
try {
// Ownership check for Verified Business Owner
if (currentUser.app_role?.name === 'Verified Business Owner') {
if (currentUser?.app_role?.name === 'Verified Business Owner') {
const records = await db.businesses.findAll({
where: {
id: { [db.Sequelize.Op.in]: ids },
@ -190,7 +233,7 @@ module.exports = class BusinessesService {
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) {
if (currentUser?.app_role?.name === 'Verified Business Owner' && business.owner_userId !== currentUser.id && business.id !== currentUser.businessId) {
throw new ForbiddenError('forbidden');
}
@ -210,4 +253,4 @@ module.exports = class BusinessesService {
}
};
};

View File

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

View File

@ -57,6 +57,9 @@ module.exports = class SearchService {
throw new ValidationError('iam.errors.searchQueryRequired');
}
const roleName = currentUser?.app_role?.name || 'Public';
const isAdmin = roleName === 'Administrator' || roleName === 'Platform Owner';
// Columns that can be searched using iLike
const searchableColumns = {
"users": [
@ -140,6 +143,12 @@ module.exports = class SearchService {
[Op.or]: searchConditions,
};
// Only show active businesses for non-admins
if (tableName === 'businesses' && !isAdmin) {
whereCondition[Op.and] = whereCondition[Op.and] || [];
whereCondition[Op.and].push({ is_active: true });
}
// If location is provided, bias local results by location for businesses and locations
if (location && (tableName === 'businesses' || tableName === 'locations')) {
const locationConditions = [
@ -153,11 +162,10 @@ module.exports = class SearchService {
locationConditions.push({ address: { [Op.iLike]: `%${location}%` } });
}
whereCondition[Op.and] = [
{
whereCondition[Op.and] = whereCondition[Op.and] || [];
whereCondition[Op.and].push({
[Op.or]: locationConditions
}
];
});
}
const hasPerm = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser);
@ -215,6 +223,7 @@ module.exports = class SearchService {
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,

View File

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

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

View File

@ -5,6 +5,7 @@ import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'
import Link from 'next/link';
import Logo from './Logo'
type Props = {
@ -37,11 +38,10 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
<div
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">
<b className="font-black">Fix It Local</b>
<div className="text-center flex-1 flex items-center justify-center">
<Link href="/">
<Logo className="h-8 w-auto" />
</Link>
</div>
<button
className="hidden lg:inline-block xl:hidden p-3"
@ -60,4 +60,4 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
</div>
</aside>
)
}
}

View File

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

View File

@ -8,8 +8,8 @@ export const localStorageStyleKey = 'style'
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
export const appTitle = 'Fix It Local'
export const appTitle = 'Fix-It-Local'
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 || ''

View File

@ -4,6 +4,7 @@ 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 = {
children: ReactNode
@ -27,10 +28,7 @@ export default function LayoutGuest({ children }: Props) {
<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">
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20 group-hover:scale-110 transition-transform">
<BaseIcon path={mdiShieldCheck} size={24} className="text-white" />
</div>
<span className="text-xl font-black tracking-tight dark:text-white">Fix It Local<span className="text-emerald-500 italic"></span></span>
<Logo className="h-10 w-auto" />
</Link>
<nav className="hidden md:flex items-center gap-10">
@ -94,10 +92,7 @@ export default function LayoutGuest({ children }: Props) {
<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">
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center mr-3">
<BaseIcon path={mdiShieldCheck} size={24} className="text-white" />
</div>
<span className="text-2xl font-bold tracking-tight dark:text-white text-slate-900">Fix It Local</span>
<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>
@ -107,10 +102,10 @@ export default function LayoutGuest({ children }: Props) {
</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.
© 2026 Fix-It-Local. Built with Trust & Transparency.
</div>
</div>
</footer>
</div>
)
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,327 +1,259 @@
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 Head from 'next/head'
import axios from 'axios'
import {
mdiAccountMultiple,
mdiCartOutline,
mdiChartTimelineVariant,
mdiShieldCheck,
mdiStore,
mdiCalendarRange,
mdiCurrencyUsd,
mdiAlertCircle
} from '@mdi/js'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import BaseIcon from "../components/BaseIcon";
import BaseButton from "../components/BaseButton";
import CardBox from "../components/CardBox";
import CardBoxComponentBody from "../components/CardBoxComponentBody";
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import CardBoxComponentFooter from "../components/CardBoxComponentFooter";
import ProgressBar from "../components/ProgressBar";
import CardBox from '../components/CardBox'
import CardBoxComponentTitle from '../components/CardBoxComponentTitle'
import BaseIcon from '../components/BaseIcon'
import IconRounded from '../components/IconRounded'
import Link from 'next/link'
import LayoutAuthenticated from '../layouts/Authenticated'
import { getPageTitle } from '../config'
import Link from "next/link";
import moment from 'moment';
import { useAppSelector } from '../stores/hooks'
import { ColorKey } from '../interfaces'
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const ActionQueueItem = ({ label, count, iconPath, color, href }: any) => (
<Link href={href}>
<div className="flex items-center p-4 bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 rounded-[2rem] shadow-sm hover:shadow-xl hover:shadow-emerald-500/10 transition-all cursor-pointer group">
<div className={`p-4 rounded-2xl mr-4 ${color} shadow-lg shadow-current/20 group-hover:scale-110 transition-transform`}>
<BaseIcon path={iconPath} size={24} className="text-white" />
</div>
<div>
<div className="text-2xl font-black text-slate-800 dark:text-white">{count}</div>
<div className="text-xs font-bold text-slate-400 uppercase tracking-tighter">{label}</div>
</div>
</div>
</Link>
);
const PipelineStat = ({ label, count, color }: any) => (
<div className="text-center p-4 border-r last:border-r-0 border-slate-100 dark:border-slate-800">
<div className={`text-3xl font-black ${color}`}>{count}</div>
<div className="text-[10px] text-slate-400 uppercase tracking-widest font-black mt-2">{label}</div>
</div>
);
const BusinessDashboardView = ({ metrics, currentUser }: any) => {
if (metrics.no_business) {
return (
<CardBox className="text-center p-12 border-dashed border-2 border-slate-200">
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-6">
<BaseIcon path={icon.mdiStorePlus} size={40} className="text-slate-300" />
</div>
<h2 className="text-2xl font-bold mb-2 text-slate-800">No active business found</h2>
<p className="text-slate-500 mb-8 max-w-sm mx-auto text-sm">Create your first listing to start receiving leads and managing your beauty business with AI-powered tools.</p>
<BaseButton href="/businesses/businesses-new" label="List New Business" color="info" icon={icon.mdiPlus} className="px-8 py-3 rounded-2xl font-bold shadow-lg shadow-emerald-500/20" />
</CardBox>
);
}
const { action_queue, pipeline, recentMessages, performance, healthScore, businesses } = metrics;
const business = businesses[0];
type CardBoxWidgetProps = {
number: number | string
icon: string
label: string
title: string
textColor: string
color: ColorKey
}
const CardBoxWidget = (props: CardBoxWidgetProps) => {
return (
<div className="space-y-8 animate-fade-in">
{/* Action Queue */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<ActionQueueItem
label="New Leads (24h)"
count={action_queue.newLeads24h}
iconPath={icon.mdiFlash}
color="bg-amber-400"
href="/leads/leads-list"
/>
<ActionQueueItem
label="Needs Response"
count={action_queue.leadsNeedingResponse}
iconPath={icon.mdiMessageProcessing}
color="bg-rose-400"
href="/leads/leads-list"
/>
<ActionQueueItem
label="Verifications"
count={action_queue.verificationPending}
iconPath={icon.mdiShieldCheckOutline}
color="bg-emerald-400"
href="/verification_submissions/verification_submissions-list"
/>
<ActionQueueItem
label="Health Score"
count={`${healthScore}%`}
iconPath={icon.mdiHeartPulse}
color="bg-rose-500"
href={`/businesses/businesses-edit/?id=${business.id}`}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Lead Pipeline */}
<CardBox className="lg:col-span-2 overflow-hidden border-none shadow-2xl shadow-slate-200/50">
<CardBoxComponentTitle title="Beauty Pipeline Snapshot">
<div className="text-[10px] font-black uppercase tracking-widest text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full border border-emerald-100">
{pipeline.winRate30d.toFixed(1)}% Conversion
</div>
</CardBoxComponentTitle>
<div className="grid grid-cols-5 mt-6 bg-slate-50/50 rounded-3xl p-2 border border-slate-100">
<PipelineStat label="New" count={pipeline.NEW || 0} color="text-slate-900 dark:text-white" />
<PipelineStat label="Consulted" count={pipeline.CONTACTED || 0} color="text-rose-400" />
<PipelineStat label="Booked" count={pipeline.SCHEDULED || 0} color="text-amber-400" />
<PipelineStat label="Completed" count={pipeline.WON || 0} color="text-emerald-500" />
<PipelineStat label="Archived" count={pipeline.LOST || 0} color="text-slate-400" />
</div>
</CardBox>
{/* Listing Health */}
<CardBox className="border-none shadow-2xl shadow-slate-200/50">
<CardBoxComponentTitle title="Profile Vitality" />
<div className="mt-6">
<ProgressBar value={healthScore} label="Profile Strength" color={healthScore > 80 ? 'green' : healthScore > 50 ? 'yellow' : 'red'} />
<div className="mt-8 space-y-3">
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">Improve visibility:</div>
{action_queue.missingFields.slice(0, 3).map((field: string) => (
<div key={field} className="flex items-center text-xs font-semibold text-rose-500 bg-rose-50/50 p-2 rounded-xl border border-rose-100/50">
<BaseIcon path={icon.mdiAlertCircleOutline} size={14} className="mr-2" />
Add {field.replace('_json', '').replace('_', ' ')}
</div>
))}
<Link href={`/businesses/businesses-edit/?id=${business.id}`} className="block text-xs text-emerald-600 font-bold hover:underline mt-4 text-center p-2 bg-emerald-50 rounded-xl transition-colors">
Enhance Profile
</Link>
</div>
</div>
</CardBox>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Recent Messages */}
<CardBox className="lg:col-span-2 border-none shadow-2xl shadow-slate-200/50">
<CardBoxComponentTitle title="Recent Client Love" />
<div className="mt-6 space-y-4">
{recentMessages.length > 0 ? recentMessages.map((msg: any) => (
<div key={msg.id} className="flex items-start p-4 hover:bg-emerald-50/30 dark:hover:bg-slate-800 rounded-[1.5rem] transition-all border border-transparent hover:border-emerald-100 group">
<div className="bg-emerald-100 dark:bg-slate-700 w-12 h-12 rounded-2xl flex items-center justify-center mr-4 flex-shrink-0 text-emerald-600 font-black shadow-inner">
{msg.sender_user?.firstName?.[0] || 'U'}
</div>
<div className="flex-grow min-w-0">
<div className="flex justify-between items-baseline mb-1">
<span className="font-bold text-slate-800 dark:text-white truncate">{msg.sender_user?.firstName} {msg.sender_user?.lastName}</span>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">{moment(msg.createdAt).fromNow()}</span>
</div>
<p className="text-sm text-slate-500 dark:text-gray-400 line-clamp-1 italic">&quot;{msg.body}&quot;</p>
</div>
<div className="ml-4 opacity-0 group-hover:opacity-100 transition-opacity">
<BaseButton small color="success" icon={icon.mdiReply} href={`/messages/messages-list?leadId=${msg.leadId}`} className="rounded-xl shadow-lg shadow-emerald-500/20" />
</div>
</div>
)) : (
<div className="text-center py-12 text-slate-300 italic font-medium">No recent messages yet</div>
)}
</div>
<CardBoxComponentFooter className="bg-slate-50/50">
<BaseButton label="View All Messages" color="white" small href="/leads/leads-list" className="rounded-xl border-slate-200 shadow-sm" />
</CardBoxComponentFooter>
</CardBox>
{/* Performance & Billing */}
<div className="space-y-8">
<CardBox className="border-none shadow-2xl shadow-slate-200/50">
<CardBoxComponentTitle title="Growth (30d)" />
<div className="mt-6 grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-2xl border border-slate-100">
<div className="text-[10px] text-slate-400 uppercase font-black tracking-widest mb-1">Views</div>
<div className="text-2xl font-black text-slate-800 dark:text-white">{performance.views30d}</div>
<div className="text-[10px] font-bold text-emerald-500 mt-1 flex items-center">
<BaseIcon path={icon.mdiTrendingUp} size={12} className="mr-1" />
7d: {performance.views7d}
</div>
</div>
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-2xl border border-slate-100">
<div className="text-[10px] text-slate-400 uppercase font-black tracking-widest mb-1">Interactions</div>
<div className="text-2xl font-black text-slate-800 dark:text-white">{performance.calls30d + performance.website30d}</div>
<div className="text-[10px] font-bold text-emerald-500 mt-1 flex items-center">
<BaseIcon path={icon.mdiTrendingUp} size={12} className="mr-1" />
7d: {performance.calls7d + performance.website7d}
</div>
</div>
</div>
<div className="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800">
<div className="flex justify-between items-center">
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">Conversion Rate</span>
<span className="text-lg font-black text-emerald-500">{performance.conversionRate.toFixed(1)}%</span>
</div>
</div>
</CardBox>
<CardBox className="bg-gradient-to-br from-emerald-500 via-teal-600 to-emerald-700 text-white border-none shadow-2xl shadow-emerald-500/30 overflow-hidden relative group">
<div className="absolute top-0 right-0 -mt-4 -mr-4 w-24 h-24 bg-white/10 rounded-full blur-2xl group-hover:scale-150 transition-transform duration-700"></div>
<div className="relative z-10">
<div className="flex justify-between items-start">
<div>
<div className="text-emerald-100 text-[10px] font-black uppercase tracking-[0.2em]">Tier Plan</div>
<div className="text-3xl font-black mt-2 tracking-tighter">{business.plan?.name || 'Professional'}</div>
</div>
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl shadow-xl">
<BaseIcon path={icon.mdiCrownOutline} size={28} className="text-amber-300" />
</div>
</div>
<div className="mt-8">
<div className="text-emerald-100 text-[10px] uppercase tracking-[0.2em] font-black">Renewal Date</div>
<div className="text-lg font-bold mt-1">{moment(business.renewal_date).format('MMMM Do, YYYY')}</div>
</div>
<div className="mt-10">
<BaseButton label="Manage Subscription" color="white" small className="w-full text-emerald-700 font-black py-4 rounded-2xl shadow-xl hover:scale-[1.02] transition-transform" />
</div>
</div>
</CardBox>
<CardBox>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg leading-tight text-slate-500 dark:text-slate-400">
{props.title}
</h3>
<h1 className="text-3xl leading-tight font-semibold">
{props.number}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{props.label}
</p>
</div>
<IconRounded icon={props.icon} color={props.color} bg />
</div>
</div>
);
};
</CardBox>
)
}
const Dashboard = () => {
const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const { currentUser } = useAppSelector((state) => state.auth)
const [dashboardData, setDashboardData] = useState<any>(null)
const [isFetching, setIsFetching] = useState(false)
const isBusinessOwner = currentUser?.role === 'Verified Business Owner'
const { currentUser } = useAppSelector((state) => state.auth);
const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner';
const [loading, setLoading] = useState(true);
const [metrics, setMetrics] = useState<any>(null);
const [counts, setCounts] = useState<any>({});
async function loadAdminData() {
const entities = ['users','roles','permissions','categories','locations','businesses'];
const requests = entities.map(entity => axios.get(`/${entity}/count`));
const results = await Promise.allSettled(requests);
const newCounts: any = {};
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
newCounts[entities[i]] = result.value.data.count;
}
});
setCounts(newCounts);
useEffect(() => {
const fetchDashboard = async () => {
setIsFetching(true)
try {
const response = await axios.get('/dashboard')
setDashboardData(response.data)
} catch (error) {
console.error('Error fetching dashboard data:', error)
} finally {
setIsFetching(false)
}
}
async function loadBusinessMetrics() {
try {
const response = await axios.get('/dashboard/business-metrics');
setMetrics(response.data);
} catch (err) {
console.error('Failed to load metrics', err);
} finally {
setLoading(false);
}
}
fetchDashboard()
}, [])
useEffect(() => {
if (!currentUser) return;
if (isBusinessOwner) {
loadBusinessMetrics();
} else {
loadAdminData().then(() => setLoading(false));
}
}, [currentUser, isBusinessOwner]);
const stats = [
{
title: 'Total Views',
icon: mdiChartTimelineVariant,
number: dashboardData?.totalViews || 0,
label: 'Overall visibility',
color: 'success' as ColorKey,
},
{
title: 'Active Leads',
icon: mdiCartOutline,
number: dashboardData?.activeLeads || 0,
label: 'Potential clients',
color: 'info' as ColorKey,
},
{
title: 'Conversion Rate',
icon: mdiShieldCheck,
number: dashboardData?.conversionRate ? `${dashboardData.conversionRate}%` : '0%',
label: 'Leads to jobs',
color: 'warning' as ColorKey,
},
]
if (loading) {
return (
<SectionMain>
<div className="flex flex-col items-center justify-center h-96 animate-pulse">
<div className="w-16 h-16 bg-emerald-100 rounded-3xl flex items-center justify-center mb-4">
<BaseIcon path={icon.mdiLoading} size={32} className="animate-spin text-emerald-500" />
</div>
<div className="text-xs font-black text-slate-400 uppercase tracking-widest">Curating your experience...</div>
</div>
</SectionMain>
);
}
const adminStats = [
{
title: 'Active Users',
icon: mdiAccountMultiple,
number: dashboardData?.totalUsers || 0,
label: 'Verified members',
color: 'success' as ColorKey,
},
{
title: 'Total Businesses',
icon: mdiStore,
number: dashboardData?.totalBusinesses || 0,
label: 'Listed companies',
color: 'info' as ColorKey,
},
{
title: 'Revenue Flow',
icon: mdiCurrencyUsd,
number: dashboardData?.totalRevenue ? `$${dashboardData.totalRevenue.toLocaleString()}` : '$0',
label: 'Network throughput',
color: 'warning' as ColorKey,
},
]
return (
<>
<Head>
<title>{getPageTitle('Beauty Studio Dashboard')}</title>
<title>{getPageTitle('Dashboard')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={icon.mdiStarFourPoints}
title={isBusinessOwner ? 'Beauty Studio Hub' : 'Network System Pulse'}
main
className="mb-8"
icon={mdiChartTimelineVariant}
title={isBusinessOwner ? 'Business Performance' : 'Network Overview'}
main
>
{isBusinessOwner && (
<div className="flex space-x-3">
<BaseButton label="Client Leads" color="info" icon={icon.mdiCalendarHeart} href="/leads/leads-list" small className="rounded-xl px-4 font-bold" />
<BaseButton label="Docs" color="white" icon={icon.mdiFileUploadOutline} href="/verification_submissions/verification_submissions-list" small className="rounded-xl border-slate-200 font-bold" />
</div>
)}
{''}
</SectionTitleLineWithButton>
{isBusinessOwner ? (
<BusinessDashboardView metrics={metrics} currentUser={currentUser} />
) : (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3 mb-10 animate-fade-in">
{Object.keys(counts).map(entity => (
<Link key={entity} href={`/${entity}/${entity}-list`}>
<CardBox className="hover:shadow-2xl hover:shadow-emerald-500/10 transition-all border-none group cursor-pointer p-6">
<div className="flex justify-between items-center">
<div>
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{entity.replace('_', ' ')}</div>
<div className="text-4xl font-black text-slate-800 dark:text-white group-hover:text-emerald-500 transition-colors">{counts[entity]}</div>
</div>
<div className="w-16 h-16 bg-slate-50 dark:bg-slate-800 rounded-2xl flex items-center justify-center group-hover:bg-emerald-50 transition-colors">
<BaseIcon path={icon.mdiLayersOutline} size={32} className={iconsColor} />
</div>
</div>
</CardBox>
</Link>
))}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6">
{(isBusinessOwner ? stats : adminStats).map((stat, index) => (
<CardBoxWidget
key={index}
color={stat.color}
textColor={''}
icon={stat.icon}
number={stat.number}
label={stat.label}
title={stat.title}
/>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Quick Actions */}
<CardBox className="h-full">
<CardBoxComponentTitle title="Quick Operations" />
<div className="grid grid-cols-2 gap-4">
{isBusinessOwner ? (
<>
<Link href="/businesses/businesses-new" className="p-4 rounded-2xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 hover:shadow-lg transition-all text-center">
<BaseIcon path={mdiStore} size={32} className="text-emerald-500 mx-auto mb-2" />
<span className="font-bold text-sm text-slate-700 dark:text-slate-300">Add Business</span>
</Link>
<Link href="/leads/leads-list" className="p-4 rounded-2xl bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 hover:shadow-lg transition-all text-center">
<BaseIcon path={mdiCartOutline} size={32} className="text-blue-500 mx-auto mb-2" />
<span className="font-bold text-sm text-slate-700 dark:text-slate-300">Manage Leads</span>
</Link>
</>
) : (
<>
<Link href="/users/users-list" className="p-4 rounded-2xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 hover:shadow-lg transition-all text-center">
<BaseIcon path={mdiAccountMultiple} size={32} className="text-emerald-500 mx-auto mb-2" />
<span className="font-bold text-sm text-slate-700 dark:text-slate-300">Review Users</span>
</Link>
<Link href="/verification_submissions/verification_submissions-list" className="p-4 rounded-2xl bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800 hover:shadow-lg transition-all text-center">
<BaseIcon path={mdiShieldCheck} size={32} className="text-amber-500 mx-auto mb-2" />
<span className="font-bold text-sm text-slate-700 dark:text-slate-300">Verifications</span>
</Link>
</>
)}
<Link href="/disputes/disputes-list" className="p-4 rounded-2xl bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 hover:shadow-lg transition-all text-center">
<BaseIcon path={mdiAlertCircle} size={32} className="text-red-500 mx-auto mb-2" />
<span className="font-bold text-sm text-slate-700 dark:text-slate-300">Open Disputes</span>
</Link>
<Link href="/messages/messages-list" className="p-4 rounded-2xl bg-purple-50 dark:bg-purple-900/20 border border-purple-100 dark:border-purple-800 hover:shadow-lg transition-all text-center">
<BaseIcon path={mdiCalendarRange} size={32} className="text-purple-500 mx-auto mb-2" />
<span className="font-bold text-sm text-slate-700 dark:text-slate-300">Inbox</span>
</Link>
</div>
</CardBox>
{/* Recent Activity / Status */}
<CardBox className="h-full">
<CardBoxComponentTitle title={isBusinessOwner ? 'Business Status' : 'System Status'} />
<div className="space-y-6">
<div className="flex items-center justify-between p-4 rounded-2xl bg-slate-50 dark:bg-slate-800 border border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-emerald-500 flex items-center justify-center">
<BaseIcon path={mdiShieldCheck} size={20} className="text-white" />
</div>
<div>
<h4 className="font-bold text-sm">Verification Status</h4>
<p className="text-xs text-slate-500">Identity & Business verified</p>
</div>
</div>
<span className="px-3 py-1 bg-emerald-100 text-emerald-600 text-[10px] font-bold uppercase tracking-wider rounded-full">Active</span>
</div>
<div className="flex items-center justify-between p-4 rounded-2xl bg-slate-50 dark:bg-slate-800 border border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center">
<BaseIcon path={mdiChartTimelineVariant} size={20} className="text-white" />
</div>
<div>
<h4 className="font-bold text-sm">Account Standing</h4>
<p className="text-xs text-slate-500">Perfect track record</p>
</div>
</div>
<span className="px-3 py-1 bg-blue-100 text-blue-600 text-[10px] font-bold uppercase tracking-wider rounded-full">Good</span>
</div>
</div>
</CardBox>
</div>
{/* Informational Widget */}
<CardBox className="mt-6 bg-slate-900 text-white border-none overflow-hidden relative">
<div className="absolute top-0 right-0 w-64 h-64 bg-emerald-500/20 rounded-full -mr-32 -mt-32 blur-3xl"></div>
<div className="relative z-10 p-4">
<CardBoxComponentTitle
title={isBusinessOwner ? 'Fix-It-Local' : 'Network System Pulse'}
className="text-white border-white/10"
/>
<p className="text-slate-400 text-sm leading-relaxed max-w-2xl mb-6">
{isBusinessOwner
? 'Welcome to your professional control center. From here you can manage your listings, respond to new leads, and track your business growth across our verified network.'
: 'The network is operating at optimal capacity. All verification systems are online and AI matching is currently processing requests with 98% efficiency.'}
</p>
<div className="flex gap-4">
<Link href="/docs" className="text-xs font-bold text-emerald-400 hover:text-emerald-300 underline underline-offset-4">
View Documentation
</Link>
<Link href="/support" className="text-xs font-bold text-slate-400 hover:text-white underline underline-offset-4">
Contact Support
</Link>
</div>
</div>
)}
</CardBox>
</SectionMain>
</>
)
}
Dashboard.getLayout = function getLayout(page: ReactElement) {
Dashboard.getLayout = function getLayout(page: React.ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default Dashboard;
export default Dashboard

View File

@ -96,7 +96,7 @@ export default function Forgot() {
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
</div>
<span className="text-2xl font-black tracking-tight text-slate-900">
Fix It Local<span className="text-emerald-500 italic"></span>
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>
@ -138,7 +138,7 @@ export default function Forgot() {
</CardBox>
<div className="text-center text-slate-400 text-xs pt-8">
© 2026 Fix It Local. All rights reserved. <br/>
© 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>

View File

@ -53,7 +53,7 @@ export default function LandingPage() {
return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
<Head>
<title>Fix It Local | 21st Century Service Directory</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>
@ -72,7 +72,7 @@ export default function LandingPage() {
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">Crafted</span> Service Network
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.

View File

@ -1,4 +1,3 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
@ -20,6 +19,7 @@ import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
import Logo from '../components/Logo'
export default function Login() {
const router = useRouter();
@ -43,7 +43,7 @@ export default function Login() {
password: 'b2096650',
remember: true })
const title = 'Fix It Local'
const title = 'Fix-It-Local'
// Fetch Pexels image/video
useEffect( () => {
@ -171,12 +171,7 @@ export default function Login() {
{/* Branding */}
<div className="flex flex-col items-center mb-8">
<Link href="/" className="flex items-center gap-3 group mb-6">
<div className="w-12 h-12 bg-emerald-500 rounded-2xl flex items-center justify-center shadow-xl shadow-emerald-500/20 group-hover:scale-110 transition-transform">
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
</div>
<span className="text-2xl font-black tracking-tight text-slate-900">
Fix It Local<span className="text-emerald-500 italic"></span>
</span>
<Logo className="h-12 w-auto" />
</Link>
<h2 className="text-3xl font-bold text-slate-900">Account Login</h2>
<p className="text-slate-500 mt-2">Enter your credentials to access your dashboard</p>
@ -270,7 +265,7 @@ export default function Login() {
</div>
<div className="text-center text-slate-400 text-xs pt-8">
© 2026 Fix It Local. All rights reserved. <br/>
© 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>
@ -283,4 +278,4 @@ export default function Login() {
Login.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

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

View File

@ -105,7 +105,7 @@ const BusinessDetailsPublic = () => {
return (
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
<Head>
<title>{business.name} | Fix It Local</title>
<title>{business.name} | Fix-It-Local</title>
</Head>
{/* Hero Header */}
@ -279,7 +279,9 @@ const BusinessDetailsPublic = () => {
<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)}</span>
<span className="text-xs text-slate-400 font-medium">
{dataFormatter.dateFormatter(review.created_at_ts || review.createdAt)}
</span>
</div>
<p className="text-slate-700 leading-relaxed mb-4 italic text-lg">&quot;{review.text}&quot;</p>
<div className="flex items-center justify-between">
@ -425,4 +427,4 @@ BusinessDetailsPublic.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
export default BusinessDetailsPublic;
export default BusinessDetailsPublic;

View File

@ -65,7 +65,7 @@ const RequestServicePage = () => {
return (
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
<Head>
<title>Request Service | Fix It Local</title>
<title>Request Service | Fix-It-Local</title>
</Head>
<div className="container mx-auto px-6 max-w-4xl">

View File

@ -14,6 +14,7 @@ import { getPageTitle } from '../config';
import Link from 'next/link';
import axios from "axios";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
import Logo from '../components/Logo'
export default function Register() {
const [loading, setLoading] = React.useState(false);
@ -135,12 +136,7 @@ export default function Register() {
{/* Branding */}
<div className="flex flex-col items-center mb-8">
<Link href="/" className="flex items-center gap-3 group mb-6">
<div className="w-12 h-12 bg-emerald-500 rounded-2xl flex items-center justify-center shadow-xl shadow-emerald-500/20 group-hover:scale-110 transition-transform">
<BaseIcon path={mdiShieldCheck} size={28} className="text-white" />
</div>
<span className="text-2xl font-black tracking-tight text-slate-900">
Fix It Local<span className="text-emerald-500 italic"></span>
</span>
<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>
@ -213,7 +209,7 @@ export default function Register() {
<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.
© 2026 Fix-It-Local. All rights reserved.
</div>
</div>
</div>

View File

@ -2,7 +2,7 @@ import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload, mdiStar, mdiMe
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import LayoutGuest from '../../layouts/Guest'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
@ -16,6 +16,9 @@ import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import { create } from '../../stores/reviews/reviewsSlice'
import BaseIcon from '../../components/BaseIcon'
import axios from 'axios'
import { ToastContainer, toast } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css'
const ReviewsNew = () => {
const router = useRouter()
@ -23,19 +26,18 @@ const ReviewsNew = () => {
const dispatch = useAppDispatch()
const { currentUser } = useAppSelector((state) => state.auth)
const [businessName, setBusinessName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (businessId) {
// Optionally fetch business name for display
fetchBusinessName()
}
}, [businessId])
const fetchBusinessName = async () => {
try {
const response = await fetch(`/api/businesses/${businessId}`)
const data = await response.json()
setBusinessName(data.name)
const response = await axios.get(`/businesses/${businessId}`)
setBusinessName(response.data.name)
} catch (e) {
console.error(e)
}
@ -54,18 +56,33 @@ const ReviewsNew = () => {
}
const handleSubmit = async (values) => {
// Ensure rating is a number
setIsSubmitting(true)
const data = {
...values,
rating: Number(values.rating),
// If coming from public page, we might want to redirect back there
business: values.business || businessId
business: businessId || values.business // Use businessId from query as priority
}
await dispatch(create(data))
if (businessId) {
router.push(`/public/businesses-details?id=${businessId}`)
} else {
router.push('/reviews/reviews-list')
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)
}
}
@ -74,81 +91,91 @@ const ReviewsNew = () => {
<Head>
<title>{getPageTitle('Write a Review')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiMessageDraw} title={businessName ? `Review for ${businessName}` : "Write a Review"} main>
{''}
</SectionTitleLineWithButton>
<div className="pt-24 pb-12">
<SectionMain>
<SectionTitleLineWithButton icon={mdiMessageDraw} title={businessName ? `Review for ${businessName}` : "Write a Review"} main>
{''}
</SectionTitleLineWithButton>
<div className="max-w-3xl mx-auto">
<CardBox>
<Formik
initialValues={initialValues}
enableReinitialize={true}
onSubmit={(values) => handleSubmit(values)}
>
{({ values, setFieldValue }) => (
<Form>
<div className="mb-8 text-center">
<p className="text-slate-500 mb-4 font-medium uppercase tracking-widest text-xs">Overall Experience</p>
<div className="flex justify-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setFieldValue('rating', star)}
className={`p-2 transition-all transform hover:scale-110 ${values.rating >= star ? 'text-amber-400' : 'text-slate-200'}`}
>
<BaseIcon path={mdiStar} size={48} />
</button>
))}
<div className="max-w-3xl mx-auto">
<CardBox>
<Formik
initialValues={initialValues}
enableReinitialize={true}
onSubmit={(values) => handleSubmit(values)}
>
{({ values, setFieldValue }) => (
<Form>
<div className="mb-8 text-center">
<p className="text-slate-500 mb-4 font-medium uppercase tracking-widest text-xs">Overall Experience</p>
<div className="flex justify-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setFieldValue('rating', star)}
className={`p-2 transition-all transform hover:scale-110 ${values.rating >= star ? 'text-amber-400' : 'text-slate-200'}`}
>
<BaseIcon path={mdiStar} size={48} />
</button>
))}
</div>
<div className="text-amber-500 font-black text-xl mt-2">
{values.rating === 1 && 'Poor'}
{values.rating === 2 && 'Fair'}
{values.rating === 3 && 'Good'}
{values.rating === 4 && 'Very Good'}
{values.rating === 5 && 'Excellent!'}
</div>
</div>
<div className="text-amber-500 font-black text-xl mt-2">
{values.rating === 1 && 'Poor'}
{values.rating === 2 && 'Fair'}
{values.rating === 3 && 'Good'}
{values.rating === 4 && 'Very Good'}
{values.rating === 5 && 'Excellent!'}
</div>
</div>
<FormField label="Your Review" help="Share details of your experience with this professional.">
<Field
name="text"
as="textarea"
placeholder="What was it like working with them?"
className="w-full rounded-2xl border-slate-200 focus:ring-emerald-500 focus:border-emerald-500"
rows={5}
/>
</FormField>
<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 />
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="emerald" label="Submit Review" className="w-full md:w-auto px-12 py-4 rounded-2xl" />
<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"
/>
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</div>
</SectionMain>
<BaseButtons>
<BaseButton
type="submit"
color="emerald"
label={isSubmitting ? "Submitting..." : "Submit Review"}
className="w-full md:w-auto px-12 py-4 rounded-2xl"
disabled={isSubmitting}
/>
<BaseButton
type="button"
color="info"
outline
label="Cancel"
onClick={() => businessId ? router.push(`/public/businesses-details?id=${businessId}`) : router.push('/reviews/reviews-list')}
className="w-full md:w-auto px-12 py-4 rounded-2xl"
disabled={isSubmitting}
/>
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</div>
</SectionMain>
</div>
<ToastContainer />
</>
)
}
ReviewsNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'CREATE_REVIEWS'}>
<LayoutGuest>
{page}
</LayoutAuthenticated>
</LayoutGuest>
)
}

View File

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