111
This commit is contained in:
parent
f7df7e331e
commit
b03b911e99
2
502.html
2
502.html
@ -129,7 +129,7 @@
|
||||
<p class="tip">The application is currently launching. The page will automatically refresh once site is
|
||||
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">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
# Fix It Local
|
||||
# Fix-It-Local
|
||||
|
||||
|
||||
## This project was generated by [Flatlogic Platform](https://flatlogic.com).
|
||||
|
||||
BIN
assets/pasted-20260218-034356-d8337609.png
Normal file
BIN
assets/pasted-20260218-034356-d8337609.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
@ -1,5 +1,5 @@
|
||||
|
||||
#Fix It Local - template backend,
|
||||
#Fix-It-Local - template backend,
|
||||
|
||||
#### Run App on local machine:
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}));
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
const errors = {
|
||||
app: {
|
||||
title: 'Fix It Local',
|
||||
title: 'Fix-It-Local',
|
||||
},
|
||||
|
||||
auth: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 || ''
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
@ -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
@ -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">"{msg.body}"</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
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
};
|
||||
@ -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(() => {
|
||||
|
||||
@ -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">"{review.text}"</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;
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user