Compare commits
26 Commits
a686dd55dd
...
44091e0432
Author | SHA1 | Date | |
---|---|---|---|
![]() |
44091e0432 | ||
![]() |
279496ea38 | ||
![]() |
a450747670 | ||
![]() |
215476cfaa | ||
![]() |
c93b31b2f6 | ||
![]() |
4cf93f431e | ||
![]() |
2b09261f0a | ||
![]() |
1325a361dc | ||
![]() |
a4d0d8b746 | ||
![]() |
f8919b8c84 | ||
![]() |
583e31fd4d | ||
![]() |
ae7f005236 | ||
![]() |
834f9360ba | ||
![]() |
1cb018d4d5 | ||
![]() |
448825545e | ||
![]() |
7c7a532381 | ||
![]() |
eb515dbe68 | ||
![]() |
7a793be610 | ||
![]() |
1d89134ea2 | ||
![]() |
44d324b40e | ||
![]() |
ee2a377bf6 | ||
![]() |
0941ab6dd1 | ||
![]() |
e93c5dcf62 | ||
![]() |
17aaf97722 | ||
![]() |
47660be0cd | ||
![]() |
cf5cfb8d63 |
21
01_Requirements/REQ0188/index.md
Normal file
21
01_Requirements/REQ0188/index.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
tags: frontend, party-user
|
||||||
|
---
|
||||||
|
|
||||||
|
# REQ0188 frontend party-user
|
||||||
|
|
||||||
|
frontend page to handle party-user (CRUD)
|
||||||
|
|
||||||
|
edit page T.B.A.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
## sources
|
||||||
|
|
||||||
|
T.B.A.
|
||||||
|
|
||||||
|
## branch
|
||||||
|
|
||||||
|
develop/requirements/REQ0188
|
||||||
|
develop/frontend/party-user/trunk
|
||||||
|
develop/frontend/party-user-auth/trunk
|
@@ -6,6 +6,7 @@ RUN npm install -g pnpm
|
|||||||
|
|
||||||
RUN apt-get update -y
|
RUN apt-get update -y
|
||||||
RUN apt-get install -y openssl
|
RUN apt-get install -y openssl
|
||||||
|
RUN apt-get install -qqy psmisc
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
@@ -16,11 +16,11 @@ model Helloworld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
type String
|
type String
|
||||||
provider String
|
provider String
|
||||||
providerAccountId String @map("provider_account_id")
|
providerAccountId String @map("provider_account_id")
|
||||||
refresh_token String?
|
refresh_token String?
|
||||||
access_token String?
|
access_token String?
|
||||||
expires_at Int?
|
expires_at Int?
|
||||||
@@ -30,18 +30,21 @@ model Account {
|
|||||||
session_state String?
|
session_state String?
|
||||||
oauth_token_secret String?
|
oauth_token_secret String?
|
||||||
oauth_token String?
|
oauth_token String?
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
PartyUser PartyUser? @relation(fields: [partyUserId], references: [id])
|
||||||
|
partyUserId String?
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
@@unique([provider, providerAccountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sessionToken String @unique @map("session_token")
|
sessionToken String @unique @map("session_token")
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
expires DateTime
|
expires DateTime
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
PartyUser PartyUser? @relation(fields: [partyUserId], references: [id])
|
||||||
|
partyUserId String?
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -1257,3 +1260,36 @@ model PartyOrderItem {
|
|||||||
// OrderPayment OrderPayment[]
|
// OrderPayment OrderPayment[]
|
||||||
// OrderShippingAddress OrderShippingAddress[]
|
// OrderShippingAddress OrderShippingAddress[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PartyUser {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
//
|
||||||
|
username String? @unique
|
||||||
|
password String?
|
||||||
|
//
|
||||||
|
name String?
|
||||||
|
email String @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
avatarUrl String?
|
||||||
|
bucketImage String?
|
||||||
|
admin Boolean @default(false)
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
info Json?
|
||||||
|
phoneNumber String @default("")
|
||||||
|
company String @default("")
|
||||||
|
status String @default("pending")
|
||||||
|
role String @default("")
|
||||||
|
isVerified Boolean @default(false)
|
||||||
|
//
|
||||||
|
country String @default("")
|
||||||
|
state String @default("")
|
||||||
|
city String @default("")
|
||||||
|
address String @default("")
|
||||||
|
zipCode String @default("")
|
||||||
|
//
|
||||||
|
rank String @default("user")
|
||||||
|
sex String @default("")
|
||||||
|
}
|
||||||
|
@@ -31,7 +31,9 @@ import { EventReviewSeed } from './seeds/eventReview';
|
|||||||
import { appLogSeed } from './seeds/AppLog';
|
import { appLogSeed } from './seeds/AppLog';
|
||||||
import { accessLogSeed } from './seeds/AccessLog';
|
import { accessLogSeed } from './seeds/AccessLog';
|
||||||
import { userMetaSeed } from './seeds/userMeta';
|
import { userMetaSeed } from './seeds/userMeta';
|
||||||
|
//
|
||||||
import { partyOrderItemSeed } from './seeds/partyOrderItem';
|
import { partyOrderItemSeed } from './seeds/partyOrderItem';
|
||||||
|
import { partyUserSeed } from './seeds/partyUser';
|
||||||
|
|
||||||
//
|
//
|
||||||
// import { Blog } from './seeds/blog';
|
// import { Blog } from './seeds/blog';
|
||||||
@@ -60,8 +62,9 @@ import { partyOrderItemSeed } from './seeds/partyOrderItem';
|
|||||||
//
|
//
|
||||||
await appLogSeed;
|
await appLogSeed;
|
||||||
await accessLogSeed;
|
await accessLogSeed;
|
||||||
|
//
|
||||||
await partyOrderItemSeed;
|
await partyOrderItemSeed;
|
||||||
|
await partyUserSeed;
|
||||||
|
|
||||||
// await Blog;
|
// await Blog;
|
||||||
// await Mail;
|
// await Mail;
|
||||||
|
135
03_source/cms_backend/prisma/seeds/partyUser.ts
Normal file
135
03_source/cms_backend/prisma/seeds/partyUser.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Party User seed data generator
|
||||||
|
* Creates initial user accounts for development and testing
|
||||||
|
* Includes:
|
||||||
|
* - Fixed demo accounts (alice, demo)
|
||||||
|
* - Randomly generated test accounts with CJK locale data
|
||||||
|
*/
|
||||||
|
import { faker as enFaker } from '@faker-js/faker/locale/en_US';
|
||||||
|
import { faker as zhFaker } from '@faker-js/faker/locale/zh_CN';
|
||||||
|
import { faker as jaFaker } from '@faker-js/faker/locale/ja';
|
||||||
|
import { faker as koFaker } from '@faker-js/faker/locale/ko';
|
||||||
|
import { faker as twFaker } from '@faker-js/faker/locale/zh_TW';
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const ROLE = [
|
||||||
|
`CEO`,
|
||||||
|
`CTO`,
|
||||||
|
`Project Coordinator`,
|
||||||
|
`Team Leader`,
|
||||||
|
`Software Developer`,
|
||||||
|
`Marketing Strategist`,
|
||||||
|
`Data Analyst`,
|
||||||
|
`Product Owner`,
|
||||||
|
`Graphic Designer`,
|
||||||
|
`Operations Manager`,
|
||||||
|
`Customer Support Specialist`,
|
||||||
|
`Sales Manager`,
|
||||||
|
`HR Recruiter`,
|
||||||
|
`Business Consultant`,
|
||||||
|
`Financial Planner`,
|
||||||
|
`Network Engineer`,
|
||||||
|
`Content Creator`,
|
||||||
|
`Quality Assurance Tester`,
|
||||||
|
`Public Relations Officer`,
|
||||||
|
`IT Administrator`,
|
||||||
|
`Compliance Officer`,
|
||||||
|
`Event Planner`,
|
||||||
|
`Legal Counsel`,
|
||||||
|
`Training Coordinator`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS = ['active', 'pending', 'banned'];
|
||||||
|
|
||||||
|
async function partyUser() {
|
||||||
|
const alice = await prisma.partyUser.upsert({
|
||||||
|
where: { email: 'alice@prisma.io' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'alice@prisma.io',
|
||||||
|
name: 'Alice',
|
||||||
|
username: 'pualice',
|
||||||
|
password: 'Aa12345678',
|
||||||
|
emailVerified: new Date(),
|
||||||
|
phoneNumber: '+85291234567',
|
||||||
|
company: 'helloworld company',
|
||||||
|
status: STATUS[0],
|
||||||
|
role: ROLE[0],
|
||||||
|
isVerified: true,
|
||||||
|
sex: 'F',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.partyUser.upsert({
|
||||||
|
where: { email: 'demo@minimals.cc' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'demo@minimals.cc',
|
||||||
|
password: '@2Minimal',
|
||||||
|
//
|
||||||
|
username: 'pudemo',
|
||||||
|
name: 'Demo',
|
||||||
|
emailVerified: new Date(),
|
||||||
|
phoneNumber: '+85291234568',
|
||||||
|
company: 'helloworld company',
|
||||||
|
status: STATUS[1],
|
||||||
|
role: ROLE[1],
|
||||||
|
isVerified: true,
|
||||||
|
avatarUrl: 'https://images.unsplash.com/photo-1619970096024-c7b438a3b82a',
|
||||||
|
rank: 'user',
|
||||||
|
sex: 'M',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const CJK_LOCALES = {
|
||||||
|
en: enFaker,
|
||||||
|
zh: zhFaker,
|
||||||
|
ja: jaFaker,
|
||||||
|
ko: koFaker,
|
||||||
|
tw: twFaker,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRandomCJKFaker() {
|
||||||
|
const locales = Object.keys(CJK_LOCALES);
|
||||||
|
const randomKey = locales[Math.floor(Math.random() * locales.length)] as keyof typeof CJK_LOCALES;
|
||||||
|
return CJK_LOCALES[randomKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomFaker = getRandomCJKFaker();
|
||||||
|
|
||||||
|
await prisma.partyUser.upsert({
|
||||||
|
where: { email: `party_user${i}@prisma.io` },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: `party_user${i}@prisma.io`,
|
||||||
|
name: `Party Dummy ${i}`,
|
||||||
|
username: `pu${i.toString()}`,
|
||||||
|
password: 'Aa12345678',
|
||||||
|
emailVerified: new Date(),
|
||||||
|
phoneNumber: `+8529123456${i.toString()}`,
|
||||||
|
company: randomFaker.company.name(),
|
||||||
|
role: ROLE[Math.floor(Math.random() * ROLE.length)],
|
||||||
|
status: STATUS[Math.floor(Math.random() * STATUS.length)],
|
||||||
|
isVerified: true,
|
||||||
|
sex: i % 2 ? 'F' : 'M',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('seed partyUser done');
|
||||||
|
}
|
||||||
|
|
||||||
|
const partyUserSeed = partyUser()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
})
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error(e);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { partyUserSeed };
|
@@ -7,9 +7,13 @@ clear
|
|||||||
while true; do
|
while true; do
|
||||||
yarn db:studio &
|
yarn db:studio &
|
||||||
|
|
||||||
npx nodemon --ext ts,tsx,prisma --exec "yarn db:push && yarn seed && yarn dev"
|
npx nodemon --ext ts,tsx,prisma --exec "yarn dev"
|
||||||
|
# npx nodemon --ext ts,tsx,prisma --exec "yarn db:push && yarn seed && yarn dev"
|
||||||
# yarn dev
|
# yarn dev
|
||||||
|
|
||||||
|
killall node
|
||||||
|
killall yarn
|
||||||
|
|
||||||
echo "restarting..."
|
echo "restarting..."
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
@@ -4,8 +4,6 @@ yarn --dev
|
|||||||
|
|
||||||
clear
|
clear
|
||||||
|
|
||||||
while true; do
|
yarn db:push && yarn seed
|
||||||
npx nodemon --ext prisma --exec "yarn db:push && yarn seed"
|
|
||||||
echo "restarting..."
|
echo "done"
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
|
killall node
|
||||||
|
killall yarn
|
||||||
rm -rf ./**/*Zone.Identifier
|
rm -rf ./**/*Zone.Identifier
|
||||||
|
|
||||||
# yarn db:push
|
# yarn db:push
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
###
|
###
|
||||||
|
|
||||||
# username and password ok
|
# username and password ok
|
||||||
|
|
||||||
POST http://localhost:7272/api/auth/sign-in
|
POST http://localhost:7272/api/auth/sign-in
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
|
|
||||||
@@ -9,7 +11,9 @@ content-type: application/json
|
|||||||
}
|
}
|
||||||
|
|
||||||
###
|
###
|
||||||
|
|
||||||
# There is no user corresponding to the email address.
|
# There is no user corresponding to the email address.
|
||||||
|
|
||||||
POST http://localhost:7272/api/auth/sign-in
|
POST http://localhost:7272/api/auth/sign-in
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
|
|
||||||
@@ -19,7 +23,9 @@ content-type: application/json
|
|||||||
}
|
}
|
||||||
|
|
||||||
###
|
###
|
||||||
|
|
||||||
# Wrong password
|
# Wrong password
|
||||||
|
|
||||||
POST http://localhost:7272/api/auth/sign-in
|
POST http://localhost:7272/api/auth/sign-in
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
|
|
||||||
|
@@ -0,0 +1,58 @@
|
|||||||
|
// src/app/api/product/createEvent/route.ts
|
||||||
|
//
|
||||||
|
// PURPOSE:
|
||||||
|
// create product to db
|
||||||
|
//
|
||||||
|
// RULES:
|
||||||
|
// T.B.A.
|
||||||
|
//
|
||||||
|
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
|
import { getEventItemById } from 'src/app/services/eventItem.service';
|
||||||
|
import { getPartyUserByEmail } from 'src/app/services/party-user.service';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
***************************************
|
||||||
|
* POST - Events
|
||||||
|
***************************************
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
// logger('[Event] list', events.length);
|
||||||
|
const { data } = await req.json();
|
||||||
|
const { eventItemId, email } = data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventItem = await getEventItemById(eventItemId);
|
||||||
|
const partyUser = await getPartyUserByEmail(email);
|
||||||
|
|
||||||
|
if (partyUser) {
|
||||||
|
if (eventItem && eventItem?.joinMembers) {
|
||||||
|
const foundJoined = _.find(eventItem.joinMembers, { email });
|
||||||
|
|
||||||
|
if (foundJoined) {
|
||||||
|
console.log('user joined event already, skipping');
|
||||||
|
} else {
|
||||||
|
const { sex } = partyUser;
|
||||||
|
eventItem.joinMembers.push({ email, sex });
|
||||||
|
|
||||||
|
await prisma?.eventItem.update({
|
||||||
|
where: { id: eventItem.id },
|
||||||
|
data: { joinMembers: JSON.parse(JSON.stringify(eventItem.joinMembers)) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response({ result: 'joined' }, STATUS.OK);
|
||||||
|
} catch (error) {
|
||||||
|
console.log({ hello: 'world', data });
|
||||||
|
return handleError('Event - Create', error);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,13 @@
|
|||||||
|
###
|
||||||
|
|
||||||
|
# username and password ok
|
||||||
|
|
||||||
|
POST http://localhost:7272/api/event/partyUserJoinEvent
|
||||||
|
content-type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"eventItemId": "e99f09a7-dd88-49d5-b1c8-1daf80c2d7b01",
|
||||||
|
"email": "alice@prisma.io"
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,68 @@
|
|||||||
|
import type { User } from '@prisma/client';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
import { verify } from 'src/utils/jwt';
|
||||||
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
|
import { JWT_SECRET } from 'src/_mock/_auth';
|
||||||
|
import { getUserById } from 'src/app/services/user.service';
|
||||||
|
import { createAccessLog } from 'src/app/services/access-log.service';
|
||||||
|
|
||||||
|
import { flattenNextjsRequest } from '../sign-in/flattenNextjsRequest';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// export const runtime = 'edge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This API is used for demo purpose only
|
||||||
|
* You should use a real database
|
||||||
|
* You should hash the password before saving to database
|
||||||
|
* You should not save the password in the database
|
||||||
|
* You should not expose the JWT_SECRET in the client side
|
||||||
|
*/
|
||||||
|
|
||||||
|
const USER_TOKEN_CHECK_FAILED = 'user token check failed';
|
||||||
|
const INVALID_AUTH_TOKEN = 'Invalid authorization token';
|
||||||
|
const USER_ID_NOT_FOUND = 'userId not found';
|
||||||
|
const USER_TOKEN_OK = 'user token check ok';
|
||||||
|
const AUTHORIZATION_TOKEN_MISSING_OR_INVALID = 'Authorization token missing or invalid';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const debug = { 'req.headers': flattenNextjsRequest(req) };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headersList = headers();
|
||||||
|
const authorization = headersList.get('authorization');
|
||||||
|
|
||||||
|
if (!authorization || !authorization.startsWith('Bearer ')) {
|
||||||
|
return response({ message: AUTHORIZATION_TOKEN_MISSING_OR_INVALID }, STATUS.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = `${authorization}`.split(' ')[1];
|
||||||
|
const data = await verify(accessToken, JWT_SECRET);
|
||||||
|
console.log(data.userId);
|
||||||
|
|
||||||
|
if (data.userId) {
|
||||||
|
// TODO: remove me
|
||||||
|
// const currentUser = _users.find((user) => user.id === data.userId);
|
||||||
|
const currentUser: User | null = await getUserById(data.userId);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
createAccessLog('', USER_TOKEN_CHECK_FAILED, debug);
|
||||||
|
|
||||||
|
return response({ message: INVALID_AUTH_TOKEN }, STATUS.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
createAccessLog(currentUser.id, USER_TOKEN_OK, debug);
|
||||||
|
|
||||||
|
return response({ user: currentUser }, STATUS.OK);
|
||||||
|
} else {
|
||||||
|
return response({ message: USER_ID_NOT_FOUND }, STATUS.ERROR);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError('[Auth] - Me', error);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,25 @@
|
|||||||
|
###
|
||||||
|
# username and password ok
|
||||||
|
GET http://localhost:7272/api/auth/me
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbWJnbnUyengwMDBjaHEzaGZ3dmtjejlvIiwiaWF0IjoxNzQ4OTY0ODkyLCJleHAiOjE3NTAxNzQ0OTJ9.lo04laCxtm0IVeYaETEV3hXKyDmXPEn7SyWtY2VR4dI
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# There is no user corresponding to the email address.
|
||||||
|
POST http://localhost:7272/api/auth/sign-in
|
||||||
|
content-type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "demo@minimals1.cc",
|
||||||
|
"password": "@2Minimal"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
# Wrong password
|
||||||
|
POST http://localhost:7272/api/auth/sign-in
|
||||||
|
content-type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "demo@minimals.cc",
|
||||||
|
"password": "@2Min111imal"
|
||||||
|
}
|
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens a Next.js request object into a plain object of headers
|
||||||
|
* @param {NextRequest} req - The Next.js request object
|
||||||
|
* @returns {Record<string, string>} An object containing all request headers
|
||||||
|
*/
|
||||||
|
export function flattenNextjsRequest(req: NextRequest) {
|
||||||
|
return Object.fromEntries(req.headers.entries());
|
||||||
|
}
|
@@ -0,0 +1,56 @@
|
|||||||
|
// src/app/api/party-user-auth/sign-in/route1.ts
|
||||||
|
//
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { sign } from 'src/utils/jwt';
|
||||||
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
|
import { JWT_SECRET, JWT_EXPIRES_IN } from 'src/_mock/_auth';
|
||||||
|
import { createAccessLog } from 'src/app/services/access-log.service';
|
||||||
|
|
||||||
|
import prisma from '../../../lib/prisma';
|
||||||
|
import { flattenNextjsRequest } from './flattenNextjsRequest';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This API is used for demo purpose only
|
||||||
|
* You should use a real database
|
||||||
|
* You should hash the password before saving to database
|
||||||
|
* You should not save the password in the database
|
||||||
|
* You should not expose the JWT_SECRET in the client side
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ERR_USER_NOT_FOUND = 'There is no user corresponding to the email address.';
|
||||||
|
const ERR_WRONG_PASSWORD = 'Wrong password';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const debug = { 'req.headers': flattenNextjsRequest(req) };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { email, password } = await req.json();
|
||||||
|
|
||||||
|
const currentUser = await prisma.partyUser.findFirst({ where: { email } });
|
||||||
|
if (!currentUser) {
|
||||||
|
await createAccessLog('', `user tried login with email ${email}`, { debug });
|
||||||
|
return response({ message: ERR_USER_NOT_FOUND }, STATUS.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser?.password !== password) {
|
||||||
|
await createAccessLog(currentUser.id, 'user logged with wrong password', { debug });
|
||||||
|
return response({ message: ERR_WRONG_PASSWORD }, STATUS.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await sign({ userId: currentUser?.id }, JWT_SECRET, {
|
||||||
|
expiresIn: JWT_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAccessLog(currentUser.id, 'access granted', { debug });
|
||||||
|
|
||||||
|
return response({ user: currentUser, accessToken }, STATUS.OK);
|
||||||
|
} catch (error) {
|
||||||
|
await createAccessLog('', 'attempted login but failed', { debug, error });
|
||||||
|
|
||||||
|
return handleError('Auth - Sign in', error);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,37 @@
|
|||||||
|
# REQ0188 frontend party-user
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
# username and password ok
|
||||||
|
|
||||||
|
POST http://localhost:7272/api/party-user-auth/sign-in
|
||||||
|
content-type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "demo@minimals.cc",
|
||||||
|
"password": "@2Minimal"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
# There is no user corresponding to the email address.
|
||||||
|
|
||||||
|
POST http://localhost:7272/api/party-user-auth/sign-in
|
||||||
|
content-type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "demo@minimals1.cc",
|
||||||
|
"password": "@2Minimal"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
# Wrong password
|
||||||
|
|
||||||
|
POST http://localhost:7272/api/party-user-auth/sign-in
|
||||||
|
content-type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "demo@minimals.cc",
|
||||||
|
"password": "@2Min111imal"
|
||||||
|
}
|
@@ -0,0 +1,58 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { sign } from 'src/utils/jwt';
|
||||||
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
|
import { _users, JWT_SECRET, JWT_EXPIRES_IN } from 'src/_mock/_auth';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This API is used for demo purpose only
|
||||||
|
* You should use a real database
|
||||||
|
* You should hash the password before saving to database
|
||||||
|
* You should not save the password in the database
|
||||||
|
* You should not expose the JWT_SECRET in the client side
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email, password, firstName, lastName } = await req.json();
|
||||||
|
|
||||||
|
const userExists = _users.find((user) => user.email === email);
|
||||||
|
|
||||||
|
if (userExists) {
|
||||||
|
return response({ message: 'There already exists an account with the given email address.' }, STATUS.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: _users[0].id,
|
||||||
|
displayName: `${firstName} ${lastName}`,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
photoURL: '',
|
||||||
|
phoneNumber: '',
|
||||||
|
country: '',
|
||||||
|
address: '',
|
||||||
|
state: '',
|
||||||
|
city: '',
|
||||||
|
zipCode: '',
|
||||||
|
about: '',
|
||||||
|
role: 'user',
|
||||||
|
isPublic: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = await sign({ userId: newUser.id }, JWT_SECRET, {
|
||||||
|
expiresIn: JWT_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push new user to database
|
||||||
|
_users.push(newUser);
|
||||||
|
|
||||||
|
return response({ user: newUser, accessToken }, STATUS.OK);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError('Auth - Sign up', error);
|
||||||
|
}
|
||||||
|
}
|
60
03_source/cms_backend/src/app/api/party-user/_GUIDELINES.md
Normal file
60
03_source/cms_backend/src/app/api/party-user/_GUIDELINES.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<!-- NOTES to AI: please maintain same format and level of detail when edit this file -->
|
||||||
|
|
||||||
|
# GUIDELINE
|
||||||
|
|
||||||
|
- Party-user Management API endpoint for managing party-user accounts
|
||||||
|
- Handles party-user CRUD operations and profile updates
|
||||||
|
- Follows Next.js API route conventions
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### `create/route.ts`
|
||||||
|
|
||||||
|
Creates new party-user account with required details
|
||||||
|
|
||||||
|
### `delete/route.ts`
|
||||||
|
|
||||||
|
Deletes party-user account by ID
|
||||||
|
|
||||||
|
### `details/route.ts`
|
||||||
|
|
||||||
|
Gets detailed party-user information
|
||||||
|
|
||||||
|
### `helloworld/route.ts`
|
||||||
|
|
||||||
|
Simple test endpoint returning "Hello World"
|
||||||
|
|
||||||
|
### `list/route.ts`
|
||||||
|
|
||||||
|
Lists all party-users with pagination support
|
||||||
|
|
||||||
|
### `search/route.ts`
|
||||||
|
|
||||||
|
Searches party-users by name or email
|
||||||
|
|
||||||
|
### `update/route.ts`
|
||||||
|
|
||||||
|
Updates party-user profile information
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Test files are available per endpoint in their respective directories:
|
||||||
|
|
||||||
|
- `create/test.http`
|
||||||
|
- `delete/test.http`
|
||||||
|
- `details/test.http`
|
||||||
|
- `helloworld/test.http`
|
||||||
|
- `list/test.http`
|
||||||
|
- `update/test.http`
|
||||||
|
|
||||||
|
## Related Services
|
||||||
|
|
||||||
|
`../../services/party-user.service.ts` (assumed) would handle:
|
||||||
|
|
||||||
|
`createUser` - Register new party-user account
|
||||||
|
`updateUser` - Update party-user profile
|
||||||
|
`deleteUser` - Remove party-user account
|
||||||
|
`listUsers` - Get paginated party-user list
|
||||||
|
`searchUsers` - Search party-users by criteria
|
||||||
|
`changeUserRole` - Modify party-user permissions
|
||||||
|
`uploadUserImage` - Handle profile picture uploads
|
34
03_source/cms_backend/src/app/api/party-user/create/route.ts
Normal file
34
03_source/cms_backend/src/app/api/party-user/create/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// src/app/api/party-user/create/route.ts
|
||||||
|
//
|
||||||
|
// PURPOSE:
|
||||||
|
// create new party user in db
|
||||||
|
//
|
||||||
|
// RULES:
|
||||||
|
// 1. Validates input data shape
|
||||||
|
// 2. Returns created party user data
|
||||||
|
//
|
||||||
|
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
|
import { createPartyUser } from 'src/app/services/party-user.service';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
***************************************
|
||||||
|
* POST - create PartyUser
|
||||||
|
***************************************
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { partyUserData } = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const partyUser = await createPartyUser(partyUserData);
|
||||||
|
|
||||||
|
return response({ partyUser }, STATUS.OK);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError('PartyUser - Create', error);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,19 @@
|
|||||||
|
###
|
||||||
|
|
||||||
|
POST http://localhost:7272/api/party-user/create
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"partyUserData": {
|
||||||
|
"name": "Alice 123321",
|
||||||
|
"username": null,
|
||||||
|
"email": "alice@123111321.io",
|
||||||
|
"emailVerified": "2025-06-15T17:47:23.919Z",
|
||||||
|
"password": "Aa12345678",
|
||||||
|
"bucketImage": null,
|
||||||
|
"admin": false,
|
||||||
|
"info": null,
|
||||||
|
"phoneNumber": "+85291234567",
|
||||||
|
"avatarUrl": ""
|
||||||
|
}
|
||||||
|
}
|
35
03_source/cms_backend/src/app/api/party-user/delete/route.ts
Normal file
35
03_source/cms_backend/src/app/api/party-user/delete/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// src/app/api/party-user/delete/route.ts
|
||||||
|
//
|
||||||
|
// PURPOSE:
|
||||||
|
// delete party user from db by id
|
||||||
|
//
|
||||||
|
// RULES:
|
||||||
|
// 1. Requires valid party user ID
|
||||||
|
// 2. Returns deleted party user ID
|
||||||
|
// 3. Handles errors appropriately
|
||||||
|
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
|
import { deletePartyUser } from 'src/app/services/party-user.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
**************************************
|
||||||
|
* PATCH - Delete party user
|
||||||
|
***************************************
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { partyUserId } = await req.json();
|
||||||
|
|
||||||
|
if (!partyUserId) throw new Error('partyUserId cannot be null');
|
||||||
|
|
||||||
|
await deletePartyUser(partyUserId);
|
||||||
|
|
||||||
|
return response({ partyUserId }, STATUS.OK);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError('PartyUser - Delete', error);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,8 @@
|
|||||||
|
###
|
||||||
|
|
||||||
|
PATCH http://localhost:7272/api/party-user/delete
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"partyUserId": "cmbxz8t2b000oiigxib3o4jla"
|
||||||
|
}
|
@@ -0,0 +1,43 @@
|
|||||||
|
// src/app/api/party-user/details/route.ts
|
||||||
|
//
|
||||||
|
// PURPOSE:
|
||||||
|
// get party user details from db by id
|
||||||
|
//
|
||||||
|
// RULES:
|
||||||
|
// 1. Requires valid partyUserId parameter
|
||||||
|
// 2. Returns party user details if found
|
||||||
|
// 3. Handles not found case appropriately
|
||||||
|
// 4. Logs the operation
|
||||||
|
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { logger } from 'src/utils/logger';
|
||||||
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
|
import { getPartyUser } from 'src/app/services/party-user.service';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* GET PartyUser detail
|
||||||
|
*************************************** */
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = req.nextUrl;
|
||||||
|
|
||||||
|
// RULES: userId must exist
|
||||||
|
const partyUserId = searchParams.get('partyUserId');
|
||||||
|
if (!partyUserId) return response({ message: 'partyUserId is required!' }, STATUS.BAD_REQUEST);
|
||||||
|
|
||||||
|
// NOTE: userId confirmed exist, run below
|
||||||
|
const partyUser = await getPartyUser(partyUserId);
|
||||||
|
|
||||||
|
if (!partyUser) return response({ message: 'User not found!' }, STATUS.NOT_FOUND);
|
||||||
|
|
||||||
|
logger('[User] details', partyUser.id);
|
||||||
|
|
||||||
|
return response({ partyUser }, STATUS.OK);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError('PartyUser - Get details', error);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,4 @@
|
|||||||
|
###
|
||||||
|
|
||||||
|
GET http://localhost:7272/api/party-user/details?partyUserId=cmbxziat6000b715hmhrnwlx2
|
||||||
|
|
@@ -0,0 +1,11 @@
|
|||||||
|
import type { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, res: NextResponse) {
|
||||||
|
try {
|
||||||
|
return response({ helloworld: 'party-user' }, STATUS.OK);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError('GET - helloworld', error);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,3 @@
|
|||||||
|
###
|
||||||
|
|
||||||
|
GET http://localhost:7272/api/party-user/helloworld
|
33
03_source/cms_backend/src/app/api/party-user/list/route.ts
Normal file
33
03_source/cms_backend/src/app/api/party-user/list/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// src/app/api/party-user/update/route.ts
|
||||||
|
//
|
||||||
|
// PURPOSE:
|
||||||
|
// update existing party user in db
|
||||||
|
//
|
||||||
|
// RULES:
|
||||||
|
// 1. Requires valid party user ID
|
||||||
|
// 2. Validates input data shape
|
||||||
|
//
|
||||||
|
|
||||||
|
import { logger } from 'src/utils/logger';
|
||||||
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
|
import { listPartyUsers } from 'src/app/services/party-user.service';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
***************************************
|
||||||
|
* GET - Products
|
||||||
|
***************************************
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const partyUsers = await listPartyUsers();
|
||||||
|
|
||||||
|
logger('[User] list', partyUsers.length);
|
||||||
|
|
||||||
|
return response({ partyUsers }, STATUS.OK);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError('Product - Get list', error);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,2 @@
|
|||||||
|
###
|
||||||
|
GET http://localhost:7272/api/party-user/list
|
35
03_source/cms_backend/src/app/api/party-user/search/route.ts
Normal file
35
03_source/cms_backend/src/app/api/party-user/search/route.ts
Normal 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 { _products } from 'src/_mock/_product';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* GET - Search products
|
||||||
|
*************************************** */
|
||||||
|
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 products = _products();
|
||||||
|
|
||||||
|
// Accept search by name or sku
|
||||||
|
const results = products.filter(({ name, sku }) => name.toLowerCase().includes(query) || sku?.toLowerCase().includes(query));
|
||||||
|
|
||||||
|
logger('[Product] search-results', results.length);
|
||||||
|
|
||||||
|
return response({ results }, STATUS.OK);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError('Product - Get search', error);
|
||||||
|
}
|
||||||
|
}
|
88
03_source/cms_backend/src/app/api/party-user/update/route.ts
Normal file
88
03_source/cms_backend/src/app/api/party-user/update/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// src/app/api/party-user/update/route.ts
|
||||||
|
//
|
||||||
|
// PURPOSE:
|
||||||
|
// update existing party user in db
|
||||||
|
//
|
||||||
|
// RULES:
|
||||||
|
// 1. Requires valid party user ID
|
||||||
|
// 2. Validates input data shape
|
||||||
|
// 3. Returns updated party user data
|
||||||
|
// 4. Handles errors appropriately
|
||||||
|
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
|
import { updatePartyUser } from 'src/app/services/party-user.service';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* PUT - Update PartyUser
|
||||||
|
*************************************** */
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
// logger('[Product] list', products.length);
|
||||||
|
const { partyUserData } = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updatePartyUser(partyUserData.id, partyUserData);
|
||||||
|
return response({ partyUserData }, STATUS.OK);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError('PartyUser - Update', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IProductItem = {
|
||||||
|
id: string;
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
price: number;
|
||||||
|
taxes: number;
|
||||||
|
tags: string[];
|
||||||
|
sizes: string[];
|
||||||
|
publish: string;
|
||||||
|
gender: string[];
|
||||||
|
coverUrl: string;
|
||||||
|
images: string[];
|
||||||
|
colors: string[];
|
||||||
|
quantity: number;
|
||||||
|
category: string;
|
||||||
|
available: number;
|
||||||
|
totalSold: number;
|
||||||
|
description: string;
|
||||||
|
totalRatings: number;
|
||||||
|
totalReviews: number;
|
||||||
|
// createdAt: IDateValue;
|
||||||
|
inventoryType: string;
|
||||||
|
subDescription: string;
|
||||||
|
priceSale: number | null;
|
||||||
|
// reviews: IProductReview[];
|
||||||
|
newLabel: {
|
||||||
|
content: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
saleLabel: {
|
||||||
|
content: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
ratings: {
|
||||||
|
name: string;
|
||||||
|
starCount: number;
|
||||||
|
reviewCount: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IDateValue = string | number | null;
|
||||||
|
|
||||||
|
export type IProductReview = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
helpful: number;
|
||||||
|
avatarUrl: string;
|
||||||
|
postedAt: IDateValue;
|
||||||
|
isPurchased: boolean;
|
||||||
|
attachments?: string[];
|
||||||
|
};
|
@@ -0,0 +1,22 @@
|
|||||||
|
###
|
||||||
|
|
||||||
|
PUT http://localhost:7272/api/party-user/update
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"partyUserData": {
|
||||||
|
"id": "cmc0cedkx000boln3viy77598",
|
||||||
|
"createdAt": "2025-06-15T17:47:24.547Z",
|
||||||
|
"updatedAt": "2025-06-15T17:47:24.547Z",
|
||||||
|
"name": "Alice 123321",
|
||||||
|
"username": null,
|
||||||
|
"email": "alice@prisma.io",
|
||||||
|
"emailVerified": "2025-06-15T17:47:23.919Z",
|
||||||
|
"password": "Aa12345678",
|
||||||
|
"image": null,
|
||||||
|
"bucketImage": null,
|
||||||
|
"admin": false,
|
||||||
|
"info": null,
|
||||||
|
"phoneNumber": "+85291234567"
|
||||||
|
}
|
||||||
|
}
|
@@ -13,10 +13,7 @@ import type { NextRequest } from 'next/server';
|
|||||||
import { STATUS, response, handleError } from 'src/utils/response';
|
import { STATUS, response, handleError } from 'src/utils/response';
|
||||||
|
|
||||||
import { isDev } from 'src/constants';
|
import { isDev } from 'src/constants';
|
||||||
|
|
||||||
import prisma from '../../../lib/prisma';
|
|
||||||
import { createProduct } from 'src/app/services/product.service';
|
import { createProduct } from 'src/app/services/product.service';
|
||||||
// import { createProduct } from 'src/app/services/product.service';
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
81
03_source/cms_backend/src/app/api/user/_GUIDELINES.md
Normal file
81
03_source/cms_backend/src/app/api/user/_GUIDELINES.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<!-- AI: please maintain same format and level of detail when edit this file -->
|
||||||
|
|
||||||
|
# GUIDELINE
|
||||||
|
|
||||||
|
- User Management API endpoint for managing user accounts
|
||||||
|
- Handles user CRUD operations, role management and profile updates
|
||||||
|
- Follows Next.js API route conventions
|
||||||
|
|
||||||
|
## `route.ts`
|
||||||
|
|
||||||
|
Main user route handler with HTTP methods:
|
||||||
|
|
||||||
|
- `GET` - Get current user details
|
||||||
|
- `POST` - Create new user account
|
||||||
|
- `PUT` - Update user profile
|
||||||
|
- `DELETE` - Delete user account
|
||||||
|
|
||||||
|
## Sub-routes
|
||||||
|
|
||||||
|
### `changeToAdmin/route.ts`
|
||||||
|
|
||||||
|
Changes user role to admin
|
||||||
|
|
||||||
|
### `changeToUser/route.ts`
|
||||||
|
|
||||||
|
Changes user role to regular user
|
||||||
|
|
||||||
|
### `checkAdmin/route.ts`
|
||||||
|
|
||||||
|
Verifies if user has admin privileges
|
||||||
|
|
||||||
|
### `createUser/route.ts`
|
||||||
|
|
||||||
|
Creates new user account with required details
|
||||||
|
|
||||||
|
### `deleteUser/route.ts`
|
||||||
|
|
||||||
|
Deletes user account by ID
|
||||||
|
|
||||||
|
### `details/route.ts`
|
||||||
|
|
||||||
|
Gets detailed user information
|
||||||
|
|
||||||
|
### `list/route.ts`
|
||||||
|
|
||||||
|
Lists all users with pagination support
|
||||||
|
|
||||||
|
### `saveUser/route.ts`
|
||||||
|
|
||||||
|
Saves user profile changes
|
||||||
|
|
||||||
|
### `search/route.ts`
|
||||||
|
|
||||||
|
Searches users by name or email
|
||||||
|
|
||||||
|
### `image/upload/route.ts`
|
||||||
|
|
||||||
|
Handles user profile picture uploads
|
||||||
|
|
||||||
|
## `test.http`
|
||||||
|
|
||||||
|
Contains test requests for:
|
||||||
|
|
||||||
|
- User authentication
|
||||||
|
- Role changes (admin/user)
|
||||||
|
- Profile updates
|
||||||
|
- Account creation/deletion
|
||||||
|
- User listing/searching
|
||||||
|
- Image uploads
|
||||||
|
|
||||||
|
## Related Services
|
||||||
|
|
||||||
|
`../../services/user.service.ts` (assumed) would handle:
|
||||||
|
|
||||||
|
`createUser` - Register new user account
|
||||||
|
`updateUser` - Update user profile
|
||||||
|
`deleteUser` - Remove user account
|
||||||
|
`listUsers` - Get paginated user list
|
||||||
|
`searchUsers` - Search users by criteria
|
||||||
|
`changeUserRole` - Modify user permissions
|
||||||
|
`uploadUserImage` - Handle profile picture uploads
|
@@ -1,13 +1,13 @@
|
|||||||
Hi,
|
Hi,
|
||||||
|
|
||||||
i copied from
|
i copied from
|
||||||
`03_source/cms_backend/src/app/services/party-event.service.ts`
|
`03_source/cms_backend/src/app/services/user.service.ts`
|
||||||
to
|
to
|
||||||
`03_source/cms_backend/src/app/services/party-order.service.ts`
|
`03_source/cms_backend/src/app/services/party-user.service.ts`
|
||||||
|
|
||||||
with knowledge in `schema.prisma` file, and reference to the sibling files in same folder
|
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)
|
i want you to update `party-user.service.ts` content to handle `party user` (the user joining party)
|
||||||
please use the model `PartyOrderItem` to handle it.
|
please use the model `PartyUser` to handle it.
|
||||||
|
|
||||||
thanks.
|
thanks.
|
||||||
|
@@ -47,6 +47,10 @@ async function getEvent(eventId: string): Promise<EventItem | null> {
|
|||||||
return prisma.eventItem.findFirst({ where: { id: eventId } });
|
return prisma.eventItem.findFirst({ where: { id: eventId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getEventItemById(eventId: string): Promise<EventItem | null> {
|
||||||
|
return prisma.eventItem.findFirst({ where: { id: eventId } });
|
||||||
|
}
|
||||||
|
|
||||||
// async function createNewEvent(createForm: CreateEvent) {
|
// async function createNewEvent(createForm: CreateEvent) {
|
||||||
// return prisma.event.create({ data: createForm });
|
// return prisma.event.create({ data: createForm });
|
||||||
// }
|
// }
|
||||||
@@ -68,4 +72,5 @@ export {
|
|||||||
// updateEvent,
|
// updateEvent,
|
||||||
// deleteEvent,
|
// deleteEvent,
|
||||||
// createNewEvent,
|
// createNewEvent,
|
||||||
|
getEventItemById,
|
||||||
};
|
};
|
||||||
|
84
03_source/cms_backend/src/app/services/party-user.service.ts
Normal file
84
03_source/cms_backend/src/app/services/party-user.service.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// src/app/services/user.service.ts
|
||||||
|
//
|
||||||
|
// PURPOSE:
|
||||||
|
// - Handle User Record CRUD operations
|
||||||
|
//
|
||||||
|
// RULES:
|
||||||
|
// - Follow Prisma best practices for database operations
|
||||||
|
// - Validate input data before processing
|
||||||
|
//
|
||||||
|
|
||||||
|
import type { User, PartyUser } from '@prisma/client';
|
||||||
|
|
||||||
|
import prisma from '../lib/prisma';
|
||||||
|
|
||||||
|
type CreateUser = {
|
||||||
|
email: string;
|
||||||
|
// name?: string;
|
||||||
|
// password: string;
|
||||||
|
// role?: Role;
|
||||||
|
// isEmailVerified?: boolean;
|
||||||
|
// admin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateUser = {
|
||||||
|
email?: string;
|
||||||
|
// name?: string;
|
||||||
|
// password?: string;
|
||||||
|
// role?: Role;
|
||||||
|
// isEmailVerified?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function listPartyUsers(): Promise<PartyUser[]> {
|
||||||
|
return prisma.partyUser.findMany({});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPartyUser(partyUserId: string): Promise<PartyUser | null> {
|
||||||
|
return prisma.partyUser.findUnique({
|
||||||
|
where: { id: partyUserId },
|
||||||
|
// include: { reviews: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPartyUserByEmail(email: string): Promise<PartyUser | null> {
|
||||||
|
return prisma.partyUser.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserById(id: string): Promise<User | null> {
|
||||||
|
return prisma.user.findFirst({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPartyUser(partyUserData: any): Promise<PartyUser> {
|
||||||
|
return prisma.partyUser.create({ data: partyUserData });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePartyUser(partyUserId: string, partyUserData: any): Promise<PartyUser | null> {
|
||||||
|
return prisma.partyUser.update({
|
||||||
|
where: { id: partyUserId },
|
||||||
|
data: partyUserData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePartyUser(partyUserId: string): Promise<PartyUser | null> {
|
||||||
|
return prisma.partyUser.delete({
|
||||||
|
where: { id: partyUserId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getUserById,
|
||||||
|
getPartyUser,
|
||||||
|
listPartyUsers,
|
||||||
|
createPartyUser,
|
||||||
|
updatePartyUser,
|
||||||
|
deletePartyUser,
|
||||||
|
//
|
||||||
|
getPartyUserByEmail,
|
||||||
|
//
|
||||||
|
type CreateUser,
|
||||||
|
type UpdateUser,
|
||||||
|
//
|
||||||
|
};
|
@@ -6,6 +6,7 @@ set -ex
|
|||||||
DOCKER_COMPOSE_FILES=" -f docker-compose.yml -f docker-compose.dev.yml"
|
DOCKER_COMPOSE_FILES=" -f docker-compose.yml -f docker-compose.dev.yml"
|
||||||
|
|
||||||
# docker compose $DOCKER_COMPOSE_FILES build
|
# docker compose $DOCKER_COMPOSE_FILES build
|
||||||
|
# docker compose $DOCKER_COMPOSE_FILES push
|
||||||
docker compose $DOCKER_COMPOSE_FILES up -d
|
docker compose $DOCKER_COMPOSE_FILES up -d
|
||||||
|
|
||||||
# cd ../api_server
|
# cd ../api_server
|
||||||
|
40
03_source/frontend/.env.example
Normal file
40
03_source/frontend/.env.example
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Server url
|
||||||
|
# Local server configuration at: https://docs.minimals.cc/mock-server/
|
||||||
|
VITE_SERVER_URL=http://localhost:7272
|
||||||
|
# VITE_SERVER_URL=https://api-dev-minimal-v630.pages.dev
|
||||||
|
|
||||||
|
# Public resource directory
|
||||||
|
VITE_ASSETS_DIR=
|
||||||
|
|
||||||
|
# Mapbox
|
||||||
|
VITE_MAPBOX_API_KEY=
|
||||||
|
|
||||||
|
# Firebase
|
||||||
|
VITE_FIREBASE_API_KEY=
|
||||||
|
VITE_FIREBASE_AUTH_DOMAIN=
|
||||||
|
VITE_FIREBASE_PROJECT_ID=
|
||||||
|
VITE_FIREBASE_STORAGE_BUCKET=
|
||||||
|
VITE_FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
VITE_FIREBASE_APPID=
|
||||||
|
|
||||||
|
# Aws
|
||||||
|
VITE_AWS_AMPLIFY_USER_POOL_ID=
|
||||||
|
VITE_AWS_AMPLIFY_USER_POOL_WEB_CLIENT_ID=
|
||||||
|
VITE_AWS_AMPLIFY_REGION=
|
||||||
|
|
||||||
|
# Auth0
|
||||||
|
VITE_AUTH0_DOMAIN=
|
||||||
|
VITE_AUTH0_CLIENT_ID=
|
||||||
|
VITE_AUTH0_CALLBACK_URL=
|
||||||
|
|
||||||
|
# Supabase
|
||||||
|
VITE_SUPABASE_URL=https://prhsilyjzxbkufchywxt.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InByaHNpbHlqenhia3VmY2h5d3h0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzY2NjM5MTEsImV4cCI6MjA1MjIzOTkxMX0.UBvxEhKEMtsRuDWBSDTglfnupKf9fyPI9IvQBxS5F6U
|
||||||
|
|
||||||
|
# Google Calendar
|
||||||
|
GOOGLE_CLIENT_ID=463956183839-i7j5nt5rkpbm4npukg21vfnhav5vvgeh.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=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
|
||||||
|
GOOGLE_CALENDAR_API_KEY=AIzaSyDh4-SOuKTopXe45oDM9nQA7R4cNJ1So0c
|
||||||
|
|
||||||
|
VITE_GOOGLE_CLIENT_ID=463956183839-i7j5nt5rkpbm4npukg21vfnhav5vvgeh.apps.googleusercontent.com
|
||||||
|
VITE_GOOGLE_CALENDAR_API_KEY=AIzaSyDh4-SOuKTopXe45oDM9nQA7R4cNJ1So0c
|
@@ -3,6 +3,8 @@ FROM node:20-slim
|
|||||||
|
|
||||||
# Install pnpm globally
|
# Install pnpm globally
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
|
RUN apt-get update
|
||||||
|
RUN apt-get install -qqy psmisc
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
@@ -10,6 +10,9 @@ while true; do
|
|||||||
|
|
||||||
yarn dev --force --clearScreen
|
yarn dev --force --clearScreen
|
||||||
|
|
||||||
|
killall node
|
||||||
|
killall yarn
|
||||||
|
|
||||||
echo "restarting..."
|
echo "restarting..."
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
|
killall node
|
||||||
|
killall yarn
|
||||||
rm -rf ./**/*Zone.Identifier
|
rm -rf ./**/*Zone.Identifier
|
||||||
|
|
||||||
set -ex
|
set -ex
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
// src/actions/product.ts
|
// src/actions/party-event.ts
|
||||||
//
|
//
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import axiosInstance, { endpoints, fetcher } from 'src/lib/axios';
|
import axiosInstance, { endpoints, fetcher } from 'src/lib/axios';
|
||||||
@@ -74,6 +74,7 @@ type SearchResultsData = {
|
|||||||
results: IPartyEventItem[];
|
results: IPartyEventItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: update useSearchPartyEvents
|
||||||
export function useSearchProducts(query: string) {
|
export function useSearchProducts(query: string) {
|
||||||
const url = query ? [endpoints.product.search, { params: { query } }] : '';
|
const url = query ? [endpoints.product.search, { params: { query } }] : '';
|
||||||
|
|
||||||
@@ -135,7 +136,6 @@ export async function updatePartyEvent(partyEventData: Partial<IPartyEventItem>)
|
|||||||
/**
|
/**
|
||||||
* Work in local
|
* Work in local
|
||||||
*/
|
*/
|
||||||
|
|
||||||
mutate(
|
mutate(
|
||||||
endpoints.partyEvent.list,
|
endpoints.partyEvent.list,
|
||||||
(currentData: any) => {
|
(currentData: any) => {
|
||||||
@@ -163,7 +163,6 @@ export async function deletePartyEvent(partyEventId: string) {
|
|||||||
/**
|
/**
|
||||||
* Work in local
|
* Work in local
|
||||||
*/
|
*/
|
||||||
|
|
||||||
mutate(
|
mutate(
|
||||||
endpoints.partyEvent.list,
|
endpoints.partyEvent.list,
|
||||||
(currentData: any) => {
|
(currentData: any) => {
|
||||||
|
289
03_source/frontend/src/actions/party-user.ts
Normal file
289
03_source/frontend/src/actions/party-user.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// src/actions/party-user1.ts
|
||||||
|
//
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import axiosInstance, { endpoints, fetcher } from 'src/lib/axios';
|
||||||
|
import type { IPartyUserItem } from 'src/types/party-user';
|
||||||
|
import type { IProductItem } from 'src/types/product';
|
||||||
|
import type { SWRConfiguration } from 'swr';
|
||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const swrOptions: SWRConfiguration = {
|
||||||
|
revalidateIfStale: false,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type PartyUsersData = {
|
||||||
|
partyUsers: IPartyUserItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches list of party users with SWR caching
|
||||||
|
* @returns {Object} Contains:
|
||||||
|
* - partyUsers: Array of party user items
|
||||||
|
* - partyUsersLoading: Loading state
|
||||||
|
* - partyUsersError: Error object if any
|
||||||
|
* - partyUsersValidating: Validation state
|
||||||
|
* - partyUsersEmpty: Boolean if no users found
|
||||||
|
*/
|
||||||
|
export function useGetPartyUsers() {
|
||||||
|
const url = endpoints.partyUser.list;
|
||||||
|
|
||||||
|
const { data, isLoading, error, isValidating } = useSWR<PartyUsersData>(url, fetcher, swrOptions);
|
||||||
|
|
||||||
|
const memoizedValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
partyUsers: data?.partyUsers || [],
|
||||||
|
partyUsersLoading: isLoading,
|
||||||
|
partyUsersError: error,
|
||||||
|
partyUsersValidating: isValidating,
|
||||||
|
partyUsersEmpty: !isLoading && !isValidating && !data?.partyUsers.length,
|
||||||
|
}),
|
||||||
|
[data?.partyUsers, error, isLoading, isValidating]
|
||||||
|
);
|
||||||
|
|
||||||
|
return memoizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type PartyUserData = {
|
||||||
|
partyUser: IPartyUserItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useGetPartyUser(partyUserId: string) {
|
||||||
|
const { data, isLoading, error, isValidating } = useSWR<PartyUserData>(
|
||||||
|
endpoints.partyUser.detailsByPartyUserId(partyUserId),
|
||||||
|
fetcher,
|
||||||
|
swrOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
const memoizedValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
partyUser: data?.partyUser,
|
||||||
|
partyUserLoading: isLoading,
|
||||||
|
partyUserError: error,
|
||||||
|
partyUserValidating: isValidating,
|
||||||
|
}),
|
||||||
|
[data?.partyUser, error, isLoading, isValidating]
|
||||||
|
);
|
||||||
|
|
||||||
|
return memoizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type SearchResultsData = {
|
||||||
|
results: IProductItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches products by query with SWR caching
|
||||||
|
* @param {string} query - Search term
|
||||||
|
* @returns {Object} Contains:
|
||||||
|
* - searchResults: Array of matching products
|
||||||
|
* - searchLoading: Loading state
|
||||||
|
* - searchError: Error object if any
|
||||||
|
* - searchValidating: Validation state
|
||||||
|
* - searchEmpty: Boolean if no results found
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type SaveUserData = {
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
role: string;
|
||||||
|
email: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
address: string;
|
||||||
|
country: string;
|
||||||
|
zipCode: string;
|
||||||
|
company: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
isVerified: boolean;
|
||||||
|
//
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new party user with optimistic UI updates
|
||||||
|
* @param {CreateUserData} partyUserData - Data for the new party user
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @sideeffects
|
||||||
|
* - Makes POST request to create user on server
|
||||||
|
* - Updates local SWR cache optimistically
|
||||||
|
* - Triggers revalidation of party user list
|
||||||
|
*/
|
||||||
|
export async function createPartyUser(partyUserData: CreateUserData) {
|
||||||
|
/**
|
||||||
|
* Work on server
|
||||||
|
*/
|
||||||
|
const data = { partyUserData };
|
||||||
|
const {
|
||||||
|
data: { id },
|
||||||
|
} = await axiosInstance.post(endpoints.partyUser.create, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work in local
|
||||||
|
*/
|
||||||
|
mutate(
|
||||||
|
endpoints.partyUser.list,
|
||||||
|
(currentData: any) => {
|
||||||
|
const currentPartyUsers: IPartyUserItem[] = currentData?.partyUsers;
|
||||||
|
|
||||||
|
const partyUsers = [...currentPartyUsers, { ...partyUserData, id }];
|
||||||
|
|
||||||
|
return { ...currentData, partyUsers };
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates party user data with optimistic UI updates
|
||||||
|
* @param {Partial<IPartyUserItem>} partyUserData - Partial user data containing at least the ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @sideeffects
|
||||||
|
* - Makes PUT request to update user on server
|
||||||
|
* - Updates both list and detail views in local SWR cache
|
||||||
|
* - Preserves unchanged fields while updating modified ones
|
||||||
|
*/
|
||||||
|
export async function updatePartyUser(partyUserData: Partial<IPartyUserItem>) {
|
||||||
|
/**
|
||||||
|
* Work on server
|
||||||
|
*/
|
||||||
|
const data = { partyUserData };
|
||||||
|
await axiosInstance.put(endpoints.partyUser.update, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work in local
|
||||||
|
*/
|
||||||
|
mutate(
|
||||||
|
endpoints.partyUser.list,
|
||||||
|
(currentData: any) => {
|
||||||
|
const currentPartyUsers: IPartyUserItem[] = currentData?.partyUsers;
|
||||||
|
|
||||||
|
const partyUsers = currentPartyUsers.map((partyUser) =>
|
||||||
|
partyUser.id === partyUserData.id ? { ...partyUser, ...partyUserData } : partyUser
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...currentData, partyUsers };
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const partyUserId: string = partyUserData.id || '';
|
||||||
|
mutate(
|
||||||
|
endpoints.partyUser.detailsByPartyUserId(partyUserId),
|
||||||
|
(currentData: any) => {
|
||||||
|
const currentPartyUser: IPartyUserItem = currentData?.partyUser;
|
||||||
|
|
||||||
|
console.log({ currentPartyUser });
|
||||||
|
const partyUser = partyUserData;
|
||||||
|
|
||||||
|
return { ...currentData, partyUser };
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests connection to product API endpoint
|
||||||
|
* @param {SaveUserData} saveUserData - User data object (currently unused)
|
||||||
|
* @returns {Promise<AxiosResponse>} Response from test endpoint
|
||||||
|
* @deprecated This function should be renamed to better reflect its purpose
|
||||||
|
* TODO: Rename to testProductApiConnection() since it tests product API connection
|
||||||
|
* TODO: Or implement actual image upload functionality if needed
|
||||||
|
*/
|
||||||
|
export async function uploadUserImage(saveUserData: SaveUserData) {
|
||||||
|
console.log('uploadUserImage ?');
|
||||||
|
// const url = userId ? [endpoints.user.details, { params: { userId } }] : '';
|
||||||
|
|
||||||
|
const res = await axiosInstance.get('http://localhost:7272/api/product/helloworld');
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type CreateUserData = {
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
role: string;
|
||||||
|
email: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
address: string;
|
||||||
|
country: string;
|
||||||
|
zipCode: string;
|
||||||
|
company: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
isVerified: boolean;
|
||||||
|
//
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a party user with optimistic UI updates
|
||||||
|
* @param {string} partyUserId - ID of user to delete
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @sideeffects
|
||||||
|
* - Makes PATCH request to mark user as deleted on server
|
||||||
|
* - Removes user from local SWR cache
|
||||||
|
* - Triggers revalidation of party user list
|
||||||
|
*/
|
||||||
|
export async function deletePartyUser(partyUserId: string) {
|
||||||
|
/**
|
||||||
|
* Work on server
|
||||||
|
*/
|
||||||
|
const data = { partyUserId };
|
||||||
|
await axiosInstance.patch(endpoints.partyUser.delete, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work in local
|
||||||
|
*/
|
||||||
|
mutate(
|
||||||
|
endpoints.partyUser.list,
|
||||||
|
(currentData: any) => {
|
||||||
|
const currentPartyUsers: IPartyUserItem[] = currentData?.partyUsers;
|
||||||
|
|
||||||
|
const partyUsers = currentPartyUsers.filter((partyUser) => partyUser.id !== partyUserId);
|
||||||
|
|
||||||
|
return { ...currentData, partyUsers };
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
@@ -111,6 +111,19 @@ export const navData: NavSectionProps['data'] = [
|
|||||||
{ title: 'Details', path: paths.dashboard.partyOrder.demo.details },
|
{ title: 'Details', path: paths.dashboard.partyOrder.demo.details },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'party-user',
|
||||||
|
path: paths.dashboard.partyUser.root,
|
||||||
|
icon: ICONS.user,
|
||||||
|
children: [
|
||||||
|
{ title: 'Profile', path: paths.dashboard.partyUser.root },
|
||||||
|
{ title: 'Cards', path: paths.dashboard.partyUser.cards },
|
||||||
|
{ title: 'List', path: paths.dashboard.partyUser.list },
|
||||||
|
{ title: 'Create', path: paths.dashboard.partyUser.new },
|
||||||
|
{ title: 'Edit', path: paths.dashboard.partyUser.demo.edit },
|
||||||
|
{ title: 'Account', path: paths.dashboard.partyUser.account },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Product',
|
title: 'Product',
|
||||||
path: paths.dashboard.product.root,
|
path: paths.dashboard.product.root,
|
||||||
|
@@ -106,4 +106,15 @@ export const endpoints = {
|
|||||||
changeStatus: (partyOrderId: string) =>
|
changeStatus: (partyOrderId: string) =>
|
||||||
`/api/party-order/changeStatus?partyOrderId=${partyOrderId}`,
|
`/api/party-order/changeStatus?partyOrderId=${partyOrderId}`,
|
||||||
},
|
},
|
||||||
|
partyUser: {
|
||||||
|
list: '/api/party-user/list',
|
||||||
|
details: '/api/party-user/details',
|
||||||
|
search: '/api/party-user/search',
|
||||||
|
create: '/api/party-user/create',
|
||||||
|
update: '/api/party-user/update',
|
||||||
|
delete: '/api/party-user/delete',
|
||||||
|
//
|
||||||
|
detailsByPartyUserId: (partyUserId: string) =>
|
||||||
|
`/api/party-user/details?partyUserId=${partyUserId}`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@@ -22,7 +22,7 @@ i18next
|
|||||||
.init({
|
.init({
|
||||||
...i18nOptions(lng),
|
...i18nOptions(lng),
|
||||||
detection: { caches: ['localStorage'] },
|
detection: { caches: ['localStorage'] },
|
||||||
debug: isDev,
|
debug: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
@@ -0,0 +1,16 @@
|
|||||||
|
import { CONFIG } from 'src/global-config';
|
||||||
|
import { AccountBillingView } from 'src/sections/account/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metadata = { title: `Account billing settings | Dashboard - ${CONFIG.appName}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{metadata.title}</title>
|
||||||
|
|
||||||
|
<AccountBillingView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,16 @@
|
|||||||
|
import { CONFIG } from 'src/global-config';
|
||||||
|
import { AccountChangePasswordView } from 'src/sections/account/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metadata = { title: `Account change password settings | Dashboard - ${CONFIG.appName}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{metadata.title}</title>
|
||||||
|
|
||||||
|
<AccountChangePasswordView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,16 @@
|
|||||||
|
import { CONFIG } from 'src/global-config';
|
||||||
|
import { AccountGeneralView } from 'src/sections/account/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metadata = { title: `Account general settings | Dashboard - ${CONFIG.appName}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{metadata.title}</title>
|
||||||
|
|
||||||
|
<AccountGeneralView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,18 @@
|
|||||||
|
import { CONFIG } from 'src/global-config';
|
||||||
|
import { AccountNotificationsView } from 'src/sections/account/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
title: `Account notifications settings | Dashboard - ${CONFIG.appName}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{metadata.title}</title>
|
||||||
|
|
||||||
|
<AccountNotificationsView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,16 @@
|
|||||||
|
import { CONFIG } from 'src/global-config';
|
||||||
|
import { AccountSocialsView } from 'src/sections/account/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metadata = { title: `Account socials settings | Dashboard - ${CONFIG.appName}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{metadata.title}</title>
|
||||||
|
|
||||||
|
<AccountSocialsView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
16
03_source/frontend/src/pages/dashboard/party-user/cards.tsx
Normal file
16
03_source/frontend/src/pages/dashboard/party-user/cards.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { CONFIG } from 'src/global-config';
|
||||||
|
import { UserCardsView } from 'src/sections/user/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metadata = { title: `User cards | Dashboard - ${CONFIG.appName}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{metadata.title}</title>
|
||||||
|
|
||||||
|
<UserCardsView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
25
03_source/frontend/src/pages/dashboard/party-user/edit.tsx
Normal file
25
03_source/frontend/src/pages/dashboard/party-user/edit.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// import { _userList } from 'src/_mock/_user';
|
||||||
|
import { useGetPartyUser } from 'src/actions/party-user';
|
||||||
|
import { CONFIG } from 'src/global-config';
|
||||||
|
import { useParams } from 'src/routes/hooks';
|
||||||
|
import { PartyUserEditView } from 'src/sections/party-user/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metadata = { title: `User edit | Dashboard - ${CONFIG.appName}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { id = '' } = useParams();
|
||||||
|
|
||||||
|
// TODO: remove unused code
|
||||||
|
// const currentUser = _userList.find((user) => user.id === id);
|
||||||
|
const { partyUser: user } = useGetPartyUser(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{metadata.title}</title>
|
||||||
|
|
||||||
|
<PartyUserEditView user={user} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
16
03_source/frontend/src/pages/dashboard/party-user/list.tsx
Normal file
16
03_source/frontend/src/pages/dashboard/party-user/list.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { CONFIG } from 'src/global-config';
|
||||||
|
import { PartyUserListView } from 'src/sections/party-user/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metadata = { title: `User list | Dashboard - ${CONFIG.appName}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{metadata.title}</title>
|
||||||
|
|
||||||
|
<PartyUserListView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
16
03_source/frontend/src/pages/dashboard/party-user/new.tsx
Normal file
16
03_source/frontend/src/pages/dashboard/party-user/new.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { CONFIG } from 'src/global-config';
|
||||||
|
import { UserCreateView } from 'src/sections/party-user/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metadata = { title: `Create a new user | Dashboard - ${CONFIG.appName}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{metadata.title}</title>
|
||||||
|
|
||||||
|
<UserCreateView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,16 @@
|
|||||||
|
import { CONFIG } from 'src/global-config';
|
||||||
|
import { UserProfileView } from 'src/sections/user/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const metadata = { title: `User profile | Dashboard - ${CONFIG.appName}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{metadata.title}</title>
|
||||||
|
|
||||||
|
<UserProfileView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -196,5 +196,15 @@ export const paths = {
|
|||||||
details: (id: string) => `${ROOTS.DASHBOARD}/party-order/${id}`,
|
details: (id: string) => `${ROOTS.DASHBOARD}/party-order/${id}`,
|
||||||
demo: { details: `${ROOTS.DASHBOARD}/party-order/${MOCK_ID}` },
|
demo: { details: `${ROOTS.DASHBOARD}/party-order/${MOCK_ID}` },
|
||||||
},
|
},
|
||||||
|
partyUser: {
|
||||||
|
root: `${ROOTS.DASHBOARD}/party-user`,
|
||||||
|
new: `${ROOTS.DASHBOARD}/party-user/new`,
|
||||||
|
list: `${ROOTS.DASHBOARD}/party-user/list`,
|
||||||
|
cards: `${ROOTS.DASHBOARD}/party-user/cards`,
|
||||||
|
profile: `${ROOTS.DASHBOARD}/party-user/profile`,
|
||||||
|
account: `${ROOTS.DASHBOARD}/party-user/account`,
|
||||||
|
edit: (id: string) => `${ROOTS.DASHBOARD}/party-user/${id}/edit`,
|
||||||
|
demo: { edit: `${ROOTS.DASHBOARD}/party-user/${MOCK_ID}/edit` },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@@ -87,6 +87,13 @@ const PartyEventEditPage = lazy(() => import('src/pages/dashboard/party-event/ed
|
|||||||
const PartyOrderListPage = lazy(() => import('src/pages/dashboard/party-order/list'));
|
const PartyOrderListPage = lazy(() => import('src/pages/dashboard/party-order/list'));
|
||||||
const PartyOrderDetailsPage = lazy(() => import('src/pages/dashboard/party-order/details'));
|
const PartyOrderDetailsPage = lazy(() => import('src/pages/dashboard/party-order/details'));
|
||||||
|
|
||||||
|
// PartyUser
|
||||||
|
const PartyUserProfilePage = lazy(() => import('src/pages/dashboard/party-user/profile'));
|
||||||
|
const PartyUserCardsPage = lazy(() => import('src/pages/dashboard/party-user/cards'));
|
||||||
|
const PartyUserListPage = lazy(() => import('src/pages/dashboard/party-user/list'));
|
||||||
|
const PartyUserCreatePage = lazy(() => import('src/pages/dashboard/party-user/new'));
|
||||||
|
const PartyUserEditPage = lazy(() => import('src/pages/dashboard/party-user/edit'));
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
function SuspenseOutlet() {
|
function SuspenseOutlet() {
|
||||||
@@ -229,6 +236,28 @@ export const dashboardRoutes: RouteObject[] = [
|
|||||||
{ path: ':id', element: <PartyOrderDetailsPage /> },
|
{ path: ':id', element: <PartyOrderDetailsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'party-user',
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <PartyUserProfilePage /> },
|
||||||
|
{ path: 'profile', element: <PartyUserProfilePage /> },
|
||||||
|
{ path: 'cards', element: <PartyUserCardsPage /> },
|
||||||
|
{ path: 'list', element: <PartyUserListPage /> },
|
||||||
|
{ path: 'new', element: <PartyUserCreatePage /> },
|
||||||
|
{ path: ':id/edit', element: <PartyUserEditPage /> },
|
||||||
|
{
|
||||||
|
path: 'account',
|
||||||
|
element: accountLayout(),
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <AccountGeneralPage /> },
|
||||||
|
{ path: 'billing', element: <AccountBillingPage /> },
|
||||||
|
{ path: 'notifications', element: <AccountNotificationsPage /> },
|
||||||
|
{ path: 'socials', element: <AccountSocialsPage /> },
|
||||||
|
{ path: 'change-password', element: <AccountChangePasswordPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
// AI: this file store page routeing of app
|
||||||
|
//
|
||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import type { RouteObject } from 'react-router';
|
import type { RouteObject } from 'react-router';
|
||||||
import { SplashScreen } from 'src/components/loading-screen';
|
import { SplashScreen } from 'src/components/loading-screen';
|
||||||
|
@@ -0,0 +1,47 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Pagination from '@mui/material/Pagination';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { IPartyUserCard } from 'src/types/party-user';
|
||||||
|
import { UserCard } from './party-user-card';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
users: IPartyUserCard[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserCardList({ users }: Props) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const rowsPerPage = 12;
|
||||||
|
|
||||||
|
const handleChangePage = useCallback((event: React.ChangeEvent<unknown>, newPage: number) => {
|
||||||
|
setPage(newPage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
gap: 3,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{users
|
||||||
|
.slice((page - 1) * rowsPerPage, (page - 1) * rowsPerPage + rowsPerPage)
|
||||||
|
.map((user) => (
|
||||||
|
<UserCard key={user.id} user={user} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
shape="circular"
|
||||||
|
count={Math.ceil(users.length / rowsPerPage)}
|
||||||
|
onChange={handleChangePage}
|
||||||
|
sx={{ mt: { xs: 5, md: 8 }, mx: 'auto' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
119
03_source/frontend/src/sections/party-user/party-user-card.tsx
Normal file
119
03_source/frontend/src/sections/party-user/party-user-card.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import type { CardProps } from '@mui/material/Card';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import { varAlpha } from 'minimal-shared/utils';
|
||||||
|
import { _socials } from 'src/_mock';
|
||||||
|
import { AvatarShape } from 'src/assets/illustrations';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { Image } from 'src/components/image';
|
||||||
|
import type { IPartyUserCard } from 'src/types/party-user';
|
||||||
|
import { fShortenNumber } from 'src/utils/format-number';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = CardProps & {
|
||||||
|
user: IPartyUserCard;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserCard({ user, sx, ...other }: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={[{ textAlign: 'center' }, ...(Array.isArray(sx) ? sx : [sx])]} {...other}>
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<AvatarShape
|
||||||
|
sx={{
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
mx: 'auto',
|
||||||
|
bottom: -26,
|
||||||
|
position: 'absolute',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar
|
||||||
|
alt={user.name}
|
||||||
|
src={user.avatarUrl}
|
||||||
|
sx={{
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
zIndex: 11,
|
||||||
|
mx: 'auto',
|
||||||
|
bottom: -32,
|
||||||
|
position: 'absolute',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
src={user.coverUrl}
|
||||||
|
alt={user.coverUrl}
|
||||||
|
ratio="16/9"
|
||||||
|
slotProps={{
|
||||||
|
overlay: {
|
||||||
|
sx: (theme) => ({
|
||||||
|
bgcolor: varAlpha(theme.vars.palette.common.blackChannel, 0.48),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ListItemText
|
||||||
|
sx={{ mt: 7, mb: 1 }}
|
||||||
|
primary={user.name}
|
||||||
|
secondary={user.role}
|
||||||
|
slotProps={{
|
||||||
|
primary: { sx: { typography: 'subtitle1' } },
|
||||||
|
secondary: { sx: { mt: 0.5 } },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 2.5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{_socials.map((social) => (
|
||||||
|
<IconButton key={social.label}>
|
||||||
|
{social.value === 'twitter' && <Iconify icon="socials:twitter" />}
|
||||||
|
{social.value === 'facebook' && <Iconify icon="socials:facebook" />}
|
||||||
|
{social.value === 'instagram' && <Iconify icon="socials:instagram" />}
|
||||||
|
{social.value === 'linkedin' && <Iconify icon="socials:linkedin" />}
|
||||||
|
</IconButton>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderStyle: 'dashed' }} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
py: 3,
|
||||||
|
display: 'grid',
|
||||||
|
typography: 'subtitle1',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: 'Follower', value: user.totalFollowers },
|
||||||
|
{ label: 'Following', value: user.totalFollowing },
|
||||||
|
{ label: 'Total post', value: user.totalPosts },
|
||||||
|
].map((stat) => (
|
||||||
|
<Box key={stat.label} sx={{ gap: 0.5, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box component="span" sx={{ typography: 'caption', color: 'text.secondary' }}>
|
||||||
|
{stat.label}
|
||||||
|
</Box>
|
||||||
|
{fShortenNumber(stat.value)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,304 @@
|
|||||||
|
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 FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { isValidPhoneNumber } from 'react-phone-number-input/input';
|
||||||
|
import { createPartyUser, deletePartyUser, updatePartyUser } from 'src/actions/party-user';
|
||||||
|
import { Field, Form, schemaHelper } from 'src/components/hook-form';
|
||||||
|
import { Label } from 'src/components/label';
|
||||||
|
import { toast } from 'src/components/snackbar';
|
||||||
|
import { useRouter } from 'src/routes/hooks';
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
import type { IPartyUserItem } from 'src/types/party-user';
|
||||||
|
import { fileToBase64 } from 'src/utils/file-to-base64';
|
||||||
|
import { fData } from 'src/utils/format-number';
|
||||||
|
import { z as zod } from 'zod';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type NewUserSchemaType = zod.infer<typeof NewUserSchema>;
|
||||||
|
|
||||||
|
export const NewUserSchema = zod.object({
|
||||||
|
name: zod.string().min(1, { message: 'Name is required!' }).optional().or(zod.literal('')),
|
||||||
|
city: zod.string().min(1, { message: 'City is required!' }).optional().or(zod.literal('')),
|
||||||
|
role: zod.string().min(1, { message: 'Role is required!' }),
|
||||||
|
email: zod
|
||||||
|
.string()
|
||||||
|
.min(1, { message: 'Email is required!' })
|
||||||
|
.email({ message: 'Email must be a valid email address!' }),
|
||||||
|
state: zod.string().min(1, { message: 'State is required!' }).optional().or(zod.literal('')),
|
||||||
|
status: zod.string(),
|
||||||
|
address: zod.string().min(1, { message: 'Address is required!' }).optional().or(zod.literal('')),
|
||||||
|
country: schemaHelper
|
||||||
|
.nullableInput(zod.string().min(1, { message: 'Country is required!' }), {
|
||||||
|
// message for null value
|
||||||
|
message: 'Country is required!',
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.or(zod.literal('')),
|
||||||
|
zipCode: zod.string().min(1, { message: 'Zip code is required!' }).optional().or(zod.literal('')),
|
||||||
|
company: zod.string().min(1, { message: 'Company is required!' }).optional().or(zod.literal('')),
|
||||||
|
avatarUrl: zod.string().optional().or(zod.literal('')),
|
||||||
|
phoneNumber: zod.string().optional().or(zod.literal('')),
|
||||||
|
isVerified: zod.boolean().default(true),
|
||||||
|
//
|
||||||
|
username: zod.string().optional().or(zod.literal('')),
|
||||||
|
password: zod.string().optional().or(zod.literal('')),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentUser?: IPartyUserItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PartyUserNewEditForm({ currentUser }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const defaultValues: NewUserSchemaType = {
|
||||||
|
status: '',
|
||||||
|
avatarUrl: '',
|
||||||
|
name: '新用戶名字',
|
||||||
|
email: 'user@123.com',
|
||||||
|
phoneNumber: '',
|
||||||
|
country: '',
|
||||||
|
state: '',
|
||||||
|
city: '',
|
||||||
|
address: '',
|
||||||
|
zipCode: '',
|
||||||
|
company: '',
|
||||||
|
role: 'user',
|
||||||
|
// Email is verified
|
||||||
|
isVerified: true,
|
||||||
|
//
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const methods = useForm<NewUserSchemaType>({
|
||||||
|
mode: 'onSubmit',
|
||||||
|
resolver: zodResolver(NewUserSchema),
|
||||||
|
defaultValues,
|
||||||
|
values: currentUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = methods;
|
||||||
|
|
||||||
|
const values = watch();
|
||||||
|
|
||||||
|
const [disableDeleteUserButton, setDisableDeleteUserButton] = useState<boolean>(false);
|
||||||
|
const handleDeleteUserClick = async () => {
|
||||||
|
setDisableDeleteUserButton(true);
|
||||||
|
try {
|
||||||
|
if (currentUser) {
|
||||||
|
await deletePartyUser(currentUser.id);
|
||||||
|
toast.success(t('party user deleted'));
|
||||||
|
router.push(paths.dashboard.partyUser.list);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisableDeleteUserButton(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (data: any) => {
|
||||||
|
try {
|
||||||
|
const temp: any = data.avatarUrl;
|
||||||
|
if (temp instanceof File) {
|
||||||
|
data.avatarUrl = await fileToBase64(temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedValues: IPartyUserItem = values as unknown as IPartyUserItem;
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
// perform
|
||||||
|
await updatePartyUser(sanitizedValues);
|
||||||
|
} else {
|
||||||
|
// perform create
|
||||||
|
await createPartyUser(sanitizedValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(currentUser ? t('Update success!') : t('Create success!'));
|
||||||
|
|
||||||
|
router.push(paths.dashboard.partyUser.list);
|
||||||
|
|
||||||
|
// console.info('DATA', data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form methods={methods} onSubmit={onSubmit}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<Card sx={{ pt: 10, pb: 5, px: 3 }}>
|
||||||
|
{currentUser && (
|
||||||
|
<Label
|
||||||
|
color={
|
||||||
|
(values.status === 'active' && 'success') ||
|
||||||
|
(values.status === 'banned' && 'error') ||
|
||||||
|
'warning'
|
||||||
|
}
|
||||||
|
sx={{ position: 'absolute', top: 24, right: 24 }}
|
||||||
|
>
|
||||||
|
{values.status}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mb: 5 }}>
|
||||||
|
<Field.UploadAvatar
|
||||||
|
name="avatarUrl"
|
||||||
|
maxSize={3145728}
|
||||||
|
helperText={
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
mt: 3,
|
||||||
|
mx: 'auto',
|
||||||
|
display: 'block',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'text.disabled',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Allowed')} *.jpeg, *.jpg, *.png, *.gif
|
||||||
|
<br /> {t('max size of')} {fData(3145728)}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<FormControlLabel
|
||||||
|
labelPlacement="start"
|
||||||
|
control={
|
||||||
|
<Controller
|
||||||
|
name="status"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
{...field}
|
||||||
|
checked={field.value !== 'active'}
|
||||||
|
onChange={(event) =>
|
||||||
|
field.onChange(event.target.checked ? 'banned' : 'active')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
|
{t('Banned')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('Apply disable account')}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
mx: 0,
|
||||||
|
mb: 3,
|
||||||
|
width: 1,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field.Switch
|
||||||
|
name="isVerified"
|
||||||
|
labelPlacement="start"
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
|
{t('Email verified')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('Disabling this will automatically send the user a verification email')}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
sx={{ mx: 0, width: 1, justifyContent: 'space-between' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<Stack sx={{ mt: 3, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Button
|
||||||
|
disabled={disableDeleteUserButton}
|
||||||
|
loading={disableDeleteUserButton}
|
||||||
|
variant="soft"
|
||||||
|
color="error"
|
||||||
|
onClick={handleDeleteUserClick}
|
||||||
|
>
|
||||||
|
{t('Delete user')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 8 }}>
|
||||||
|
<Card sx={{ p: 3 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
rowGap: 3,
|
||||||
|
columnGap: 2,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field.Text name="username" label={t('username')} />
|
||||||
|
<Field.Text name="password" label={t('password')} />
|
||||||
|
|
||||||
|
<Field.Text name="name" label={t('Full name')} />
|
||||||
|
<Field.Text name="email" label={t('Email address')} />
|
||||||
|
<Field.Phone name="phoneNumber" label={t('Phone number')} country="HK" />
|
||||||
|
|
||||||
|
<Field.CountrySelect
|
||||||
|
fullWidth
|
||||||
|
name="country"
|
||||||
|
label={t('Country')}
|
||||||
|
placeholder={t('Choose a country')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field.Text name="state" label={t('State/region')} />
|
||||||
|
<Field.Text name="city" label={t('City')} />
|
||||||
|
<Field.Text name="address" label={t('Address')} />
|
||||||
|
<Field.Text name="zipCode" label={t('Zip/code')} required={false} />
|
||||||
|
<Field.Text name="company" label={t('Company')} />
|
||||||
|
<Field.Text name="role" label={t('Role')} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack sx={{ mt: 3, alignItems: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
{!currentUser ? t('create user') : t('save changes')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,173 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
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 MenuItem from '@mui/material/MenuItem';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { isValidPhoneNumber } from 'react-phone-number-input/input';
|
||||||
|
import { USER_STATUS_OPTIONS } from 'src/_mock';
|
||||||
|
import { Field, Form, schemaHelper } from 'src/components/hook-form';
|
||||||
|
import { toast } from 'src/components/snackbar';
|
||||||
|
import { useTranslate } from 'src/locales';
|
||||||
|
import type { IUserItem } from 'src/types/user';
|
||||||
|
import { z as zod } from 'zod';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type UserQuickEditSchemaType = zod.infer<typeof UserQuickEditSchema>;
|
||||||
|
|
||||||
|
export const UserQuickEditSchema = zod.object({
|
||||||
|
name: zod.string().min(1, { message: 'Name is required!' }),
|
||||||
|
email: zod
|
||||||
|
.string()
|
||||||
|
.min(1, { message: 'Email is required!' })
|
||||||
|
.email({ message: 'Email must be a valid email address!' }),
|
||||||
|
phoneNumber: schemaHelper.phoneNumber({ isValid: isValidPhoneNumber }),
|
||||||
|
country: schemaHelper.nullableInput(zod.string().min(1, { message: 'Country is required!' }), {
|
||||||
|
// message for null value
|
||||||
|
message: 'Country is required!',
|
||||||
|
}),
|
||||||
|
state: zod.string().min(1, { message: 'State is required!' }),
|
||||||
|
city: zod.string().min(1, { message: 'City is required!' }),
|
||||||
|
address: zod.string().min(1, { message: 'Address is required!' }),
|
||||||
|
zipCode: zod.string().min(1, { message: 'Zip code is required!' }),
|
||||||
|
company: zod.string().min(1, { message: 'Company is required!' }),
|
||||||
|
role: zod.string().min(1, { message: 'Role is required!' }),
|
||||||
|
// Not required
|
||||||
|
status: zod.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentUser?: IUserItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PartyUserQuickEditForm({ currentUser, open, onClose }: Props) {
|
||||||
|
const { t } = useTranslate();
|
||||||
|
|
||||||
|
const defaultValues: UserQuickEditSchemaType = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phoneNumber: '',
|
||||||
|
address: '',
|
||||||
|
country: '',
|
||||||
|
state: '',
|
||||||
|
city: '',
|
||||||
|
zipCode: '',
|
||||||
|
status: '',
|
||||||
|
company: '',
|
||||||
|
role: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const methods = useForm<UserQuickEditSchemaType>({
|
||||||
|
mode: 'all',
|
||||||
|
resolver: zodResolver(UserQuickEditSchema),
|
||||||
|
defaultValues,
|
||||||
|
values: currentUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
reset,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = methods;
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
|
const promise = new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
try {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: 'Loading...',
|
||||||
|
success: 'Update success!',
|
||||||
|
error: 'Update error!',
|
||||||
|
});
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
console.info('DATA', data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
fullWidth
|
||||||
|
maxWidth={false}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: { maxWidth: 720 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>{t('Quick update')}</DialogTitle>
|
||||||
|
|
||||||
|
<Form methods={methods} onSubmit={onSubmit}>
|
||||||
|
<DialogContent>
|
||||||
|
<Alert variant="outlined" severity="info" sx={{ mb: 3 }}>
|
||||||
|
Account is waiting for confirmation
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
rowGap: 3,
|
||||||
|
columnGap: 2,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field.Select name="status" label="Status">
|
||||||
|
{USER_STATUS_OPTIONS.map((status) => (
|
||||||
|
<MenuItem key={status.value} value={status.value}>
|
||||||
|
{status.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Field.Select>
|
||||||
|
|
||||||
|
<Box sx={{ display: { xs: 'none', sm: 'block' } }} />
|
||||||
|
|
||||||
|
<Field.Text name="name" label="Full name" />
|
||||||
|
<Field.Text name="email" label="Email address" />
|
||||||
|
<Field.Phone name="phoneNumber" label="Phone number" />
|
||||||
|
|
||||||
|
<Field.CountrySelect
|
||||||
|
fullWidth
|
||||||
|
name="country"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Choose a country"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field.Text name="state" label="State/region" />
|
||||||
|
<Field.Text name="city" label="City" />
|
||||||
|
<Field.Text name="address" label="Address" />
|
||||||
|
<Field.Text name="zipCode" label="Zip/code" />
|
||||||
|
<Field.Text name="company" label="Company" />
|
||||||
|
<Field.Text name="role" label="Role" />
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="outlined" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" variant="contained" loading={isSubmitting}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,65 @@
|
|||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
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 { IPartyUserTableFilters } from 'src/types/party-user';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = FiltersResultProps & {
|
||||||
|
onResetPage: () => void;
|
||||||
|
filters: UseSetStateReturn<IPartyUserTableFilters>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PartyUserTableFiltersResult({ filters, onResetPage, totalResults, sx }: Props) {
|
||||||
|
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
|
||||||
|
|
||||||
|
const handleRemoveKeyword = useCallback(() => {
|
||||||
|
onResetPage();
|
||||||
|
updateFilters({ name: '' });
|
||||||
|
}, [onResetPage, updateFilters]);
|
||||||
|
|
||||||
|
const handleRemoveStatus = useCallback(() => {
|
||||||
|
onResetPage();
|
||||||
|
updateFilters({ status: 'all' });
|
||||||
|
}, [onResetPage, updateFilters]);
|
||||||
|
|
||||||
|
const handleRemoveRole = useCallback(
|
||||||
|
(inputValue: string) => {
|
||||||
|
const newValue = currentFilters.role.filter((item) => item !== inputValue);
|
||||||
|
|
||||||
|
onResetPage();
|
||||||
|
updateFilters({ role: newValue });
|
||||||
|
},
|
||||||
|
[onResetPage, updateFilters, currentFilters.role]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
onResetPage();
|
||||||
|
resetFilters();
|
||||||
|
}, [onResetPage, resetFilters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FiltersResult totalResults={totalResults} onReset={handleReset} sx={sx}>
|
||||||
|
<FiltersBlock label="Status:" isShow={currentFilters.status !== 'all'}>
|
||||||
|
<Chip
|
||||||
|
{...chipProps}
|
||||||
|
label={currentFilters.status}
|
||||||
|
onDelete={handleRemoveStatus}
|
||||||
|
sx={{ textTransform: 'capitalize' }}
|
||||||
|
/>
|
||||||
|
</FiltersBlock>
|
||||||
|
|
||||||
|
<FiltersBlock label="Role:" isShow={!!currentFilters.role.length}>
|
||||||
|
{currentFilters.role.map((item) => (
|
||||||
|
<Chip {...chipProps} key={item} label={item} onDelete={() => handleRemoveRole(item)} />
|
||||||
|
))}
|
||||||
|
</FiltersBlock>
|
||||||
|
|
||||||
|
<FiltersBlock label="Keyword:" isShow={!!currentFilters.name}>
|
||||||
|
<Chip {...chipProps} label={currentFilters.name} onDelete={handleRemoveKeyword} />
|
||||||
|
</FiltersBlock>
|
||||||
|
</FiltersResult>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,183 @@
|
|||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import MenuList from '@mui/material/MenuList';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import { useBoolean, usePopover } from 'minimal-shared/hooks';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ConfirmDialog } from 'src/components/custom-dialog';
|
||||||
|
import { CustomPopover } from 'src/components/custom-popover';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { Label } from 'src/components/label';
|
||||||
|
import { RouterLink } from 'src/routes/components';
|
||||||
|
import type { IPartyUserItem } from 'src/types/party-user';
|
||||||
|
import { PartyUserQuickEditForm } from './party-user-quick-edit-form';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
row: IPartyUserItem;
|
||||||
|
selected: boolean;
|
||||||
|
editHref: string;
|
||||||
|
onSelectRow: () => void;
|
||||||
|
onDeleteRow: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PartyUserTableRow({ row, selected, editHref, onSelectRow, onDeleteRow }: Props) {
|
||||||
|
const menuActions = usePopover();
|
||||||
|
const confirmDialog = useBoolean();
|
||||||
|
const quickEditForm = useBoolean();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const renderQuickEditForm = () => (
|
||||||
|
<PartyUserQuickEditForm
|
||||||
|
currentUser={row}
|
||||||
|
open={quickEditForm.value}
|
||||||
|
onClose={quickEditForm.onFalse}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMenuActions = () => (
|
||||||
|
<CustomPopover
|
||||||
|
open={menuActions.open}
|
||||||
|
anchorEl={menuActions.anchorEl}
|
||||||
|
onClose={menuActions.onClose}
|
||||||
|
slotProps={{ arrow: { placement: 'right-top' } }}
|
||||||
|
>
|
||||||
|
<MenuList>
|
||||||
|
<li>
|
||||||
|
<MenuItem component={RouterLink} href={editHref} onClick={() => menuActions.onClose()}>
|
||||||
|
<Iconify icon="solar:pen-bold" />
|
||||||
|
{t('Edit')}
|
||||||
|
</MenuItem>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
confirmDialog.onTrue();
|
||||||
|
menuActions.onClose();
|
||||||
|
}}
|
||||||
|
sx={{ color: 'error.main' }}
|
||||||
|
>
|
||||||
|
<Iconify icon="solar:trash-bin-trash-bold" />
|
||||||
|
{t('Delete')}
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</CustomPopover>
|
||||||
|
);
|
||||||
|
|
||||||
|
const [disableDeleteButton, setDisableDeleteButton] = useState<boolean>(false);
|
||||||
|
const renderConfirmDialog = () => (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDialog.value}
|
||||||
|
onClose={confirmDialog.onFalse}
|
||||||
|
title={t('Delete')}
|
||||||
|
content={t('Are you sure want to delete user?')}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
disabled={disableDeleteButton}
|
||||||
|
loading={disableDeleteButton}
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
setDisableDeleteButton(true);
|
||||||
|
onDeleteRow();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Delete')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow hover selected={selected} aria-checked={selected} tabIndex={-1}>
|
||||||
|
<TableCell padding="checkbox">
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
onClick={onSelectRow}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
id: `${row.id}-checkbox`,
|
||||||
|
'aria-label': `${row.id} checkbox`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ gap: 2, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Avatar alt={row.name} src={row.avatarUrl} />
|
||||||
|
|
||||||
|
<Stack sx={{ typography: 'body2', flex: '1 1 auto', alignItems: 'flex-start' }}>
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
href={editHref}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{row.name}
|
||||||
|
</Link>
|
||||||
|
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||||
|
{row.email}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>{row.phoneNumber}</TableCell>
|
||||||
|
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>{row.company}</TableCell>
|
||||||
|
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>{row.role}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Label
|
||||||
|
variant="soft"
|
||||||
|
color={
|
||||||
|
(row.status === 'active' && 'success') ||
|
||||||
|
(row.status === 'pending' && 'warning') ||
|
||||||
|
(row.status === 'banned' && 'error') ||
|
||||||
|
'default'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</Label>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Tooltip title={t('Quick Edit')} placement="top" arrow>
|
||||||
|
<IconButton
|
||||||
|
color={quickEditForm.value ? 'inherit' : 'default'}
|
||||||
|
onClick={quickEditForm.onTrue}
|
||||||
|
>
|
||||||
|
<Iconify icon="solar:pen-bold" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
color={menuActions.open ? 'inherit' : 'default'}
|
||||||
|
onClick={menuActions.onOpen}
|
||||||
|
>
|
||||||
|
<Iconify icon="eva:more-vertical-fill" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
|
||||||
|
{renderQuickEditForm()}
|
||||||
|
{renderMenuActions()}
|
||||||
|
{renderConfirmDialog()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,150 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
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 TextField from '@mui/material/TextField';
|
||||||
|
import type { UseSetStateReturn } from 'minimal-shared/hooks';
|
||||||
|
import { usePopover } from 'minimal-shared/hooks';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { CustomPopover } from 'src/components/custom-popover';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import type { IPartyUserTableFilters } from 'src/types/party-user';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onResetPage: () => void;
|
||||||
|
filters: UseSetStateReturn<IPartyUserTableFilters>;
|
||||||
|
options: {
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PartyUserTableToolbar({ filters, options, onResetPage }: Props) {
|
||||||
|
const menuActions = usePopover();
|
||||||
|
|
||||||
|
const { state: currentFilters, setState: updateFilters } = filters;
|
||||||
|
|
||||||
|
const handleFilterName = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onResetPage();
|
||||||
|
updateFilters({ name: event.target.value });
|
||||||
|
},
|
||||||
|
[onResetPage, updateFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterRole = useCallback(
|
||||||
|
(event: SelectChangeEvent<string[]>) => {
|
||||||
|
const newValue =
|
||||||
|
typeof event.target.value === 'string' ? event.target.value.split(',') : event.target.value;
|
||||||
|
|
||||||
|
onResetPage();
|
||||||
|
updateFilters({ role: newValue });
|
||||||
|
},
|
||||||
|
[onResetPage, updateFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
gap: 2,
|
||||||
|
display: 'flex',
|
||||||
|
pr: { xs: 2.5, md: 1 },
|
||||||
|
flexDirection: { xs: 'column', md: 'row' },
|
||||||
|
alignItems: { xs: 'flex-end', md: 'center' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl sx={{ flexShrink: 0, width: { xs: 1, md: 200 } }}>
|
||||||
|
<InputLabel htmlFor="filter-role-select">Role</InputLabel>
|
||||||
|
<Select
|
||||||
|
multiple
|
||||||
|
value={currentFilters.role}
|
||||||
|
onChange={handleFilterRole}
|
||||||
|
input={<OutlinedInput label="Role" />}
|
||||||
|
renderValue={(selected) => selected.map((value) => value).join(', ')}
|
||||||
|
inputProps={{ id: 'filter-role-select' }}
|
||||||
|
MenuProps={{ PaperProps: { sx: { maxHeight: 240 } } }}
|
||||||
|
>
|
||||||
|
{options.roles.map((option) => (
|
||||||
|
<MenuItem key={option} value={option}>
|
||||||
|
<Checkbox
|
||||||
|
disableRipple
|
||||||
|
size="small"
|
||||||
|
checked={currentFilters.role.includes(option)}
|
||||||
|
/>
|
||||||
|
{option}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
gap: 2,
|
||||||
|
width: 1,
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
value={currentFilters.name}
|
||||||
|
onChange={handleFilterName}
|
||||||
|
placeholder="Search..."
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Iconify icon="eva:search-fill" sx={{ color: 'text.disabled' }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton onClick={menuActions.onOpen}>
|
||||||
|
<Iconify icon="eva:more-vertical-fill" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{renderMenuActions()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
75
03_source/frontend/src/sections/party-user/profile-cover.tsx
Normal file
75
03_source/frontend/src/sections/party-user/profile-cover.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import type { BoxProps } from '@mui/material/Box';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import { varAlpha } from 'minimal-shared/utils';
|
||||||
|
import type { IPartyUserProfileCover } from 'src/types/party-user';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ProfileCover({
|
||||||
|
sx,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
coverUrl,
|
||||||
|
avatarUrl,
|
||||||
|
...other
|
||||||
|
}: BoxProps & IPartyUserProfileCover) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={[
|
||||||
|
(theme) => ({
|
||||||
|
...theme.mixins.bgGradient({
|
||||||
|
images: [
|
||||||
|
`linear-gradient(0deg, ${varAlpha(theme.vars.palette.primary.darkerChannel, 0.8)}, ${varAlpha(theme.vars.palette.primary.darkerChannel, 0.8)})`,
|
||||||
|
`url(${coverUrl})`,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
height: 1,
|
||||||
|
color: 'common.white',
|
||||||
|
}),
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
left: { md: 24 },
|
||||||
|
bottom: { md: 24 },
|
||||||
|
zIndex: { md: 10 },
|
||||||
|
pt: { xs: 6, md: 0 },
|
||||||
|
position: { md: 'absolute' },
|
||||||
|
flexDirection: { xs: 'column', md: 'row' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
alt={name}
|
||||||
|
src={avatarUrl}
|
||||||
|
sx={[
|
||||||
|
(theme) => ({
|
||||||
|
mx: 'auto',
|
||||||
|
width: { xs: 64, md: 128 },
|
||||||
|
height: { xs: 64, md: 128 },
|
||||||
|
border: `solid 2px ${theme.vars.palette.common.white}`,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{name?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<ListItemText
|
||||||
|
primary={name}
|
||||||
|
secondary={role}
|
||||||
|
slotProps={{
|
||||||
|
primary: { sx: { typography: 'h4' } },
|
||||||
|
secondary: {
|
||||||
|
sx: { mt: 0.5, opacity: 0.48, color: 'inherit' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{ mt: 3, ml: { md: 3 }, textAlign: { xs: 'center', md: 'unset' } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
123
03_source/frontend/src/sections/party-user/profile-followers.tsx
Normal file
123
03_source/frontend/src/sections/party-user/profile-followers.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import type { CardProps } from '@mui/material/Card';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import type { IPartyUserProfileFollower } from 'src/types/party-user';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
followers: IPartyUserProfileFollower[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileFollowers({ followers }: Props) {
|
||||||
|
const _mockFollowed = followers.slice(4, 8).map((i) => i.id);
|
||||||
|
|
||||||
|
const [followed, setFollowed] = useState<string[]>(_mockFollowed);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(item: string) => {
|
||||||
|
const selected = followed.includes(item)
|
||||||
|
? followed.filter((value) => value !== item)
|
||||||
|
: [...followed, item];
|
||||||
|
|
||||||
|
setFollowed(selected);
|
||||||
|
},
|
||||||
|
[followed]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography variant="h4" sx={{ my: 5 }}>
|
||||||
|
Followers
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
gap: 3,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{followers.map((follower) => (
|
||||||
|
<CardItem
|
||||||
|
key={follower.id}
|
||||||
|
follower={follower}
|
||||||
|
selected={followed.includes(follower.id)}
|
||||||
|
onSelected={() => handleClick(follower.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type CardItemProps = CardProps & {
|
||||||
|
selected: boolean;
|
||||||
|
onSelected: () => void;
|
||||||
|
follower: IPartyUserProfileFollower;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CardItem({ follower, selected, onSelected, sx, ...other }: CardItemProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={[
|
||||||
|
(theme) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: theme.spacing(3, 2, 3, 3),
|
||||||
|
}),
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
alt={follower?.name}
|
||||||
|
src={follower?.avatarUrl}
|
||||||
|
sx={{ width: 48, height: 48, mr: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListItemText
|
||||||
|
primary={follower?.name}
|
||||||
|
secondary={
|
||||||
|
<>
|
||||||
|
<Iconify icon="mingcute:location-fill" width={16} sx={{ flexShrink: 0, mr: 0.5 }} />
|
||||||
|
{follower?.country}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
slotProps={{
|
||||||
|
primary: { noWrap: true },
|
||||||
|
secondary: {
|
||||||
|
noWrap: true,
|
||||||
|
sx: {
|
||||||
|
mt: 0.5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
typography: 'caption',
|
||||||
|
color: 'text.disabled',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={selected ? 'text' : 'outlined'}
|
||||||
|
color={selected ? 'success' : 'inherit'}
|
||||||
|
startIcon={
|
||||||
|
selected ? <Iconify width={18} icon="eva:checkmark-fill" sx={{ mr: -0.75 }} /> : null
|
||||||
|
}
|
||||||
|
onClick={onSelected}
|
||||||
|
sx={{ flexShrink: 0, ml: 1.5 }}
|
||||||
|
>
|
||||||
|
{selected ? 'Followed' : 'Follow'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
183
03_source/frontend/src/sections/party-user/profile-friends.tsx
Normal file
183
03_source/frontend/src/sections/party-user/profile-friends.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import MenuList from '@mui/material/MenuList';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { usePopover } from 'minimal-shared/hooks';
|
||||||
|
import { _socials } from 'src/_mock';
|
||||||
|
import { CustomPopover } from 'src/components/custom-popover';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { SearchNotFound } from 'src/components/search-not-found';
|
||||||
|
import type { IPartyUserProfileFriend } from 'src/types/party-user';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
searchFriends: string;
|
||||||
|
friends: IPartyUserProfileFriend[];
|
||||||
|
onSearchFriends: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileFriends({ friends, searchFriends, onSearchFriends }: Props) {
|
||||||
|
const dataFiltered = applyFilter({ inputData: friends, query: searchFriends });
|
||||||
|
|
||||||
|
const notFound = !dataFiltered.length && !!searchFriends;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
my: 5,
|
||||||
|
gap: 2,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4">Friends</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
value={searchFriends}
|
||||||
|
onChange={onSearchFriends}
|
||||||
|
placeholder="Search friends..."
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Iconify icon="eva:search-fill" sx={{ color: 'text.disabled' }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{ width: { xs: 1, sm: 260 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{notFound ? (
|
||||||
|
<SearchNotFound query={searchFriends} sx={{ py: 10 }} />
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
gap: 3,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: 'repeat(1, 1fr)',
|
||||||
|
sm: 'repeat(2, 1fr)',
|
||||||
|
md: 'repeat(3, 1fr)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dataFiltered.map((item) => (
|
||||||
|
<FriendCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type FriendCardProps = {
|
||||||
|
item: IPartyUserProfileFriend;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FriendCard({ item }: FriendCardProps) {
|
||||||
|
const menuActions = usePopover();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
menuActions.onClose();
|
||||||
|
console.info('DELETE', item.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
menuActions.onClose();
|
||||||
|
console.info('EDIT', item.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMenuActions = () => (
|
||||||
|
<CustomPopover
|
||||||
|
open={menuActions.open}
|
||||||
|
anchorEl={menuActions.anchorEl}
|
||||||
|
onClose={menuActions.onClose}
|
||||||
|
slotProps={{ arrow: { placement: 'right-top' } }}
|
||||||
|
>
|
||||||
|
<MenuList>
|
||||||
|
<MenuItem onClick={handleDelete} sx={{ color: 'error.main' }}>
|
||||||
|
<Iconify icon="solar:trash-bin-trash-bold" />
|
||||||
|
Delete
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem onClick={handleEdit}>
|
||||||
|
<Iconify icon="solar:pen-bold" />
|
||||||
|
Edit
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</CustomPopover>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
py: 5,
|
||||||
|
display: 'flex',
|
||||||
|
position: 'relative',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar alt={item.name} src={item.avatarUrl} sx={{ width: 64, height: 64, mb: 3 }} />
|
||||||
|
|
||||||
|
<Link variant="subtitle1" color="text.primary">
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1, mt: 0.5 }}>
|
||||||
|
{item.role}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{_socials.map((social) => (
|
||||||
|
<IconButton key={social.label}>
|
||||||
|
{social.value === 'twitter' && <Iconify icon="socials:twitter" />}
|
||||||
|
{social.value === 'facebook' && <Iconify icon="socials:facebook" />}
|
||||||
|
{social.value === 'instagram' && <Iconify icon="socials:instagram" />}
|
||||||
|
{social.value === 'linkedin' && <Iconify icon="socials:linkedin" />}
|
||||||
|
</IconButton>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
color={menuActions.open ? 'inherit' : 'default'}
|
||||||
|
onClick={menuActions.onOpen}
|
||||||
|
sx={{ top: 8, right: 8, position: 'absolute' }}
|
||||||
|
>
|
||||||
|
<Iconify icon="eva:more-vertical-fill" />
|
||||||
|
</IconButton>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{renderMenuActions()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ApplyFilterProps = {
|
||||||
|
query: string;
|
||||||
|
inputData: IPartyUserProfileFriend[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyFilter({ inputData, query }: ApplyFilterProps) {
|
||||||
|
if (!query) return inputData;
|
||||||
|
|
||||||
|
return inputData.filter(({ name, role }) =>
|
||||||
|
[name, role].some((field) => field?.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,89 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { Image } from 'src/components/image';
|
||||||
|
import { Lightbox, useLightBox } from 'src/components/lightbox';
|
||||||
|
import type { IPartyUserProfileGallery } from 'src/types/party-user';
|
||||||
|
import { fDate } from 'src/utils/format-time';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
gallery: IPartyUserProfileGallery[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileGallery({ gallery }: Props) {
|
||||||
|
const slides = gallery.map((slide) => ({ src: slide.imageUrl }));
|
||||||
|
const lightbox = useLightBox(slides);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography variant="h4" sx={{ my: 5 }}>
|
||||||
|
Gallery
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
gap: 3,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gallery.map((image) => (
|
||||||
|
<Card key={image.id} sx={{ cursor: 'pointer', color: 'common.white' }}>
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
sx={{
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
zIndex: 9,
|
||||||
|
position: 'absolute',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Iconify icon="eva:more-vertical-fill" />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<ListItemText
|
||||||
|
sx={{ p: 3, left: 0, width: 1, bottom: 0, zIndex: 9, position: 'absolute' }}
|
||||||
|
primary={image.title}
|
||||||
|
secondary={fDate(image.postedAt)}
|
||||||
|
slotProps={{
|
||||||
|
primary: {
|
||||||
|
noWrap: true,
|
||||||
|
sx: { typography: 'subtitle1' },
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
sx: { mt: 0.5, opacity: 0.48, color: 'inherit' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
alt="Gallery"
|
||||||
|
ratio="1/1"
|
||||||
|
src={image.imageUrl}
|
||||||
|
onClick={() => lightbox.onOpen(image.imageUrl)}
|
||||||
|
slotProps={{
|
||||||
|
overlay: {
|
||||||
|
sx: (theme) => ({
|
||||||
|
backgroundImage: `linear-gradient(to bottom, transparent 0%, ${theme.vars.palette.common.black} 75%)`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Lightbox
|
||||||
|
index={lightbox.selected}
|
||||||
|
slides={slides}
|
||||||
|
open={lightbox.open}
|
||||||
|
close={lightbox.onClose}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
208
03_source/frontend/src/sections/party-user/profile-home.tsx
Normal file
208
03_source/frontend/src/sections/party-user/profile-home.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
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 Divider from '@mui/material/Divider';
|
||||||
|
import Fab from '@mui/material/Fab';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import InputBase from '@mui/material/InputBase';
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import { varAlpha } from 'minimal-shared/utils';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { _socials } from 'src/_mock';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import type { IPartyUserProfile, IPartyUserProfilePost } from 'src/types/party-user';
|
||||||
|
import { fNumber } from 'src/utils/format-number';
|
||||||
|
import { ProfilePostItem } from './profile-post-item';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
info: IPartyUserProfile;
|
||||||
|
posts: IPartyUserProfilePost[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileHome({ info, posts }: Props) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleAttach = () => {
|
||||||
|
if (fileRef.current) {
|
||||||
|
fileRef.current.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFollows = () => (
|
||||||
|
<Card sx={{ py: 3, textAlign: 'center', typography: 'h4' }}>
|
||||||
|
<Stack
|
||||||
|
divider={<Divider orientation="vertical" flexItem sx={{ borderStyle: 'dashed' }} />}
|
||||||
|
sx={{ flexDirection: 'row' }}
|
||||||
|
>
|
||||||
|
<Stack sx={{ width: 1 }}>
|
||||||
|
{fNumber(info.totalFollowers)}
|
||||||
|
<Box component="span" sx={{ color: 'text.secondary', typography: 'body2' }}>
|
||||||
|
{t('Follower')}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack sx={{ width: 1 }}>
|
||||||
|
{fNumber(info.totalFollowing)}
|
||||||
|
<Box component="span" sx={{ color: 'text.secondary', typography: 'body2' }}>
|
||||||
|
{t('Following')}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderAbout = () => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title={t('About')} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
gap: 2,
|
||||||
|
display: 'flex',
|
||||||
|
typography: 'body2',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{info.quote}</div>
|
||||||
|
|
||||||
|
<Box sx={{ gap: 2, display: 'flex', lineHeight: '24px' }}>
|
||||||
|
<Iconify width={24} icon="mingcute:location-fill" />
|
||||||
|
<span>
|
||||||
|
Live at
|
||||||
|
<Link variant="subtitle2" color="inherit">
|
||||||
|
{info.country}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gap: 2, display: 'flex', lineHeight: '24px' }}>
|
||||||
|
<Iconify width={24} icon="solar:letter-bold" />
|
||||||
|
{info.email}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gap: 2, display: 'flex', lineHeight: '24px' }}>
|
||||||
|
<Iconify width={24} icon="solar:case-minimalistic-bold" />
|
||||||
|
<span>
|
||||||
|
{info.role} at
|
||||||
|
<Link variant="subtitle2" color="inherit">
|
||||||
|
{info.company}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gap: 2, display: 'flex', lineHeight: '24px' }}>
|
||||||
|
<Iconify width={24} icon="solar:case-minimalistic-bold" />
|
||||||
|
<span>
|
||||||
|
Studied at
|
||||||
|
<Link variant="subtitle2" color="inherit">
|
||||||
|
{info.school}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPostInput = () => (
|
||||||
|
<Card sx={{ p: 3 }}>
|
||||||
|
<InputBase
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
rows={4}
|
||||||
|
placeholder="Share what you are thinking here..."
|
||||||
|
inputProps={{ id: 'post-input' }}
|
||||||
|
sx={[
|
||||||
|
(theme) => ({
|
||||||
|
p: 2,
|
||||||
|
mb: 3,
|
||||||
|
borderRadius: 1,
|
||||||
|
border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
gap: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Fab size="small" color="inherit" variant="softExtended" onClick={handleAttach}>
|
||||||
|
<Iconify icon="solar:gallery-wide-bold" width={24} sx={{ color: 'success.main' }} />
|
||||||
|
{t('Image/Video')}
|
||||||
|
</Fab>
|
||||||
|
|
||||||
|
<Fab size="small" color="inherit" variant="softExtended">
|
||||||
|
<Iconify icon="solar:videocamera-record-bold" width={24} sx={{ color: 'error.main' }} />
|
||||||
|
{t('Streaming')}
|
||||||
|
</Fab>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button variant="contained">{t('Post')}</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<input ref={fileRef} type="file" style={{ display: 'none' }} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSocials = () => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title={t('Social')} />
|
||||||
|
|
||||||
|
<Box sx={{ p: 3, gap: 2, display: 'flex', flexDirection: 'column', typography: 'body2' }}>
|
||||||
|
{_socials.map((social) => (
|
||||||
|
<Box
|
||||||
|
key={social.label}
|
||||||
|
sx={{
|
||||||
|
gap: 2,
|
||||||
|
display: 'flex',
|
||||||
|
lineHeight: '20px',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{social.value === 'twitter' && <Iconify icon="socials:twitter" />}
|
||||||
|
{social.value === 'facebook' && <Iconify icon="socials:facebook" />}
|
||||||
|
{social.value === 'instagram' && <Iconify icon="socials:instagram" />}
|
||||||
|
{social.value === 'linkedin' && <Iconify icon="socials:linkedin" />}
|
||||||
|
|
||||||
|
<Link color="inherit">
|
||||||
|
{social.value === 'facebook' && info.socialLinks.facebook}
|
||||||
|
{social.value === 'instagram' && info.socialLinks.instagram}
|
||||||
|
{social.value === 'linkedin' && info.socialLinks.linkedin}
|
||||||
|
{social.value === 'twitter' && info.socialLinks.twitter}
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, md: 4 }} sx={{ gap: 3, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{renderFollows()}
|
||||||
|
{renderAbout()}
|
||||||
|
{renderSocials()}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 8 }} sx={{ gap: 3, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{renderPostInput()}
|
||||||
|
|
||||||
|
{posts.map((post) => (
|
||||||
|
<ProfilePostItem key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
221
03_source/frontend/src/sections/party-user/profile-post-item.tsx
Normal file
221
03_source/frontend/src/sections/party-user/profile-post-item.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import AvatarGroup, { avatarGroupClasses } from '@mui/material/AvatarGroup';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import CardHeader from '@mui/material/CardHeader';
|
||||||
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import InputBase from '@mui/material/InputBase';
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { varAlpha } from 'minimal-shared/utils';
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useMockedUser } from 'src/auth/hooks';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { Image } from 'src/components/image';
|
||||||
|
import type { IPartyUserProfilePost } from 'src/types/party-user';
|
||||||
|
import { fShortenNumber } from 'src/utils/format-number';
|
||||||
|
import { fDate } from 'src/utils/format-time';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
post: IPartyUserProfilePost;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfilePostItem({ post }: Props) {
|
||||||
|
const { user } = useMockedUser();
|
||||||
|
|
||||||
|
const commentRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
const handleChangeMessage = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setMessage(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAttach = useCallback(() => {
|
||||||
|
if (fileRef.current) {
|
||||||
|
fileRef.current.click();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClickComment = useCallback(() => {
|
||||||
|
if (commentRef.current) {
|
||||||
|
commentRef.current.focus();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderHead = () => (
|
||||||
|
<CardHeader
|
||||||
|
disableTypography
|
||||||
|
avatar={
|
||||||
|
<Avatar src={user?.photoURL} alt={user?.displayName}>
|
||||||
|
{user?.displayName?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<Link color="inherit" variant="subtitle1">
|
||||||
|
{user?.displayName}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
subheader={
|
||||||
|
<Box sx={{ color: 'text.disabled', typography: 'caption', mt: 0.5 }}>
|
||||||
|
{fDate(post.createdAt)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<IconButton>
|
||||||
|
<Iconify icon="eva:more-vertical-fill" />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderCommentList = () => (
|
||||||
|
<Stack spacing={1.5} sx={{ px: 3, pb: 2 }}>
|
||||||
|
{post.comments.map((comment) => (
|
||||||
|
<Box key={comment.id} sx={{ gap: 2, display: 'flex' }}>
|
||||||
|
<Avatar alt={comment.author.name} src={comment.author.avatarUrl} />
|
||||||
|
|
||||||
|
<Paper sx={{ p: 1.5, flexGrow: 1, bgcolor: 'background.neutral' }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 0.5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: { sm: 'center' },
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ typography: 'subtitle2' }}>{comment.author.name}</Box>
|
||||||
|
|
||||||
|
<Box sx={{ typography: 'caption', color: 'text.disabled' }}>
|
||||||
|
{fDate(comment.createdAt)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ typography: 'body2', color: 'text.secondary' }}>{comment.message}</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderInput = () => (
|
||||||
|
<Box
|
||||||
|
sx={[
|
||||||
|
(theme) => ({
|
||||||
|
gap: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: theme.spacing(0, 3, 3, 3),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Avatar src={user?.photoURL} alt={user?.displayName}>
|
||||||
|
{user?.displayName?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<InputBase
|
||||||
|
fullWidth
|
||||||
|
value={message}
|
||||||
|
inputRef={commentRef}
|
||||||
|
placeholder="Write a comment…"
|
||||||
|
onChange={handleChangeMessage}
|
||||||
|
endAdornment={
|
||||||
|
<InputAdornment position="end" sx={{ mr: 1 }}>
|
||||||
|
<IconButton size="small" onClick={handleAttach}>
|
||||||
|
<Iconify icon="solar:gallery-add-bold" />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton size="small">
|
||||||
|
<Iconify icon="eva:smiling-face-fill" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
}
|
||||||
|
inputProps={{
|
||||||
|
id: `comment-${post.id}-input`,
|
||||||
|
'aria-label': `Comment ${post.id} input`,
|
||||||
|
}}
|
||||||
|
sx={[
|
||||||
|
(theme) => ({
|
||||||
|
pl: 1.5,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 1,
|
||||||
|
border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.32)}`,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input type="file" ref={fileRef} style={{ display: 'none' }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderActions = () => (
|
||||||
|
<Box
|
||||||
|
sx={[(theme) => ({ display: 'flex', alignItems: 'center', p: theme.spacing(2, 3, 3, 3) })]}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked
|
||||||
|
color="error"
|
||||||
|
icon={<Iconify icon="solar:heart-bold" />}
|
||||||
|
checkedIcon={<Iconify icon="solar:heart-bold" />}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
id: `favorite-${post.id}-checkbox`,
|
||||||
|
'aria-label': `Favorite ${post.id} checkbox`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={fShortenNumber(post.personLikes.length)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!post.personLikes.length && (
|
||||||
|
<AvatarGroup sx={{ [`& .${avatarGroupClasses.avatar}`]: { width: 32, height: 32 } }}>
|
||||||
|
{post.personLikes.map((person) => (
|
||||||
|
<Avatar key={person.name} alt={person.name} src={person.avatarUrl} />
|
||||||
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
|
<IconButton onClick={handleClickComment}>
|
||||||
|
<Iconify icon="solar:chat-round-dots-bold" />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton>
|
||||||
|
<Iconify icon="solar:share-bold" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{renderHead()}
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={[(theme) => ({ p: theme.spacing(3, 3, 2, 3) })]}>
|
||||||
|
{post.message}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ p: 1 }}>
|
||||||
|
<Image alt={post.media} src={post.media} ratio="16/9" sx={{ borderRadius: 1.5 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{renderActions()}
|
||||||
|
{!!post.comments.length && renderCommentList()}
|
||||||
|
{renderInput()}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
9
03_source/frontend/src/sections/party-user/view/index.ts
Normal file
9
03_source/frontend/src/sections/party-user/view/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './party-user-edit-view';
|
||||||
|
|
||||||
|
export * from './party-user-list-view';
|
||||||
|
|
||||||
|
export * from './party-user-cards-view';
|
||||||
|
|
||||||
|
export * from './party-user-create-view';
|
||||||
|
|
||||||
|
export * from './party-user-profile-view';
|
@@ -0,0 +1,41 @@
|
|||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { _userCards } from 'src/_mock';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
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 { UserCardList } from '../party-user-card-list';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function UserCardsView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="User cards"
|
||||||
|
links={[
|
||||||
|
//
|
||||||
|
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||||
|
{ name: t('User'), href: paths.dashboard.user.root },
|
||||||
|
{ name: t('Cards') },
|
||||||
|
]}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
|
href={paths.dashboard.user.new}
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Iconify icon="mingcute:add-line" />}
|
||||||
|
>
|
||||||
|
{t('New user')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
sx={{ mb: { xs: 3, md: 5 } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UserCardList users={_userCards} />
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,28 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
import { PartyUserNewEditForm } from '../party-user-new-edit-form';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function UserCreateView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading={t('Create a new party user')}
|
||||||
|
links={[
|
||||||
|
//
|
||||||
|
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||||
|
{ name: t('Party User'), href: paths.dashboard.user.root },
|
||||||
|
{ name: t('New') },
|
||||||
|
]}
|
||||||
|
sx={{ mb: { xs: 3, md: 5 } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PartyUserNewEditForm />
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,33 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
import type { IPartyUserItem } from 'src/types/party-user';
|
||||||
|
import { PartyUserNewEditForm } from '../party-user-new-edit-form';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user?: IPartyUserItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PartyUserEditView({ user: currentUser }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="Edit"
|
||||||
|
backHref={paths.dashboard.partyUser.list}
|
||||||
|
links={[
|
||||||
|
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||||
|
{ name: t('Party User'), href: paths.dashboard.partyUser.root },
|
||||||
|
{ name: currentUser?.name },
|
||||||
|
]}
|
||||||
|
sx={{ mb: { xs: 3, md: 5 } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PartyUserNewEditForm currentUser={currentUser} />
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,354 @@
|
|||||||
|
// src/sections/user/view/user-list-view.tsx
|
||||||
|
//
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tab from '@mui/material/Tab';
|
||||||
|
import Table from '@mui/material/Table';
|
||||||
|
import TableBody from '@mui/material/TableBody';
|
||||||
|
import Tabs from '@mui/material/Tabs';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import { useBoolean, useSetState } from 'minimal-shared/hooks';
|
||||||
|
import { varAlpha } from 'minimal-shared/utils';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { _roles, USER_STATUS_OPTIONS } from 'src/_mock';
|
||||||
|
import { deletePartyUser, useGetPartyUsers } from 'src/actions/party-user';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
import { ConfirmDialog } from 'src/components/custom-dialog';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { Label } from 'src/components/label';
|
||||||
|
import { Scrollbar } from 'src/components/scrollbar';
|
||||||
|
import { toast } from 'src/components/snackbar';
|
||||||
|
import type { TableHeadCellProps } from 'src/components/table';
|
||||||
|
import {
|
||||||
|
emptyRows,
|
||||||
|
getComparator,
|
||||||
|
rowInPage,
|
||||||
|
TableEmptyRows,
|
||||||
|
TableHeadCustom,
|
||||||
|
TableNoData,
|
||||||
|
TablePaginationCustom,
|
||||||
|
TableSelectedAction,
|
||||||
|
useTable,
|
||||||
|
} from 'src/components/table';
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
import { useRouter } from 'src/routes/hooks';
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
import type { IPartyUserItem, IPartyUserTableFilters } from 'src/types/party-user';
|
||||||
|
import { PartyUserTableFiltersResult } from '../party-user-table-filters-result';
|
||||||
|
import { PartyUserTableRow } from '../party-user-table-row';
|
||||||
|
import { PartyUserTableToolbar } from '../party-user-table-toolbar';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [{ value: 'all', label: 'All' }, ...USER_STATUS_OPTIONS];
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function PartyUserListView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const TABLE_HEAD: TableHeadCellProps[] = [
|
||||||
|
{ id: 'name', label: t('Name') },
|
||||||
|
{ id: 'phoneNumber', label: t('Phone number'), width: 180 },
|
||||||
|
{ id: 'company', label: t('Company'), width: 220 },
|
||||||
|
{ id: 'role', label: t('Role'), width: 180 },
|
||||||
|
{ id: 'status', label: t('Status'), width: 100 },
|
||||||
|
{ id: '', width: 88 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { partyUsers } = useGetPartyUsers();
|
||||||
|
const [processNewUser, setProcessNewUser] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const table = useTable();
|
||||||
|
|
||||||
|
const confirmDialog = useBoolean();
|
||||||
|
|
||||||
|
const [tableData, setTableData] = useState<IPartyUserItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTableData(partyUsers);
|
||||||
|
}, [partyUsers]);
|
||||||
|
|
||||||
|
const filters = useSetState<IPartyUserTableFilters>({ name: '', role: [], status: 'all' });
|
||||||
|
const { state: currentFilters, setState: updateFilters } = filters;
|
||||||
|
|
||||||
|
const dataFiltered = applyFilter({
|
||||||
|
inputData: tableData,
|
||||||
|
comparator: getComparator(table.order, table.orderBy),
|
||||||
|
filters: currentFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataInPage = rowInPage(dataFiltered, table.page, table.rowsPerPage);
|
||||||
|
|
||||||
|
const canReset =
|
||||||
|
!!currentFilters.name || currentFilters.role.length > 0 || currentFilters.status !== 'all';
|
||||||
|
|
||||||
|
const notFound = (!dataFiltered.length && canReset) || !dataFiltered.length;
|
||||||
|
|
||||||
|
const handleDeleteRow = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
// const deleteRow = tableData.filter((row) => row.id !== id);
|
||||||
|
// toast.success('Delete success!');
|
||||||
|
// setTableData(deleteRow);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePartyUser(id);
|
||||||
|
toast.success('Delete success!');
|
||||||
|
|
||||||
|
table.onUpdatePageDeleteRow(dataInPage.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Delete failed!');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[table, tableData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteRows = useCallback(() => {
|
||||||
|
const deleteRows = tableData.filter((row) => !table.selected.includes(row.id));
|
||||||
|
|
||||||
|
toast.success('Delete success!');
|
||||||
|
|
||||||
|
setTableData(deleteRows);
|
||||||
|
|
||||||
|
table.onUpdatePageDeleteRows(dataInPage.length, dataFiltered.length);
|
||||||
|
}, [dataFiltered.length, dataInPage.length, table, tableData]);
|
||||||
|
|
||||||
|
const handleFilterStatus = useCallback(
|
||||||
|
(event: React.SyntheticEvent, newValue: string) => {
|
||||||
|
table.onResetPage();
|
||||||
|
updateFilters({ status: newValue });
|
||||||
|
},
|
||||||
|
[updateFilters, table]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderConfirmDialog = () => (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDialog.value}
|
||||||
|
onClose={confirmDialog.onFalse}
|
||||||
|
title={t('Delete')}
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
Are you sure want to delete <strong> {table.selected.length} </strong> items?
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteRows();
|
||||||
|
// confirmDialog.onFalse();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Delete')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="List"
|
||||||
|
links={[
|
||||||
|
//
|
||||||
|
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||||
|
{ name: t('PartyUser'), href: paths.dashboard.partyUser.root },
|
||||||
|
{ name: t('List') },
|
||||||
|
]}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
disabled={processNewUser}
|
||||||
|
loading={processNewUser}
|
||||||
|
// component={RouterLink}
|
||||||
|
// href={paths.dashboard.partyUser.new}
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Iconify icon="mingcute:add-line" />}
|
||||||
|
onClick={() => {
|
||||||
|
setProcessNewUser(true);
|
||||||
|
router.push(paths.dashboard.partyUser.new);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('New user')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
sx={{ mb: { xs: 3, md: 5 } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Tabs
|
||||||
|
value={currentFilters.status}
|
||||||
|
onChange={handleFilterStatus}
|
||||||
|
sx={[
|
||||||
|
(theme) => ({
|
||||||
|
px: 2.5,
|
||||||
|
boxShadow: `inset 0 -2px 0 0 ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab.value}
|
||||||
|
iconPosition="end"
|
||||||
|
value={tab.value}
|
||||||
|
label={t(tab.label)}
|
||||||
|
icon={
|
||||||
|
<Label
|
||||||
|
variant={
|
||||||
|
((tab.value === 'all' || tab.value === currentFilters.status) && 'filled') ||
|
||||||
|
'soft'
|
||||||
|
}
|
||||||
|
color={
|
||||||
|
(tab.value === 'active' && 'success') ||
|
||||||
|
(tab.value === 'pending' && 'warning') ||
|
||||||
|
(tab.value === 'banned' && 'error') ||
|
||||||
|
'default'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{['active', 'pending', 'banned', 'rejected'].includes(tab.value)
|
||||||
|
? tableData.filter((partyUser) => partyUser.status === tab.value).length
|
||||||
|
: tableData.length}
|
||||||
|
</Label>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<PartyUserTableToolbar
|
||||||
|
filters={filters}
|
||||||
|
onResetPage={table.onResetPage}
|
||||||
|
options={{ roles: _roles }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{canReset && (
|
||||||
|
<PartyUserTableFiltersResult
|
||||||
|
filters={filters}
|
||||||
|
totalResults={dataFiltered.length}
|
||||||
|
onResetPage={table.onResetPage}
|
||||||
|
sx={{ p: 2.5, pt: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<TableSelectedAction
|
||||||
|
dense={table.dense}
|
||||||
|
numSelected={table.selected.length}
|
||||||
|
rowCount={dataFiltered.length}
|
||||||
|
onSelectAllRows={(checked) =>
|
||||||
|
table.onSelectAllRows(
|
||||||
|
checked,
|
||||||
|
dataFiltered.map((row) => row.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Tooltip title={t('Delete')}>
|
||||||
|
<IconButton color="primary" onClick={confirmDialog.onTrue}>
|
||||||
|
<Iconify icon="solar:trash-bin-trash-bold" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Scrollbar>
|
||||||
|
<Table size={table.dense ? 'small' : 'medium'} sx={{ minWidth: 960 }}>
|
||||||
|
<TableHeadCustom
|
||||||
|
order={table.order}
|
||||||
|
orderBy={table.orderBy}
|
||||||
|
headCells={TABLE_HEAD}
|
||||||
|
rowCount={dataFiltered.length}
|
||||||
|
numSelected={table.selected.length}
|
||||||
|
onSort={table.onSort}
|
||||||
|
onSelectAllRows={(checked) =>
|
||||||
|
table.onSelectAllRows(
|
||||||
|
checked,
|
||||||
|
dataFiltered.map((row) => row.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{dataFiltered
|
||||||
|
.slice(
|
||||||
|
table.page * table.rowsPerPage,
|
||||||
|
table.page * table.rowsPerPage + table.rowsPerPage
|
||||||
|
)
|
||||||
|
.map((row) => (
|
||||||
|
<PartyUserTableRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
selected={table.selected.includes(row.id)}
|
||||||
|
onSelectRow={() => table.onSelectRow(row.id)}
|
||||||
|
onDeleteRow={() => handleDeleteRow(row.id)}
|
||||||
|
editHref={paths.dashboard.partyUser.edit(row.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TableEmptyRows
|
||||||
|
height={table.dense ? 56 : 56 + 20}
|
||||||
|
emptyRows={emptyRows(table.page, table.rowsPerPage, dataFiltered.length)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TableNoData notFound={notFound} />
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Scrollbar>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TablePaginationCustom
|
||||||
|
page={table.page}
|
||||||
|
dense={table.dense}
|
||||||
|
count={dataFiltered.length}
|
||||||
|
rowsPerPage={table.rowsPerPage}
|
||||||
|
onPageChange={table.onChangePage}
|
||||||
|
onChangeDense={table.onChangeDense}
|
||||||
|
onRowsPerPageChange={table.onChangeRowsPerPage}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</DashboardContent>
|
||||||
|
|
||||||
|
{renderConfirmDialog()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ApplyFilterProps = {
|
||||||
|
inputData: IPartyUserItem[];
|
||||||
|
filters: IPartyUserTableFilters;
|
||||||
|
comparator: (a: any, b: any) => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyFilter({ inputData, comparator, filters }: ApplyFilterProps) {
|
||||||
|
const { name, status, role } = filters;
|
||||||
|
|
||||||
|
const stabilizedThis = inputData.map((el, index) => [el, index] as const);
|
||||||
|
|
||||||
|
stabilizedThis.sort((a, b) => {
|
||||||
|
const order = comparator(a[0], b[0]);
|
||||||
|
if (order !== 0) return order;
|
||||||
|
return a[1] - b[1];
|
||||||
|
});
|
||||||
|
|
||||||
|
inputData = stabilizedThis.map((el) => el[0]);
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
inputData = inputData.filter((user) => user.name.toLowerCase().includes(name.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== 'all') {
|
||||||
|
inputData = inputData.filter((user) => user.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.length) {
|
||||||
|
inputData = inputData.filter((user) => role.includes(user.role));
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputData;
|
||||||
|
}
|
@@ -0,0 +1,132 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Tab from '@mui/material/Tab';
|
||||||
|
import Tabs from '@mui/material/Tabs';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { _userAbout, _userFeeds, _userFollowers, _userFriends, _userGallery } from 'src/_mock';
|
||||||
|
import { useMockedUser } from 'src/auth/hooks';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
import { RouterLink } from 'src/routes/components';
|
||||||
|
import { usePathname, useSearchParams } from 'src/routes/hooks';
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
import { ProfileCover } from '../profile-cover';
|
||||||
|
import { ProfileFollowers } from '../profile-followers';
|
||||||
|
import { ProfileFriends } from '../profile-friends';
|
||||||
|
import { ProfileGallery } from '../profile-gallery';
|
||||||
|
import { ProfileHome } from '../profile-home';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
label: 'Profile',
|
||||||
|
icon: <Iconify width={24} icon="solar:user-id-bold" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'followers',
|
||||||
|
label: 'Followers',
|
||||||
|
icon: <Iconify width={24} icon="solar:heart-bold" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'friends',
|
||||||
|
label: 'Friends',
|
||||||
|
icon: <Iconify width={24} icon="solar:users-group-rounded-bold" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gallery',
|
||||||
|
label: 'Gallery',
|
||||||
|
icon: <Iconify width={24} icon="solar:gallery-wide-bold" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TAB_PARAM = 'tab';
|
||||||
|
|
||||||
|
export function UserProfileView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const selectedTab = searchParams.get(TAB_PARAM) ?? '';
|
||||||
|
|
||||||
|
const { user } = useMockedUser();
|
||||||
|
|
||||||
|
const [searchFriends, setSearchFriends] = useState('');
|
||||||
|
|
||||||
|
const handleSearchFriends = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchFriends(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createRedirectPath = (currentPath: string, query: string) => {
|
||||||
|
const queryString = new URLSearchParams({ [TAB_PARAM]: query }).toString();
|
||||||
|
return query ? `${currentPath}?${queryString}` : currentPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="Profile"
|
||||||
|
links={[
|
||||||
|
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||||
|
{ name: t('User'), href: paths.dashboard.user.root },
|
||||||
|
{ name: user?.displayName },
|
||||||
|
]}
|
||||||
|
sx={{ mb: { xs: 3, md: 5 } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card sx={{ mb: 3, height: 290 }}>
|
||||||
|
<ProfileCover
|
||||||
|
role={_userAbout.role}
|
||||||
|
name={user?.displayName}
|
||||||
|
avatarUrl={user?.photoURL}
|
||||||
|
coverUrl={_userAbout.coverUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 1,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 9,
|
||||||
|
px: { md: 3 },
|
||||||
|
display: 'flex',
|
||||||
|
position: 'absolute',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
justifyContent: { xs: 'center', md: 'flex-end' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs value={selectedTab}>
|
||||||
|
{NAV_ITEMS.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
component={RouterLink}
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
icon={tab.icon}
|
||||||
|
label={t(tab.label)}
|
||||||
|
href={createRedirectPath(pathname, tab.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedTab === '' && <ProfileHome info={_userAbout} posts={_userFeeds} />}
|
||||||
|
|
||||||
|
{selectedTab === 'followers' && <ProfileFollowers followers={_userFollowers} />}
|
||||||
|
|
||||||
|
{selectedTab === 'friends' && (
|
||||||
|
<ProfileFriends
|
||||||
|
friends={_userFriends}
|
||||||
|
searchFriends={searchFriends}
|
||||||
|
onSearchFriends={handleSearchFriends}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTab === 'gallery' && <ProfileGallery gallery={_userGallery} />}
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
@@ -188,10 +188,11 @@ export function ProductNewEditForm({ currentProduct }: Props) {
|
|||||||
const values = watch();
|
const values = watch();
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (data) => {
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
const updatedData = {
|
// TODO: remove unused code
|
||||||
...data,
|
// const updatedData = {
|
||||||
taxes: includeTaxes ? defaultValues.taxes : data.taxes,
|
// ...data,
|
||||||
};
|
// taxes: includeTaxes ? defaultValues.taxes : data.taxes,
|
||||||
|
// };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// sanitize file field
|
// sanitize file field
|
||||||
|
102
03_source/frontend/src/types/party-user.ts
Normal file
102
03_source/frontend/src/types/party-user.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { IDateValue, ISocialLink } from './common';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type IPartyUserTableFilters = {
|
||||||
|
name: string;
|
||||||
|
role: string[];
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPartyUserProfileCover = {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
coverUrl: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPartyUserProfile = {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
quote: string;
|
||||||
|
email: string;
|
||||||
|
school: string;
|
||||||
|
country: string;
|
||||||
|
company: string;
|
||||||
|
totalFollowers: number;
|
||||||
|
totalFollowing: number;
|
||||||
|
socialLinks: ISocialLink;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPartyUserProfileFollower = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPartyUserProfileGallery = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
imageUrl: string;
|
||||||
|
postedAt: IDateValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPartyUserProfileFriend = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPartyUserProfilePost = {
|
||||||
|
id: string;
|
||||||
|
media: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: IDateValue;
|
||||||
|
personLikes: { name: string; avatarUrl: string }[];
|
||||||
|
comments: {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: IDateValue;
|
||||||
|
author: { id: string; name: string; avatarUrl: string };
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPartyUserCard = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
coverUrl: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
totalPosts: number;
|
||||||
|
totalFollowers: number;
|
||||||
|
totalFollowing: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPartyUserItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
role: string;
|
||||||
|
email: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
address: string;
|
||||||
|
country: string;
|
||||||
|
zipCode: string;
|
||||||
|
company: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
isVerified: boolean;
|
||||||
|
//
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IPartyUserAccountBillingHistory = {
|
||||||
|
id: string;
|
||||||
|
price: number;
|
||||||
|
invoiceNumber: string;
|
||||||
|
createdAt: IDateValue;
|
||||||
|
};
|
17
03_source/mobile/dockerfile
Normal file
17
03_source/mobile/dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Use official Node 18 base image
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# Install pnpm globally
|
||||||
|
# RUN npm install -g pnpm
|
||||||
|
RUN apt update
|
||||||
|
RUN apt-get install -qqy psmisc
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy your application code (optional, comment out if not needed)
|
||||||
|
# COPY . /app
|
||||||
|
# RUN yarn
|
||||||
|
|
||||||
|
# Default command (optional)
|
||||||
|
CMD ["npm run start"]
|
@@ -7,6 +7,9 @@ clear
|
|||||||
while true; do
|
while true; do
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
|
killall node
|
||||||
|
killall yarn
|
||||||
|
|
||||||
echo "restarting..."
|
echo "restarting..."
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
npm i -D
|
killall node
|
||||||
|
killall yarn
|
||||||
rm -rf ./**/*Zone.Identifier
|
rm -rf ./**/*Zone.Identifier
|
||||||
|
|
||||||
|
npm i -D
|
||||||
|
|
||||||
set -ex
|
set -ex
|
||||||
|
|
||||||
npm run format
|
npm run format
|
||||||
|
@@ -48,16 +48,24 @@ import { setIsLoggedIn, setUsername, loadUserData } from './data/user/user.actio
|
|||||||
import Account from './pages/Account';
|
import Account from './pages/Account';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import MyLogin from './pages/MyLogin';
|
import MyLogin from './pages/MyLogin';
|
||||||
|
import PartyUserLogin from './pages/PartyUserLogin';
|
||||||
import Signup from './pages/Signup';
|
import Signup from './pages/Signup';
|
||||||
import Support from './pages/Support';
|
import Support from './pages/Support';
|
||||||
import Tutorial from './pages/Tutorial';
|
import Tutorial from './pages/Tutorial';
|
||||||
import HomeOrTutorial from './components/HomeOrTutorial';
|
import HomeOrTutorial from './components/HomeOrTutorial';
|
||||||
import { Schedule } from './models/Schedule';
|
import { Schedule } from './models/Schedule';
|
||||||
import RedirectToLogin from './components/RedirectToLogin';
|
import RedirectToLogin from './components/RedirectToLogin';
|
||||||
import AppRoute from './AppRoute';
|
|
||||||
|
|
||||||
import AppDemoRoute from './routes/DemoRoute';
|
import AppDemoRoute from './routes/DemoRoute';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
import PATHS from './PATHS';
|
||||||
|
import NotImplemented from './pages/NotImplemented';
|
||||||
|
import ChangeLanguage from './pages/ChangeLanguage';
|
||||||
|
import ServiceAgreement from './pages/ServiceAgreement';
|
||||||
|
import PrivacyAgreement from './pages/PrivacyAgreement';
|
||||||
|
import EventDetail from './pages/EventDetail';
|
||||||
|
import MemberProfile from './pages/MemberProfile';
|
||||||
|
import OrderDetail from './pages/OrderDetail';
|
||||||
|
|
||||||
setupIonicReact();
|
setupIonicReact();
|
||||||
|
|
||||||
@@ -84,6 +92,7 @@ interface DispatchProps {
|
|||||||
interface IonicAppProps extends StateProps, DispatchProps {}
|
interface IonicAppProps extends StateProps, DispatchProps {}
|
||||||
|
|
||||||
const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn, setUsername, loadConfData, loadUserData }) => {
|
const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn, setUsername, loadConfData, loadUserData }) => {
|
||||||
|
// Load initial user and conference data when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUserData();
|
loadUserData();
|
||||||
loadConfData();
|
loadConfData();
|
||||||
@@ -104,7 +113,6 @@ const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn,
|
|||||||
which makes transitions between tabs and non tab pages smooth
|
which makes transitions between tabs and non tab pages smooth
|
||||||
*/}
|
*/}
|
||||||
|
|
||||||
<AppRoute />
|
|
||||||
<AppDemoRoute />
|
<AppDemoRoute />
|
||||||
|
|
||||||
<Route path="/tabs" render={() => <MainTabs />} />
|
<Route path="/tabs" render={() => <MainTabs />} />
|
||||||
@@ -116,7 +124,20 @@ const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn,
|
|||||||
<Route path="/signup" component={Signup} />
|
<Route path="/signup" component={Signup} />
|
||||||
<Route path="/support" component={Support} />
|
<Route path="/support" component={Support} />
|
||||||
<Route path="/tutorial" component={Tutorial} />
|
<Route path="/tutorial" component={Tutorial} />
|
||||||
<Route path="/settings" component={Settings} />
|
{/* */}
|
||||||
|
<Route exact={true} path={PATHS.SETTINGS} component={Settings} />
|
||||||
|
<Route exact={true} path={PATHS.CHANGE_LANGUAGE} component={ChangeLanguage} />
|
||||||
|
<Route exact={true} path={PATHS.SERVICE_AGREEMENT} component={ServiceAgreement} />
|
||||||
|
<Route exact={true} path={PATHS.PRIVACY_AGREEMENT} component={PrivacyAgreement} />
|
||||||
|
|
||||||
|
{/* Event and profile detail pages */}
|
||||||
|
<Route exact={true} path="/event_detail/:id" component={EventDetail} />
|
||||||
|
<Route exact={true} path="/profile/:id" component={MemberProfile} />
|
||||||
|
|
||||||
|
{/* component make the ":id" available in the "OrderDetail" */}
|
||||||
|
<Route exact={true} path="/order_detail/:id" component={OrderDetail} />
|
||||||
|
|
||||||
|
<Route exact={true} path="/helloworld" component={Helloworld} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/logout"
|
path="/logout"
|
||||||
@@ -124,6 +145,12 @@ const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn,
|
|||||||
return <RedirectToLogin setIsLoggedIn={setIsLoggedIn} setUsername={setUsername} />;
|
return <RedirectToLogin setIsLoggedIn={setIsLoggedIn} setUsername={setUsername} />;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* PartyUser */}
|
||||||
|
<Route path={PATHS.PARTY_USER_SIGN_IN} component={PartyUserLogin} />
|
||||||
|
|
||||||
|
<Route exact={true} path={PATHS.NOT_IMPLEMENTED} component={NotImplemented} />
|
||||||
|
|
||||||
<Route path="/" component={HomeOrTutorial} exact />
|
<Route path="/" component={HomeOrTutorial} exact />
|
||||||
</IonRouterOutlet>
|
</IonRouterOutlet>
|
||||||
</IonSplitPane>
|
</IonSplitPane>
|
||||||
@@ -147,3 +174,7 @@ const IonicAppConnected = connect<{}, StateProps, DispatchProps>({
|
|||||||
},
|
},
|
||||||
component: IonicApp,
|
component: IonicApp,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function Helloworld() {
|
||||||
|
return <>helloworld</>;
|
||||||
|
}
|
||||||
|
@@ -1,39 +0,0 @@
|
|||||||
//
|
|
||||||
// pages without bottom tab bar
|
|
||||||
//
|
|
||||||
|
|
||||||
import { Route } from 'react-router';
|
|
||||||
import NotImplemented from './pages/NotImplemented';
|
|
||||||
import EventDetail from './pages/EventDetail';
|
|
||||||
import MemberProfile from './pages/MemberProfile';
|
|
||||||
import PATHS from './PATHS';
|
|
||||||
import Settings from './pages/Settings';
|
|
||||||
import ChangeLanguage from './pages/ChangeLanguage';
|
|
||||||
import ServiceAgreement from './pages/ServiceAgreement';
|
|
||||||
import PrivacyAgreement from './pages/PrivacyAgreement';
|
|
||||||
// import OrderDetails from './pages/OrderDetail';
|
|
||||||
import OrderDetail from './pages/OrderDetail';
|
|
||||||
|
|
||||||
const AppRoute: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Route path="/not_implemented" component={NotImplemented} />
|
|
||||||
|
|
||||||
{/* */}
|
|
||||||
<Route exact={true} path="/event_detail/:id" component={EventDetail} />
|
|
||||||
<Route exact={true} path="/profile/:id" component={MemberProfile} />
|
|
||||||
|
|
||||||
{/* component make the ":id" available in the "OrderDetail" */}
|
|
||||||
<Route exact={true} path="/order_detail/:id" component={OrderDetail} />
|
|
||||||
{/* <Route path="/tabs/speakers/:id" component={SpeakerDetail} exact={true} /> */}
|
|
||||||
|
|
||||||
{/* */}
|
|
||||||
<Route exact={true} path={PATHS.SETTINGS} component={Settings} />
|
|
||||||
<Route exact={true} path={PATHS.CHANGE_LANGUAGE} component={ChangeLanguage} />
|
|
||||||
<Route exact={true} path={PATHS.SERVICE_AGREEMENT} component={ServiceAgreement} />
|
|
||||||
<Route exact={true} path={PATHS.PRIVACY_AGREEMENT} component={PrivacyAgreement} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AppRoute;
|
|
@@ -1,3 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Centralized route path constants for the application
|
||||||
|
*
|
||||||
|
* Contains:
|
||||||
|
* - Main app routes
|
||||||
|
* - Tab navigation routes
|
||||||
|
* - Demo/example routes
|
||||||
|
* - Helper functions for dynamic routes
|
||||||
|
*/
|
||||||
const PATHS = {
|
const PATHS = {
|
||||||
NOT_IMPLEMENTED: '/not_implemented',
|
NOT_IMPLEMENTED: '/not_implemented',
|
||||||
SETTINGS: '/settings',
|
SETTINGS: '/settings',
|
||||||
@@ -5,10 +14,11 @@ const PATHS = {
|
|||||||
SERVICE_AGREEMENT: '/service_agreement',
|
SERVICE_AGREEMENT: '/service_agreement',
|
||||||
PRIVACY_AGREEMENT: '/privacy_agreement',
|
PRIVACY_AGREEMENT: '/privacy_agreement',
|
||||||
SIGN_IN: '/mylogin',
|
SIGN_IN: '/mylogin',
|
||||||
//
|
|
||||||
|
// Order-related routes
|
||||||
ORDER_DETAIL: '/order_detail/:id',
|
ORDER_DETAIL: '/order_detail/:id',
|
||||||
getOrderDetail: (id: string) => `/order_detail/${id}`,
|
getOrderDetail: (id: string) => `/order_detail/${id}`,
|
||||||
//
|
// Tab navigation routes
|
||||||
TAB_NOT_IMPLEMENTED: '/tabs/not_implemented',
|
TAB_NOT_IMPLEMENTED: '/tabs/not_implemented',
|
||||||
EVENT_LIST: `/tabs/events`,
|
EVENT_LIST: `/tabs/events`,
|
||||||
MESSAGE_LIST: `/tabs/messages`,
|
MESSAGE_LIST: `/tabs/messages`,
|
||||||
@@ -17,6 +27,13 @@ const PATHS = {
|
|||||||
FAVOURITES_LIST: `/tabs/favourites`,
|
FAVOURITES_LIST: `/tabs/favourites`,
|
||||||
PROFILE: '/tabs/my_profile',
|
PROFILE: '/tabs/my_profile',
|
||||||
|
|
||||||
|
// partyUser
|
||||||
|
PARTY_USER_SIGN_IN: '/partyUserlogin',
|
||||||
|
PARTY_USER_SIGN_UP: '/partyUserSignUp',
|
||||||
|
|
||||||
|
//
|
||||||
|
TABS_DEBUG: '/tabs/debug',
|
||||||
|
|
||||||
//
|
//
|
||||||
// DEMO_WEATHER_APP: '/demo-weather-app',
|
// DEMO_WEATHER_APP: '/demo-weather-app',
|
||||||
DEMO_WEATHER_APP_UI: '/demo-weather-app-ui',
|
DEMO_WEATHER_APP_UI: '/demo-weather-app-ui',
|
||||||
|
@@ -1,3 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Route definitions for pages that should show the bottom tab navigation
|
||||||
|
*
|
||||||
|
* These routes are typically main app sections like:
|
||||||
|
* - Nearby members
|
||||||
|
* - Orders
|
||||||
|
* - Messages
|
||||||
|
* - Favorites
|
||||||
|
* - Events
|
||||||
|
* - Profile
|
||||||
|
* - Demo pages
|
||||||
|
*/
|
||||||
import { Route } from 'react-router';
|
import { Route } from 'react-router';
|
||||||
import NotImplemented from './pages/NotImplemented';
|
import NotImplemented from './pages/NotImplemented';
|
||||||
import EventDetail from './pages/EventDetail';
|
import EventDetail from './pages/EventDetail';
|
||||||
@@ -12,6 +24,7 @@ import EventList from './pages/EventList';
|
|||||||
import Helloworld from './pages/Helloworld';
|
import Helloworld from './pages/Helloworld';
|
||||||
// import WeatherDemo from './pages/WeatherDemo/Tab1';
|
// import WeatherDemo from './pages/WeatherDemo/Tab1';
|
||||||
import DemoList from './pages/DemoList';
|
import DemoList from './pages/DemoList';
|
||||||
|
import DebugPage from './pages/DebugPage';
|
||||||
// import DemoReactShop from './pages/DemoReactShop';
|
// import DemoReactShop from './pages/DemoReactShop';
|
||||||
|
|
||||||
const TabAppRoute: React.FC = () => {
|
const TabAppRoute: React.FC = () => {
|
||||||
@@ -19,29 +32,31 @@ const TabAppRoute: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<Route path={PATHS.TAB_NOT_IMPLEMENTED} component={NotImplemented} />
|
<Route path={PATHS.TAB_NOT_IMPLEMENTED} component={NotImplemented} />
|
||||||
|
|
||||||
{/* */}
|
{/* Displays list of members nearby with distance and contact info */}
|
||||||
<Route path={PATHS.NEARBY_LIST} render={() => <MembersNearByList />} exact={true} />
|
<Route path={PATHS.NEARBY_LIST} render={() => <MembersNearByList />} exact={true} />
|
||||||
|
|
||||||
{/* */}
|
{/* Shows user's current and past orders with status */}
|
||||||
<Route path={PATHS.ORDERS_LIST} render={() => <OrderList />} exact={true} />
|
<Route path={PATHS.ORDERS_LIST} render={() => <OrderList />} exact={true} />
|
||||||
|
|
||||||
{/* */}
|
{/* Message inbox showing conversations with other members */}
|
||||||
<Route path={PATHS.MESSAGE_LIST} render={() => <MessageList />} exact={true} />
|
<Route path={PATHS.MESSAGE_LIST} render={() => <MessageList />} exact={true} />
|
||||||
|
|
||||||
{/* */}
|
{/* List of favorited members and events */}
|
||||||
<Route path={PATHS.FAVOURITES_LIST} render={() => <Favourites />} exact={true} />
|
<Route path={PATHS.FAVOURITES_LIST} render={() => <Favourites />} exact={true} />
|
||||||
|
|
||||||
{/* */}
|
{/* Upcoming and past events calendar view */}
|
||||||
<Route path={PATHS.EVENT_LIST} render={() => <EventList />} exact={true} />
|
<Route path={PATHS.EVENT_LIST} render={() => <EventList />} exact={true} />
|
||||||
|
|
||||||
{/* */}
|
{/* User's profile page with personal info and settings */}
|
||||||
<Route path={PATHS.PROFILE} render={() => <MyProfile />} exact={true} />
|
<Route path={PATHS.PROFILE} render={() => <MyProfile />} exact={true} />
|
||||||
|
|
||||||
{/* */}
|
{/* Demo features list for development/testing */}
|
||||||
<Route path="/tabs/demo-list" render={() => <DemoList />} exact={true} />
|
<Route path="/tabs/demo-list" render={() => <DemoList />} exact={true} />
|
||||||
|
|
||||||
{/* */}
|
{/* Simple hello world test page */}
|
||||||
<Route path="/tabs/helloworld" render={() => <Helloworld />} exact={true} />
|
<Route path="/tabs/helloworld" render={() => <Helloworld />} exact={true} />
|
||||||
|
|
||||||
|
<Route path={PATHS.TABS_DEBUG} render={() => <DebugPage />} exact={true} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -1,6 +1,16 @@
|
|||||||
const isDev = import.meta.env.DEV;
|
const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
|
// TODO: Rename API_ENDPOINT to API_HOST in next major version
|
||||||
|
// Current API endpoint configuration - uses different values for dev/prod
|
||||||
|
const API_ENDPOINT = isDev
|
||||||
|
? import.meta.env.VITE_API_ENDPOINT
|
||||||
|
: import.meta.env.VITE_PROD_API_ENDPOINT;
|
||||||
|
|
||||||
const constants = {
|
const constants = {
|
||||||
API_ENDPOINT: isDev ? import.meta.env.VITE_API_ENDPOINT : import.meta.env.VITE_PROD_API_ENDPOINT,
|
// Base API endpoint URL (e.g. '//localhost:7272' or '//api.example.com')
|
||||||
|
// Used to construct all API request URLs
|
||||||
|
API_ENDPOINT,
|
||||||
|
SIGN_IN: `${API_ENDPOINT}/api/party-user-auth/sign-in`,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!constants.API_ENDPOINT) {
|
if (!constants.API_ENDPOINT) {
|
||||||
|
@@ -16,6 +16,8 @@ const USERNAME = 'username';
|
|||||||
const ACCESS_TOKEN = 'a_token';
|
const ACCESS_TOKEN = 'a_token';
|
||||||
const ACTIVE_SESSION = 'a_session';
|
const ACTIVE_SESSION = 'a_session';
|
||||||
|
|
||||||
|
const PARTY_USER_META = 'party_user_meta';
|
||||||
|
|
||||||
export const getConfData = async () => {
|
export const getConfData = async () => {
|
||||||
console.log({ t: constants.API_ENDPOINT });
|
console.log({ t: constants.API_ENDPOINT });
|
||||||
|
|
||||||
@@ -89,14 +91,21 @@ export const getUserData = async () => {
|
|||||||
Storage.get({ key: HAS_LOGGED_IN }),
|
Storage.get({ key: HAS_LOGGED_IN }),
|
||||||
Storage.get({ key: HAS_SEEN_TUTORIAL }),
|
Storage.get({ key: HAS_SEEN_TUTORIAL }),
|
||||||
Storage.get({ key: USERNAME }),
|
Storage.get({ key: USERNAME }),
|
||||||
|
Storage.get({ key: PARTY_USER_META }),
|
||||||
]);
|
]);
|
||||||
const isLoggedin = (await response[0].value) === 'true';
|
const isLoggedin = (await response[0].value) === 'true';
|
||||||
const hasSeenTutorial = (await response[1].value) === 'true';
|
const hasSeenTutorial = (await response[1].value) === 'true';
|
||||||
const username = (await response[2].value) || undefined;
|
const username = (await response[2].value) || undefined;
|
||||||
|
|
||||||
|
let result = (await response[3].value) || undefined;
|
||||||
|
const meta = result ? JSON.parse(result) : undefined;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
isLoggedin,
|
isLoggedin,
|
||||||
hasSeenTutorial,
|
hasSeenTutorial,
|
||||||
username,
|
username,
|
||||||
|
|
||||||
|
meta,
|
||||||
};
|
};
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
@@ -120,6 +129,14 @@ export const setUsernameData = async (username?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setPartyUserMetaData = async (party_user?: Record<string, any>) => {
|
||||||
|
if (!party_user) {
|
||||||
|
await Storage.remove({ key: PARTY_USER_META });
|
||||||
|
} else {
|
||||||
|
await Storage.set({ key: PARTY_USER_META, value: JSON.stringify(party_user) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const setAccessTokenData = async (accessToken?: string) => {
|
export const setAccessTokenData = async (accessToken?: string) => {
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
await Storage.remove({ key: ACCESS_TOKEN });
|
await Storage.remove({ key: ACCESS_TOKEN });
|
||||||
|
@@ -1,3 +1,16 @@
|
|||||||
|
// selectors.ts - Redux selectors for application state
|
||||||
|
//
|
||||||
|
// Contains memoized selector functions that:
|
||||||
|
// - Derive computed data from the Redux store
|
||||||
|
// - Filter and transform state for UI components
|
||||||
|
// - Optimize performance by memoizing results
|
||||||
|
//
|
||||||
|
// Key selectors:
|
||||||
|
// - getFilteredSchedule: Filters sessions by track
|
||||||
|
// - getSearchedSchedule: Filters sessions by search text
|
||||||
|
// - getGroupedFavorites: Gets favorited sessions grouped by time
|
||||||
|
// - Various entity getters (getSession, getSpeaker, etc.)
|
||||||
|
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { Schedule, Session, ScheduleGroup } from '../models/Schedule';
|
import { Schedule, Session, ScheduleGroup } from '../models/Schedule';
|
||||||
import { Speaker } from '../models/Speaker';
|
import { Speaker } from '../models/Speaker';
|
||||||
@@ -195,3 +208,6 @@ export const mapCenter = (state: AppState) => {
|
|||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPartyUserUsername = (state: AppState) => state.user.username;
|
||||||
|
export const getPartyUserState = (state: AppState) => state.user;
|
||||||
|
@@ -1,9 +1,19 @@
|
|||||||
import { combineReducers } from './combineReducers';
|
// state.ts - Defines the Redux store state shape and reducers
|
||||||
//
|
//
|
||||||
|
// Initial state structure:
|
||||||
|
// - data: Contains app data like sessions, speakers, events etc.
|
||||||
|
// - user: User preferences and authentication state
|
||||||
|
// - locations: Location data for maps and navigation
|
||||||
|
// - order: Order and transaction related state
|
||||||
|
|
||||||
|
import { combineReducers } from './combineReducers';
|
||||||
|
|
||||||
|
// Main feature reducers
|
||||||
import { sessionsReducer } from './sessions/sessions.reducer';
|
import { sessionsReducer } from './sessions/sessions.reducer';
|
||||||
import { userReducer } from './user/user.reducer';
|
import { userReducer } from './user/user.reducer';
|
||||||
import { locationsReducer } from './locations/locations.reducer';
|
import { locationsReducer } from './locations/locations.reducer';
|
||||||
//
|
|
||||||
|
// Additional feature reducers
|
||||||
import { orderReducer } from './sessions/orders.reducer';
|
import { orderReducer } from './sessions/orders.reducer';
|
||||||
|
|
||||||
export const initialState: AppState = {
|
export const initialState: AppState = {
|
||||||
|
@@ -6,6 +6,7 @@ import {
|
|||||||
setAccessTokenData,
|
setAccessTokenData,
|
||||||
getAccessTokenData,
|
getAccessTokenData,
|
||||||
setActiveSessionData,
|
setActiveSessionData,
|
||||||
|
setPartyUserMetaData,
|
||||||
} from '../dataApi';
|
} from '../dataApi';
|
||||||
import { ActionType } from '../../util/types';
|
import { ActionType } from '../../util/types';
|
||||||
import { UserState } from './user.state';
|
import { UserState } from './user.state';
|
||||||
@@ -34,6 +35,14 @@ export const setData = (data: Partial<UserState>) =>
|
|||||||
data,
|
data,
|
||||||
}) as const;
|
}) as const;
|
||||||
|
|
||||||
|
export const setPartyUserMeta = async (partyUserMeta: Record<string, any>) => {
|
||||||
|
await setPartyUserMetaData(partyUserMeta);
|
||||||
|
return {
|
||||||
|
type: 'set-party-user-meta',
|
||||||
|
partyUserMeta,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
|
|
||||||
export const logoutUser = () => async (dispatch: React.Dispatch<any>) => {
|
export const logoutUser = () => async (dispatch: React.Dispatch<any>) => {
|
||||||
//
|
//
|
||||||
await setIsLoggedInData(false);
|
await setIsLoggedInData(false);
|
||||||
@@ -124,6 +133,7 @@ export const setDarkMode = (darkMode: boolean) =>
|
|||||||
export type UserActions =
|
export type UserActions =
|
||||||
| ActionType<typeof setLoading>
|
| ActionType<typeof setLoading>
|
||||||
| ActionType<typeof setData>
|
| ActionType<typeof setData>
|
||||||
|
| ActionType<typeof setPartyUserMeta>
|
||||||
| ActionType<typeof setIsLoggedIn>
|
| ActionType<typeof setIsLoggedIn>
|
||||||
| ActionType<typeof setUsername>
|
| ActionType<typeof setUsername>
|
||||||
| ActionType<typeof setHasSeenTutorial>
|
| ActionType<typeof setHasSeenTutorial>
|
||||||
|
@@ -19,6 +19,9 @@ export function userReducer(state: UserState, action: UserActions): UserState {
|
|||||||
return { ...state, token: action.token };
|
return { ...state, token: action.token };
|
||||||
case 'check-user-session':
|
case 'check-user-session':
|
||||||
return { ...state, isSessionValid: action.sessionValid };
|
return { ...state, isSessionValid: action.sessionValid };
|
||||||
|
case 'set-party-user-meta':
|
||||||
|
return { ...state, meta: action.partyUserMeta };
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { ...state };
|
return { ...state };
|
||||||
}
|
}
|
||||||
|
@@ -7,4 +7,19 @@ export interface UserState {
|
|||||||
isSessionValid: boolean;
|
isSessionValid: boolean;
|
||||||
session?: any;
|
session?: any;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
|
||||||
|
//
|
||||||
|
meta?: {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
company?: string;
|
||||||
|
role?: string;
|
||||||
|
rank?: string;
|
||||||
|
isVerified?: Boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
accessToken?: string;
|
||||||
}
|
}
|
||||||
|
13
03_source/mobile/src/pages/DebugPage/TestContent.tsx
Normal file
13
03_source/mobile/src/pages/DebugPage/TestContent.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const TestContent = {
|
||||||
|
eventDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
title: 'helloworld',
|
||||||
|
price: 123,
|
||||||
|
currency: 'HKD',
|
||||||
|
duration_m: 480,
|
||||||
|
ageBottom: 12,
|
||||||
|
ageTop: 48,
|
||||||
|
location: 'Hong Kong Island',
|
||||||
|
avatar: 'https://www.ionics.io/img/ionic-logo.png',
|
||||||
|
};
|
100
03_source/mobile/src/pages/DebugPage/index.tsx
Normal file
100
03_source/mobile/src/pages/DebugPage/index.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// REQ0054/user-setting
|
||||||
|
//
|
||||||
|
// PURPOSE:
|
||||||
|
// - Provides functionality view user profile
|
||||||
|
//
|
||||||
|
// RULES:
|
||||||
|
// - T.B.A.
|
||||||
|
//
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
IonHeader,
|
||||||
|
IonToolbar,
|
||||||
|
IonTitle,
|
||||||
|
IonContent,
|
||||||
|
IonPage,
|
||||||
|
IonButtons,
|
||||||
|
useIonRouter,
|
||||||
|
IonButton,
|
||||||
|
IonIcon,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { Speaker } from '../../models/Speaker';
|
||||||
|
import { Session } from '../../models/Schedule';
|
||||||
|
import { connect } from '../../data/connect';
|
||||||
|
import * as selectors from '../../data/selectors';
|
||||||
|
import '../SpeakerList.scss';
|
||||||
|
import { chevronBackOutline, settingsOutline } from 'ionicons/icons';
|
||||||
|
import { logoutUser, setAccessToken, setIsLoggedIn } from '../../data/user/user.actions';
|
||||||
|
import { UserState } from '../../data/user/user.state';
|
||||||
|
|
||||||
|
interface OwnProps {}
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
speakers: Speaker[];
|
||||||
|
speakerSessions: { [key: string]: Session[] };
|
||||||
|
partyUserState: UserState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
logoutUser: typeof logoutUser;
|
||||||
|
setAccessToken: typeof setAccessToken;
|
||||||
|
setIsLoggedIn: typeof setIsLoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageProps extends OwnProps, StateProps, DispatchProps {}
|
||||||
|
|
||||||
|
const DemoList: React.FC<PageProps> = ({ partyUserState }) => {
|
||||||
|
const router = useIonRouter();
|
||||||
|
|
||||||
|
function handleBackButtonClick() {
|
||||||
|
router.goBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage id="speaker-list">
|
||||||
|
<IonHeader translucent={true} className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonButton shape="round" onClick={() => handleBackButtonClick()}>
|
||||||
|
<IonIcon slot="icon-only" icon={chevronBackOutline}></IonIcon>
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||||
|
<IonIcon icon={settingsOutline} size="large"></IonIcon>
|
||||||
|
<IonTitle>Debug page</IonTitle>
|
||||||
|
</div>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent fullscreen={true}>
|
||||||
|
<IonHeader collapse="condense">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle size="large">Debug page</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<div>helloworld debug page</div>
|
||||||
|
<pre>{JSON.stringify({ partyUserState }, null, 2)}</pre>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect<OwnProps, StateProps, DispatchProps>({
|
||||||
|
mapStateToProps: (state) => {
|
||||||
|
console.log({ state });
|
||||||
|
return {
|
||||||
|
speakers: selectors.getSpeakers(state),
|
||||||
|
speakerSessions: selectors.getSpeakerSessions(state),
|
||||||
|
//
|
||||||
|
partyUserState: selectors.getPartyUserState(state),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mapDispatchToProps: {
|
||||||
|
logoutUser,
|
||||||
|
setAccessToken,
|
||||||
|
setIsLoggedIn,
|
||||||
|
},
|
||||||
|
component: React.memo(DemoList),
|
||||||
|
});
|
103
03_source/mobile/src/pages/DebugPage/style.scss
Normal file
103
03_source/mobile/src/pages/DebugPage/style.scss
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
#about-page {
|
||||||
|
ion-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
--background: transparent;
|
||||||
|
--color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-toolbar ion-back-button,
|
||||||
|
ion-toolbar ion-button,
|
||||||
|
ion-toolbar ion-menu-button {
|
||||||
|
--color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header .about-image {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
transition: opacity 500ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header .madison {
|
||||||
|
background-image: url('/assets/img/about/madison.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header .austin {
|
||||||
|
background-image: url('/assets/img/about/austin.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header .chicago {
|
||||||
|
background-image: url('/assets/img/about/chicago.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header .seattle {
|
||||||
|
background-image: url('/assets/img/about/seattle.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info {
|
||||||
|
position: relative;
|
||||||
|
margin-top: -10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--ion-background-color, #fff);
|
||||||
|
z-index: 2; // display rounded border above header image
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info ion-list {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info p {
|
||||||
|
line-height: 130%;
|
||||||
|
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info ion-icon {
|
||||||
|
margin-inline-end: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* iOS Only
|
||||||
|
*/
|
||||||
|
|
||||||
|
.ios .about-info {
|
||||||
|
--ion-padding: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios .about-info h3 {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#date-input-popover {
|
||||||
|
--offset-y: -var(--ion-safe-area-bottom);
|
||||||
|
|
||||||
|
--max-width: 90%;
|
||||||
|
--width: 336px;
|
||||||
|
}
|
14
03_source/mobile/src/pages/DebugPage/types.ts
Normal file
14
03_source/mobile/src/pages/DebugPage/types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export interface Event {
|
||||||
|
eventDate: Date;
|
||||||
|
joinMembers: undefined;
|
||||||
|
title: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
duration_m: number;
|
||||||
|
ageBottom: number;
|
||||||
|
ageTop: number;
|
||||||
|
location: string;
|
||||||
|
avatar: string;
|
||||||
|
//
|
||||||
|
id: string;
|
||||||
|
}
|
@@ -73,7 +73,7 @@ interface StateProps {}
|
|||||||
|
|
||||||
interface DispatchProps {}
|
interface DispatchProps {}
|
||||||
|
|
||||||
interface EventDetailProps extends OwnProps, StateProps, DispatchProps {}
|
interface PageProps extends OwnProps, StateProps, DispatchProps {}
|
||||||
|
|
||||||
const showJoinedMembers = (joinMembers: Record<string, any>[]) => {
|
const showJoinedMembers = (joinMembers: Record<string, any>[]) => {
|
||||||
const avatars = joinMembers.map((jm) => jm.avatar);
|
const avatars = joinMembers.map((jm) => jm.avatar);
|
||||||
@@ -90,7 +90,7 @@ const showJoinedMembers = (joinMembers: Record<string, any>[]) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EventDetail: React.FC<EventDetailProps> = ({ event_detail }) => {
|
const EventDetail: React.FC<PageProps> = ({ event_detail }) => {
|
||||||
const router = useIonRouter();
|
const router = useIonRouter();
|
||||||
|
|
||||||
const [showPopover, setShowPopover] = useState(false);
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
|
30
03_source/mobile/src/pages/Login/Login.scss
Normal file
30
03_source/mobile/src/pages/Login/Login.scss
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Shared Authentication Styles
|
||||||
|
*
|
||||||
|
* Contains common styling for:
|
||||||
|
* - Login page
|
||||||
|
* - Signup page
|
||||||
|
* - Support page
|
||||||
|
*
|
||||||
|
* Key features:
|
||||||
|
* - Logo positioning and sizing
|
||||||
|
* - Form list styling
|
||||||
|
* - Responsive design for all screen sizes
|
||||||
|
*/
|
||||||
|
#login-page,
|
||||||
|
#signup-page,
|
||||||
|
#support-page {
|
||||||
|
.login-logo {
|
||||||
|
padding: 20px 0;
|
||||||
|
min-height: 200px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo img {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user