Compare commits

..

20 Commits

Author SHA1 Message Date
louiscklaw
dfc9873815 feat: add REQ0187 frontend party-order CRUD functionality and backend API endpoints 2025-06-15 22:27:44 +08:00
louiscklaw
53b112e488 "docs: update FAQ with questions about modifying .tsx.draft files and replacement starting point" 2025-06-15 22:27:06 +08:00
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
louiscklaw
c1b71fca64 add helloworld API endpoint for product module with test case 2025-06-15 13:54:35 +08:00
louiscklaw
08642a2bf6 "update EventItem model schema with reordered and reorganized fields" 2025-06-15 13:54:24 +08:00
louiscklaw
043d45862c update markdown files and instructions in AI workspace 2025-06-15 13:16:53 +08:00
louiscklaw
8444b947a4 "update product list rendering" 2025-06-15 13:07:47 +08:00
102 changed files with 6113 additions and 105 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

@@ -0,0 +1,18 @@
---
tags: frontend, party-order
---
# REQ0185 frontend party-order
frontend page to handle party-order (CRUD)
edit page T.B.A.
## sources
T.B.A.
## branch
develop/frontend/party-order/trunk
develop/requirements/REQ0187

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

@@ -1146,48 +1146,60 @@ model EventReview {
// NOTE: need to consider with Event
// 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
//
sku String
name String
code String
price Float
taxes Float
tags String[]
sizes String[]
publish String
gender String[]
coverUrl String
images String[]
colors String[]
quantity Int
category String
available Int
totalSold Int
description String
totalRatings Float
totalReviews Int
inventoryType String
subDescription String
priceSale Float?
newLabel Json
saleLabel Json
ratings Json[]
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)
//
eventDate DateTime @default(now())
joinMembers Json[]
title String
currency String
duration_m Float
ageBottom Float
ageTop Float
location String
avatar 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 {
@@ -1218,3 +1230,30 @@ model AccessLog {
@@index([timestamp])
@@index([userId])
}
model PartyOrderItem {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
//
taxes Float
status String
shipping Float
discount Float
subtotal Float
orderNumber String
totalAmount Float
totalQuantity Float
history Json
payment Json
customer Json
delivery Json
items Json[]
shippingAddress Json
// OrderProductItem OrderProductItem[]
// OrderHistory OrderHistory[]
// OrderDelivery OrderDelivery[]
// OrderCustomer OrderCustomer[]
// OrderPayment OrderPayment[]
// OrderShippingAddress OrderShippingAddress[]
}

View File

@@ -31,6 +31,7 @@ import { EventReviewSeed } from './seeds/eventReview';
import { appLogSeed } from './seeds/AppLog';
import { accessLogSeed } from './seeds/AccessLog';
import { userMetaSeed } from './seeds/userMeta';
import { partyOrderItemSeed } from './seeds/partyOrderItem';
//
// import { Blog } from './seeds/blog';
@@ -60,6 +61,8 @@ import { userMetaSeed } from './seeds/userMeta';
await appLogSeed;
await accessLogSeed;
await partyOrderItemSeed;
// await Blog;
// await Mail;
// await File;

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

@@ -0,0 +1,94 @@
import { PrismaClient } from '@prisma/client';
import { _mock } from './_mock';
const prisma = new PrismaClient();
const ITEMS = Array.from({ length: 3 }, (_, index) => ({
id: _mock.id(index),
sku: `16H9UR${index}`,
quantity: index + 1,
name: _mock.productName(index),
coverUrl: _mock.image.product(index),
price: _mock.number.price(index),
}));
async function partyOrderItem() {
await prisma.partyOrderItem.deleteMany({});
for (let index = 1; index < 20 + 1; index++) {
const shipping = 10;
const discount = 10;
const taxes = 10;
const items = (index % 2 && ITEMS.slice(0, 1)) || (index % 3 && ITEMS.slice(1, 3)) || ITEMS;
const totalQuantity = items.reduce((accumulator, item) => accumulator + item.quantity, 0);
const subtotal = items.reduce((accumulator, item) => accumulator + item.price * item.quantity, 0);
const totalAmount = subtotal - shipping - discount + taxes;
const customer = {
id: _mock.id(index),
name: _mock.fullName(index),
email: _mock.email(index),
avatarUrl: _mock.image.avatar(index),
ipAddress: '192.158.1.38',
};
const delivery = { shipBy: 'DHL', speedy: 'Standard', trackingNumber: 'SPX037739199373' };
const history = {
orderTime: _mock.time(1),
paymentTime: _mock.time(2),
deliveryTime: _mock.time(3),
completionTime: _mock.time(4),
timeline: [
{ title: 'Delivery successful', time: _mock.time(1) },
{ title: 'Transporting to [2]', time: _mock.time(2) },
{ title: 'Transporting to [1]', time: _mock.time(3) },
{ title: 'The shipping unit has picked up the goods', time: _mock.time(4) },
{ title: 'Order has been created', time: _mock.time(5) },
],
};
const temp = await prisma.partyOrderItem.upsert({
where: { id: index.toString() },
update: {},
create: {
id: _mock.id(index),
orderNumber: `#601${index}`,
taxes,
items,
history,
subtotal: items.reduce((accumulator, item) => accumulator + item.price * item.quantity, 0),
shipping,
discount,
customer,
delivery,
totalAmount,
totalQuantity,
shippingAddress: {
fullAddress: '19034 Verna Unions Apt. 164 - Honolulu, RI / 87535',
phoneNumber: '365-374-4961',
},
payment: {
//
cardType: 'mastercard',
cardNumber: '4111 1111 1111 1111',
},
status: (index % 2 && 'completed') || (index % 3 && 'pending') || (index % 4 && 'cancelled') || 'refunded',
},
});
}
console.log('seed partyOrderItemSeed done');
}
const partyOrderItemSeed = partyOrderItem()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
export { partyOrderItemSeed };

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,14 @@
Hi,
i copied from
`03_source/cms_backend/src/app/api/party-event`
to
`03_source/cms_backend/src/app/api/party-order`
with knowledge in `schema.prisma` file, and take a look into the sibling files in the same directory.
i want you to update `03_source/cms_backend/src/app/api/party-order` content to handle `party-order` (the purchase order of the party)
`party-order.service.ts` is already prepared and you can use it
please maintain same format and level of detail when you edit.
thanks.

View File

@@ -0,0 +1,35 @@
<!-- AI: please maintain same format and level of detail when edit this file -->
# GUIDELINE
- Party Event API endpoint for managing party events
- Handles CRUD operations for party events
- Follows single file for single db table/collection pattern
## `route.ts`
Handles HTTP methods:
- `GET` - Retrieve party events
- `POST` - Create new party event
- `PUT` - Update existing party event
- `DELETE` - Remove party event
## `test.http`
Contains test requests for:
- Listing all party events
- Creating new party event
- Updating existing party event
- Deleting party event
## `../../services/party-event.service.ts`
Party Event CRUD operations:
`listPartyEvents` - List all party events with optional filters
`getPartyEvent` - Get single party event by ID
`createNewPartyEvent` - Create new party event record
`updatePartyEvent` - Update existing party event by ID
`deletePartyEvent` - Delete party event by ID

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,35 @@
<!-- AI: please maintain same format and level of detail when edit this file -->
# GUIDELINE
- Party Order API endpoint for managing party orders
- Handles CRUD operations for party orders
- Follows single file for single db table/collection pattern
## `route.ts`
Handles HTTP methods:
- `GET` - Retrieve party orders
- `POST` - Create new party order
- `PUT` - Update existing party order
- `DELETE` - Remove party order
## `test.http`
Contains test requests for:
- Listing all party orders
- Creating new party order
- Updating existing party order
- Deleting party order
## `../../services/party-order.service.ts`
Party Order CRUD operations:
`listPartyOrders` - List all party orders with optional filters
`getPartyOrder` - Get single party order by ID
`createNewPartyOrder` - Create new party order record
`updatePartyOrder` - Update existing party order by ID
`deletePartyOrder` - Delete party order by ID

View File

@@ -0,0 +1,40 @@
// src/app/api/party-order/create/route.ts
//
// PURPOSE:
// Create new party order 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 { createOrder } from 'src/app/services/party-order.service';
// ----------------------------------------------------------------------
/** **************************************
* POST - Create PartyOrder
*************************************** */
export async function POST(req: NextRequest) {
const { partyOrderData } = await req.json();
try {
if (isDev) {
console.log({ partyOrderData });
}
const created = await createOrder(partyOrderData);
if (isDev) {
console.log('Order created successfully');
}
return response(created, STATUS.OK);
} catch (error) {
console.error('Error creating order:', { partyOrderData });
return handleError('PartyOrder - Create', error);
}
}

View File

@@ -0,0 +1,31 @@
###
POST http://localhost:7272/api/party-order/create
Content-Type: application/json
{
"partyOrderData": {
"taxes": 15.00,
"status": "pending",
"shipping": 10.00,
"discount": 20.00,
"subtotal": 290.00,
"orderNumber": "ORD_20231115001",
"totalAmount": 300.00,
"totalQuantity": 2.0,
"history": "{\"actions\":[{\"timestamp\":\"2023-11-15T10:00:00Z\",\"action\":\"order_created\"}]}",
"payment": "{\"method\":\"credit_card\",\"status\":\"unpaid\",\"transaction_id\":\"txn_123456\"}",
"customer": "{\"name\":\"John Doe\",\"email\":\"john.doe@example.com\",\"phone\":\"+1234567890\"}",
"delivery": "{\"method\":\"courier\",\"estimated_delivery\":\"2023-11-18\"}",
"items": [
{
"id": "ticket_001",
"name": "General Admission",
"quantity": 2,
"price": 150.00
}
],
"shippingAddress": "{\"street\":\"123 Main St\",\"city\":\"New York\",\"state\":\"NY\",\"zip\":\"10001\",\"country\":\"USA\"}"
}
}

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
// src/app/api/party-order/details/route.ts
//
// PURPOSE:
// Get party order from db by id
//
// RULES:
// Do not modify the format and level of details thanks.
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 { getOrder } from 'src/app/services/party-order.service';
import { createAppLog } from 'src/app/services/app-log.service';
import { flattenNextjsRequest } from '../../auth/sign-in/flattenNextjsRequest';
// ----------------------------------------------------------------------
/**
**************************************
* GET PartyOrder detail
***************************************
*/
export async function GET(req: NextRequest) {
const debug = { 'req.headers': flattenNextjsRequest(req) };
try {
const { searchParams } = req.nextUrl;
// RULES: partyOrderId must exist
const partyOrderId = searchParams.get('partyOrderId');
if (!partyOrderId) {
return response({ message: 'Order ID is required!' }, STATUS.BAD_REQUEST);
}
// NOTE: partyOrderId confirmed exist, run below
const order = await getOrder(partyOrderId);
if (!order) {
return response({ message: 'Order not found!' }, STATUS.NOT_FOUND);
}
logger('[PartyOrder] details', order.id);
createAppLog(L_INFO, 'Get order detail OK', debug);
return response({ order }, STATUS.OK);
} catch (error) {
createAppLog(L_ERROR, 'order detail error', debug);
return handleError('PartyOrder - Get details', error);
}
}

View File

@@ -0,0 +1,9 @@
###
# Get details for a specific party order
GET http://localhost:7272/api/party-order/details?partyOrderId=e99f09a7-dd88-49d5-b1c8-1daf80c2d7b02
###
# Alternative format with different ID
GET http://localhost:7272/api/party-order/details?id=ord_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-order' }, STATUS.OK);
} catch (error) {
return handleError('Helloworld - Get all', error);
}
}

View File

@@ -0,0 +1,2 @@
###
GET http://localhost:7272/api/party-order/helloworld

View File

@@ -0,0 +1,38 @@
// src/app/api/party-order/list/route.ts
//
// PURPOSE:
// List all party orders 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 { listPartyOrders } from 'src/app/services/party-order.service';
import { flattenNextjsRequest } from '../../auth/sign-in/flattenNextjsRequest';
// ----------------------------------------------------------------------
/** **************************************
* GET - PartyOrders list
*************************************** */
export async function GET(req: NextRequest) {
const debug = { 'req.headers': flattenNextjsRequest(req) };
try {
const orders = await listPartyOrders();
createAppLog(L_INFO, 'party-order list ok', {});
return response({ orders }, STATUS.OK);
} catch (error) {
createAppLog(L_ERROR, 'party-order list error', debug);
return handleError('PartyOrder - Get list', error);
}
}

View File

@@ -0,0 +1,19 @@
###
# Basic list all orders
GET http://localhost:7272/api/party-order/list
###
# List orders by status
GET http://localhost:7272/api/party-order/list?status=completed
###
# List orders by user
GET http://localhost:7272/api/party-order/list?userId=usr_987654321
###
# List orders by payment status
GET http://localhost:7272/api/party-order/list?paymentStatus=paid

View File

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

View File

@@ -0,0 +1,35 @@
import type { NextRequest } from 'next/server';
import { logger } from 'src/utils/logger';
import { STATUS, response, handleError } from 'src/utils/response';
import type { IOrderItem } 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: IOrderItem[] = [];
// 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,51 @@
// src/app/api/party-order/update/route.ts
//
// PURPOSE:
// Update party order in db by id
//
// RULES:
// T.B.A.
import type { NextRequest } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { updateOrder } from 'src/app/services/party-order.service';
// ----------------------------------------------------------------------
/** **************************************
* PUT - Update PartyOrder
*************************************** */
export async function PUT(req: NextRequest) {
const { orderData } = await req.json();
const { id } = orderData;
if (!id) return response({ message: 'id not found' }, STATUS.ERROR);
try {
const result = await updateOrder(id, orderData);
return response({ result }, STATUS.OK);
} catch (error) {
console.error('Error updating order:', { orderData });
return handleError('PartyOrder - Update', error);
}
}
export type IOrderItem = {
id: string;
eventId: string;
userId: string;
status: string;
paymentStatus: string;
totalAmount: number;
items: {
id: string;
name: string;
quantity: number;
price: number;
}[];
createdAt: Date;
updatedAt: Date;
};

View File

@@ -0,0 +1,51 @@
###
PUT http://localhost:7272/api/party-order/update
Content-Type: application/json
{
"orderData": {
"id":"e99f09a7-dd88-49d5-b1c8-1daf80c2d7b02",
"taxes": 10.50,
"status": "completed",
"shipping": 5.00,
"discount": 20.00,
"subtotal": 290.00,
"orderNumber": "ORD-2023-001",
"totalAmount": 300.00,
"totalQuantity": 2,
"history": {
"payment": "2023-01-01",
"status_changes": [
"pending",
"paid"
]
},
"payment": {
"method": "credit_card",
"card_last4": "4321"
},
"customer": {
"name": "John Doe",
"email": "john@example.com"
},
"delivery": {
"method": "express",
"tracking_number": "TRK123456"
},
"items": [
{
"id": "ticket_001",
"name": "General Admission",
"quantity": 2,
"price": 150.00,
"status": "used"
}
],
"shippingAddress": {
"street": "123 Main St",
"city": "New York",
"zip": "10001"
}
}
}

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: 'product' }, STATUS.OK);
} catch (error) {
return handleError('Helloworld - Get all', error);
}
}

View File

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

View File

@@ -1,30 +0,0 @@
// src/app/api/product/image/upload/route.ts
//
// PURPOSE:
// handle upload product image
//
// RULES:
// T.B.A.
import type { NextRequest } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
// import prisma from '../../../lib/prisma';
// ----------------------------------------------------------------------
/** **************************************
* GET - Products
*************************************** */
export async function POST(req: NextRequest) {
try {
const { data } = await req.json();
console.log('helloworld');
return response({ hello: 'world' }, STATUS.OK);
} catch (error) {
console.log({ hello: 'world' });
return handleError('Product - store product image', error);
}
}

View File

@@ -1,7 +1,13 @@
with knowledge in schema.prisma file,
please refer the below helloworld example `helloworld.service.ts`
and create `user.service.ts` to cover user record
Hi,
thanks
i copied from
`03_source/cms_backend/src/app/services/party-event.service.ts`
to
`03_source/cms_backend/src/app/services/party-order.service.ts`
`/home/logic/_wsl_workspace/001_github_ws/HKSingleParty-ws/HKSingleParty/03_source/cms_backend/src/app/services/helloworld.service.ts`
with knowledge in `schema.prisma` file, and reference to the sibling files in same folder
i want you to update `party-order.service.ts` content to handle party order (the purchase order of the party)
please use the model `PartyOrderItem` to handle it.
thanks.

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

@@ -0,0 +1,66 @@
// src/app/services/party-order.service.ts
//
// PURPOSE:
// - Service for handling PartyOrderItem Record
//
import type { PartyOrderItem } from '@prisma/client';
import prisma from '../lib/prisma';
type CreateOrder = {
status: string;
taxes: number;
shipping: number;
discount: number;
items: any[];
customer: any;
payment: any;
delivery: any;
shippingAddress: any;
billingAddress: any;
};
type UpdateOrder = {
status?: string;
taxes?: number;
shipping?: number;
discount?: number;
items?: any[];
customer?: any;
payment?: any;
delivery?: any;
shippingAddress?: any;
billingAddress?: any;
};
async function listPartyOrders(): Promise<PartyOrderItem[]> {
return prisma.partyOrderItem.findMany();
}
async function getPartyOrder(partyOrderId: string): Promise<PartyOrderItem | null> {
return prisma.partyOrderItem.findUnique({
where: { id: partyOrderId },
});
}
async function createOrder(orderData: any) {
return prisma.partyOrderItem.create({
data: orderData,
});
}
async function updateOrder(orderId: string, updateData: UpdateOrder) {
return prisma.partyOrderItem.update({
where: { id: orderId },
data: updateData,
});
}
async function deleteOrder(orderId: string) {
return prisma.partyOrderItem.delete({
where: { id: orderId },
});
}
export { getPartyOrder as getOrder, createOrder, updateOrder, deleteOrder, listPartyOrders, type CreateOrder, type UpdateOrder };

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

@@ -17,15 +17,13 @@ export function ProductList({ products, loading, sx, ...other }: Props) {
const renderLoading = () => <ProductItemSkeleton />;
const renderList = () =>
products.map((product) => {
return (
<ProductItem
key={product.id}
product={product}
detailsHref={paths.product.details(product.id)}
/>
);
});
products.map((product) => (
<ProductItem
key={product.id}
product={product}
detailsHref={paths.product.details(product.id)}
/>
));
return (
<>

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

@@ -0,0 +1 @@
T.B.A.

View File

@@ -1,6 +1,6 @@
```markdown
# Greetings
```markdown
Hi,
Imagine you are a software engineer and i will send you the guideline.

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.
@@ -20,3 +20,6 @@ A: using command like `find src/db/UserMetas -name "*.tsx.draft" -type f -ls` to
Q: when user want to modify `.tsx.draft` file, do i need to take care the `.tsx` file as well?
A: No, no don't need to, user will handle the remaining modifications. please restrict your modification in the mentioned file or directory only.
Q: when user want you to replace something, where should you start ?
A: you should look for a `helloworld` example and start with it when available. by doing this, you can get familiar to the user coding style and convention.

Some files were not shown because too many files have changed in this diff Show More