Compare commits

...

14 Commits

Author SHA1 Message Date
louiscklaw
a9dd265658 update REQ0185 content, 2025-06-15 21:07:07 +08:00
louiscklaw
ae39b7ca67 refactor: rename product to partyEvent in frontend components and views 2025-06-15 18:06:17 +08:00
louiscklaw
4c2a06585d refactor: rename product to partyEvent in list page and view component 2025-06-15 17:53:47 +08:00
louiscklaw
816d88c2c1 refactor: rename eventId to partyEventId in delete API route and test case 2025-06-15 17:08:36 +08:00
louiscklaw
843e459527 "chore: add db:push and seed commands to dev script startup sequence" 2025-06-15 17:08:17 +08:00
louiscklaw
cd0ae5ba62 "chore: increase seed event items count from 2 to 5" 2025-06-15 17:07:56 +08:00
louiscklaw
ecdbc45c4a refactor: rename product to partyEvent in API route, types and frontend components 2025-06-15 16:44:27 +08:00
louiscklaw
4b64778b59 refactor: rename product to party-event across frontend modules and types 2025-06-15 16:33:50 +08:00
louiscklaw
a88de2f17f "add PartyEvent frontend module with mock data, API actions, UI components and routing configuration" 2025-06-15 16:13:09 +08:00
louiscklaw
d987b0fe36 "update AI workspace instructions and move setup steps to init_AI.md" 2025-06-15 16:13:02 +08:00
louiscklaw
0142c9ba24 update frontend requirement REQ0185 from product update to party-event and create new REQ0186 with product update content 2025-06-15 16:12:33 +08:00
louiscklaw
48e1f821ad "adjust nodemon delay timing for TypeScript watch scripts across all projects" 2025-06-15 16:12:02 +08:00
louiscklaw
9943283eff "add PartyEvent API endpoints with service layer and test cases" 2025-06-15 15:01:18 +08:00
louiscklaw
9ac13787aa "update FAQ with prisma schema file path clarification" 2025-06-15 13:55:13 +08:00
73 changed files with 5300 additions and 60 deletions

View File

@@ -1,10 +1,10 @@
---
tags: frontend, product, update
tags: frontend, party-event
---
# REQ0185 frontend product update
# REQ0185 frontend party-event
frontend page to update product
frontend page to handle party-event (CRUD)
edit page T.B.A.
@@ -14,4 +14,5 @@ T.B.A.
## branch
develop/frontend/party-event/trunk
develop/requirements/REQ0185

View File

@@ -0,0 +1,17 @@
---
tags: frontend, product, update
---
# REQ0185 frontend product update
frontend page to update product
edit page T.B.A.
## sources
T.B.A.
## branch
develop/requirements/REQ0185

View File

@@ -5,6 +5,7 @@
"description": "Mock server & assets",
"private": true,
"scripts": {
"dev:check": "yarn tsc:w",
"dev": "next dev -p 7272 -H 0.0.0.0",
"start": "next start -p 7272 -H 0.0.0.0",
"build": "next build",
@@ -20,7 +21,7 @@
"re:build-npm": "npm run clean && npm install && npm run build",
"tsc:dev": "yarn dev & yarn tsc:watch",
"tsc:print": "npx tsc --showConfig",
"tsc:w": "npx nodemon --delay 3 --ext ts,tsx --exec \"yarn tsc\"",
"tsc:w": "npx nodemon --delay 1 --ext ts,tsx --exec \"yarn tsc\"",
"tsc:watch": "tsc --noEmit --watch",
"tsc": "tsc --noEmit",
"migrate": "npx prisma migrate dev --skip-seed",
@@ -31,7 +32,9 @@
"db:push": "prisma db push --force-reset",
"db:push:w": "npx nodemon --delay 1 --watch prisma --ext \"ts,tsx,prisma\" --exec \"yarn db:push && yarn seed\"",
"db:studio": "prisma studio",
"db:studio:w": "npx nodemon --delay 1 --watch prisma --ext \"prisma\" --exec \"yarn db:studio\""
"db:studio:w": "npx nodemon --delay 1 --watch prisma --ext \"prisma\" --exec \"yarn db:studio\"",
"db:dev": "yarn db:push && yarn seed && yarn dev",
"db:dev:w": "npx nodemon --delay 3 --ext \"ts,tsx,prisma\" --exec \"yarn db:dev\""
},
"engines": {
"node": ">=20"
@@ -68,6 +71,7 @@
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/parser": "^8.28.0",
"concurrently": "^9.1.2",
"eslint": "^9.23.0",
"eslint-import-resolver-typescript": "^4.2.2",
"eslint-plugin-import": "^2.31.0",

View File

@@ -1148,47 +1148,58 @@ model EventReview {
// mapped to IEventItem
// a.k.a. PartyEvent party-event
model EventItem {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
//
available Int
category String
code String
colors String[]
coverUrl String
description String
gender String[]
images String[]
inventoryType String
name String
newLabel Json
price Float
priceSale Float?
publish String
quantity Int
ratings Json[]
saleLabel Json
sizes String[]
sku String
subDescription String
tags String[]
taxes Float
totalRatings Float
totalReviews Int
totalSold Int
available Int @default(99)
category String
code String @default("")
colors String[]
coverUrl String
description String
gender String[]
images String[]
inventoryType String @default("")
name String @default("")
newLabel Json @default("{}")
price Float @default(999.9)
priceSale Float? @default(111.1)
publish String @default("")
quantity Int @default(99)
ratings Json[]
saleLabel Json @default("{}")
sizes String[] @default([""])
sku String @default("")
subDescription String @default("")
tags String[] @default([""])
taxes Float @default(5.0)
totalRatings Float @default(5.0)
totalReviews Int @default(10)
totalSold Int @default(10)
//
ageBottom Float
ageTop Float
avatar String[]
currency String
duration_m Float
eventDate DateTime @default(now())
joinMembers Json[]
location String
title String
ageBottom Float @default(-1)
ageTop Float @default(-1)
avatar String[] @default([""])
currency String @default("HKD")
capacity Int @default(10)
duration_m Float @default(180)
endDate String? @default("")
eventDate DateTime @default(now())
isFeatured Boolean @default(false)
joinMembers Json[] @default([])
location String @default("HK")
organizer String @default("")
registrationDeadline String @default("")
requirements String @default("")
schedule String @default("")
speakers String[] @default([])
sponsors String[] @default([])
startDate String? @default("")
status String? @default("")
title String @default("")
//
reviews EventReview[]
reviews EventReview[]
}
model AppLog {

View File

@@ -125,7 +125,7 @@ const generateRatings = () =>
const generateImages = () => Array.from({ length: 8 }, (_, index) => _mock.image.event(index));
const _events = () =>
Array.from({ length: 2 }, (_, index) => {
Array.from({ length: 5 }, (_, index) => {
const reviews = generateReviews();
const images = generateImages();
const ratings = generateRatings();

View File

@@ -5,13 +5,10 @@ yarn --dev
clear
while true; do
yarn db:push
yarn seed
yarn db:studio &
yarn dev
npx nodemon --ext ts,tsx,prisma --exec "yarn db:push && yarn seed && yarn dev"
# yarn dev
echo "restarting..."
sleep 1

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
yarn --dev
clear
while true; do
npx nodemon --ext prisma --exec "yarn db:push && yarn seed"
echo "restarting..."
sleep 1
done

View File

@@ -0,0 +1,40 @@
// src/app/api/party-event/create/route.ts
//
// PURPOSE:
// Create new party event in db
//
// RULES:
// T.B.A.
import type { NextRequest } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { isDev } from 'src/constants';
import { createEvent } from 'src/app/services/party-event.service';
// ----------------------------------------------------------------------
/** **************************************
* POST - Create PartyEvent
*************************************** */
export async function POST(req: NextRequest) {
const { partyEventData } = await req.json();
try {
if (isDev) {
console.log({ partyEventData });
}
const created = await createEvent(partyEventData);
if (isDev) {
console.log('Event created successfully');
}
return response(created, STATUS.OK);
} catch (error) {
console.error('Error creating event:', { partyEventData });
return handleError('PartyEvent - Create', error);
}
}

View File

@@ -0,0 +1,34 @@
###
POST http://localhost:7272/api/party-event/create
Content-Type: application/json
{
"partyEventData": {
"title": "Summer Music Festival",
"description": "Annual summer music festival featuring local bands and artists",
"startDate": "2024-07-15T18:00:00Z",
"endDate": "2024-07-15T23:00:00Z",
"location": "Central Park, Hong Kong",
"coverUrl": "",
"images": [
"data:image/png;base64,C",
"data:image/png;base64,C"
],
"tags": [
"Music",
"Festival"
],
"status": "upcoming",
"capacity": 500,
"price": 150.00,
"organizer": "HK Music Society",
"category": "Music",
"isFeatured": true,
"registrationDeadline": "2024-07-10T00:00:00Z",
"requirements": "Age 18+",
"schedule": "18:00 Doors open\n19:00 First performance\n21:00 Main act",
"speakers": ["DJ Lee", "Band XYZ"],
"sponsors": ["HK Radio", "Music Magazine"]
}
}

View File

@@ -0,0 +1,22 @@
import type { NextRequest } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { deleteEvent } from 'src/app/services/party-event.service';
/** **************************************
* PATCH - Delete PartyEvent
*************************************** */
export async function PATCH(req: NextRequest) {
try {
const { partyEventId } = await req.json();
if (!partyEventId) throw new Error('partyEventId cannot be null');
await deleteEvent(partyEventId);
return response({ partyEventId }, STATUS.OK);
} catch (error) {
return handleError('PartyEvent - Delete', error);
}
}

View File

@@ -0,0 +1,8 @@
###
PATCH http://localhost:7272/api/party-event/delete
Content-Type: application/json
{
"partyEventId": "e99f09a7-dd88-49d5-b1c8-1daf80c2d7b01"
}

View File

@@ -0,0 +1,56 @@
// src/app/api/party-event/details/route.ts
//
// PURPOSE:
// Get party event from db by id
//
// RULES:
// T.B.A.
import type { NextRequest } from 'next/server';
import { logger } from 'src/utils/logger';
import { STATUS, response, handleError } from 'src/utils/response';
import { L_INFO, L_ERROR } from 'src/constants';
import { getEvent } from 'src/app/services/party-event.service';
import { createAppLog } from 'src/app/services/app-log.service';
import { flattenNextjsRequest } from '../../auth/sign-in/flattenNextjsRequest';
// ----------------------------------------------------------------------
/**
**************************************
* GET PartyEvent detail
***************************************
*/
export async function GET(req: NextRequest) {
const debug = { 'req.headers': flattenNextjsRequest(req) };
try {
const { searchParams } = req.nextUrl;
// RULES: eventId must exist
const partyEventId = searchParams.get('partyEventId');
if (!partyEventId) {
return response({ message: 'PartyEvent ID is required!' }, STATUS.BAD_REQUEST);
}
// NOTE: eventId confirmed exist, run below
const partyEvent = await getEvent(partyEventId);
if (!partyEvent) {
return response({ message: 'PartyEvent not found!' }, STATUS.NOT_FOUND);
}
logger('[PartyEvent] details', partyEvent.id);
createAppLog(L_INFO, 'Get event detail OK', debug);
return response({ partyEvent }, STATUS.OK);
} catch (error) {
createAppLog(L_ERROR, 'event detail error', debug);
return handleError('PartyEvent - Get details', error);
}
}

View File

@@ -0,0 +1,9 @@
###
# Get details for a specific party event
GET http://localhost:7272/api/party-event/details?partyEventId=e99f09a7-dd88-49d5-b1c8-1daf80c2d7b01
###
# Alternative format with different ID
GET http://localhost:7272/api/party-event/details?eventId=evt_987654321

View File

@@ -0,0 +1,16 @@
import type { NextRequest, NextResponse } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
/**
***************************************
* GET - helloworld
***************************************
*/
export async function GET(req: NextRequest, res: NextResponse) {
try {
return response({ helloworld: 'party-event' }, STATUS.OK);
} catch (error) {
return handleError('Helloworld - Get all', error);
}
}

View File

@@ -0,0 +1,4 @@
###
GET /api/party-event/helloworld HTTP/1.1
Host: localhost:7272

View File

@@ -0,0 +1,38 @@
// src/app/api/party-event/list/route.ts
//
// PURPOSE:
// List all party events from db
//
// RULES:
// T.B.A.
import type { NextRequest } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { L_INFO, L_ERROR } from 'src/constants';
import { createAppLog } from 'src/app/services/app-log.service';
import { listEvents } from 'src/app/services/party-event.service';
import { flattenNextjsRequest } from '../../auth/sign-in/flattenNextjsRequest';
// ----------------------------------------------------------------------
/** **************************************
* GET - PartyEvents list
*************************************** */
export async function GET(req: NextRequest) {
const debug = { 'req.headers': flattenNextjsRequest(req) };
try {
const partyEvents = await listEvents();
createAppLog(L_INFO, 'party-event list ok', {});
return response({ partyEvents }, STATUS.OK);
} catch (error) {
createAppLog(L_ERROR, 'party-event list error', debug);
return handleError('PartyEvent - Get list', error);
}
}

View File

@@ -0,0 +1,14 @@
###
# Basic list all party events
GET http://localhost:7272/api/party-event/list
###
# List upcoming party events
GET http://localhost:7272/api/party-event/list?status=upcoming
###
# List featured party events
GET http://localhost:7272/api/party-event/list?isFeatured=true

View File

@@ -0,0 +1,75 @@
import type { NextRequest, NextResponse } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { listEvents, deleteEvent, updateEvent, createEvent } from 'src/app/services/party-event.service';
/**
**************************************
* GET - PartyEvent
***************************************
*/
export async function GET(req: NextRequest, res: NextResponse) {
try {
const events = await listEvents();
return response(events, STATUS.OK);
} catch (error) {
return handleError('PartyEvent - Get list', error);
}
}
/**
***************************************
* POST - Create PartyEvent
***************************************
*/
export async function POST(req: NextRequest) {
const OPERATION = 'PartyEvent - Create';
const { data } = await req.json();
try {
const event = await createEvent(data);
return response(OPERATION, STATUS.OK);
} catch (error) {
return handleError(OPERATION, error);
}
}
/**
***************************************
* PUT - Update PartyEvent
***************************************
*/
export async function PUT(req: NextRequest) {
const { searchParams } = req.nextUrl;
const eventId = searchParams.get('eventId');
const { data } = await req.json();
try {
if (!eventId) throw new Error('eventId cannot be null');
const result = await updateEvent(eventId, data);
return response(result, STATUS.OK);
} catch (error) {
return handleError('PartyEvent - Update', error);
}
}
/**
***************************************
* DELETE - Delete PartyEvent
***************************************
*/
export async function DELETE(req: NextRequest) {
const { searchParams } = req.nextUrl;
const eventId = searchParams.get('eventId');
try {
if (!eventId) throw new Error('eventId cannot be null');
await deleteEvent(eventId);
return response({ success: true }, STATUS.OK);
} catch (error) {
return handleError('PartyEvent - Delete', error);
}
}

View File

@@ -0,0 +1,34 @@
import type { NextRequest } from 'next/server';
import { logger } from 'src/utils/logger';
import { STATUS, response, handleError } from 'src/utils/response';
import { IEventItem } from '../update/route';
// import { searchEvents } from 'src/app/services/party-event.service';
// ----------------------------------------------------------------------
/** **************************************
* GET - Search PartyEvents
*************************************** */
export async function GET(req: NextRequest) {
try {
const { searchParams } = req.nextUrl;
const query = searchParams.get('query')?.trim().toLowerCase();
if (!query) {
return response({ results: [] }, STATUS.OK);
}
const results: IEventItem[] = [];
// TODO: search party event not implemented
console.log('search party event not implemented');
// const results = await searchEvents(query);
logger('[PartyEvent] search-results', results?.length);
return response({ results }, STATUS.OK);
} catch (error) {
return handleError('PartyEvent - Get search', error);
}
}

View File

@@ -0,0 +1,24 @@
###
# Search party events by title
GET http://localhost:7272/api/party-event/search?query=Music
###
# Search party events by location
GET http://localhost:7272/api/party-event/search?query=Central+Park
###
# Search party events by tag
GET http://localhost:7272/api/party-event/search?query=Festival
###
# Combined search with multiple parameters
GET http://localhost:7272/api/party-event/search?query=Summer&location=Hong+Kong&category=Music
###
# No results expected
GET http://localhost:7272/api/party-event/search?query=zzzzzz

View File

@@ -0,0 +1,57 @@
// src/app/api/party-event/update/route.ts
//
// PURPOSE:
// Update party event in db by id
//
// RULES:
// T.B.A.
import type { NextRequest } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { updateEvent } from 'src/app/services/party-event.service';
// ----------------------------------------------------------------------
/** **************************************
* PUT - Update PartyEvent
*************************************** */
export async function PUT(req: NextRequest) {
const { partyEventData } = await req.json();
const { id } = partyEventData;
if (!id) return response({ message: 'id not found' }, STATUS.ERROR);
try {
const result = await updateEvent(id, partyEventData);
return response({ result }, STATUS.OK);
} catch (error) {
console.error('Error updating event:', { partyEventData });
return handleError('PartyEvent - Update', error);
}
}
export type IEventItem = {
id: string;
title: string;
description: string;
startDate: Date;
endDate: Date;
location: string;
coverUrl: string;
images: string[];
tags: string[];
status: string;
capacity: number;
price: number;
organizer: string;
category: string;
isFeatured: boolean;
registrationDeadline: Date;
requirements: string[];
schedule: string;
speakers: string[];
sponsors: string[];
};

View File

@@ -0,0 +1,31 @@
###
PUT http://localhost:7272/api/party-event/update
Content-Type: application/json
{
"partyEventData": {
"id":"e99f09a7-dd88-49d5-b1c8-1daf80c2d7b01",
"title": "Summer Music Festival 111",
"name": "Summer Music Festival 111",
"description": "Annual summer music festival featuring local bands and artists",
"startDate": "2024-07-15T18:00:00Z",
"endDate": "2024-07-15T23:00:00Z",
"location": "Central Park, Hong Kong",
"coverUrl": "",
"images": [ "data:image/png;base64,C", "data:image/png;base64,C" ],
"tags": [ "Music", "Festival" ],
"status": "upcoming",
"capacity": 500,
"price": 150.00,
"organizer": "HK Music Society",
"category": "Music",
"isFeatured": true,
"registrationDeadline": "2024-07-10T00:00:00Z",
"requirements": "Age 18+",
"schedule": "18:00 Doors open\n19:00 First performance\n21:00 Main act",
"speakers": ["DJ Lee", "Band XYZ"],
"sponsors": ["HK Radio", "Music Magazine"],
"reviews":[]
}
}

View File

@@ -0,0 +1,139 @@
// src/app/services/party-event.service.ts
//
// PURPOSE:
// - Service for handling EventItem (PartyEvent) Record
//
import type { EventItem } from '@prisma/client';
import prisma from '../lib/prisma';
type CreateEvent = {
name: string;
title: string;
eventDate: Date;
location: string;
duration_m: number;
ageBottom?: number;
ageTop?: number;
currency?: string;
price?: number;
priceSale?: number;
coverUrl?: string;
images?: string[];
description?: string;
subDescription?: string;
publish?: string;
category?: string;
tags?: string[];
joinMembers?: any[];
};
type UpdateEvent = {
name?: string;
title?: string;
eventDate?: Date;
location?: string;
duration_m?: number;
ageBottom?: number;
ageTop?: number;
currency?: string;
price?: number;
priceSale?: number;
coverUrl?: string;
images?: string[];
description?: string;
subDescription?: string;
publish?: string;
category?: string;
tags?: string[];
joinMembers?: any[];
};
async function listEvents(): Promise<EventItem[]> {
return prisma.eventItem.findMany({
include: { reviews: true },
});
}
async function getEvent(eventId: string): Promise<EventItem | null> {
return prisma.eventItem.findUnique({
where: { id: eventId },
include: { reviews: true },
});
}
async function getEventByNameOrTitle(searchText: string): Promise<EventItem[] | null> {
return prisma.eventItem.findMany({
where: {
OR: [{ name: { contains: searchText, mode: 'insensitive' } }, { title: { contains: searchText, mode: 'insensitive' } }],
},
include: { reviews: true },
});
}
async function createEvent(eventData: any) {
return await prisma.eventItem.create({ data: eventData });
}
async function updateEvent(eventId: string, updateForm: any) {
return prisma.eventItem.update({
where: { id: eventId },
data: {
available: updateForm.available,
category: updateForm.category,
code: updateForm.code,
colors: updateForm.colors,
coverUrl: updateForm.coverUrl,
description: updateForm.description,
gender: updateForm.gender,
images: updateForm.images,
inventoryType: updateForm.inventoryType,
name: updateForm.name,
newLabel: updateForm.newLabel,
price: updateForm.price,
priceSale: updateForm.priceSale,
publish: updateForm.publish,
quantity: updateForm.quantity,
ratings: updateForm.ratings,
saleLabel: updateForm.saleLabel,
sizes: updateForm.sizes,
sku: updateForm.sku,
subDescription: updateForm.subDescription,
tags: updateForm.tags,
taxes: updateForm.taxes,
totalRatings: updateForm.totalRatings,
totalReviews: updateForm.totalReviews,
totalSold: updateForm.totalSold,
//
ageBottom: updateForm.ageBottom,
ageTop: updateForm.ageTop,
avatar: updateForm.avatar,
currency: updateForm.currency,
capacity: updateForm.capacity,
duration_m: updateForm.duration_m,
endDate: updateForm.endDate,
eventDate: updateForm.eventDate,
isFeatured: updateForm.isFeatured,
joinMembers: updateForm.joinMembers,
location: updateForm.location,
organizer: updateForm.organizer,
registrationDeadline: updateForm.registrationDeadline,
requirements: updateForm.requirements,
schedule: updateForm.schedule,
speakers: updateForm.speakers,
sponsors: updateForm.sponsors,
startDate: updateForm.startDate,
status: updateForm.status,
title: updateForm.title,
},
});
}
async function deleteEvent(eventId: string) {
return prisma.eventItem.delete({
where: { id: eventId },
});
}
export { getEvent, listEvents, createEvent, updateEvent, deleteEvent, getEventByNameOrTitle, type CreateEvent, type UpdateEvent };

View File

@@ -1021,7 +1021,7 @@ ansi-regex@^5.0.1:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
ansi-styles@^4.1.0:
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
version "4.3.0"
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
@@ -1239,7 +1239,7 @@ caniuse-lite@^1.0.30001579:
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz"
integrity sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==
chalk@^4.0.0:
chalk@^4.0.0, chalk@^4.1.2:
version "4.1.2"
resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -1257,6 +1257,15 @@ client-only@0.0.1:
resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
cliui@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa"
integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==
dependencies:
string-width "^4.2.0"
strip-ansi "^6.0.1"
wrap-ansi "^7.0.0"
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
@@ -1284,6 +1293,19 @@ concat-map@0.0.1:
resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concurrently@^9.1.2:
version "9.1.2"
resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.1.2.tgz#22d9109296961eaee773e12bfb1ce9a66bc9836c"
integrity sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==
dependencies:
chalk "^4.1.2"
lodash "^4.17.21"
rxjs "^7.8.1"
shell-quote "^1.8.1"
supports-color "^8.1.1"
tree-kill "^1.2.2"
yargs "^17.7.2"
console-control-strings@^1.0.0, console-control-strings@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
@@ -1614,6 +1636,11 @@ esbuild@~0.25.0:
"@esbuild/win32-ia32" "0.25.5"
"@esbuild/win32-x64" "0.25.5"
escalade@^3.1.1:
version "3.2.0"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
escape-string-regexp@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz"
@@ -1945,6 +1972,11 @@ gauge@^3.0.0:
strip-ansi "^6.0.1"
wide-align "^1.1.2"
get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
@@ -2985,6 +3017,11 @@ regexp.prototype.flags@^1.5.3:
gopd "^1.2.0"
set-function-name "^2.0.2"
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
resolve-from@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"
@@ -3049,6 +3086,13 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
rxjs@^7.8.1:
version "7.8.2"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b"
integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
dependencies:
tslib "^2.1.0"
safe-array-concat@^1.1.3:
version "1.1.3"
resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz"
@@ -3152,6 +3196,11 @@ shebang-regex@^3.0.0:
resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
shell-quote@^1.8.1:
version "1.8.3"
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b"
integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==
side-channel-list@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz"
@@ -3222,7 +3271,7 @@ streamsearch@^1.1.0:
resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3:
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -3297,7 +3346,7 @@ string_decoder@^1.1.1:
dependencies:
safe-buffer "~5.2.0"
strip-ansi@^6.0.1:
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -3333,6 +3382,13 @@ supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
supports-color@^8.1.1:
version "8.1.1"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c"
integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
dependencies:
has-flag "^4.0.0"
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
@@ -3370,6 +3426,11 @@ tr46@~0.0.3:
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
tree-kill@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
ts-api-utils@^2.0.1:
version "2.1.0"
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz"
@@ -3404,7 +3465,7 @@ tsconfig-paths@^3.15.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@^2.4.0:
tslib@^2.1.0, tslib@^2.4.0:
version "2.8.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
@@ -3612,6 +3673,15 @@ word-wrap@^1.2.5:
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
@@ -3622,6 +3692,11 @@ xtend@^4.0.0:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
@@ -3632,6 +3707,24 @@ yaml@^1.10.0:
resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yargs-parser@^21.1.1:
version "21.1.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
yargs@^17.7.2:
version "17.7.2"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==
dependencies:
cliui "^8.0.1"
escalade "^3.1.1"
get-caller-file "^2.0.5"
require-directory "^2.1.1"
string-width "^4.2.3"
y18n "^5.0.5"
yargs-parser "^21.1.1"
yn@3.1.1:
version "3.1.1"
resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz"

View File

@@ -6,6 +6,7 @@
"private": true,
"type": "module",
"scripts": {
"dev:check": "yarn tsc:w",
"dev": "vite",
"start": "vite preview",
"build": "tsc && vite build",
@@ -22,7 +23,7 @@
"re:build-npm": "npm run clean && npm install && npm run build",
"tsc:dev": "yarn dev & yarn tsc:watch",
"tsc:print": "npx tsc --showConfig",
"tsc:w": "npx nodemon --delay 3 --ext ts,tsx --exec \"yarn tsc\"",
"tsc:w": "npx nodemon --delay 1 --ext ts,tsx --exec \"yarn tsc\"",
"tsc:watch": "tsc --noEmit --watch",
"tsc": "tsc --noEmit"
},

View File

@@ -0,0 +1,70 @@
export const PRODUCT_GENDER_OPTIONS = [
{ label: 'Men', value: 'Men' },
{ label: 'Women', value: 'Women' },
{ label: 'Kids', value: 'Kids' },
];
export const PRODUCT_CATEGORY_OPTIONS = ['Shose', 'Apparel', 'Accessories'];
export const PRODUCT_RATING_OPTIONS = ['up4Star', 'up3Star', 'up2Star', 'up1Star'];
export const PRODUCT_COLOR_OPTIONS = [
'#FF4842',
'#1890FF',
'#FFC0CB',
'#00AB55',
'#FFC107',
'#7F00FF',
'#000000',
'#FFFFFF',
];
export const PRODUCT_COLOR_NAME_OPTIONS = [
{ value: '#FF4842', label: 'Red' },
{ value: '#1890FF', label: 'Blue' },
{ value: '#FFC0CB', label: 'Pink' },
{ value: '#00AB55', label: 'Green' },
{ value: '#FFC107', label: 'Yellow' },
{ value: '#7F00FF', label: 'Violet' },
{ value: '#000000', label: 'Black' },
{ value: '#FFFFFF', label: 'White' },
];
export const PRODUCT_SIZE_OPTIONS = [
{ value: '7', label: '7' },
{ value: '8', label: '8' },
{ value: '8.5', label: '8.5' },
{ value: '9', label: '9' },
{ value: '9.5', label: '9.5' },
{ value: '10', label: '10' },
{ value: '10.5', label: '10.5' },
{ value: '11', label: '11' },
{ value: '11.5', label: '11.5' },
{ value: '12', label: '12' },
{ value: '13', label: '13' },
];
export const PRODUCT_STOCK_OPTIONS = [
{ value: 'in stock', label: 'In stock' },
{ value: 'low stock', label: 'Low stock' },
{ value: 'out of stock', label: 'Out of stock' },
];
// not used due to i18n
export const PRODUCT_PUBLISH_OPTIONS = [
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' },
];
export const PRODUCT_SORT_OPTIONS = [
{ value: 'featured', label: 'Featured' },
{ value: 'newest', label: 'Newest' },
{ value: 'priceDesc', label: 'Price: High - Low' },
{ value: 'priceAsc', label: 'Price: Low - High' },
];
export const PRODUCT_CATEGORY_GROUP_OPTIONS = [
{ group: 'Clothing', classify: ['Shirts', 'T-shirts', 'Jeans', 'Leather', 'Accessories'] },
{ group: 'Tailored', classify: ['Suits', 'Blazers', 'Trousers', 'Waistcoats', 'Apparel'] },
{ group: 'Accessories', classify: ['Shoes', 'Backpacks and bags', 'Bracelets', 'Face masks'] },
];

View File

@@ -0,0 +1,178 @@
// src/actions/product.ts
//
import { useMemo } from 'react';
import axiosInstance, { endpoints, fetcher } from 'src/lib/axios';
import type { IPartyEventItem } from 'src/types/party-event';
import type { SWRConfiguration } from 'swr';
import useSWR, { mutate } from 'swr';
// ----------------------------------------------------------------------
const swrOptions: SWRConfiguration = {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
};
// ----------------------------------------------------------------------
type PartyEventsData = {
partyEvents: IPartyEventItem[];
};
export function useGetPartyEvents() {
const url = endpoints.partyEvent.list;
const { data, isLoading, error, isValidating } = useSWR<PartyEventsData>(
url,
fetcher,
swrOptions
);
const memoizedValue = useMemo(
() => ({
partyEvents: data?.partyEvents || [],
partyEventsLoading: isLoading,
partyEventsError: error,
partyEventsValidating: isValidating,
partyEventsEmpty: !isLoading && !isValidating && !data?.partyEvents.length,
}),
[data?.partyEvents, error, isLoading, isValidating]
);
return memoizedValue;
}
// ----------------------------------------------------------------------
type PartyEventData = {
partyEvent: IPartyEventItem;
};
export function useGetPartyEvent(partyEventId: string) {
const url = partyEventId ? [endpoints.partyEvent.details, { params: { partyEventId } }] : '';
const { data, isLoading, error, isValidating } = useSWR<PartyEventData>(url, fetcher, swrOptions);
const memoizedValue = useMemo(
() => ({
partyEvent: data?.partyEvent,
partyEventLoading: isLoading,
partyEventError: error,
partyEventValidating: isValidating,
mutate,
}),
[data?.partyEvent, error, isLoading, isValidating]
);
return memoizedValue;
}
// ----------------------------------------------------------------------
type SearchResultsData = {
results: IPartyEventItem[];
};
export function useSearchProducts(query: string) {
const url = query ? [endpoints.product.search, { params: { query } }] : '';
const { data, isLoading, error, isValidating } = useSWR<SearchResultsData>(url, fetcher, {
...swrOptions,
keepPreviousData: true,
});
const memoizedValue = useMemo(
() => ({
searchResults: data?.results || [],
searchLoading: isLoading,
searchError: error,
searchValidating: isValidating,
searchEmpty: !isLoading && !isValidating && !data?.results.length,
}),
[data?.results, error, isLoading, isValidating]
);
return memoizedValue;
}
// ----------------------------------------------------------------------
export async function createPartyEvent(partyEventData: IPartyEventItem) {
/**
* Work on server
*/
const data = { partyEventData };
const {
data: { id },
} = await axiosInstance.post(endpoints.partyEvent.create, data);
/**
* Work in local
*/
mutate(
endpoints.partyEvent.list,
(currentData: any) => {
const currentPartyEvents: IPartyEventItem[] = currentData?.partyEvents;
const partyEvents = [...currentPartyEvents, { ...partyEventData, id }];
return { ...currentData, partyEvents };
},
false
);
}
// ----------------------------------------------------------------------
export async function updatePartyEvent(partyEventData: Partial<IPartyEventItem>) {
/**
* Work on server
*/
const data = { partyEventData };
await axiosInstance.put(endpoints.partyEvent.update, data);
/**
* Work in local
*/
mutate(
endpoints.partyEvent.list,
(currentData: any) => {
const currentPartyEvents: IPartyEventItem[] = currentData?.partyEvents;
const partyEvents = currentPartyEvents.map((partyEvent) =>
partyEvent.id === partyEventData.id ? { ...partyEvent, ...partyEventData } : partyEvent
);
return { ...currentData, partyEvents };
},
false
);
}
// ----------------------------------------------------------------------
export async function deletePartyEvent(partyEventId: string) {
/**
* Work on server
*/
const data = { partyEventId };
await axiosInstance.patch(endpoints.partyEvent.delete, data);
/**
* Work in local
*/
mutate(
endpoints.partyEvent.list,
(currentData: any) => {
const currentProducts: IPartyEventItem[] = currentData?.partyEvents;
const partyEvents = currentProducts.filter((partyEvent) => partyEvent.id !== partyEventId);
return { ...currentData, partyEvents };
},
false
);
}

View File

@@ -91,6 +91,17 @@ export const navData: NavSectionProps['data'] = [
{ title: 'Account', path: paths.dashboard.user.account },
],
},
{
title: 'party-event',
path: paths.dashboard.partyEvent.root,
icon: ICONS.product,
children: [
{ title: 'List', path: paths.dashboard.partyEvent.root },
{ title: 'Details', path: paths.dashboard.partyEvent.demo.details },
{ title: 'Create', path: paths.dashboard.partyEvent.new },
{ title: 'Edit', path: paths.dashboard.partyEvent.demo.edit },
],
},
{
title: 'Product',
path: paths.dashboard.product.root,

View File

@@ -84,4 +84,12 @@ export const endpoints = {
changeStatus: (invoiceId: string) => `/api/invoice/changeStatus?invoiceId=${invoiceId}`,
search: '/api/invoice/search',
},
partyEvent: {
list: '/api/party-event/list',
details: '/api/party-event/details',
search: '/api/party-event/search',
create: '/api/party-event/create',
update: '/api/party-event/update',
delete: '/api/party-event/delete',
},
};

View File

@@ -0,0 +1,28 @@
// src/pages/dashboard/party-event/details.tsx
import { useGetPartyEvent } from 'src/actions/party-event';
import { CONFIG } from 'src/global-config';
import { useParams } from 'src/routes/hooks';
import { PartyEventDetailsView } from 'src/sections/party-event/view';
// ----------------------------------------------------------------------
const metadata = { title: `PartyEvent details | Dashboard - ${CONFIG.appName}` };
export default function Page() {
const { id = '' } = useParams();
const { partyEvent, partyEventLoading, partyEventError } = useGetPartyEvent(id);
return (
<>
<title>{metadata.title}</title>
<PartyEventDetailsView
partyEvent={partyEvent}
loading={partyEventLoading}
error={partyEventError}
/>
</>
);
}

View File

@@ -0,0 +1,22 @@
import { useGetPartyEvent } from 'src/actions/party-event';
import { CONFIG } from 'src/global-config';
import { useParams } from 'src/routes/hooks';
import { PartyEventEditView } from 'src/sections/party-event/view';
// ----------------------------------------------------------------------
const metadata = { title: `PartyEvent edit | Dashboard - ${CONFIG.appName}` };
export default function Page() {
const { id = '' } = useParams();
const { partyEvent } = useGetPartyEvent(id);
return (
<>
<title>{metadata.title}</title>
<PartyEventEditView partyEvent={partyEvent} />
</>
);
}

View File

@@ -0,0 +1,18 @@
// src/pages/dashboard/party-event/list.tsx
//
import { CONFIG } from 'src/global-config';
import { PartyEventListView } from 'src/sections/party-event/view';
// ----------------------------------------------------------------------
const metadata = { title: `PartyEvent list | Dashboard - ${CONFIG.appName}` };
export default function Page() {
return (
<>
<title>{metadata.title}</title>
<PartyEventListView />
</>
);
}

View File

@@ -0,0 +1,16 @@
import { CONFIG } from 'src/global-config';
import { PartyEventCreateView } from 'src/sections/party-event/view';
// ----------------------------------------------------------------------
const metadata = { title: `Create a new party-event | Dashboard - ${CONFIG.appName}` };
export default function Page() {
return (
<>
<title>{metadata.title}</title>
<PartyEventCreateView />
</>
);
}

View File

@@ -0,0 +1,16 @@
import { CONFIG } from 'src/global-config';
import { CheckoutView } from 'src/sections/checkout/view';
// ----------------------------------------------------------------------
const metadata = { title: `Checkout - ${CONFIG.appName}` };
export default function Page() {
return (
<>
<title>{metadata.title}</title>
<CheckoutView />
</>
);
}

View File

@@ -0,0 +1,22 @@
import { useGetProduct } from 'src/actions/product';
import { CONFIG } from 'src/global-config';
import { useParams } from 'src/routes/hooks';
import { ProductShopDetailsView } from 'src/sections/product/view';
// ----------------------------------------------------------------------
const metadata = { title: `Product details - ${CONFIG.appName}` };
export default function Page() {
const { id = '' } = useParams();
const { product, productLoading, productError } = useGetProduct(id);
return (
<>
<title>{metadata.title}</title>
<ProductShopDetailsView product={product} loading={productLoading} error={productError} />
</>
);
}

View File

@@ -0,0 +1,5 @@
function Helloworld() {
return <>helloworld</>;
}
export default Helloworld;

View File

@@ -0,0 +1,19 @@
import { useGetPartyEvents } from 'src/actions/party-event';
import { CONFIG } from 'src/global-config';
import { PartyEventShopView } from 'src/sections/party-event/view';
// ----------------------------------------------------------------------
const metadata = { title: `Product shop - ${CONFIG.appName}` };
export default function Page() {
const { partyEvents, partyEventsLoading } = useGetPartyEvents();
return (
<>
<title>{metadata.title}</title>
<PartyEventShopView products={partyEvents} loading={partyEventsLoading} />
</>
);
}

View File

@@ -87,6 +87,13 @@ export const paths = {
verify: `${ROOTS.AUTH_DEMO}/centered/verify`,
},
},
//
partyEvent: {
root: `/party-event`,
checkout: `/party-event/checkout`,
details: (id: string) => `/party-event/${id}`,
demo: { details: `/party-event/${MOCK_ID}` },
},
// DASHBOARD
dashboard: {
root: ROOTS.DASHBOARD,
@@ -171,5 +178,16 @@ export const paths = {
edit: `${ROOTS.DASHBOARD}/tour/${MOCK_ID}/edit`,
},
},
//
partyEvent: {
root: `${ROOTS.DASHBOARD}/party-event`,
new: `${ROOTS.DASHBOARD}/party-event/new`,
details: (id: string) => `${ROOTS.DASHBOARD}/party-event/${id}`,
edit: (id: string) => `${ROOTS.DASHBOARD}/party-event/${id}/edit`,
demo: {
details: `${ROOTS.DASHBOARD}/party-event/${MOCK_ID}`,
edit: `${ROOTS.DASHBOARD}/party-event/${MOCK_ID}/edit`,
},
},
},
};

View File

@@ -1,3 +1,5 @@
// src/routes/sections/dashboard.tsx
//
import { lazy, Suspense } from 'react';
import type { RouteObject } from 'react-router';
import { Outlet } from 'react-router';
@@ -75,6 +77,12 @@ const PermissionDeniedPage = lazy(() => import('src/pages/dashboard/permission')
const ParamsPage = lazy(() => import('src/pages/dashboard/params'));
const BlankPage = lazy(() => import('src/pages/dashboard/blank'));
// PartyEvent
const PartyEventDetailsPage = lazy(() => import('src/pages/dashboard/party-event/details'));
const PartyEventListPage = lazy(() => import('src/pages/dashboard/party-event/list'));
const PartyEventCreatePage = lazy(() => import('src/pages/dashboard/party-event/new'));
const PartyEventEditPage = lazy(() => import('src/pages/dashboard/party-event/edit'));
// ----------------------------------------------------------------------
function SuspenseOutlet() {
@@ -198,6 +206,17 @@ export const dashboardRoutes: RouteObject[] = [
{ path: 'permission', element: <PermissionDeniedPage /> },
{ path: 'params', element: <ParamsPage /> },
{ path: 'blank', element: <BlankPage /> },
//
{
path: 'party-event',
children: [
{ index: true, element: <PartyEventListPage /> },
{ path: 'list', element: <PartyEventListPage /> },
{ path: ':id', element: <PartyEventDetailsPage /> },
{ path: 'new', element: <PartyEventCreatePage /> },
{ path: ':id/edit', element: <PartyEventEditPage /> },
],
},
],
},
];

View File

@@ -27,6 +27,10 @@ const Page403 = lazy(() => import('src/pages/error/403'));
const Page404 = lazy(() => import('src/pages/error/404'));
// Blank
const BlankPage = lazy(() => import('src/pages/blank'));
// PartyEvent
const PartyEventDetailsPage = lazy(() => import('src/pages/dashboard/party-event/details'));
const PartyEventListPage = lazy(() => import('src/pages/dashboard/party-event/list'));
const PartyEventCheckoutPage = lazy(() => import('src/pages/party-event/checkout'));
// ----------------------------------------------------------------------
@@ -58,6 +62,15 @@ export const mainRoutes: RouteObject[] = [
{ path: 'checkout', element: <ProductCheckoutPage /> },
],
},
{
path: 'party-event',
children: [
{ index: true, element: <PartyEventListPage /> },
{ path: 'list', element: <PartyEventListPage /> },
{ path: ':id', element: <PartyEventDetailsPage /> },
{ path: 'checkout', element: <PartyEventCheckoutPage /> },
],
},
{
path: 'post',
children: [

View File

@@ -0,0 +1,45 @@
import Badge from '@mui/material/Badge';
import type { BoxProps } from '@mui/material/Box';
import Box from '@mui/material/Box';
import { Iconify } from 'src/components/iconify';
import { RouterLink } from 'src/routes/components';
import { paths } from 'src/routes/paths';
// ----------------------------------------------------------------------
type Props = BoxProps<'a'> & {
totalItems: number;
};
export function CartIcon({ totalItems, sx, ...other }: Props) {
return (
<Box
component={RouterLink}
href={paths.product.checkout}
sx={[
(theme) => ({
right: 0,
top: 112,
zIndex: 999,
display: 'flex',
cursor: 'pointer',
position: 'fixed',
color: 'text.primary',
borderTopLeftRadius: 16,
borderBottomLeftRadius: 16,
bgcolor: 'background.paper',
padding: theme.spacing(1, 3, 1, 2),
boxShadow: theme.vars.customShadows.dropdown,
transition: theme.transitions.create(['opacity']),
'&:hover': { opacity: 0.72 },
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
{...other}
>
<Badge showZero badgeContent={totalItems} color="error" max={99}>
<Iconify icon="solar:cart-3-bold" width={24} />
</Badge>
</Box>
);
}

View File

@@ -0,0 +1,86 @@
import Box from '@mui/material/Box';
import { useEffect } from 'react';
import {
Carousel,
CarouselArrowNumberButtons,
CarouselThumb,
CarouselThumbs,
useCarousel,
} from 'src/components/carousel';
import { Image } from 'src/components/image';
import { Lightbox, useLightBox } from 'src/components/lightbox';
import type { IPartyEventItem } from 'src/types/party-event';
// ----------------------------------------------------------------------
type Props = {
images?: IPartyEventItem['images'];
};
export function ProductDetailsCarousel({ images }: Props) {
const carousel = useCarousel({ thumbs: { slidesToShow: 'auto' } });
const slides = images?.map((img) => ({ src: img })) || [];
const lightbox = useLightBox(slides);
useEffect(() => {
if (lightbox.open) {
carousel.mainApi?.scrollTo(lightbox.selected, true);
}
}, [carousel.mainApi, lightbox.open, lightbox.selected]);
return (
<>
<div>
<Box sx={{ mb: 2.5, position: 'relative' }}>
<CarouselArrowNumberButtons
{...carousel.arrows}
options={carousel.options}
totalSlides={carousel.dots.dotCount}
selectedIndex={carousel.dots.selectedIndex + 1}
sx={{ right: 16, bottom: 16, position: 'absolute' }}
/>
<Carousel carousel={carousel} sx={{ borderRadius: 2 }}>
{slides.map((slide) => (
<Image
key={slide.src}
alt={slide.src}
src={slide.src}
ratio="1/1"
onClick={() => lightbox.onOpen(slide.src)}
sx={{ cursor: 'zoom-in', minWidth: 320 }}
/>
))}
</Carousel>
</Box>
<CarouselThumbs
ref={carousel.thumbs.thumbsRef}
options={carousel.options?.thumbs}
slotProps={{ disableMask: true }}
sx={{ width: 360 }}
>
{slides.map((item, index) => (
<CarouselThumb
key={item.src}
index={index}
src={item.src}
selected={index === carousel.thumbs.selectedIndex}
onClick={() => carousel.thumbs.onClickThumb(index)}
/>
))}
</CarouselThumbs>
</div>
<Lightbox
index={lightbox.selected}
slides={slides}
open={lightbox.open}
close={lightbox.onClose}
onGetCurrentIndex={(index) => lightbox.setSelected(index)}
/>
</>
);
}

View File

@@ -0,0 +1,31 @@
import type { SxProps, Theme } from '@mui/material/styles';
import { Markdown } from 'src/components/markdown';
// ----------------------------------------------------------------------
type Props = {
description?: string;
sx?: SxProps<Theme>;
};
export function ProductDetailsDescription({ description, sx }: Props) {
return (
<Markdown
children={description}
sx={[
() => ({
p: 3,
'& p, li, ol, table': { typography: 'body2' },
'& table': {
mt: 2,
maxWidth: 640,
'& td': { px: 2 },
'& td:first-of-type': { color: 'text.secondary' },
'tbody tr:nth-of-type(odd)': { bgcolor: 'transparent' },
},
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
/>
);
}

View File

@@ -0,0 +1,125 @@
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import LinearProgress from '@mui/material/LinearProgress';
import Rating from '@mui/material/Rating';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { sumBy } from 'es-toolkit';
import { useBoolean } from 'minimal-shared/hooks';
import { Iconify } from 'src/components/iconify';
import type { IProductReview } from 'src/types/party-event';
import { fShortenNumber } from 'src/utils/format-number';
import { ProductReviewList } from './party-event-review-list';
import { ProductReviewNewForm } from './party-event-review-new-form';
// ----------------------------------------------------------------------
type Props = {
totalRatings?: number;
totalReviews?: number;
reviews?: IProductReview[];
ratings?: { name: string; starCount: number; reviewCount: number }[];
};
export function ProductDetailsReview({
totalRatings,
totalReviews,
ratings = [],
reviews = [],
}: Props) {
const review = useBoolean();
const total = sumBy(ratings, (star) => star.starCount);
const renderSummary = () => (
<Stack spacing={1} sx={{ alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="subtitle2">Average rating</Typography>
<Typography variant="h2">
{totalRatings}
/5
</Typography>
<Rating readOnly value={totalRatings} precision={0.1} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
({fShortenNumber(totalReviews)} reviews)
</Typography>
</Stack>
);
const renderProgress = () => (
<Stack
spacing={1.5}
sx={[
(theme) => ({
py: 5,
px: { xs: 3, md: 5 },
borderLeft: { md: `dashed 1px ${theme.vars.palette.divider}` },
borderRight: { md: `dashed 1px ${theme.vars.palette.divider}` },
}),
]}
>
{ratings
.slice(0)
.reverse()
.map((rating) => (
<Box key={rating.name} sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="subtitle2" component="span" sx={{ width: 42 }}>
{rating.name}
</Typography>
<LinearProgress
color="inherit"
variant="determinate"
value={(rating.starCount / total) * 100}
sx={{ mx: 2, flexGrow: 1 }}
/>
<Typography
variant="body2"
component="span"
sx={{ minWidth: 48, color: 'text.secondary' }}
>
{fShortenNumber(rating.reviewCount)}
</Typography>
</Box>
))}
</Stack>
);
const renderReviewButton = () => (
<Stack sx={{ alignItems: 'center', justifyContent: 'center' }}>
<Button
size="large"
variant="soft"
color="inherit"
onClick={review.onTrue}
startIcon={<Iconify icon="solar:pen-bold" />}
>
Write your review
</Button>
</Stack>
);
return (
<>
<Box
sx={{
display: 'grid',
py: { xs: 5, md: 0 },
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' },
}}
>
{renderSummary()}
{renderProgress()}
{renderReviewButton()}
</Box>
<Divider sx={{ borderStyle: 'dashed' }} />
<ProductReviewList reviews={reviews} />
<ProductReviewNewForm open={review.value} onClose={review.onFalse} />
</>
);
}

View File

@@ -0,0 +1,321 @@
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import { formHelperTextClasses } from '@mui/material/FormHelperText';
import Link, { linkClasses } from '@mui/material/Link';
import MenuItem from '@mui/material/MenuItem';
import Rating from '@mui/material/Rating';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useCallback } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { ColorPicker } from 'src/components/color-utils';
import { Field, Form } from 'src/components/hook-form';
import { Iconify } from 'src/components/iconify';
import { Label } from 'src/components/label';
import { NumberInput } from 'src/components/number-input';
import { useRouter } from 'src/routes/hooks';
import { paths } from 'src/routes/paths';
import type { CheckoutContextValue } from 'src/types/checkout';
import type { IPartyEventItem } from 'src/types/party-event';
import { fCurrency, fShortenNumber } from 'src/utils/format-number';
// ----------------------------------------------------------------------
type Props = {
partyEvent: IPartyEventItem;
disableActions?: boolean;
items?: CheckoutContextValue['state']['items'];
onAddToCart?: CheckoutContextValue['onAddToCart'];
};
export function PartyEventDetailsSummary({
items,
partyEvent,
onAddToCart,
disableActions,
...other
}: Props) {
const router = useRouter();
const {
id,
name,
sizes,
price,
colors,
coverUrl,
newLabel,
available,
priceSale,
saleLabel,
totalRatings,
totalReviews,
inventoryType,
subDescription,
} = partyEvent;
const existProduct = !!items?.length && items.map((item) => item.id).includes(id);
const isMaxQuantity =
!!items?.length &&
items.filter((item) => item.id === id).map((item) => item.quantity)[0] >= available;
const defaultValues = {
id,
name,
coverUrl,
available,
price,
colors: colors[0],
size: sizes[4],
quantity: available < 1 ? 0 : 1,
};
const methods = useForm<typeof defaultValues>({
defaultValues,
});
const { watch, control, setValue, handleSubmit } = methods;
const values = watch();
const onSubmit = handleSubmit(async (data) => {
console.info('DATA', JSON.stringify(data, null, 2));
try {
if (!existProduct) {
onAddToCart?.({ ...data, colors: [values.colors] });
}
router.push(paths.product.checkout);
} catch (error) {
console.error(error);
}
});
const handleAddCart = useCallback(() => {
try {
onAddToCart?.({
...values,
colors: [values.colors],
subtotal: values.price * values.quantity,
});
} catch (error) {
console.error(error);
}
}, [onAddToCart, values]);
const renderPrice = () => (
<Box sx={{ typography: 'h5' }}>
{priceSale && (
<Box
component="span"
sx={{ color: 'text.disabled', textDecoration: 'line-through', mr: 0.5 }}
>
{fCurrency(priceSale)}
</Box>
)}
{fCurrency(price)}
</Box>
);
const renderShare = () => (
<Box
sx={{
gap: 3,
display: 'flex',
justifyContent: 'center',
[`& .${linkClasses.root}`]: {
gap: 1,
alignItems: 'center',
display: 'inline-flex',
color: 'text.secondary',
typography: 'subtitle2',
},
}}
>
<Link>
<Iconify icon="mingcute:add-line" width={16} />
Compare
</Link>
<Link>
<Iconify icon="solar:heart-bold" width={16} />
Favorite
</Link>
<Link>
<Iconify icon="solar:share-bold" width={16} />
Share
</Link>
</Box>
);
const renderColorOptions = () => (
<Box sx={{ display: 'flex' }}>
<Typography variant="subtitle2" sx={{ flexGrow: 1 }}>
Color
</Typography>
<Controller
name="colors"
control={control}
render={({ field }) => (
<ColorPicker
options={colors}
value={field.value}
onChange={(color) => field.onChange(color as string)}
limit={4}
/>
)}
/>
</Box>
);
const renderSizeOptions = () => (
<Box sx={{ display: 'flex' }}>
<Typography variant="subtitle2" sx={{ flexGrow: 1 }}>
Size
</Typography>
<Field.Select
name="size"
size="small"
helperText={
<Link underline="always" color="text.primary">
Size chart
</Link>
}
sx={{
maxWidth: 88,
[`& .${formHelperTextClasses.root}`]: { mx: 0, mt: 1, textAlign: 'right' },
}}
>
{sizes.map((size) => (
<MenuItem key={size} value={size}>
{size}
</MenuItem>
))}
</Field.Select>
</Box>
);
const renderQuantity = () => (
<Box sx={{ display: 'flex' }}>
<Typography variant="subtitle2" sx={{ flexGrow: 1 }}>
Quantity
</Typography>
<Stack spacing={1}>
<NumberInput
hideDivider
value={values.quantity}
onChange={(event, quantity: number) => setValue('quantity', quantity)}
max={available}
sx={{ maxWidth: 112 }}
/>
<Typography
variant="caption"
component="div"
sx={{ textAlign: 'right', color: 'text.secondary' }}
>
Available: {available}
</Typography>
</Stack>
</Box>
);
const renderActions = () => (
<Box sx={{ gap: 2, display: 'flex' }}>
<Button
fullWidth
disabled={isMaxQuantity || disableActions}
size="large"
color="warning"
variant="contained"
startIcon={<Iconify icon="solar:cart-plus-bold" width={24} />}
onClick={handleAddCart}
sx={{ whiteSpace: 'nowrap' }}
>
Add to cart
</Button>
<Button fullWidth size="large" type="submit" variant="contained" disabled={disableActions}>
Buy now
</Button>
</Box>
);
const renderSubDescription = () => (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{subDescription}
</Typography>
);
const renderRating = () => (
<Box
sx={{
display: 'flex',
typography: 'body2',
alignItems: 'center',
color: 'text.disabled',
}}
>
<Rating size="small" value={totalRatings} precision={0.1} readOnly sx={{ mr: 1 }} />
{`(${fShortenNumber(totalReviews)} reviews)`}
</Box>
);
const renderLabels = () =>
(newLabel.enabled || saleLabel.enabled) && (
<Box sx={{ gap: 1, display: 'flex', alignItems: 'center' }}>
{newLabel.enabled && <Label color="info">{newLabel.content}</Label>}
{saleLabel.enabled && <Label color="error">{saleLabel.content}</Label>}
</Box>
);
const renderInventoryType = () => (
<Box
component="span"
sx={{
typography: 'overline',
color:
(inventoryType === 'out of stock' && 'error.main') ||
(inventoryType === 'low stock' && 'warning.main') ||
'success.main',
}}
>
{inventoryType}
</Box>
);
return (
<Form methods={methods} onSubmit={onSubmit}>
<Stack spacing={3} sx={{ pt: 3 }} {...other}>
<Stack spacing={2} alignItems="flex-start">
{renderLabels()}
{renderInventoryType()}
<Typography variant="h5">{name}</Typography>
{renderRating()}
{renderPrice()}
{renderSubDescription()}
</Stack>
<Divider sx={{ borderStyle: 'dashed' }} />
{renderColorOptions()}
{renderSizeOptions()}
{renderQuantity()}
<Divider sx={{ borderStyle: 'dashed' }} />
{renderActions()}
{renderShare()}
</Stack>
</Form>
);
}

View File

@@ -0,0 +1,113 @@
import type { BoxProps } from '@mui/material/Box';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import Tooltip from '@mui/material/Tooltip';
import { usePopover } from 'minimal-shared/hooks';
import { useTranslation } from 'react-i18next';
import { CustomPopover } from 'src/components/custom-popover';
import { Iconify } from 'src/components/iconify';
import { RouterLink } from 'src/routes/components';
// ----------------------------------------------------------------------
type Props = BoxProps & {
backHref: string;
editHref: string;
liveHref: string;
publish: string;
onChangePublish: (newValue: string) => void;
publishOptions: { value: string; label: string }[];
};
export function ProductDetailsToolbar({
sx,
publish,
backHref,
editHref,
liveHref,
publishOptions,
onChangePublish,
...other
}: Props) {
const menuActions = usePopover();
const { t } = useTranslation();
const renderMenuActions = () => (
<CustomPopover
open={menuActions.open}
anchorEl={menuActions.anchorEl}
onClose={menuActions.onClose}
slotProps={{ arrow: { placement: 'top-right' } }}
>
<MenuList>
{publishOptions.map((option) => (
<MenuItem
key={option.value}
selected={option.value === publish}
onClick={() => {
menuActions.onClose();
onChangePublish(option.value);
}}
>
{option.value === 'published' && <Iconify icon="eva:cloud-upload-fill" />}
{option.value === 'draft' && <Iconify icon="solar:file-text-bold" />}
{option.label}
</MenuItem>
))}
</MenuList>
</CustomPopover>
);
return (
<>
<Box
sx={[
{ gap: 1.5, display: 'flex', mb: { xs: 3, md: 5 } },
...(Array.isArray(sx) ? sx : [sx]),
]}
{...other}
>
<Button
component={RouterLink}
href={backHref}
startIcon={<Iconify icon="eva:arrow-ios-back-fill" width={16} />}
>
{t('back')}
</Button>
<Box sx={{ flexGrow: 1 }} />
{publish === 'published' && (
<Tooltip title="Go Live">
<IconButton component={RouterLink} href={liveHref}>
<Iconify icon="eva:external-link-fill" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Edit">
<IconButton component={RouterLink} href={editHref}>
<Iconify icon="solar:pen-bold" />
</IconButton>
</Tooltip>
<Button
color="inherit"
variant="contained"
loading={!publish}
loadingIndicator="Loading…"
endIcon={<Iconify icon="eva:arrow-ios-downward-fill" />}
onClick={menuActions.onOpen}
sx={{ textTransform: 'capitalize' }}
>
{publish}
</Button>
</Box>
{renderMenuActions()}
</>
);
}

View File

@@ -0,0 +1,334 @@
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import Divider from '@mui/material/Divider';
import Drawer from '@mui/material/Drawer';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import { inputBaseClasses } from '@mui/material/InputBase';
import Radio from '@mui/material/Radio';
import Rating from '@mui/material/Rating';
import Slider from '@mui/material/Slider';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import type { UseSetStateReturn } from 'minimal-shared/hooks';
import { useCallback } from 'react';
import { ColorPicker } from 'src/components/color-utils';
import { Iconify } from 'src/components/iconify';
import { NumberInput } from 'src/components/number-input';
import { Scrollbar } from 'src/components/scrollbar';
import type { IProductFilters } from 'src/types/party-event';
// ----------------------------------------------------------------------
type Props = {
open: boolean;
canReset: boolean;
onOpen: () => void;
onClose: () => void;
filters: UseSetStateReturn<IProductFilters>;
options: {
colors: string[];
ratings: string[];
categories: string[];
genders: { value: string; label: string }[];
};
};
const MAX_AMOUNT = 200;
const marksLabel = Array.from({ length: 21 }, (_, index) => {
const value = index * 10;
const firstValue = index === 0 ? `$${value}` : `${value}`;
return {
value,
label: index % 4 ? '' : firstValue,
};
});
export function ProductFiltersDrawer({ open, onOpen, onClose, canReset, filters, options }: Props) {
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
const handleFilterGender = useCallback(
(newValue: string) => {
const checked = currentFilters.gender.includes(newValue)
? currentFilters.gender.filter((value) => value !== newValue)
: [...currentFilters.gender, newValue];
updateFilters({ gender: checked });
},
[updateFilters, currentFilters.gender]
);
const handleFilterCategory = useCallback(
(newValue: string) => {
updateFilters({ category: newValue });
},
[updateFilters]
);
const handleFilterColors = useCallback(
(newValue: string[]) => {
updateFilters({ colors: newValue });
},
[updateFilters]
);
const handleFilterPriceRange = useCallback(
(event: Event, newValue: number | number[]) => {
updateFilters({ priceRange: newValue as number[] });
},
[updateFilters]
);
const handleFilterRating = useCallback(
(newValue: string) => {
updateFilters({ rating: newValue });
},
[updateFilters]
);
const renderHead = () => (
<>
<Box
sx={{
py: 2,
pr: 1,
pl: 2.5,
display: 'flex',
alignItems: 'center',
}}
>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Filters
</Typography>
<Tooltip title="Reset">
<IconButton onClick={() => resetFilters()}>
<Badge color="error" variant="dot" invisible={!canReset}>
<Iconify icon="solar:restart-bold" />
</Badge>
</IconButton>
</Tooltip>
<IconButton onClick={onClose}>
<Iconify icon="mingcute:close-line" />
</IconButton>
</Box>
<Divider sx={{ borderStyle: 'dashed' }} />
</>
);
const renderGender = () => (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Gender
</Typography>
{options.genders.map((option) => (
<FormControlLabel
key={option.value}
label={option.label}
control={
<Checkbox
checked={currentFilters.gender.includes(option.label)}
onClick={() => handleFilterGender(option.label)}
slotProps={{ input: { id: `${option.value}-checkbox` } }}
/>
}
/>
))}
</Box>
);
const renderCategory = () => (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Category
</Typography>
{options.categories.map((option) => (
<FormControlLabel
key={option}
label={option}
control={
<Radio
checked={option === currentFilters.category}
onClick={() => handleFilterCategory(option)}
slotProps={{ input: { id: `${option}-radio` } }}
/>
}
sx={{ ...(option === 'all' && { textTransform: 'capitalize' }) }}
/>
))}
</Box>
);
const renderColor = () => (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Color
</Typography>
<ColorPicker
options={options.colors}
value={currentFilters.colors}
onChange={(colors) => handleFilterColors(colors as string[])}
limit={6}
/>
</Box>
);
const renderPrice = () => (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="subtitle2">Price</Typography>
<Box sx={{ my: 2, gap: 5, display: 'flex' }}>
<InputRange type="min" value={currentFilters.priceRange} onChange={updateFilters} />
<InputRange type="max" value={currentFilters.priceRange} onChange={updateFilters} />
</Box>
<Slider
value={currentFilters.priceRange}
onChange={handleFilterPriceRange}
step={10}
min={0}
max={MAX_AMOUNT}
marks={marksLabel}
getAriaValueText={(value) => `$${value}`}
valueLabelFormat={(value) => `$${value}`}
sx={{ alignSelf: 'center', width: `calc(100% - 24px)` }}
/>
</Box>
);
const renderRating = () => (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Rating
</Typography>
{options.ratings.map((item, index) => (
<Box
key={item}
onClick={() => handleFilterRating(item)}
sx={{
mb: 1,
gap: 1,
ml: -1,
p: 0.5,
display: 'flex',
borderRadius: 1,
cursor: 'pointer',
typography: 'body2',
alignItems: 'center',
'&:hover': { opacity: 0.48 },
...(currentFilters.rating === item && { bgcolor: 'action.selected' }),
}}
>
<Rating readOnly value={4 - index} /> & Up
</Box>
))}
</Box>
);
return (
<>
<Button
disableRipple
color="inherit"
endIcon={
<Badge color="error" variant="dot" invisible={!canReset}>
<Iconify icon="ic:round-filter-list" />
</Badge>
}
onClick={onOpen}
>
Filters
</Button>
<Drawer
anchor="right"
open={open}
onClose={onClose}
slotProps={{
backdrop: { invisible: true },
paper: { sx: { width: 320 } },
}}
>
{renderHead()}
<Scrollbar sx={{ px: 2.5, py: 3 }}>
<Stack spacing={3}>
{renderGender()}
{renderCategory()}
{renderColor()}
{renderPrice()}
{renderRating()}
</Stack>
</Scrollbar>
</Drawer>
</>
);
}
// ----------------------------------------------------------------------
type InputRangeProps = {
value: number[];
type: 'min' | 'max';
onChange: UseSetStateReturn<IProductFilters>['setState'];
};
function InputRange({ type, value, onChange: onFilters }: InputRangeProps) {
const minValue = value[0];
const maxValue = value[1];
const handleBlur = useCallback(() => {
const newMin = Math.max(0, Math.min(minValue, MAX_AMOUNT));
const newMax = Math.max(0, Math.min(maxValue, MAX_AMOUNT));
if (newMin !== minValue || newMax !== maxValue) {
onFilters({ priceRange: [newMin, newMax] });
}
}, [minValue, maxValue, onFilters]);
return (
<Box sx={{ width: 1, display: 'flex', alignItems: 'center' }}>
<Typography
variant="caption"
sx={{
flexGrow: 1,
color: 'text.disabled',
textTransform: 'capitalize',
fontWeight: 'fontWeightSemiBold',
}}
>
{`${type} ($)`}
</Typography>
<NumberInput
hideButtons
max={MAX_AMOUNT}
value={type === 'min' ? minValue : maxValue}
onChange={(event, newValue) =>
onFilters({ priceRange: type === 'min' ? [newValue, maxValue] : [minValue, newValue] })
}
onBlur={handleBlur}
sx={{ maxWidth: 64 }}
slotProps={{
input: {
sx: {
[`& .${inputBaseClasses.input}`]: {
pr: 1,
textAlign: 'right',
},
},
},
}}
/>
</Box>
);
}

View File

@@ -0,0 +1,101 @@
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import type { UseSetStateReturn } from 'minimal-shared/hooks';
import { varAlpha } from 'minimal-shared/utils';
import { useCallback } from 'react';
import type { FiltersResultProps } from 'src/components/filters-result';
import { chipProps, FiltersBlock, FiltersResult } from 'src/components/filters-result';
import type { IProductFilters } from 'src/types/party-event';
// ----------------------------------------------------------------------
type Props = FiltersResultProps & {
filters: UseSetStateReturn<IProductFilters>;
};
export function ProductFiltersResult({ filters, totalResults, sx }: Props) {
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
const handleRemoveGender = useCallback(
(inputValue: string) => {
const newValue = currentFilters.gender.filter((item) => item !== inputValue);
updateFilters({ gender: newValue });
},
[updateFilters, currentFilters.gender]
);
const handleRemoveCategory = useCallback(() => {
updateFilters({ category: 'all' });
}, [updateFilters]);
const handleRemoveColor = useCallback(
(inputValue: string | string[]) => {
const newValue = currentFilters.colors.filter((item: string) => item !== inputValue);
updateFilters({ colors: newValue });
},
[updateFilters, currentFilters.colors]
);
const handleRemovePrice = useCallback(() => {
updateFilters({ priceRange: [0, 200] });
}, [updateFilters]);
const handleRemoveRating = useCallback(() => {
updateFilters({ rating: '' });
}, [updateFilters]);
return (
<FiltersResult totalResults={totalResults} onReset={() => resetFilters()} sx={sx}>
<FiltersBlock label="Gender:" isShow={!!currentFilters.gender.length}>
{currentFilters.gender.map((item) => (
<Chip {...chipProps} key={item} label={item} onDelete={() => handleRemoveGender(item)} />
))}
</FiltersBlock>
<FiltersBlock label="Category:" isShow={currentFilters.category !== 'all'}>
<Chip {...chipProps} label={currentFilters.category} onDelete={handleRemoveCategory} />
</FiltersBlock>
<FiltersBlock label="Colors:" isShow={!!currentFilters.colors.length}>
{currentFilters.colors.map((item) => (
<Chip
{...chipProps}
key={item}
label={
<Box
sx={[
(theme) => ({
ml: -0.5,
width: 18,
height: 18,
bgcolor: item,
borderRadius: '50%',
border: `solid 1px ${varAlpha(theme.vars.palette.common.whiteChannel, 0.24)}`,
}),
]}
/>
}
onDelete={() => handleRemoveColor(item)}
/>
))}
</FiltersBlock>
<FiltersBlock
label="Price:"
isShow={currentFilters.priceRange[0] !== 0 || currentFilters.priceRange[1] !== 200}
>
<Chip
{...chipProps}
label={`$${currentFilters.priceRange[0]} - ${currentFilters.priceRange[1]}`}
onDelete={handleRemovePrice}
/>
</FiltersBlock>
<FiltersBlock label="Rating:" isShow={!!currentFilters.rating}>
<Chip {...chipProps} label={currentFilters.rating} onDelete={handleRemoveRating} />
</FiltersBlock>
</FiltersResult>
);
}

View File

@@ -0,0 +1,147 @@
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Fab, { fabClasses } from '@mui/material/Fab';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import { ColorPreview } from 'src/components/color-utils';
import { Iconify } from 'src/components/iconify';
import { Image } from 'src/components/image';
import { Label } from 'src/components/label';
import { RouterLink } from 'src/routes/components';
import type { IPartyEventItem } from 'src/types/party-event';
import { fCurrency } from 'src/utils/format-number';
import { useCheckoutContext } from '../checkout/context';
// ----------------------------------------------------------------------
type Props = {
partyEvent: IPartyEventItem;
detailsHref: string;
};
export function ProductItem({ partyEvent, detailsHref }: Props) {
const { onAddToCart } = useCheckoutContext();
const { id, name, coverUrl, price, colors, available, sizes, priceSale, newLabel, saleLabel } =
partyEvent;
const handleAddCart = async () => {
const newProduct = {
id,
name,
coverUrl,
available,
price,
colors: [colors[0]],
size: sizes[0],
quantity: 1,
};
try {
onAddToCart(newProduct);
} catch (error) {
console.error(error);
}
};
const renderLabels = () =>
(newLabel.enabled || saleLabel.enabled) && (
<Box
sx={{
gap: 1,
top: 16,
zIndex: 9,
right: 16,
display: 'flex',
position: 'absolute',
alignItems: 'center',
}}
>
{newLabel.enabled && (
<Label variant="filled" color="info">
{newLabel.content}
</Label>
)}
{saleLabel.enabled && (
<Label variant="filled" color="error">
{saleLabel.content}
</Label>
)}
</Box>
);
const renderImage = () => (
<Box sx={{ position: 'relative', p: 1 }}>
{!!available && (
<Fab
size="medium"
color="warning"
onClick={handleAddCart}
sx={[
(theme) => ({
right: 16,
zIndex: 9,
bottom: 16,
opacity: 0,
position: 'absolute',
transform: 'scale(0)',
transition: theme.transitions.create(['opacity', 'transform'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.shorter,
}),
}),
]}
>
<Iconify icon="solar:cart-plus-bold" width={24} />
</Fab>
)}
<Tooltip title={!available && 'Out of stock'} placement="bottom-end">
<Image
alt={name}
src={coverUrl}
ratio="1/1"
sx={{ borderRadius: 1.5, ...(!available && { opacity: 0.48, filter: 'grayscale(1)' }) }}
/>
</Tooltip>
</Box>
);
const renderContent = () => (
<Stack spacing={2.5} sx={{ p: 3, pt: 2 }}>
<Link component={RouterLink} href={detailsHref} color="inherit" variant="subtitle2" noWrap>
{name}
</Link>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Tooltip title="Color">
<ColorPreview colors={colors} />
</Tooltip>
<Box sx={{ gap: 0.5, display: 'flex', typography: 'subtitle1' }}>
{priceSale && (
<Box component="span" sx={{ color: 'text.disabled', textDecoration: 'line-through' }}>
{fCurrency(priceSale)}
</Box>
)}
<Box component="span">{fCurrency(price)}</Box>
</Box>
</Box>
</Stack>
);
return (
<Card
sx={{
'&:hover': {
[`& .${fabClasses.root}`]: { opacity: 1, transform: 'scale(1)' },
},
}}
>
{renderLabels()}
{renderImage()}
{renderContent()}
</Card>
);
}

View File

@@ -0,0 +1,60 @@
import type { BoxProps } from '@mui/material/Box';
import Box from '@mui/material/Box';
import Pagination, { paginationClasses } from '@mui/material/Pagination';
import { paths } from 'src/routes/paths';
import type { IPartyEventItem } from 'src/types/party-event';
import { ProductItem } from './party-event-item';
import { ProductItemSkeleton } from './party-event-skeleton';
// ----------------------------------------------------------------------
type Props = BoxProps & {
loading?: boolean;
partyEvents: IPartyEventItem[];
};
export function PartyEventList({ partyEvents, loading, sx, ...other }: Props) {
const renderLoading = () => <ProductItemSkeleton />;
const renderList = () =>
partyEvents.map((partyEvent) => (
<ProductItem
key={partyEvent.id}
partyEvent={partyEvent}
detailsHref={paths.partyEvent.details(partyEvent.id)}
/>
));
return (
<>
<Box
sx={[
() => ({
gap: 3,
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(1, 1fr)',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
{...other}
>
{loading ? renderLoading() : renderList()}
</Box>
{partyEvents.length > 8 && (
<Pagination
count={8}
sx={{
mt: { xs: 5, md: 8 },
[`& .${paginationClasses.ul}`]: { justifyContent: 'center' },
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,560 @@
// src/sections/product/product-new-edit-form.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import Chip from '@mui/material/Chip';
import Collapse from '@mui/material/Collapse';
import Divider from '@mui/material/Divider';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import Typography from '@mui/material/Typography';
import { useBoolean } from 'minimal-shared/hooks';
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import {
PRODUCT_CATEGORY_GROUP_OPTIONS,
PRODUCT_COLOR_NAME_OPTIONS,
PRODUCT_SIZE_OPTIONS,
} from 'src/_mock';
import { createPartyEvent, updatePartyEvent } from 'src/actions/party-event';
import { Field, Form, schemaHelper } from 'src/components/hook-form';
import { Iconify } from 'src/components/iconify';
import { toast } from 'src/components/snackbar';
import { useRouter } from 'src/routes/hooks';
import { paths } from 'src/routes/paths';
import type { IPartyEventItem } from 'src/types/party-event';
import { fileToBase64 } from 'src/utils/file-to-base64';
import { z as zod } from 'zod';
// ----------------------------------------------------------------------
const PRODUCT_PUBLISH_OPTIONS = [
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' },
];
const PRODUCT_COLOR_OPTIONS = [
'#FF4842',
'#1890FF',
'#FFC0CB',
'#00AB55',
'#FFC107',
'#7F00FF',
'#000000',
'#FFFFFF',
];
const _tags = [
`Technology`,
`Health and Wellness`,
`Travel`,
`Finance`,
`Education`,
`Food and Beverage`,
`Fashion`,
`Home and Garden`,
`Sports`,
`Entertainment`,
`Business`,
`Science`,
`Automotive`,
`Beauty`,
`Fitness`,
`Lifestyle`,
`Real Estate`,
`Parenting`,
`Pet Care`,
`Environmental`,
`DIY and Crafts`,
`Gaming`,
`Photography`,
`Music`,
];
const PRODUCT_GENDER_OPTIONS = [
{ label: 'Men', value: 'Men' },
{ label: 'Women', value: 'Women' },
{ label: 'Kids', value: 'Kids' },
];
export type NewPartyEventSchemaType = zod.infer<typeof NewProductSchema>;
export const NewProductSchema = zod.object({
sku: zod.string().min(1, { message: 'Product sku is required!' }),
name: zod.string().min(1, { message: 'Name is required!' }),
code: zod.string().min(1, { message: 'Product code is required!' }),
price: schemaHelper.nullableInput(
zod.number({ coerce: true }).min(1, { message: 'Price is required!' }),
{
// message for null value
message: 'Price is required!',
}
),
taxes: zod.number({ coerce: true }).nullable(),
tags: zod.string().array().min(2, { message: 'Must have at least 2 items!' }),
sizes: zod.string().array().min(1, { message: 'Choose at least one option!' }),
publish: zod.string(),
gender: zod.array(zod.string()).min(1, { message: 'Choose at least one option!' }),
coverUrl: zod.string(),
images: schemaHelper.files({ message: 'Images is required!' }),
colors: zod.string().array().min(1, { message: 'Choose at least one option!' }),
quantity: schemaHelper.nullableInput(
zod.number({ coerce: true }).min(1, { message: 'Quantity is required!' }),
{
// message for null value
message: 'Quantity is required!',
}
),
category: zod.string(),
available: zod.number(),
totalSold: zod.number(),
description: schemaHelper
.editor({ message: 'Description is required!' })
.min(10, { message: 'Description must be at least 10 characters' })
.max(50000, { message: 'Description must be less than 50000 characters' }),
totalRatings: zod.number(),
totalReviews: zod.number(),
inventoryType: zod.string(),
subDescription: zod.string(),
priceSale: zod.number({ coerce: true }).nullable(),
newLabel: zod.object({ enabled: zod.boolean(), content: zod.string() }),
saleLabel: zod.object({ enabled: zod.boolean(), content: zod.string() }),
});
// ----------------------------------------------------------------------
type Props = {
currentPartyEvent?: IPartyEventItem;
};
export function PartyEventNewEditForm({ currentPartyEvent }: Props) {
const router = useRouter();
const openDetails = useBoolean(true);
const openProperties = useBoolean(true);
const openPricing = useBoolean(true);
const [includeTaxes, setIncludeTaxes] = useState(false);
const defaultValues: NewPartyEventSchemaType = {
sku: '321',
name: 'hello party event',
code: '123',
price: 1.1,
taxes: 1.1,
tags: [_tags[0], _tags[1]],
sizes: ['9'],
publish: PRODUCT_PUBLISH_OPTIONS[0].value,
gender: [
PRODUCT_GENDER_OPTIONS[0].value,
PRODUCT_GENDER_OPTIONS[1].value,
PRODUCT_GENDER_OPTIONS[2].value,
],
coverUrl: '',
images: [],
colors: [PRODUCT_COLOR_OPTIONS[0], PRODUCT_COLOR_OPTIONS[1]],
quantity: 3,
category: PRODUCT_CATEGORY_GROUP_OPTIONS[0].classify[1],
available: 0,
totalSold: 0,
description: 'hello description',
totalRatings: 0,
totalReviews: 0,
inventoryType: '',
subDescription: '',
priceSale: 0.9,
newLabel: { enabled: false, content: '' },
saleLabel: { enabled: false, content: '' },
};
const methods = useForm<NewPartyEventSchemaType>({
resolver: zodResolver(NewProductSchema),
defaultValues,
values: currentPartyEvent,
});
const {
reset,
watch,
setValue,
handleSubmit,
formState: { errors, isSubmitting },
} = methods;
const values = watch();
const onSubmit = handleSubmit(async (data) => {
const updatedData = {
...data,
taxes: includeTaxes ? defaultValues.taxes : data.taxes,
};
try {
// sanitize file field
for (let i = 0; i < values.images.length; i++) {
const temp: any = values.images[i];
if (temp instanceof File) {
values.images[i] = await fileToBase64(temp);
}
}
const sanitizedValues: IPartyEventItem = values as unknown as IPartyEventItem;
if (currentPartyEvent) {
// perform save
updatePartyEvent(sanitizedValues);
} else {
// perform create
createPartyEvent(sanitizedValues);
}
toast.success(currentPartyEvent ? 'update-success!' : 'create-success!');
router.push(paths.dashboard.partyEvent.root);
// console.info('DATA', updatedData);
} catch (error) {
console.error(error);
}
});
const handleRemoveFile = useCallback(
(inputFile: File | string) => {
const filtered = values.images && values.images?.filter((file) => file !== inputFile);
setValue('images', filtered);
},
[setValue, values.images]
);
const handleRemoveAllFiles = useCallback(() => {
setValue('images', [], { shouldValidate: true });
}, [setValue]);
const handleChangeIncludeTaxes = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setIncludeTaxes(event.target.checked);
}, []);
const renderCollapseButton = (value: boolean, onToggle: () => void) => (
<IconButton onClick={onToggle}>
<Iconify icon={value ? 'eva:arrow-ios-downward-fill' : 'eva:arrow-ios-forward-fill'} />
</IconButton>
);
function handleProductImageUpload() {
console.log(values);
}
const [disableUserInput, setDisableUserInput] = useState<boolean>(false);
useEffect(() => {
setDisableUserInput(isSubmitting);
}, [isSubmitting]);
const renderDetails = () => (
<Card>
<CardHeader
title="Details"
subheader="Title, short description, image..."
action={renderCollapseButton(openDetails.value, openDetails.onToggle)}
sx={{ mb: 3 }}
/>
<Collapse in={openDetails.value}>
<Divider />
<Stack spacing={3} sx={{ p: 3 }}>
<Field.Text disabled={disableUserInput} name="name" label="產品名稱 / Product name" />
<Field.Text
disabled={disableUserInput}
name="subDescription"
label="Sub description"
multiline
rows={4}
/>
<Stack spacing={1.5}>
<Typography variant="subtitle2">Content</Typography>
<Field.Editor name="description" sx={{ maxHeight: 480 }} />
</Stack>
<Stack spacing={1.5}>
<Typography variant="subtitle2">Images</Typography>
<Field.Upload
multiple
thumbnail
name="images"
maxSize={3145728}
onRemove={handleRemoveFile}
onRemoveAll={handleRemoveAllFiles}
onUpload={handleProductImageUpload}
/>
</Stack>
</Stack>
</Collapse>
</Card>
);
const renderProperties = () => (
<Card>
<CardHeader
title="Properties"
subheader="Additional functions and attributes..."
action={renderCollapseButton(openProperties.value, openProperties.onToggle)}
sx={{ mb: 3 }}
/>
<Collapse in={openProperties.value}>
<Divider />
<Stack spacing={3} sx={{ p: 3 }}>
<Box
sx={{
rowGap: 3,
columnGap: 2,
display: 'grid',
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(2, 1fr)' },
}}
>
<Field.Text disabled={disableUserInput} name="code" label="Product code" />
<Field.Text disabled={disableUserInput} name="sku" label="Product SKU" />
<Field.Text
disabled={disableUserInput}
name="quantity"
label="Quantity"
placeholder="0"
type="number"
slotProps={{ inputLabel: { shrink: true } }}
/>
<Field.Select
disabled={disableUserInput}
name="category"
label="Category"
slotProps={{
select: { native: true },
inputLabel: { shrink: true },
}}
>
{PRODUCT_CATEGORY_GROUP_OPTIONS.map((category) => (
<optgroup key={category.group} label={category.group}>
{category.classify.map((classify) => (
<option key={classify} value={classify}>
{classify}
</option>
))}
</optgroup>
))}
</Field.Select>
<Field.MultiSelect
checkbox
name="colors"
label="Colors"
options={PRODUCT_COLOR_NAME_OPTIONS}
/>
<Field.MultiSelect checkbox name="sizes" label="Sizes" options={PRODUCT_SIZE_OPTIONS} />
</Box>
<Field.Autocomplete
disabled={disableUserInput}
name="tags"
label="Tags"
placeholder="+ Tags"
multiple
freeSolo
disableCloseOnSelect
options={_tags.map((option) => option)}
getOptionLabel={(option) => option}
renderOption={(props, option) => (
<li {...props} key={option}>
{option}
</li>
)}
renderTags={(selected, getTagProps) =>
selected.map((option, index) => (
<Chip
{...getTagProps({ index })}
key={option}
label={option}
size="small"
color="info"
variant="soft"
/>
))
}
/>
<Stack spacing={1}>
<Typography variant="subtitle2">Gender</Typography>
<Field.MultiCheckbox
row
name="gender"
options={PRODUCT_GENDER_OPTIONS}
sx={{ gap: 2 }}
/>
</Stack>
<Divider sx={{ borderStyle: 'dashed' }} />
<Box sx={{ gap: 3, display: 'flex', alignItems: 'center' }}>
<Field.Switch name="saleLabel.enabled" label={null} sx={{ m: 0 }} />
<Field.Text
name="saleLabel.content"
label="Sale label"
fullWidth
disabled={!values.saleLabel.enabled}
/>
</Box>
<Box sx={{ gap: 3, display: 'flex', alignItems: 'center' }}>
<Field.Switch name="newLabel.enabled" label={null} sx={{ m: 0 }} />
<Field.Text
name="newLabel.content"
label="New label"
fullWidth
disabled={!values.newLabel.enabled}
/>
</Box>
</Stack>
</Collapse>
</Card>
);
const renderPricing = () => (
<Card>
<CardHeader
title="Pricing"
subheader="Price related inputs"
action={renderCollapseButton(openPricing.value, openPricing.onToggle)}
sx={{ mb: 3 }}
/>
<Collapse in={openPricing.value}>
<Divider />
<Stack spacing={3} sx={{ p: 3 }}>
<Field.Text
disabled={disableUserInput}
name="price"
label="Regular price"
placeholder="0.00"
type="number"
slotProps={{
inputLabel: { shrink: true },
input: {
startAdornment: (
<InputAdornment position="start" sx={{ mr: 0.75 }}>
<Box component="span" sx={{ color: 'text.disabled' }}>
$
</Box>
</InputAdornment>
),
},
}}
/>
<Field.Text
disabled={disableUserInput}
name="priceSale"
label="Sale price"
placeholder="0.00"
type="number"
slotProps={{
inputLabel: { shrink: true },
input: {
startAdornment: (
<InputAdornment position="start" sx={{ mr: 0.75 }}>
<Box component="span" sx={{ color: 'text.disabled' }}>
$
</Box>
</InputAdornment>
),
},
}}
/>
<FormControlLabel
control={
<Switch
disabled={disableUserInput}
id="toggle-taxes"
checked={includeTaxes}
onChange={handleChangeIncludeTaxes}
/>
}
label="Price includes taxes"
/>
{!includeTaxes && (
<Field.Text
disabled={disableUserInput}
name="taxes"
label="Tax (%)"
placeholder="0.00"
type="number"
slotProps={{
inputLabel: { shrink: true },
input: {
startAdornment: (
<InputAdornment position="start" sx={{ mr: 0.75 }}>
<Box component="span" sx={{ color: 'text.disabled' }}>
%
</Box>
</InputAdornment>
),
},
}}
/>
)}
</Stack>
</Collapse>
</Card>
);
const renderActions = () => (
<Box
sx={{
gap: 3,
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<div>{JSON.stringify({ errors })}</div>
<FormControlLabel
label="Publish"
control={
<Switch
disabled={disableUserInput}
defaultChecked
slotProps={{ input: { id: 'publish-switch' } }}
/>
}
sx={{ pl: 3, flexGrow: 1 }}
/>
<Button type="submit" variant="contained" size="large" loading={isSubmitting}>
{!currentPartyEvent ? 'create-party' : 'save-edit'}
</Button>
</Box>
);
return (
<Form methods={methods} onSubmit={onSubmit}>
<Stack spacing={{ xs: 3, md: 5 }} sx={{ mx: 'auto', maxWidth: { xs: 720, xl: 880 } }}>
{renderDetails()}
{renderProperties()}
{renderPricing()}
{renderActions()}
</Stack>
</Form>
);
}

View File

@@ -0,0 +1,124 @@
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
import ListItemText from '@mui/material/ListItemText';
import Rating from '@mui/material/Rating';
import Typography from '@mui/material/Typography';
import { Iconify } from 'src/components/iconify';
import type { IProductReview } from 'src/types/party-event';
import { fDate } from 'src/utils/format-time';
// ----------------------------------------------------------------------
type Props = {
review: IProductReview;
};
export function ProductReviewItem({ review }: Props) {
const renderInfo = () => (
<Box
sx={{
gap: 2,
display: 'flex',
width: { md: 240 },
alignItems: 'center',
textAlign: { md: 'center' },
flexDirection: { xs: 'row', md: 'column' },
}}
>
<Avatar
src={review.avatarUrl}
sx={{ width: { xs: 48, md: 64 }, height: { xs: 48, md: 64 } }}
/>
<ListItemText
primary={review.name}
secondary={fDate(review.postedAt)}
slotProps={{
primary: { noWrap: true },
secondary: {
noWrap: true,
sx: { mt: 0.5, typography: 'caption' },
},
}}
/>
</Box>
);
const renderContent = () => (
<Box
sx={{
gap: 1,
display: 'flex',
flex: '1 1 auto',
flexDirection: 'column',
}}
>
<Rating size="small" value={review.rating} precision={0.1} readOnly />
{review.isPurchased && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
color: 'success.main',
typography: 'caption',
}}
>
<Iconify icon="solar:verified-check-bold" width={16} sx={{ mr: 0.5 }} />
Verified purchase
</Box>
)}
<Typography variant="body2">{review.comment}</Typography>
{!!review.attachments?.length && (
<Box
sx={{
pt: 1,
gap: 1,
display: 'flex',
flexWrap: 'wrap',
}}
>
{review.attachments.map((attachment) => (
<Box
key={attachment}
component="img"
alt={attachment}
src={attachment}
sx={{ width: 64, height: 64, borderRadius: 1.5 }}
/>
))}
</Box>
)}
<Box sx={{ gap: 2, pt: 1.5, display: 'flex' }}>
<ButtonBase disableRipple sx={{ gap: 0.5, typography: 'caption' }}>
<Iconify icon="solar:like-outline" width={16} />
123
</ButtonBase>
<ButtonBase disableRipple sx={{ gap: 0.5, typography: 'caption' }}>
<Iconify icon="solar:dislike-outline" width={16} />
34
</ButtonBase>
</Box>
</Box>
);
return (
<Box
sx={{
mt: 5,
gap: 2,
display: 'flex',
px: { xs: 2.5, md: 0 },
flexDirection: { xs: 'column', md: 'row' },
}}
>
{renderInfo()}
{renderContent()}
</Box>
);
}

View File

@@ -0,0 +1,27 @@
import Pagination, { paginationClasses } from '@mui/material/Pagination';
import type { IProductReview } from 'src/types/party-event';
import { ProductReviewItem } from './party-event-review-item';
// ----------------------------------------------------------------------
type Props = {
reviews: IProductReview[];
};
export function ProductReviewList({ reviews }: Props) {
return (
<>
{reviews.map((review) => (
<ProductReviewItem key={review.id} review={review} />
))}
<Pagination
count={10}
sx={{
mx: 'auto',
[`& .${paginationClasses.ul}`]: { my: 5, mx: 'auto', justifyContent: 'center' },
}}
/>
</>
);
}

View File

@@ -0,0 +1,102 @@
import { zodResolver } from '@hookform/resolvers/zod';
import Button from '@mui/material/Button';
import type { DialogProps } from '@mui/material/Dialog';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Typography from '@mui/material/Typography';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { Field, Form } from 'src/components/hook-form';
import { z as zod } from 'zod';
// ----------------------------------------------------------------------
export type ReviewSchemaType = zod.infer<typeof ReviewSchema>;
export const ReviewSchema = zod.object({
rating: zod.number().min(1, 'Rating must be greater than or equal to 1!'),
name: zod.string().min(1, { message: 'Name is required!' }),
review: zod.string().min(1, { message: 'Review is required!' }),
email: zod
.string()
.min(1, { message: 'Email is required!' })
.email({ message: 'Email must be a valid email address!' }),
});
// ----------------------------------------------------------------------
type Props = DialogProps & {
onClose: () => void;
};
export function ProductReviewNewForm({ onClose, ...other }: Props) {
const defaultValues: ReviewSchemaType = {
rating: 0,
review: '',
name: '',
email: '',
};
const methods = useForm<ReviewSchemaType>({
mode: 'all',
resolver: zodResolver(ReviewSchema),
defaultValues,
});
const {
reset,
handleSubmit,
formState: { isSubmitting },
} = methods;
const onSubmit = handleSubmit(async (data) => {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
reset();
onClose();
console.info('DATA', data);
} catch (error) {
console.error(error);
}
});
const onCancel = useCallback(() => {
onClose();
reset();
}, [onClose, reset]);
return (
<Dialog onClose={onClose} {...other}>
<Form methods={methods} onSubmit={onSubmit}>
<DialogTitle> Add Review </DialogTitle>
<DialogContent>
<div>
<Typography variant="body2" sx={{ mb: 1 }}>
Your review about this product:
</Typography>
<Field.Rating name="rating" />
</div>
<Field.Text name="review" label="Review *" multiline rows={3} sx={{ mt: 3 }} />
<Field.Text name="name" label="Name *" sx={{ mt: 3 }} />
<Field.Text name="email" label="Email *" sx={{ mt: 3 }} />
</DialogContent>
<DialogActions>
<Button color="inherit" variant="outlined" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="contained" loading={isSubmitting}>
Post
</Button>
</DialogActions>
</Form>
</Dialog>
);
}

View File

@@ -0,0 +1,150 @@
import Autocomplete, { autocompleteClasses, createFilterOptions } from '@mui/material/Autocomplete';
import Avatar from '@mui/material/Avatar';
import CircularProgress from '@mui/material/CircularProgress';
import InputAdornment from '@mui/material/InputAdornment';
import Link, { linkClasses } from '@mui/material/Link';
import type { SxProps, Theme } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import match from 'autosuggest-highlight/match';
import parse from 'autosuggest-highlight/parse';
import { useDebounce } from 'minimal-shared/hooks';
import { useCallback, useState } from 'react';
import { useSearchProducts } from 'src/actions/party-event';
import { Iconify } from 'src/components/iconify';
import { SearchNotFound } from 'src/components/search-not-found';
import { RouterLink } from 'src/routes/components';
import { useRouter } from 'src/routes/hooks';
import type { IPartyEventItem } from 'src/types/party-event';
// ----------------------------------------------------------------------
type Props = {
sx?: SxProps<Theme>;
redirectPath: (id: string) => string;
};
export function ProductSearch({ redirectPath, sx }: Props) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [selectedItem, setSelectedItem] = useState<IPartyEventItem | null>(null);
const debouncedQuery = useDebounce(searchQuery);
const { searchResults: options, searchLoading: loading } = useSearchProducts(debouncedQuery);
const handleChange = useCallback(
(item: IPartyEventItem | null) => {
setSelectedItem(item);
if (item) {
router.push(redirectPath(item.id));
}
},
[redirectPath, router]
);
const filterOptions = createFilterOptions({
matchFrom: 'any',
stringify: (option: IPartyEventItem) => `${option.name} ${option.sku}`,
});
const paperStyles: SxProps<Theme> = {
width: 320,
[`& .${autocompleteClasses.listbox}`]: {
[`& .${autocompleteClasses.option}`]: {
p: 0,
[`& .${linkClasses.root}`]: {
p: 0.75,
gap: 1.5,
width: 1,
display: 'flex',
alignItems: 'center',
},
},
},
};
return (
<Autocomplete
autoHighlight
popupIcon={null}
loading={loading}
options={options}
value={selectedItem}
filterOptions={filterOptions}
onChange={(event, newValue) => handleChange(newValue)}
onInputChange={(event, newValue) => setSearchQuery(newValue)}
getOptionLabel={(option) => option.name}
noOptionsText={<SearchNotFound query={debouncedQuery} />}
isOptionEqualToValue={(option, value) => option.id === value.id}
slotProps={{ paper: { sx: paperStyles } }}
sx={[{ width: { xs: 1, sm: 260 } }, ...(Array.isArray(sx) ? sx : [sx])]}
renderInput={(params) => (
<TextField
{...params}
placeholder="Search..."
slotProps={{
input: {
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<Iconify icon="eva:search-fill" sx={{ ml: 1, color: 'text.disabled' }} />
</InputAdornment>
),
endAdornment: (
<>
{loading ? <CircularProgress size={18} color="inherit" sx={{ mr: -3 }} /> : null}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
renderOption={(props, product, { inputValue }) => {
const matches = match(product.name, inputValue);
const parts = parse(product.name, matches);
return (
<li {...props} key={product.id}>
<Link
component={RouterLink}
href={redirectPath(product.id)}
color="inherit"
underline="none"
>
<Avatar
key={product.id}
alt={product.name}
src={product.coverUrl}
variant="rounded"
sx={{
width: 48,
height: 48,
flexShrink: 0,
borderRadius: 1,
}}
/>
<div key={inputValue}>
{parts.map((part, index) => (
<Typography
key={index}
component="span"
color={part.highlight ? 'primary' : 'textPrimary'}
sx={{
typography: 'body2',
fontWeight: part.highlight ? 'fontWeightSemiBold' : 'fontWeightMedium',
}}
>
{part.text}
</Typography>
))}
</div>
</Link>
</li>
);
}}
/>
);
}

View File

@@ -0,0 +1,85 @@
import Box from '@mui/material/Box';
import type { GridProps } from '@mui/material/Grid';
import Grid from '@mui/material/Grid';
import type { PaperProps } from '@mui/material/Paper';
import Paper from '@mui/material/Paper';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
// ----------------------------------------------------------------------
type ProductItemSkeletonProps = PaperProps & {
itemCount?: number;
};
export function ProductItemSkeleton({ sx, itemCount = 16, ...other }: ProductItemSkeletonProps) {
return Array.from({ length: itemCount }, (_, index) => (
<Paper
key={index}
variant="outlined"
sx={[{ borderRadius: 2 }, ...(Array.isArray(sx) ? sx : [sx])]}
{...other}
>
<Box sx={{ p: 1 }}>
<Skeleton sx={{ pt: '100%' }} />
</Box>
<Stack spacing={2} sx={{ p: 3, pt: 2 }}>
<Skeleton sx={{ width: 0.5, height: 16 }} />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex' }}>
<Skeleton variant="circular" sx={{ width: 16, height: 16 }} />
<Skeleton variant="circular" sx={{ width: 16, height: 16 }} />
<Skeleton variant="circular" sx={{ width: 16, height: 16 }} />
</Box>
<Skeleton sx={{ width: 40, height: 16 }} />
</Box>
</Stack>
</Paper>
));
}
// ----------------------------------------------------------------------
export function ProductDetailsSkeleton({ ...other }: GridProps) {
return (
<Grid container spacing={8} {...other}>
<Grid size={{ xs: 12, md: 6, lg: 7 }}>
<Skeleton sx={{ pt: '100%' }} />
</Grid>
<Grid size={{ xs: 12, md: 6, lg: 5 }}>
<Stack spacing={3}>
<Skeleton sx={{ height: 16, width: 48 }} />
<Skeleton sx={{ height: 16, width: 80 }} />
<Skeleton sx={{ height: 16, width: 0.5 }} />
<Skeleton sx={{ height: 16, width: 0.75 }} />
<Skeleton sx={{ height: 120 }} />
</Stack>
</Grid>
<Grid size={12}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{Array.from({ length: 3 }, (_, index) => (
<Box
key={index}
sx={{
gap: 2,
width: 1,
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<Skeleton variant="circular" sx={{ width: 80, height: 80 }} />
<Skeleton sx={{ height: 16, width: 160 }} />
<Skeleton sx={{ height: 16, width: 80 }} />
</Box>
))}
</Box>
</Grid>
</Grid>
);
}

View File

@@ -0,0 +1,70 @@
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import { usePopover } from 'minimal-shared/hooks';
import { CustomPopover } from 'src/components/custom-popover';
import { Iconify } from 'src/components/iconify';
// ----------------------------------------------------------------------
type Props = {
sort: string;
onSort: (newValue: string) => void;
sortOptions: {
value: string;
label: string;
}[];
};
export function ProductSort({ sort, onSort, sortOptions }: Props) {
const menuActions = usePopover();
const sortLabel = sortOptions.find((option) => option.value === sort)?.label;
const renderMenuActions = () => (
<CustomPopover
open={menuActions.open}
anchorEl={menuActions.anchorEl}
onClose={menuActions.onClose}
>
<MenuList>
{sortOptions.map((option) => (
<MenuItem
key={option.value}
selected={option.value === sort}
onClick={() => {
menuActions.onClose();
onSort(option.value);
}}
>
{option.label}
</MenuItem>
))}
</MenuList>
</CustomPopover>
);
return (
<>
<Button
disableRipple
color="inherit"
onClick={menuActions.onOpen}
endIcon={
<Iconify
icon={menuActions.open ? 'eva:arrow-ios-upward-fill' : 'eva:arrow-ios-downward-fill'}
/>
}
sx={{ fontWeight: 'fontWeightSemiBold' }}
>
Sort by:
<Box component="span" sx={{ ml: 0.5, fontWeight: 'fontWeightBold' }}>
{sortLabel}
</Box>
</Button>
{renderMenuActions()}
</>
);
}

View File

@@ -0,0 +1,61 @@
import Chip from '@mui/material/Chip';
import { upperFirst } from 'es-toolkit';
import type { UseSetStateReturn } from 'minimal-shared/hooks';
import { useCallback } from 'react';
import type { FiltersResultProps } from 'src/components/filters-result';
import { chipProps, FiltersBlock, FiltersResult } from 'src/components/filters-result';
import type { IProductTableFilters } from 'src/types/party-event';
// ----------------------------------------------------------------------
type Props = FiltersResultProps & {
filters: UseSetStateReturn<IProductTableFilters>;
};
export function ProductTableFiltersResult({ filters, totalResults, sx }: Props) {
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
const handleRemoveStock = useCallback(
(inputValue: string) => {
const newValue = currentFilters.stock.filter((item) => item !== inputValue);
updateFilters({ stock: newValue });
},
[updateFilters, currentFilters.stock]
);
const handleRemovePublish = useCallback(
(inputValue: string) => {
const newValue = currentFilters.publish.filter((item) => item !== inputValue);
updateFilters({ publish: newValue });
},
[updateFilters, currentFilters.publish]
);
return (
<FiltersResult totalResults={totalResults} onReset={() => resetFilters()} sx={sx}>
<FiltersBlock label="Stock:" isShow={!!currentFilters.stock.length}>
{currentFilters.stock.map((item) => (
<Chip
{...chipProps}
key={item}
label={upperFirst(item)}
onDelete={() => handleRemoveStock(item)}
/>
))}
</FiltersBlock>
<FiltersBlock label="Publish:" isShow={!!currentFilters.publish.length}>
{currentFilters.publish.map((item) => (
<Chip
{...chipProps}
key={item}
label={upperFirst(item)}
onDelete={() => handleRemovePublish(item)}
/>
))}
</FiltersBlock>
</FiltersResult>
);
}

View File

@@ -0,0 +1,92 @@
// src/sections/product/product-table-row.tsx
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import LinearProgress from '@mui/material/LinearProgress';
import Link from '@mui/material/Link';
import ListItemText from '@mui/material/ListItemText';
import type { GridCellParams } from '@mui/x-data-grid';
import { Label } from 'src/components/label';
import { RouterLink } from 'src/routes/components';
import { fCurrency } from 'src/utils/format-number';
import { fDate, fTime } from 'src/utils/format-time';
// ----------------------------------------------------------------------
type ParamsProps = {
params: GridCellParams;
};
export function RenderCellPrice({ params }: ParamsProps) {
return fCurrency(params.row.price);
}
export function RenderCellPublish({ params }: ParamsProps) {
return (
<Label variant="soft" color={params.row.publish === 'published' ? 'info' : 'default'}>
{params.row.publish}
</Label>
);
}
export function RenderCellCreatedAt({ params }: ParamsProps) {
return (
<Box sx={{ gap: 0.5, display: 'flex', flexDirection: 'column' }}>
<span>{fDate(params.row.createdAt)}</span>
<Box component="span" sx={{ typography: 'caption', color: 'text.secondary' }}>
{fTime(params.row.createdAt)}
</Box>
</Box>
);
}
export function RenderCellStock({ params }: ParamsProps) {
return (
<Box sx={{ width: 1, typography: 'caption', color: 'text.secondary' }}>
<LinearProgress
value={(params.row.available * 100) / params.row.quantity}
variant="determinate"
color={
(params.row.inventoryType === 'out of stock' && 'error') ||
(params.row.inventoryType === 'low stock' && 'warning') ||
'success'
}
sx={{ mb: 1, height: 6, width: 80 }}
/>
{!!params.row.available && params.row.available} {params.row.inventoryType}
</Box>
);
}
export function RenderCellProduct({ params, href }: ParamsProps & { href: string }) {
return (
<Box
sx={{
py: 2,
gap: 2,
width: 1,
display: 'flex',
alignItems: 'center',
}}
>
<Avatar
alt={params.row.name}
src={params.row.coverUrl}
variant="rounded"
sx={{ width: 64, height: 64 }}
/>
<ListItemText
primary={
<Link component={RouterLink} href={href} color="inherit">
{params.row.name}
</Link>
}
secondary={params.row.category}
slotProps={{
primary: { noWrap: true },
secondary: { sx: { color: 'text.disabled' } },
}}
/>
</Box>
);
}

View File

@@ -0,0 +1,184 @@
import Checkbox from '@mui/material/Checkbox';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import OutlinedInput from '@mui/material/OutlinedInput';
import type { SelectChangeEvent } from '@mui/material/Select';
import Select from '@mui/material/Select';
import type { UseSetStateReturn } from 'minimal-shared/hooks';
import { usePopover } from 'minimal-shared/hooks';
import { varAlpha } from 'minimal-shared/utils';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CustomPopover } from 'src/components/custom-popover';
import { Iconify } from 'src/components/iconify';
import type { IProductTableFilters } from 'src/types/party-event';
// ----------------------------------------------------------------------
type Props = {
filters: UseSetStateReturn<IProductTableFilters>;
options: {
stocks: { value: string; label: string }[];
publishs: { value: string; label: string }[];
};
};
export function PartyEventTableToolbar({ filters, options }: Props) {
const menuActions = usePopover();
const { state: currentFilters, setState: updateFilters } = filters;
const [stock, setStock] = useState(currentFilters.stock);
const [publish, setPublish] = useState(currentFilters.publish);
const handleChangeStock = useCallback((event: SelectChangeEvent<string[]>) => {
const {
target: { value },
} = event;
setStock(typeof value === 'string' ? value.split(',') : value);
}, []);
const handleChangePublish = useCallback((event: SelectChangeEvent<string[]>) => {
const {
target: { value },
} = event;
setPublish(typeof value === 'string' ? value.split(',') : value);
}, []);
const handleFilterStock = useCallback(() => {
updateFilters({ stock });
}, [updateFilters, stock]);
const handleFilterPublish = useCallback(() => {
updateFilters({ publish });
}, [publish, updateFilters]);
const { t } = useTranslation();
const renderMenuActions = () => (
<CustomPopover
open={menuActions.open}
anchorEl={menuActions.anchorEl}
onClose={menuActions.onClose}
slotProps={{ arrow: { placement: 'right-top' } }}
>
<MenuList>
<MenuItem onClick={() => menuActions.onClose()}>
<Iconify icon="solar:printer-minimalistic-bold" />
Print
</MenuItem>
<MenuItem onClick={() => menuActions.onClose()}>
<Iconify icon="solar:import-bold" />
Import
</MenuItem>
<MenuItem onClick={() => menuActions.onClose()}>
<Iconify icon="solar:export-bold" />
Export
</MenuItem>
</MenuList>
</CustomPopover>
);
return (
<>
<FormControl sx={{ flexShrink: 0, width: { xs: 1, md: 200 } }}>
<InputLabel htmlFor="filter-stock-select">{t('Stock')}</InputLabel>
<Select
multiple
value={stock}
onChange={handleChangeStock}
onClose={handleFilterStock}
input={<OutlinedInput label="Stock" />}
renderValue={(selected) => selected.map((value) => t(value)).join(', ')}
inputProps={{ id: 'filter-stock-select' }}
sx={{ textTransform: 'capitalize' }}
>
{options.stocks.map((option) => (
<MenuItem key={option.value} value={option.value}>
<Checkbox
disableRipple
size="small"
checked={stock.includes(option.value)}
slotProps={{
input: {
id: `${option.value}-checkbox`,
'aria-label': `${option.label} checkbox`,
},
}}
/>
{option.label}
</MenuItem>
))}
<MenuItem
onClick={handleFilterStock}
sx={[
(theme) => ({
justifyContent: 'center',
fontWeight: theme.typography.button,
bgcolor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`,
}),
]}
>
{t('Apply')}
</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ flexShrink: 0, width: { xs: 1, md: 200 } }}>
<InputLabel htmlFor="filter-publish-select">{t('Publish')}</InputLabel>
<Select
multiple
value={publish}
onChange={handleChangePublish}
onClose={handleFilterPublish}
input={<OutlinedInput label={t('Publish')} />}
renderValue={(selected) => selected.map((value) => t(value)).join(', ')}
inputProps={{ id: 'filter-publish-select' }}
sx={{ textTransform: 'capitalize' }}
>
{options.publishs.map((option) => (
<MenuItem key={option.value} value={option.value}>
<Checkbox
disableRipple
size="small"
checked={publish.includes(option.value)}
slotProps={{
input: {
id: `${option.value}-checkbox`,
'aria-label': `${option.label} checkbox`,
},
}}
/>
{option.label}
</MenuItem>
))}
<MenuItem
disableGutters
disableTouchRipple
onClick={handleFilterPublish}
sx={[
(theme) => ({
justifyContent: 'center',
fontWeight: theme.typography.button,
bgcolor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`,
}),
]}
>
{t('Apply')}
</MenuItem>
</Select>
</FormControl>
{renderMenuActions()}
</>
);
}

View File

@@ -0,0 +1,38 @@
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import { useBoolean } from 'minimal-shared/hooks';
// ----------------------------------------------------------------------
export function ConfirmDeleteProductDialog() {
const openDialog = useBoolean();
return (
<>
<Button color="info" variant="outlined" onClick={openDialog.onTrue}>
Open alert dialog
</Button>
<Dialog open onClose={openDialog.onFalse}>
<DialogTitle>Are you sure delete product ?</DialogTitle>
<DialogContent sx={{ color: 'text.secondary' }}>
Are you sure delete product ?
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={openDialog.onFalse}>
Cancel
</Button>
<Button loading variant="contained" onClick={openDialog.onFalse} autoFocus>
Delete
</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,11 @@
export * from './party-event-edit-view';
export * from './party-event-shop-view';
export * from './party-event-list-view';
export * from './party-event-create-view';
export * from './party-event-details-view';
export * from './party-event-shop-details-view';

View File

@@ -0,0 +1,24 @@
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { DashboardContent } from 'src/layouts/dashboard';
import { paths } from 'src/routes/paths';
import { PartyEventNewEditForm } from '../party-event-new-edit-form';
// ----------------------------------------------------------------------
export function PartyEventCreateView() {
return (
<DashboardContent>
<CustomBreadcrumbs
heading="Create a new party event"
links={[
{ name: 'Dashboard', href: paths.dashboard.root },
{ name: 'Party Event', href: paths.dashboard.product.root },
{ name: 'New party event' },
]}
sx={{ mb: { xs: 3, md: 5 } }}
/>
<PartyEventNewEditForm />
</DashboardContent>
);
}

View File

@@ -0,0 +1,186 @@
// src/sections/product/view/product-details-view.tsx
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import Grid from '@mui/material/Grid';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import Typography from '@mui/material/Typography';
import { useTabs } from 'minimal-shared/hooks';
import { varAlpha } from 'minimal-shared/utils';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
// import { PRODUCT_PUBLISH_OPTIONS } from 'src/_mock';
import { EmptyContent } from 'src/components/empty-content';
import { Iconify } from 'src/components/iconify';
import { DashboardContent } from 'src/layouts/dashboard';
import { RouterLink } from 'src/routes/components';
import { paths } from 'src/routes/paths';
import type { IPartyEventItem } from 'src/types/party-event';
import { ProductDetailsCarousel } from '../party-event-details-carousel';
import { ProductDetailsDescription } from '../party-event-details-description';
import { ProductDetailsReview } from '../party-event-details-review';
import { PartyEventDetailsSummary } from '../party-event-details-summary';
import { ProductDetailsToolbar } from '../party-event-details-toolbar';
import { ProductDetailsSkeleton } from '../party-event-skeleton';
// ----------------------------------------------------------------------
const SUMMARY = [
{
title: '100% original',
description: 'Chocolate bar candy canes ice cream toffee cookie halvah.',
icon: 'solar:verified-check-bold',
},
{
title: '10 days replacement',
description: 'Marshmallow biscuit donut dragée fruitcake wafer.',
icon: 'solar:clock-circle-bold',
},
{
title: 'Year warranty',
description: 'Cotton candy gingerbread cake I love sugar sweet.',
icon: 'solar:shield-check-bold',
},
] as const;
// ----------------------------------------------------------------------
type Props = {
partyEvent?: IPartyEventItem;
loading?: boolean;
error?: any;
};
export function PartyEventDetailsView({ partyEvent, error, loading }: Props) {
const { t } = useTranslation();
const tabs = useTabs('description');
const [publish, setPublish] = useState('');
useEffect(() => {
if (partyEvent) {
setPublish(partyEvent?.publish);
}
}, [partyEvent]);
const handleChangePublish = useCallback((newValue: string) => {
setPublish(newValue);
}, []);
const PRODUCT_PUBLISH_OPTIONS = [
{ value: 'published', label: t('Published') },
{ value: 'draft', label: t('Draft') },
];
if (loading) {
return (
<DashboardContent sx={{ pt: 5 }}>
<ProductDetailsSkeleton />
</DashboardContent>
);
}
if (error) {
return (
<DashboardContent sx={{ pt: 5 }}>
<EmptyContent
filled
title={t('Party event not found!')}
action={
<Button
component={RouterLink}
href={paths.dashboard.partyEvent.root}
startIcon={<Iconify width={16} icon="eva:arrow-ios-back-fill" />}
sx={{ mt: 3 }}
>
{t('Back to list')}
</Button>
}
sx={{ py: 10, height: 'auto', flexGrow: 'unset' }}
/>
</DashboardContent>
);
}
return (
<DashboardContent>
<ProductDetailsToolbar
backHref={paths.dashboard.partyEvent.root}
liveHref={paths.partyEvent.details(`${partyEvent?.id}`)}
editHref={paths.dashboard.partyEvent.edit(`${partyEvent?.id}`)}
publish={publish}
onChangePublish={handleChangePublish}
publishOptions={PRODUCT_PUBLISH_OPTIONS}
/>
<Grid container spacing={{ xs: 3, md: 5, lg: 8 }}>
<Grid size={{ xs: 12, md: 6, lg: 7 }}>
<ProductDetailsCarousel images={partyEvent?.images ?? []} />
</Grid>
<Grid size={{ xs: 12, md: 6, lg: 5 }}>
{partyEvent && <PartyEventDetailsSummary disableActions partyEvent={partyEvent} />}
</Grid>
</Grid>
<Box
sx={{
gap: 5,
my: 10,
display: 'grid',
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' },
}}
>
{SUMMARY.map((item) => (
<Box key={item.title} sx={{ textAlign: 'center', px: 5 }}>
<Iconify icon={item.icon} width={32} sx={{ color: 'primary.main' }} />
<Typography variant="subtitle1" sx={{ mb: 1, mt: 2 }}>
{item.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{item.description}
</Typography>
</Box>
))}
</Box>
<Card>
<Tabs
value={tabs.value}
onChange={tabs.onChange}
sx={[
(theme) => ({
px: 3,
boxShadow: `inset 0 -2px 0 0 ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`,
}),
]}
>
{[
{ value: 'description', label: 'Description' },
{ value: 'reviews', label: `Reviews (${partyEvent?.reviews.length})` },
].map((tab) => (
<Tab key={tab.value} value={tab.value} label={tab.label} />
))}
</Tabs>
{tabs.value === 'description' && (
<ProductDetailsDescription description={partyEvent?.description ?? ''} />
)}
{tabs.value === 'reviews' && (
<ProductDetailsReview
ratings={partyEvent?.ratings ?? []}
reviews={partyEvent?.reviews ?? []}
totalRatings={partyEvent?.totalRatings ?? 0}
totalReviews={partyEvent?.totalReviews ?? 0}
/>
)}
</Card>
</DashboardContent>
);
}

View File

@@ -0,0 +1,30 @@
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { DashboardContent } from 'src/layouts/dashboard';
import { paths } from 'src/routes/paths';
import type { IPartyEventItem } from 'src/types/party-event';
import { PartyEventNewEditForm } from '../party-event-new-edit-form';
// ----------------------------------------------------------------------
type Props = {
partyEvent?: IPartyEventItem;
};
export function PartyEventEditView({ partyEvent }: Props) {
return (
<DashboardContent>
<CustomBreadcrumbs
heading="Edit"
backHref={paths.dashboard.partyEvent.root}
links={[
{ name: 'Dashboard', href: paths.dashboard.root },
{ name: 'Party Event', href: paths.dashboard.partyEvent.root },
{ name: partyEvent?.name },
]}
sx={{ mb: { xs: 3, md: 5 } }}
/>
<PartyEventNewEditForm currentPartyEvent={partyEvent} />
</DashboardContent>
);
}

View File

@@ -0,0 +1,497 @@
// src/sections/party-event/view/party-event-list-view.tsx
//
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import Link from '@mui/material/Link';
import ListItemIcon from '@mui/material/ListItemIcon';
import MenuItem from '@mui/material/MenuItem';
import type { SxProps, Theme } from '@mui/material/styles';
import type {
GridActionsCellItemProps,
GridColDef,
GridColumnVisibilityModel,
GridRowSelectionModel,
GridSlotProps,
} from '@mui/x-data-grid';
import {
DataGrid,
GridActionsCellItem,
gridClasses,
GridToolbarColumnsButton,
GridToolbarContainer,
GridToolbarExport,
GridToolbarFilterButton,
GridToolbarQuickFilter,
} from '@mui/x-data-grid';
import type { UseSetStateReturn } from 'minimal-shared/hooks';
import { useBoolean, useSetState } from 'minimal-shared/hooks';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
// import { PRODUCT_STOCK_OPTIONS } from 'src/_mock';
import { deletePartyEvent, useGetPartyEvents } from 'src/actions/party-event';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { ConfirmDialog } from 'src/components/custom-dialog';
import { EmptyContent } from 'src/components/empty-content';
import { Iconify } from 'src/components/iconify';
import { toast } from 'src/components/snackbar';
import { DashboardContent } from 'src/layouts/dashboard';
import { endpoints } from 'src/lib/axios';
import { RouterLink } from 'src/routes/components';
import { paths } from 'src/routes/paths';
import type { IPartyEventItem, IProductTableFilters } from 'src/types/party-event';
import { mutate } from 'swr';
import { ProductTableFiltersResult } from '../party-event-table-filters-result';
import {
RenderCellCreatedAt,
RenderCellPrice,
RenderCellProduct,
RenderCellPublish,
RenderCellStock,
} from '../party-event-table-row';
import { PartyEventTableToolbar } from '../party-event-table-toolbar';
// ----------------------------------------------------------------------
const HIDE_COLUMNS = { category: false };
const HIDE_COLUMNS_TOGGLABLE = ['category', 'actions'];
// ----------------------------------------------------------------------
export function PartyEventListView() {
const { t } = useTranslation();
const confirmDialog = useBoolean();
const PRODUCT_STOCK_OPTIONS = [
{ value: 'in stock', label: t('In stock') },
{ value: 'low stock', label: t('Low stock') },
{ value: 'out of stock', label: t('Out of stock') },
];
const PUBLISH_OPTIONS = [
{ value: 'published', label: t('Published') },
{ value: 'draft', label: t('Draft') },
];
const confirmDeleteMultiItemsDialog = useBoolean();
const confirmDeleteSingleItemDialog = useBoolean();
const [idToDelete, setIdToDelete] = useState<string | null>(null);
const { partyEvents, partyEventsLoading } = useGetPartyEvents();
const [tableData, setTableData] = useState<IPartyEventItem[]>(partyEvents);
const [selectedRowIds, setSelectedRowIds] = useState<GridRowSelectionModel>([]);
const [filterButtonEl, setFilterButtonEl] = useState<HTMLButtonElement | null>(null);
const filters = useSetState<IProductTableFilters>({ publish: [], stock: [] });
const { state: currentFilters } = filters;
const [columnVisibilityModel, setColumnVisibilityModel] =
useState<GridColumnVisibilityModel>(HIDE_COLUMNS);
useEffect(() => {
if (partyEvents.length) {
setTableData(partyEvents);
}
}, [partyEvents]);
const canReset = currentFilters.publish.length > 0 || currentFilters.stock.length > 0;
const dataFiltered = applyFilter({
inputData: tableData,
filters: currentFilters,
});
const handleDeleteSingleRow = useCallback(async () => {
// const deleteRow = tableData.filter((row) => row.id !== id);
try {
if (idToDelete) {
await deletePartyEvent(idToDelete);
toast.success('Delete success!');
}
} catch (error) {
console.error(error);
toast.error('Delete failed!');
}
// setTableData(deleteRow);
setDeleteInProgress(false);
}, [idToDelete, mutate]);
// NOTE: this is working using example from calendar
const handleDeleteRow = useCallback(
async (id: string) => {
try {
await deletePartyEvent(id);
// invalidate cache to reload list
await mutate(endpoints.partyEvent.list);
toast.success('Delete success!');
} catch (error) {
console.error(error);
toast.error('Delete error!');
}
},
[tableData]
);
const handleDeleteRows = useCallback(() => {
const deleteRows = tableData.filter((row) => !selectedRowIds.includes(row.id));
toast.success('Delete success!');
setTableData(deleteRows);
}, [selectedRowIds, tableData]);
const CustomToolbarCallback = useCallback(
() => (
<CustomToolbar
filters={filters}
canReset={canReset}
selectedRowIds={selectedRowIds}
setFilterButtonEl={setFilterButtonEl}
filteredResults={dataFiltered.length}
onOpenConfirmDeleteRows={confirmDeleteMultiItemsDialog.onTrue}
/>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[currentFilters, selectedRowIds]
);
const columns: GridColDef[] = [
{ field: 'category', headerName: t('Category'), filterable: false },
{
field: 'name',
headerName: t('Party Event'),
flex: 1,
minWidth: 360,
hideable: false,
renderCell: (params) => (
<RenderCellProduct
params={params}
href={paths.dashboard.partyEvent.details(params.row.id)}
/>
),
},
{
field: 'createdAt',
headerName: t('Create-at'),
width: 160,
renderCell: (params) => <RenderCellCreatedAt params={params} />,
},
{
field: 'inventoryType',
headerName: t('Stock'),
width: 160,
type: 'singleSelect',
valueOptions: PRODUCT_STOCK_OPTIONS,
renderCell: (params) => <RenderCellStock params={params} />,
},
{
field: 'price',
headerName: t('Price'),
width: 140,
editable: true,
renderCell: (params) => <RenderCellPrice params={params} />,
},
{
field: 'publish',
headerName: t('Publish'),
width: 110,
type: 'singleSelect',
editable: true,
valueOptions: PUBLISH_OPTIONS,
renderCell: (params) => <RenderCellPublish params={params} />,
},
{
type: 'actions',
field: 'actions',
headerName: ' ',
align: 'right',
headerAlign: 'right',
width: 80,
sortable: false,
filterable: false,
disableColumnMenu: true,
getActions: (params) => [
<GridActionsLinkItem
showInMenu
icon={<Iconify icon="solar:eye-bold" />}
label="View"
href={paths.dashboard.partyEvent.details(params.row.id)}
/>,
<GridActionsLinkItem
showInMenu
icon={<Iconify icon="solar:pen-bold" />}
label="Edit"
href={paths.dashboard.partyEvent.edit(params.row.id)}
/>,
<GridActionsCellItem
showInMenu
icon={<Iconify icon="solar:trash-bin-trash-bold" />}
label="Delete"
onClick={() => {
setIdToDelete(params.row.id);
confirmDeleteSingleItemDialog.onTrue();
}}
sx={{ color: 'error.main' }}
/>,
],
},
];
const getTogglableColumns = () =>
columns
.filter((column) => !HIDE_COLUMNS_TOGGLABLE.includes(column.field))
.map((column) => column.field);
const renderDeleteMultipleItemsConfirmDialog = () => (
<ConfirmDialog
open={confirmDeleteMultiItemsDialog.value}
onClose={confirmDeleteMultiItemsDialog.onFalse}
title="Delete multiple party events"
content={
<>
Are you sure want to delete <strong> {selectedRowIds.length} </strong> items?
</>
}
action={
<Button
variant="contained"
color="error"
onClick={() => {
handleDeleteRows();
confirmDeleteMultiItemsDialog.onFalse();
}}
>
{t('Delete')}
</Button>
}
/>
);
const [deleteInProgress, setDeleteInProgress] = useState<boolean>(false);
const renderDeleteSingleItemConfirmDialog = () => (
<ConfirmDialog
open={confirmDeleteSingleItemDialog.value}
onClose={confirmDeleteSingleItemDialog.onFalse}
title="Delete party event"
content={<>Are you sure want to delete item?</>}
action={
<Button
loading={deleteInProgress}
variant="contained"
color="error"
onClick={() => {
setDeleteInProgress(true);
handleDeleteSingleRow();
confirmDeleteSingleItemDialog.onFalse();
}}
>
{t('Delete')}
</Button>
}
/>
);
return (
<>
<DashboardContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
<CustomBreadcrumbs
heading={t('Party Event List')}
links={[
{ name: t('Dashboard'), href: paths.dashboard.root },
{ name: t('Party Event'), href: paths.dashboard.partyEvent.root },
{ name: t('List') },
]}
action={
<Button
component={RouterLink}
href={paths.dashboard.partyEvent.new}
variant="contained"
startIcon={<Iconify icon="mingcute:add-line" />}
>
{t('new-party-event')}
</Button>
}
sx={{ mb: { xs: 3, md: 5 } }}
/>
<Card
sx={{
minHeight: 640,
flexGrow: { md: 1 },
display: { md: 'flex' },
height: { xs: 800, md: '1px' },
flexDirection: { md: 'column' },
}}
>
<DataGrid
checkboxSelection
disableRowSelectionOnClick
rows={dataFiltered}
columns={columns}
loading={partyEventsLoading}
getRowHeight={() => 'auto'}
pageSizeOptions={[5, 10, 20, { value: -1, label: 'All' }]}
initialState={{ pagination: { paginationModel: { pageSize: 10 } } }}
onRowSelectionModelChange={(newSelectionModel) => setSelectedRowIds(newSelectionModel)}
columnVisibilityModel={columnVisibilityModel}
onColumnVisibilityModelChange={(newModel) => setColumnVisibilityModel(newModel)}
slots={{
toolbar: CustomToolbarCallback,
noRowsOverlay: () => <EmptyContent />,
noResultsOverlay: () => <EmptyContent title="No results found" />,
}}
slotProps={{
toolbar: { setFilterButtonEl },
panel: { anchorEl: filterButtonEl },
columnsManagement: { getTogglableColumns },
}}
sx={{ [`& .${gridClasses.cell}`]: { alignItems: 'center', display: 'inline-flex' } }}
/>
</Card>
</DashboardContent>
{renderDeleteMultipleItemsConfirmDialog()}
{renderDeleteSingleItemConfirmDialog()}
</>
);
}
// ----------------------------------------------------------------------
declare module '@mui/x-data-grid' {
interface ToolbarPropsOverrides {
setFilterButtonEl: React.Dispatch<React.SetStateAction<HTMLButtonElement | null>>;
}
}
type CustomToolbarProps = GridSlotProps['toolbar'] & {
canReset: boolean;
filteredResults: number;
selectedRowIds: GridRowSelectionModel;
filters: UseSetStateReturn<IProductTableFilters>;
onOpenConfirmDeleteRows: () => void;
};
function CustomToolbar({
filters,
canReset,
selectedRowIds,
filteredResults,
setFilterButtonEl,
onOpenConfirmDeleteRows,
}: CustomToolbarProps) {
const { t } = useTranslation();
const PRODUCT_STOCK_OPTIONS = [
{ value: 'in stock', label: t('In stock') },
{ value: 'low stock', label: t('Low stock') },
{ value: 'out of stock', label: t('Out of stock') },
];
const PUBLISH_OPTIONS = [
{ value: 'published', label: t('Published') },
{ value: 'draft', label: t('Draft') },
];
return (
<>
<GridToolbarContainer>
<PartyEventTableToolbar
filters={filters}
options={{ stocks: PRODUCT_STOCK_OPTIONS, publishs: PUBLISH_OPTIONS }}
/>
<GridToolbarQuickFilter />
<Box
sx={{
gap: 1,
flexGrow: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
{!!selectedRowIds.length && (
<Button
size="small"
color="error"
startIcon={<Iconify icon="solar:trash-bin-trash-bold" />}
onClick={onOpenConfirmDeleteRows}
>
Delete ({selectedRowIds.length})
</Button>
)}
<GridToolbarColumnsButton />
<GridToolbarFilterButton ref={setFilterButtonEl} />
<GridToolbarExport />
</Box>
</GridToolbarContainer>
{canReset && (
<ProductTableFiltersResult
filters={filters}
totalResults={filteredResults}
sx={{ p: 2.5, pt: 0 }}
/>
)}
</>
);
}
// ----------------------------------------------------------------------
type GridActionsLinkItemProps = Pick<GridActionsCellItemProps, 'icon' | 'label' | 'showInMenu'> & {
href: string;
sx?: SxProps<Theme>;
ref?: React.RefObject<HTMLLIElement | null>;
};
export function GridActionsLinkItem({ ref, href, label, icon, sx }: GridActionsLinkItemProps) {
return (
<MenuItem ref={ref} sx={sx}>
<Link
component={RouterLink}
href={href}
underline="none"
color="inherit"
sx={{ width: 1, display: 'flex', alignItems: 'center' }}
>
{icon && <ListItemIcon>{icon}</ListItemIcon>}
{label}
</Link>
</MenuItem>
);
}
// ----------------------------------------------------------------------
type ApplyFilterProps = {
inputData: IPartyEventItem[];
filters: IProductTableFilters;
};
function applyFilter({ inputData, filters }: ApplyFilterProps) {
const { stock, publish } = filters;
if (stock.length) {
inputData = inputData.filter((partyEvent) => stock.includes(partyEvent.inventoryType));
}
if (publish.length) {
inputData = inputData.filter((partyEvent) => publish.includes(partyEvent.publish));
}
return inputData;
}

View File

@@ -0,0 +1,182 @@
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import Container from '@mui/material/Container';
import Grid from '@mui/material/Grid';
import type { SxProps, Theme } from '@mui/material/styles';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import Typography from '@mui/material/Typography';
import { useTabs } from 'minimal-shared/hooks';
import { varAlpha } from 'minimal-shared/utils';
import { useTranslation } from 'react-i18next';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { EmptyContent } from 'src/components/empty-content';
import { Iconify } from 'src/components/iconify';
import { RouterLink } from 'src/routes/components';
import { paths } from 'src/routes/paths';
import type { IPartyEventItem } from 'src/types/party-event';
import { useCheckoutContext } from '../../checkout/context';
import { CartIcon } from '../cart-icon';
import { ProductDetailsCarousel } from '../party-event-details-carousel';
import { ProductDetailsDescription } from '../party-event-details-description';
import { ProductDetailsReview } from '../party-event-details-review';
import { PartyEventDetailsSummary } from '../party-event-details-summary';
import { ProductDetailsSkeleton } from '../party-event-skeleton';
// ----------------------------------------------------------------------
const SUMMARY = [
{
title: '100% original',
description: 'Chocolate bar candy canes ice cream toffee cookie halvah.',
icon: 'solar:verified-check-bold',
},
{
title: '10 days replacement',
description: 'Marshmallow biscuit donut dragée fruitcake wafer.',
icon: 'solar:clock-circle-bold',
},
{
title: 'Year warranty',
description: 'Cotton candy gingerbread cake I love sugar sweet.',
icon: 'solar:shield-check-bold',
},
] as const;
// ----------------------------------------------------------------------
type Props = {
product?: IPartyEventItem;
loading?: boolean;
error?: any;
};
export function ProductShopDetailsView({ product, error, loading }: Props) {
const { t } = useTranslation();
const { state: checkoutState, onAddToCart } = useCheckoutContext();
const containerStyles: SxProps<Theme> = {
mt: 5,
mb: 10,
};
const tabs = useTabs('description');
if (loading) {
return (
<Container sx={containerStyles}>
<ProductDetailsSkeleton />
</Container>
);
}
if (error) {
return (
<Container sx={containerStyles}>
<EmptyContent
filled
title={t('Product not found!')}
action={
<Button
component={RouterLink}
href={paths.product.root}
startIcon={<Iconify width={16} icon="eva:arrow-ios-back-fill" />}
sx={{ mt: 3 }}
>
Back to list
</Button>
}
sx={{ py: 10 }}
/>
</Container>
);
}
return (
<Container sx={containerStyles}>
<CartIcon totalItems={checkoutState.totalItems} />
<CustomBreadcrumbs
links={[
{ name: 'Home', href: '/' },
{ name: 'Shop', href: paths.product.root },
{ name: product?.name },
]}
sx={{ mb: 5 }}
/>
<Grid container spacing={{ xs: 3, md: 5, lg: 8 }}>
<Grid size={{ xs: 12, md: 6, lg: 7 }}>
<ProductDetailsCarousel images={product?.images} />
</Grid>
<Grid size={{ xs: 12, md: 6, lg: 5 }}>
{product && (
<PartyEventDetailsSummary
partyEvent={product}
items={checkoutState.items}
onAddToCart={onAddToCart}
disableActions={!product?.available}
/>
)}
</Grid>
</Grid>
<Box
sx={{
gap: 5,
my: 10,
display: 'grid',
gridTemplateColumns: { xs: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' },
}}
>
{SUMMARY.map((item) => (
<Box key={item.title} sx={{ textAlign: 'center', px: 5 }}>
<Iconify icon={item.icon} width={32} sx={{ color: 'primary.main' }} />
<Typography variant="subtitle1" sx={{ mb: 1, mt: 2 }}>
{item.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{item.description}
</Typography>
</Box>
))}
</Box>
<Card>
<Tabs
value={tabs.value}
onChange={tabs.onChange}
sx={[
(theme) => ({
px: 3,
boxShadow: `inset 0 -2px 0 0 ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`,
}),
]}
>
{[
{ value: 'description', label: 'Description' },
{ value: 'reviews', label: `Reviews (${product?.reviews.length})` },
].map((tab) => (
<Tab key={tab.value} value={tab.value} label={tab.label} />
))}
</Tabs>
{tabs.value === 'description' && (
<ProductDetailsDescription description={product?.description} />
)}
{tabs.value === 'reviews' && (
<ProductDetailsReview
ratings={product?.ratings}
reviews={product?.reviews}
totalRatings={product?.totalRatings}
totalReviews={product?.totalReviews}
/>
)}
</Card>
</Container>
);
}

View File

@@ -0,0 +1,193 @@
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { orderBy } from 'es-toolkit';
import { useBoolean, useSetState } from 'minimal-shared/hooks';
import { useState } from 'react';
import {
PRODUCT_CATEGORY_OPTIONS,
PRODUCT_COLOR_OPTIONS,
PRODUCT_GENDER_OPTIONS,
PRODUCT_RATING_OPTIONS,
PRODUCT_SORT_OPTIONS,
} from 'src/_mock';
import { EmptyContent } from 'src/components/empty-content';
import { paths } from 'src/routes/paths';
import type { IPartyEventItem, IProductFilters } from 'src/types/party-event';
import { useCheckoutContext } from '../../checkout/context';
import { CartIcon } from '../cart-icon';
import { ProductFiltersDrawer } from '../party-event-filters-drawer';
import { ProductFiltersResult } from '../party-event-filters-result';
import { PartyEventList } from '../party-event-list';
import { ProductSearch } from '../party-event-search';
import { ProductSort } from '../party-event-sort';
// ----------------------------------------------------------------------
type Props = {
products: IPartyEventItem[];
loading?: boolean;
};
export function PartyEventShopView({ products, loading }: Props) {
const { state: checkoutState } = useCheckoutContext();
const openFilters = useBoolean();
const [sortBy, setSortBy] = useState('featured');
const filters = useSetState<IProductFilters>({
gender: [],
colors: [],
rating: '',
category: 'all',
priceRange: [0, 200],
});
const { state: currentFilters } = filters;
const dataFiltered = applyFilter({
inputData: products,
filters: currentFilters,
sortBy,
});
const canReset =
currentFilters.gender.length > 0 ||
currentFilters.colors.length > 0 ||
currentFilters.rating !== '' ||
currentFilters.category !== 'all' ||
currentFilters.priceRange[0] !== 0 ||
currentFilters.priceRange[1] !== 200;
const notFound = !dataFiltered.length && canReset;
const isEmpty = !loading && !products.length;
const renderFilters = () => (
<Box
sx={{
gap: 3,
display: 'flex',
justifyContent: 'space-between',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'flex-end', sm: 'center' },
}}
>
<ProductSearch redirectPath={(id: string) => paths.product.details(id)} />
<Box sx={{ gap: 1, flexShrink: 0, display: 'flex' }}>
<ProductFiltersDrawer
filters={filters}
canReset={canReset}
open={openFilters.value}
onOpen={openFilters.onTrue}
onClose={openFilters.onFalse}
options={{
colors: PRODUCT_COLOR_OPTIONS,
ratings: PRODUCT_RATING_OPTIONS,
genders: PRODUCT_GENDER_OPTIONS,
categories: ['all', ...PRODUCT_CATEGORY_OPTIONS],
}}
/>
<ProductSort
sort={sortBy}
onSort={(newValue: string) => setSortBy(newValue)}
sortOptions={PRODUCT_SORT_OPTIONS}
/>
</Box>
</Box>
);
const renderResults = () => (
<ProductFiltersResult filters={filters} totalResults={dataFiltered.length} />
);
const renderNotFound = () => <EmptyContent filled sx={{ py: 10 }} />;
return (
<Container sx={{ mb: 10 }}>
<CartIcon totalItems={checkoutState.totalItems} />
<Typography variant="h4" sx={{ my: { xs: 3, md: 5 } }}>
Shop
</Typography>
<Stack spacing={2.5} sx={{ mb: { xs: 3, md: 5 } }}>
{renderFilters()}
{canReset && renderResults()}
</Stack>
{notFound || isEmpty ? (
renderNotFound()
) : (
<PartyEventList partyEvents={dataFiltered} loading={loading} />
)}
</Container>
);
}
// ----------------------------------------------------------------------
type ApplyFilterProps = {
sortBy: string;
filters: IProductFilters;
inputData: IPartyEventItem[];
};
function applyFilter({ inputData, filters, sortBy }: ApplyFilterProps) {
const { gender, category, colors, priceRange, rating } = filters;
const min = priceRange[0];
const max = priceRange[1];
// Sort by
if (sortBy === 'featured') {
inputData = orderBy(inputData, ['totalSold'], ['desc']);
}
if (sortBy === 'newest') {
inputData = orderBy(inputData, ['createdAt'], ['desc']);
}
if (sortBy === 'priceDesc') {
inputData = orderBy(inputData, ['price'], ['desc']);
}
if (sortBy === 'priceAsc') {
inputData = orderBy(inputData, ['price'], ['asc']);
}
// filters
if (gender.length) {
inputData = inputData.filter((product) => product.gender.some((i) => gender.includes(i)));
}
if (category !== 'all') {
inputData = inputData.filter((product) => product.category === category);
}
if (colors.length) {
inputData = inputData.filter((product) =>
product.colors.some((color) => colors.includes(color))
);
}
if (min !== 0 || max !== 200) {
inputData = inputData.filter((product) => product.price >= min && product.price <= max);
}
if (rating) {
inputData = inputData.filter((product) => {
const convertRating = (value: string) => {
if (value === 'up4Star') return 4;
if (value === 'up3Star') return 3;
if (value === 'up2Star') return 2;
return 1;
};
return product.totalRatings > convertRating(rating);
});
}
return inputData;
}

View File

@@ -0,0 +1,60 @@
import type { IDateValue } from './common';
// ----------------------------------------------------------------------
export type IProductFilters = {
rating: string;
gender: string[];
category: string;
colors: string[];
priceRange: number[];
};
export type IProductTableFilters = {
stock: string[];
publish: string[];
};
export type IProductReview = {
id: string;
name: string;
rating: number;
comment: string;
helpful: number;
avatarUrl: string;
postedAt: IDateValue;
isPurchased: boolean;
attachments?: string[];
};
export type IPartyEventItem = {
id: string;
createdAt: IDateValue;
//
available: number;
category: string;
code: string;
colors: string[];
coverUrl: string;
description: string;
gender: string[];
images: string[];
inventoryType: string;
name: string;
newLabel: { content: string; enabled: boolean };
price: number;
priceSale: number | null;
publish: string;
quantity: number;
ratings: { name: string; starCount: number; reviewCount: number }[];
reviews: IProductReview[];
saleLabel: { content: string; enabled: boolean };
sizes: string[];
sku: string;
subDescription: string;
tags: string[];
taxes: number;
totalRatings: number;
totalReviews: number;
totalSold: number;
};

View File

@@ -79,6 +79,7 @@
"name": "ionic-react-conference-app",
"private": true,
"scripts": {
"dev:check": "yarn tsc:w",
"build": "tsc && vite build --force",
"build:w": "npx nodemon --ext \"ts,tsx\" --exec \"npm run tsc && npm run build\"",
"dev": "vite --force --host 0.0.0.0 --cors",
@@ -89,7 +90,7 @@
"start": "npm run dev",
"tsc:dev": "yarn dev & yarn tsc:watch",
"tsc:print": "npx tsc --showConfig",
"tsc:w": "npx nodemon --delay 3 --ext ts,tsx --exec \"yarn tsc\"",
"tsc:w": "npx nodemon --delay 1 --ext ts,tsx --exec \"yarn tsc\"",
"tsc:watch": "tsc --noEmit --watch",
"tsc": "tsc --noEmit"
},

View File

@@ -1,7 +1,7 @@
# FAQ
Q: where is the DB schema file ?
A: prisma DB schema file located in `prisma/schema.prisma`
A: prisma DB schema file located in `<project-root>/03_source/cms_backend/prisma/schema.prisma`
Q: when file not found, do i need to search it in `_ignore_this_directory` ?
A: No, you just stop there and voice out.

View File

@@ -24,9 +24,7 @@ T.B.A.
1 AI tools/co-pilot
```prompt
please read and follow steps in the markdown files in `98_AI_workspace/software-engineer` folder thanks.
```
[](./init_AI.md)
<!-- AI: please fill the step to setup project below -->

3
init_AI.md Normal file
View File

@@ -0,0 +1,3 @@
please read and follow steps in the markdown files in `98_AI_workspace/software-engineer` folder.
just reply `OK` when you done thanks.