Compare commits
14 Commits
a4d0d8b746
...
develop/mo
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b80939c78d | ||
![]() |
779984f65c | ||
![]() |
8bb6c9e992 | ||
![]() |
60ecca48b4 | ||
![]() |
7a6014a115 | ||
![]() |
37ace98e60 | ||
![]() |
44091e0432 | ||
![]() |
279496ea38 | ||
![]() |
a450747670 | ||
![]() |
215476cfaa | ||
![]() |
c93b31b2f6 | ||
![]() |
4cf93f431e | ||
![]() |
2b09261f0a | ||
![]() |
1325a361dc |
@@ -18,3 +18,4 @@ T.B.A.
|
||||
|
||||
develop/requirements/REQ0188
|
||||
develop/frontend/party-user/trunk
|
||||
develop/frontend/party-user-auth/trunk
|
||||
|
20
01_Requirements/REQ0189/index.md
Normal file
20
01_Requirements/REQ0189/index.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
tags: mobile, payment
|
||||
---
|
||||
|
||||
# REQ0189 party payment flow
|
||||
|
||||
frontend page to handle party-user pay join event
|
||||
|
||||
edit page T.B.A.
|
||||
|
||||
## TODO
|
||||
|
||||
## sources
|
||||
|
||||
T.B.A.
|
||||
|
||||
## branch
|
||||
|
||||
develop/requirements/REQ0189
|
||||
develop/mobile/DummyPayPage/trunk
|
@@ -1,3 +1,5 @@
|
||||
// src/cms_backend/eslint.config.mjs
|
||||
//
|
||||
import globals from 'globals';
|
||||
import eslintJs from '@eslint/js';
|
||||
import eslintTs from 'typescript-eslint';
|
||||
@@ -69,10 +71,7 @@ const importRules = () => ({
|
||||
*/
|
||||
const unusedImportsRules = () => ({
|
||||
'unused-imports/no-unused-imports': 1,
|
||||
'unused-imports/no-unused-vars': [
|
||||
0,
|
||||
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
|
||||
],
|
||||
'unused-imports/no-unused-vars': [0, { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -93,15 +92,17 @@ const sortImportsRules = () => {
|
||||
|
||||
return {
|
||||
'perfectionist/sort-named-imports': [1, { type: 'line-length', order: 'asc' }],
|
||||
'perfectionist/sort-named-exports': [1, { type: 'line-length', order: 'asc' }],
|
||||
'perfectionist/sort-exports': [
|
||||
1,
|
||||
{
|
||||
order: 'asc',
|
||||
type: 'line-length',
|
||||
groupKind: 'values-first',
|
||||
},
|
||||
],
|
||||
|
||||
// disable sorting of export, i manage the export ordering
|
||||
// 'perfectionist/sort-named-exports': [1, { type: 'line-length', order: 'asc' }],
|
||||
// 'perfectionist/sort-exports': [
|
||||
// 1,
|
||||
// {
|
||||
// order: 'asc',
|
||||
// type: 'line-length',
|
||||
// groupKind: 'values-first',
|
||||
// },
|
||||
// ],
|
||||
'perfectionist/sort-imports': [
|
||||
2,
|
||||
{
|
||||
|
@@ -30,7 +30,6 @@ model Account {
|
||||
session_state String?
|
||||
oauth_token_secret String?
|
||||
oauth_token String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
PartyUser PartyUser? @relation(fields: [partyUserId], references: [id])
|
||||
partyUserId String?
|
||||
@@ -1290,4 +1289,7 @@ model PartyUser {
|
||||
city String @default("")
|
||||
address String @default("")
|
||||
zipCode String @default("")
|
||||
//
|
||||
rank String @default("user")
|
||||
sex String @default("")
|
||||
}
|
||||
|
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -51,6 +58,7 @@ async function partyUser() {
|
||||
status: STATUS[0],
|
||||
role: ROLE[0],
|
||||
isVerified: true,
|
||||
sex: 'F',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,15 +67,19 @@ async function partyUser() {
|
||||
update: {},
|
||||
create: {
|
||||
email: 'demo@minimals.cc',
|
||||
name: 'Demo',
|
||||
username: 'pudemo',
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,6 +114,7 @@ async function partyUser() {
|
||||
role: ROLE[Math.floor(Math.random() * ROLE.length)],
|
||||
status: STATUS[Math.floor(Math.random() * STATUS.length)],
|
||||
isVerified: true,
|
||||
sex: i % 2 ? 'F' : 'M',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@@ -1,5 +1,7 @@
|
||||
###
|
||||
|
||||
# username and password ok
|
||||
|
||||
POST http://localhost:7272/api/auth/sign-in
|
||||
content-type: application/json
|
||||
|
||||
@@ -9,7 +11,9 @@ content-type: application/json
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
# There is no user corresponding to the email address.
|
||||
|
||||
POST http://localhost:7272/api/auth/sign-in
|
||||
content-type: application/json
|
||||
|
||||
@@ -19,7 +23,9 @@ content-type: application/json
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
# Wrong password
|
||||
|
||||
POST http://localhost:7272/api/auth/sign-in
|
||||
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,76 @@
|
||||
// src/app/api/party-user-auth/me/route.ts
|
||||
//
|
||||
// PURPOSE:
|
||||
// - Handle authentication for party users via JWT
|
||||
// - Verify and decode JWT tokens
|
||||
// - Return current authenticated party user details
|
||||
// - Log all access attempts (success/failure)
|
||||
// - Validate token structure and user existence
|
||||
//
|
||||
import type { NextRequest } from 'next/server';
|
||||
import type { PartyUser } from '@prisma/client';
|
||||
|
||||
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 { createAccessLog } from 'src/app/services/access-log.service';
|
||||
import { getPartyUserById } from 'src/app/services/party-user.service';
|
||||
|
||||
import { flattenNextjsRequest } from '../sign-in/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_TOKEN_CHECK_FAILED = 'user token check failed';
|
||||
const ERR_INVALID_AUTH_TOKEN = 'Invalid authorization token';
|
||||
const ERR_USER_ID_NOT_FOUND = 'userId not found';
|
||||
const ERR_AUTHORIZATION_TOKEN_MISSING_OR_INVALID = 'Authorization token missing or invalid';
|
||||
|
||||
const USER_TOKEN_OK = 'user token check ok';
|
||||
|
||||
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: ERR_AUTHORIZATION_TOKEN_MISSING_OR_INVALID }, STATUS.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const accessToken = `${authorization}`.split(' ')[1];
|
||||
const data = await verify(accessToken, JWT_SECRET);
|
||||
|
||||
if (data.userId) {
|
||||
// TODO: remove me
|
||||
// const currentUser = _users.find((user) => user.id === data.userId);
|
||||
// const currentUser: User | null = await getUserById(data.userId);
|
||||
const currentUser: PartyUser | null = await getPartyUserById(data.userId);
|
||||
|
||||
if (!currentUser) {
|
||||
createAccessLog('', ERR_USER_TOKEN_CHECK_FAILED, debug);
|
||||
|
||||
return response({ message: ERR_INVALID_AUTH_TOKEN }, STATUS.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
createAccessLog(currentUser.id, USER_TOKEN_OK, debug);
|
||||
|
||||
return response({ user: currentUser }, STATUS.OK);
|
||||
} else {
|
||||
return response({ message: ERR_USER_ID_NOT_FOUND }, STATUS.ERROR);
|
||||
}
|
||||
} catch (error) {
|
||||
return handleError('[Auth] - Me', error);
|
||||
}
|
||||
}
|
@@ -0,0 +1,38 @@
|
||||
// Test cases for Party User Authentication endpoints
|
||||
// Tests both successful and error scenarios
|
||||
// Environment: http://localhost:7272
|
||||
// Expected responses:
|
||||
// - 200 OK with user data for valid tokens
|
||||
// - 401 Unauthorized for invalid/missing tokens
|
||||
// - 400 Bad Request for invalid credentials
|
||||
|
||||
###
|
||||
|
||||
# username and password ok
|
||||
|
||||
GET http://localhost:7272/api/party-user-auth/me
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbWMwdWo4azIwMDBxM2Y1eTZlNXJzejRxIiwiaWF0IjoxNzUwMjEzOTkwLCJleHAiOjE3NTE0MjM1OTB9.MoKv3Nmrp_blE0jQ1rG1WyQ_TrJeF7kSe5xfHrF8b64
|
||||
|
||||
###
|
||||
|
||||
# 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@minimals.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,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);
|
||||
}
|
||||
}
|
@@ -47,6 +47,10 @@ async function getEvent(eventId: string): Promise<EventItem | null> {
|
||||
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) {
|
||||
// return prisma.event.create({ data: createForm });
|
||||
// }
|
||||
@@ -68,4 +72,5 @@ export {
|
||||
// updateEvent,
|
||||
// deleteEvent,
|
||||
// createNewEvent,
|
||||
getEventItemById,
|
||||
};
|
||||
|
@@ -1,14 +1,18 @@
|
||||
// src/app/services/user.service.ts
|
||||
// src/app/services/party-user.service.ts
|
||||
//
|
||||
// PURPOSE:
|
||||
// - Handle User Record CRUD operations
|
||||
// - Handle Party User Record CRUD operations
|
||||
// - Manage party member data and permissions
|
||||
// - Interface between controllers and database
|
||||
//
|
||||
// RULES:
|
||||
// - Follow Prisma best practices for database operations
|
||||
// - Validate input data before processing
|
||||
// - Validate all party user data before processing
|
||||
// - Enforce party-specific business rules
|
||||
// - Maintain audit trail for sensitive operations
|
||||
//
|
||||
|
||||
import type { User, PartyUser } from '@prisma/client';
|
||||
import type { PartyUser } from '@prisma/client';
|
||||
|
||||
import prisma from '../lib/prisma';
|
||||
|
||||
@@ -41,8 +45,14 @@ async function getPartyUser(partyUserId: string): Promise<PartyUser | null> {
|
||||
});
|
||||
}
|
||||
|
||||
async function getUserById(id: string): Promise<User | null> {
|
||||
return prisma.user.findFirst({ where: { id } });
|
||||
async function getPartyUserByEmail(email: string): Promise<PartyUser | null> {
|
||||
return prisma.partyUser.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
}
|
||||
|
||||
async function getPartyUserById(id: string): Promise<PartyUser | null> {
|
||||
return prisma.partyUser.findFirst({ where: { id } });
|
||||
}
|
||||
|
||||
async function createPartyUser(partyUserData: any): Promise<PartyUser> {
|
||||
@@ -63,13 +73,15 @@ async function deletePartyUser(partyUserId: string): Promise<PartyUser | null> {
|
||||
}
|
||||
|
||||
export {
|
||||
getUserById,
|
||||
getPartyUserById,
|
||||
getPartyUser,
|
||||
listPartyUsers,
|
||||
createPartyUser,
|
||||
updatePartyUser,
|
||||
deletePartyUser,
|
||||
//
|
||||
getPartyUserByEmail,
|
||||
//
|
||||
type CreateUser,
|
||||
type UpdateUser,
|
||||
//
|
||||
|
@@ -1,4 +1,4 @@
|
||||
// src/actions/party-user.ts
|
||||
// src/actions/party-user1.ts
|
||||
//
|
||||
import { useMemo } from 'react';
|
||||
import axiosInstance, { endpoints, fetcher } from 'src/lib/axios';
|
||||
@@ -21,7 +21,15 @@ type PartyUsersData = {
|
||||
partyUsers: IPartyUserItem[];
|
||||
};
|
||||
|
||||
// TODO: i want to refactor / tidy here
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -73,7 +81,16 @@ type SearchResultsData = {
|
||||
results: IProductItem[];
|
||||
};
|
||||
|
||||
// TODO: update useSearchProducts
|
||||
/**
|
||||
* 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 } }] : '';
|
||||
|
||||
@@ -117,6 +134,15 @@ type SaveUserData = {
|
||||
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
|
||||
@@ -144,6 +170,15 @@ export async function createPartyUser(partyUserData: CreateUserData) {
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -183,6 +218,14 @@ export async function updatePartyUser(partyUserData: Partial<IPartyUserItem>) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } }] : '';
|
||||
@@ -213,6 +256,15 @@ type CreateUserData = {
|
||||
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
|
||||
|
@@ -48,16 +48,26 @@ import { setIsLoggedIn, setUsername, loadUserData } from './data/user/user.actio
|
||||
import Account from './pages/Account';
|
||||
import Login from './pages/Login';
|
||||
import MyLogin from './pages/MyLogin';
|
||||
import PartyUserLogin from './pages/PartyUserLogin';
|
||||
import Signup from './pages/Signup';
|
||||
import Support from './pages/Support';
|
||||
import Tutorial from './pages/Tutorial';
|
||||
import HomeOrTutorial from './components/HomeOrTutorial';
|
||||
import { Schedule } from './models/Schedule';
|
||||
import RedirectToLogin from './components/RedirectToLogin';
|
||||
import AppRoute from './AppRoute';
|
||||
|
||||
import AppDemoRoute from './routes/DemoRoute';
|
||||
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';
|
||||
import DummyPayPage from './pages/DummyEventPayPage';
|
||||
import DummyEventPayPage from './pages/DummyEventPayPage';
|
||||
|
||||
setupIonicReact();
|
||||
|
||||
@@ -84,6 +94,7 @@ interface DispatchProps {
|
||||
interface IonicAppProps extends StateProps, DispatchProps {}
|
||||
|
||||
const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn, setUsername, loadConfData, loadUserData }) => {
|
||||
// Load initial user and conference data when component mounts
|
||||
useEffect(() => {
|
||||
loadUserData();
|
||||
loadConfData();
|
||||
@@ -104,7 +115,6 @@ const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn,
|
||||
which makes transitions between tabs and non tab pages smooth
|
||||
*/}
|
||||
|
||||
<AppRoute />
|
||||
<AppDemoRoute />
|
||||
|
||||
<Route path="/tabs" render={() => <MainTabs />} />
|
||||
@@ -116,7 +126,24 @@ const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn,
|
||||
<Route path="/signup" component={Signup} />
|
||||
<Route path="/support" component={Support} />
|
||||
<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="/dummy_pay_page" component={DummyPayPage} />
|
||||
|
||||
<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 exact={true} path={PATHS.DUMMY_EVENT_PAY_PAGE} component={DummyEventPayPage} />
|
||||
|
||||
<Route
|
||||
path="/logout"
|
||||
@@ -124,6 +151,12 @@ const IonicApp: React.FC<IonicAppProps> = ({ darkMode, schedule, setIsLoggedIn,
|
||||
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 />
|
||||
</IonRouterOutlet>
|
||||
</IonSplitPane>
|
||||
@@ -147,3 +180,7 @@ const IonicAppConnected = connect<{}, StateProps, DispatchProps>({
|
||||
},
|
||||
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 = {
|
||||
NOT_IMPLEMENTED: '/not_implemented',
|
||||
SETTINGS: '/settings',
|
||||
@@ -5,10 +14,11 @@ const PATHS = {
|
||||
SERVICE_AGREEMENT: '/service_agreement',
|
||||
PRIVACY_AGREEMENT: '/privacy_agreement',
|
||||
SIGN_IN: '/mylogin',
|
||||
//
|
||||
|
||||
// Order-related routes
|
||||
ORDER_DETAIL: '/order_detail/:id',
|
||||
getOrderDetail: (id: string) => `/order_detail/${id}`,
|
||||
//
|
||||
// Tab navigation routes
|
||||
TAB_NOT_IMPLEMENTED: '/tabs/not_implemented',
|
||||
EVENT_LIST: `/tabs/events`,
|
||||
MESSAGE_LIST: `/tabs/messages`,
|
||||
@@ -17,6 +27,15 @@ const PATHS = {
|
||||
FAVOURITES_LIST: `/tabs/favourites`,
|
||||
PROFILE: '/tabs/my_profile',
|
||||
|
||||
// partyUser
|
||||
PARTY_USER_SIGN_IN: '/partyUserlogin',
|
||||
PARTY_USER_SIGN_UP: '/partyUserSignUp',
|
||||
|
||||
DUMMY_EVENT_PAY_PAGE: '/DummyEventPayPage',
|
||||
|
||||
//
|
||||
TABS_DEBUG: '/tabs/debug',
|
||||
|
||||
//
|
||||
// DEMO_WEATHER_APP: '/demo-weather-app',
|
||||
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 NotImplemented from './pages/NotImplemented';
|
||||
import EventDetail from './pages/EventDetail';
|
||||
@@ -12,6 +24,7 @@ import EventList from './pages/EventList';
|
||||
import Helloworld from './pages/Helloworld';
|
||||
// import WeatherDemo from './pages/WeatherDemo/Tab1';
|
||||
import DemoList from './pages/DemoList';
|
||||
import DebugPage from './pages/DebugPage';
|
||||
// import DemoReactShop from './pages/DemoReactShop';
|
||||
|
||||
const TabAppRoute: React.FC = () => {
|
||||
@@ -19,29 +32,31 @@ const TabAppRoute: React.FC = () => {
|
||||
<>
|
||||
<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} />
|
||||
|
||||
{/* */}
|
||||
{/* Shows user's current and past orders with status */}
|
||||
<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} />
|
||||
|
||||
{/* */}
|
||||
{/* List of favorited members and events */}
|
||||
<Route path={PATHS.FAVOURITES_LIST} render={() => <Favourites />} exact={true} />
|
||||
|
||||
{/* */}
|
||||
{/* Upcoming and past events calendar view */}
|
||||
<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} />
|
||||
|
||||
{/* */}
|
||||
{/* Demo features list for development/testing */}
|
||||
<Route path="/tabs/demo-list" render={() => <DemoList />} exact={true} />
|
||||
|
||||
{/* */}
|
||||
{/* Simple hello world test page */}
|
||||
<Route path="/tabs/helloworld" render={() => <Helloworld />} exact={true} />
|
||||
|
||||
<Route path={PATHS.TABS_DEBUG} render={() => <DebugPage />} exact={true} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -1,6 +1,17 @@
|
||||
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 = {
|
||||
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`,
|
||||
PARTY_USER_JOIN_EVENT: `${API_ENDPOINT}/api/event/partyUserJoinEvent`,
|
||||
};
|
||||
|
||||
if (!constants.API_ENDPOINT) {
|
||||
|
@@ -16,6 +16,8 @@ const USERNAME = 'username';
|
||||
const ACCESS_TOKEN = 'a_token';
|
||||
const ACTIVE_SESSION = 'a_session';
|
||||
|
||||
const PARTY_USER_META = 'party_user_meta';
|
||||
|
||||
export const getConfData = async () => {
|
||||
console.log({ t: constants.API_ENDPOINT });
|
||||
|
||||
@@ -89,14 +91,21 @@ export const getUserData = async () => {
|
||||
Storage.get({ key: HAS_LOGGED_IN }),
|
||||
Storage.get({ key: HAS_SEEN_TUTORIAL }),
|
||||
Storage.get({ key: USERNAME }),
|
||||
Storage.get({ key: PARTY_USER_META }),
|
||||
]);
|
||||
const isLoggedin = (await response[0].value) === 'true';
|
||||
const hasSeenTutorial = (await response[1].value) === 'true';
|
||||
const username = (await response[2].value) || undefined;
|
||||
|
||||
let result = (await response[3].value) || undefined;
|
||||
const meta = result ? JSON.parse(result) : undefined;
|
||||
|
||||
const data = {
|
||||
isLoggedin,
|
||||
hasSeenTutorial,
|
||||
username,
|
||||
|
||||
meta,
|
||||
};
|
||||
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) => {
|
||||
if (!accessToken) {
|
||||
await Storage.remove({ key: ACCESS_TOKEN });
|
||||
|
14
03_source/mobile/src/data/dummy/dummy.actions.ts
Normal file
14
03_source/mobile/src/data/dummy/dummy.actions.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// import { setIsLoggedInData } from '../dataApi';
|
||||
import { ActionType } from '../../util/types';
|
||||
|
||||
export const setEventIdToJoin = (eventId: string) => {
|
||||
// await setIsLoggedInData(loggedIn);
|
||||
|
||||
return {
|
||||
type: 'set-dummy-event-id-to-join',
|
||||
eventId,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export type DummyActions = ActionType<typeof setEventIdToJoin>;
|
||||
// | ActionType<typeof checkUserSession>
|
13
03_source/mobile/src/data/dummy/dummy.reducer.ts
Normal file
13
03_source/mobile/src/data/dummy/dummy.reducer.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DummyActions as DummyActions } from './dummy.actions';
|
||||
import { DummyState as DummyState } from './dummy.state';
|
||||
|
||||
export function dummyReducer(state: DummyState, action: DummyActions): DummyState {
|
||||
switch (action.type) {
|
||||
case 'set-dummy-event-id-to-join':
|
||||
console.log('reducer called');
|
||||
|
||||
return { ...state, eventIdToJoin: action.eventId };
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
}
|
3
03_source/mobile/src/data/dummy/dummy.state.ts
Normal file
3
03_source/mobile/src/data/dummy/dummy.state.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface DummyState {
|
||||
eventIdToJoin?: string;
|
||||
}
|
@@ -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 { Schedule, Session, ScheduleGroup } from '../models/Schedule';
|
||||
import { Speaker } from '../models/Speaker';
|
||||
@@ -195,3 +208,8 @@ export const mapCenter = (state: AppState) => {
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
export const getPartyUserUsername = (state: AppState) => state.user.username;
|
||||
export const getPartyUserState = (state: AppState) => state.user;
|
||||
|
||||
export const getEventIdToJoin = (state: AppState) => state.dummy.eventIdToJoin;
|
||||
|
@@ -1,10 +1,21 @@
|
||||
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 { userReducer } from './user/user.reducer';
|
||||
import { locationsReducer } from './locations/locations.reducer';
|
||||
//
|
||||
|
||||
// Additional feature reducers
|
||||
import { orderReducer } from './sessions/orders.reducer';
|
||||
import { dummyReducer } from './dummy/dummy.reducer';
|
||||
|
||||
export const initialState: AppState = {
|
||||
data: {
|
||||
@@ -30,10 +41,14 @@ export const initialState: AppState = {
|
||||
loading: false,
|
||||
//
|
||||
isSessionValid: false,
|
||||
//
|
||||
},
|
||||
locations: {
|
||||
locations: [],
|
||||
},
|
||||
dummy: {
|
||||
eventIdToJoin: '',
|
||||
},
|
||||
};
|
||||
|
||||
export const reducers = combineReducers({
|
||||
@@ -42,6 +57,7 @@ export const reducers = combineReducers({
|
||||
locations: locationsReducer,
|
||||
//
|
||||
order: orderReducer,
|
||||
dummy: dummyReducer,
|
||||
});
|
||||
|
||||
export type AppState = ReturnType<typeof reducers>;
|
||||
|
@@ -6,6 +6,7 @@ import {
|
||||
setAccessTokenData,
|
||||
getAccessTokenData,
|
||||
setActiveSessionData,
|
||||
setPartyUserMetaData,
|
||||
} from '../dataApi';
|
||||
import { ActionType } from '../../util/types';
|
||||
import { UserState } from './user.state';
|
||||
@@ -34,6 +35,14 @@ export const setData = (data: Partial<UserState>) =>
|
||||
data,
|
||||
}) 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>) => {
|
||||
//
|
||||
await setIsLoggedInData(false);
|
||||
@@ -124,6 +133,7 @@ export const setDarkMode = (darkMode: boolean) =>
|
||||
export type UserActions =
|
||||
| ActionType<typeof setLoading>
|
||||
| ActionType<typeof setData>
|
||||
| ActionType<typeof setPartyUserMeta>
|
||||
| ActionType<typeof setIsLoggedIn>
|
||||
| ActionType<typeof setUsername>
|
||||
| ActionType<typeof setHasSeenTutorial>
|
||||
|
@@ -19,6 +19,9 @@ export function userReducer(state: UserState, action: UserActions): UserState {
|
||||
return { ...state, token: action.token };
|
||||
case 'check-user-session':
|
||||
return { ...state, isSessionValid: action.sessionValid };
|
||||
case 'set-party-user-meta':
|
||||
return { ...state, meta: action.partyUserMeta };
|
||||
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
|
@@ -7,4 +7,19 @@ export interface UserState {
|
||||
isSessionValid: boolean;
|
||||
session?: any;
|
||||
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;
|
||||
}
|
131
03_source/mobile/src/pages/DummyEventPayPage/index.tsx
Normal file
131
03_source/mobile/src/pages/DummyEventPayPage/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
// REQ0041/home_discover_event_tab
|
||||
|
||||
import {
|
||||
IonPage,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonTitle,
|
||||
IonContent,
|
||||
useIonRouter,
|
||||
IonToast,
|
||||
} from '@ionic/react';
|
||||
import { chevronBackOutline, menuOutline } from 'ionicons/icons';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import './style.scss';
|
||||
import PATHS from '../../PATHS';
|
||||
import axios from 'axios';
|
||||
import { UserState } from '../../data/user/user.state';
|
||||
import { connect } from '../../data/connect';
|
||||
|
||||
import * as selectors from '../../data/selectors';
|
||||
import constants from '../../constants';
|
||||
|
||||
interface OwnProps {}
|
||||
|
||||
interface StateProps {
|
||||
isLoggedin: boolean;
|
||||
//
|
||||
partyUserState: UserState;
|
||||
//
|
||||
joinEventId: string;
|
||||
}
|
||||
|
||||
interface DispatchProps {}
|
||||
|
||||
interface PageProps extends OwnProps, StateProps, DispatchProps {}
|
||||
|
||||
const DummyPayPage: React.FC<PageProps> = ({
|
||||
isLoggedin,
|
||||
partyUserState,
|
||||
//
|
||||
joinEventId,
|
||||
}) => {
|
||||
const router = useIonRouter();
|
||||
|
||||
// if (!isLoggedin) return <NotLoggedIn />;
|
||||
|
||||
async function handlePayClick() {
|
||||
try {
|
||||
await axios.post(constants.PARTY_USER_JOIN_EVENT, {
|
||||
data: {
|
||||
eventItemId: joinEventId,
|
||||
email: partyUserState.meta?.email,
|
||||
},
|
||||
});
|
||||
|
||||
router.goBack();
|
||||
|
||||
setShowJoinOKToast(true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelClick() {
|
||||
router.goBack();
|
||||
}
|
||||
|
||||
const [showJoinOKToast, setShowJoinOKToast] = useState(false);
|
||||
|
||||
return (
|
||||
<IonPage id="speaker-list">
|
||||
<IonHeader translucent={true} className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
{/* <IonMenuButton /> */}
|
||||
<IonButton shape="round">
|
||||
<IonIcon slot="icon-only" icon={chevronBackOutline}></IonIcon>
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle>Dummy pay event page</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen={true}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
textAlign: 'center',
|
||||
|
||||
padding: '3rem',
|
||||
}}
|
||||
>
|
||||
<div>This is a dummy page to emulate payment gateway work</div>
|
||||
|
||||
<div>
|
||||
<div>pay for event</div>
|
||||
<pre style={{ backgroundColor: 'RGB(0,0,0, 0.1)' }}>{JSON.stringify(joinEventId)}</pre>
|
||||
</div>
|
||||
|
||||
<IonButton onClick={handlePayClick}>Pay</IonButton>
|
||||
<IonButton onClick={handleCancelClick}>Cancel</IonButton>
|
||||
</div>
|
||||
|
||||
<IonToast
|
||||
isOpen={showJoinOKToast}
|
||||
message="ok, event paid, thank you..."
|
||||
duration={2000}
|
||||
// onDidDismiss={() => setShowJoinOKToast(false)}
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect<OwnProps, StateProps, DispatchProps>({
|
||||
mapStateToProps: (state) => ({
|
||||
isLoggedin: state.user.isLoggedin,
|
||||
//
|
||||
joinEventId: selectors.getEventIdToJoin(state),
|
||||
//
|
||||
partyUserState: selectors.getPartyUserState(state),
|
||||
}),
|
||||
component: DummyPayPage,
|
||||
});
|
103
03_source/mobile/src/pages/DummyEventPayPage/style.scss
Normal file
103
03_source/mobile/src/pages/DummyEventPayPage/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;
|
||||
}
|
@@ -13,48 +13,27 @@ import {
|
||||
IonContent,
|
||||
IonPage,
|
||||
IonButtons,
|
||||
IonMenuButton,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonDatetime,
|
||||
IonSelectOption,
|
||||
IonList,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonSelect,
|
||||
IonPopover,
|
||||
IonText,
|
||||
IonFooter,
|
||||
useIonRouter,
|
||||
IonAvatar,
|
||||
} from '@ionic/react';
|
||||
import './style.scss';
|
||||
import {
|
||||
accessibility,
|
||||
accessibilityOutline,
|
||||
chevronBackOutline,
|
||||
ellipsisHorizontal,
|
||||
ellipsisVertical,
|
||||
heart,
|
||||
locationOutline,
|
||||
locationSharp,
|
||||
logoIonic,
|
||||
man,
|
||||
manOutline,
|
||||
people,
|
||||
peopleOutline,
|
||||
timer,
|
||||
timerOutline,
|
||||
timerSharp,
|
||||
wallet,
|
||||
walletOutline,
|
||||
walletSharp,
|
||||
woman,
|
||||
womanOutline,
|
||||
} from 'ionicons/icons';
|
||||
import AboutPopover from '../../components/AboutPopover';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { TestContent } from './TestContent';
|
||||
import { Helloworld } from '../../api/Helloworld';
|
||||
import { getEventById } from '../../api/getEventById';
|
||||
import { connect } from '../../data/connect';
|
||||
@@ -62,6 +41,9 @@ import * as selectors from '../../data/selectors';
|
||||
import { Event } from '../../models/Event';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import AvatarRow from './AvatarRow';
|
||||
import { setPartyUserMeta } from '../../data/user/user.actions';
|
||||
import { setEventIdToJoin } from '../../data/dummy/dummy.actions';
|
||||
import PATHS from '../../PATHS';
|
||||
|
||||
const leftShift: number = -25;
|
||||
|
||||
@@ -71,9 +53,12 @@ interface OwnProps extends RouteComponentProps {
|
||||
|
||||
interface StateProps {}
|
||||
|
||||
interface DispatchProps {}
|
||||
interface DispatchProps {
|
||||
setPartyUserMeta: typeof setPartyUserMeta;
|
||||
setEventIdToJoin: typeof setEventIdToJoin;
|
||||
}
|
||||
|
||||
interface EventDetailProps extends OwnProps, StateProps, DispatchProps {}
|
||||
interface PageProps extends OwnProps, StateProps, DispatchProps {}
|
||||
|
||||
const showJoinedMembers = (joinMembers: Record<string, any>[]) => {
|
||||
const avatars = joinMembers.map((jm) => jm.avatar);
|
||||
@@ -90,7 +75,7 @@ const showJoinedMembers = (joinMembers: Record<string, any>[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
const EventDetail: React.FC<EventDetailProps> = ({ event_detail }) => {
|
||||
const EventDetail: React.FC<PageProps> = ({ event_detail, setEventIdToJoin }) => {
|
||||
const router = useIonRouter();
|
||||
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
@@ -136,6 +121,13 @@ const EventDetail: React.FC<EventDetailProps> = ({ event_detail }) => {
|
||||
router.goBack();
|
||||
}
|
||||
|
||||
function handleJoinClick() {
|
||||
if (event_detail && event_detail?.id) {
|
||||
setEventIdToJoin(event_detail.id);
|
||||
router.push(PATHS.DUMMY_EVENT_PAY_PAGE);
|
||||
}
|
||||
}
|
||||
|
||||
if (!event_detail) return <>loading</>;
|
||||
|
||||
return (
|
||||
@@ -268,7 +260,7 @@ const EventDetail: React.FC<EventDetailProps> = ({ event_detail }) => {
|
||||
margin: '1rem',
|
||||
}}
|
||||
>
|
||||
<IonButton expand="full" shape="round">
|
||||
<IonButton expand="full" shape="round" onClick={handleJoinClick}>
|
||||
Join
|
||||
</IonButton>
|
||||
</div>
|
||||
@@ -286,8 +278,10 @@ const EventDetail: React.FC<EventDetailProps> = ({ event_detail }) => {
|
||||
};
|
||||
|
||||
export default connect({
|
||||
mapDispatchToProps: {
|
||||
setEventIdToJoin,
|
||||
},
|
||||
mapStateToProps: (state, ownProps) => {
|
||||
console.log({ t1: selectors.getEvents(state) });
|
||||
return {
|
||||
event_detail: selectors.getEvent(state, ownProps),
|
||||
};
|
||||
|
@@ -14,7 +14,7 @@ import { menuOutline } from 'ionicons/icons';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import './style.scss';
|
||||
|
||||
const Helloworld: React.FC = () => {
|
||||
const Helloworld: React.FC = ({}) => {
|
||||
return (
|
||||
<IonPage id="speaker-list">
|
||||
<IonHeader translucent={true} className="ion-no-border">
|
||||
|
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;
|
||||
}
|
||||
}
|
@@ -1,3 +1,16 @@
|
||||
/**
|
||||
* Login Page Component
|
||||
*
|
||||
* Handles user authentication with:
|
||||
* - Username/password input
|
||||
* - Form validation
|
||||
* - Login state management
|
||||
* - Navigation to signup page
|
||||
*
|
||||
* Connects to Redux store for:
|
||||
* - Setting authentication state
|
||||
* - Storing username
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonHeader,
|
||||
@@ -16,8 +29,8 @@ import {
|
||||
IonText,
|
||||
} from '@ionic/react';
|
||||
import './Login.scss';
|
||||
import { setIsLoggedIn, setUsername } from '../data/user/user.actions';
|
||||
import { connect } from '../data/connect';
|
||||
import { setIsLoggedIn, setUsername } from '../../data/user/user.actions';
|
||||
import { connect } from '../../data/connect';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
|
||||
interface OwnProps extends RouteComponentProps {}
|
||||
@@ -59,12 +72,12 @@ const Login: React.FC<LoginProps> = ({
|
||||
|
||||
return (
|
||||
<IonPage id="login-page">
|
||||
<IonHeader>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonMenuButton></IonMenuButton>
|
||||
</IonButtons>
|
||||
<IonTitle>Login</IonTitle>
|
||||
<IonTitle>Example Login Page</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent>
|
@@ -1,11 +1,21 @@
|
||||
// REQ0053/profile-page
|
||||
//
|
||||
// PURPOSE:
|
||||
// - Provides functionality view user profile
|
||||
//
|
||||
// RULES:
|
||||
// - T.B.A.
|
||||
//
|
||||
/**
|
||||
* Not Logged In Profile View Component
|
||||
*
|
||||
* Displays when user is not authenticated, with:
|
||||
* - Visual placeholder for profile
|
||||
* - Login button (regular user)
|
||||
* - Party member login button
|
||||
* - Signup encouragement
|
||||
*
|
||||
* Features:
|
||||
* - Pull-to-refresh functionality
|
||||
* - Responsive design for all screen sizes
|
||||
* - Clear call-to-action buttons
|
||||
*
|
||||
* Connected to:
|
||||
* - Redux store for speaker data
|
||||
* - Router for navigation
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IonHeader,
|
||||
@@ -104,6 +114,18 @@ const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
||||
}
|
||||
}
|
||||
|
||||
const [disableForwardPartyUserLoginButton, setDisableForwardPartyUserLoginButton] =
|
||||
useState(false);
|
||||
function handleForwardPartyUserLoginPage() {
|
||||
try {
|
||||
setDisableForwardPartyUserLoginButton(true);
|
||||
router.push(PATHS.PARTY_USER_SIGN_IN);
|
||||
setDisableForwardPartyUserLoginButton(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getProfileById('2').then(({ data }) => {
|
||||
console.log({ data });
|
||||
@@ -180,6 +202,7 @@ const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
||||
not login yet, <br />
|
||||
please login or sign up
|
||||
</div>
|
||||
{/* */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -192,6 +215,24 @@ const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
||||
Login
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
{/* */}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonButton
|
||||
disabled={disableForwardPartyUserLoginButton}
|
||||
onClick={handleForwardPartyUserLoginPage}
|
||||
>
|
||||
Party Member Login
|
||||
</IonButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
|
@@ -57,6 +57,7 @@ import PATHS from '../../PATHS';
|
||||
import { getProfileById } from '../../api/getProfileById';
|
||||
import { defaultMember, Member } from '../MemberProfile/type';
|
||||
import NotLoggedIn from './NotLoggedIn';
|
||||
import { UserState } from '../../data/user/user.state';
|
||||
|
||||
interface OwnProps {}
|
||||
|
||||
@@ -65,13 +66,20 @@ interface StateProps {
|
||||
//
|
||||
speakers: Speaker[];
|
||||
speakerSessions: { [key: string]: Session[] };
|
||||
//
|
||||
partyUserState: UserState;
|
||||
}
|
||||
|
||||
interface DispatchProps {}
|
||||
|
||||
interface SpeakerListProps extends OwnProps, StateProps, DispatchProps {}
|
||||
interface PageProps extends OwnProps, StateProps, DispatchProps {}
|
||||
|
||||
const MyProfilePage: React.FC<SpeakerListProps> = ({ speakers, speakerSessions, isLoggedin }) => {
|
||||
const MyProfilePage: React.FC<PageProps> = ({
|
||||
speakers,
|
||||
speakerSessions,
|
||||
isLoggedin,
|
||||
partyUserState,
|
||||
}) => {
|
||||
if (!isLoggedin) return <NotLoggedIn />;
|
||||
|
||||
const [profile, setProfile] = useState<Member>(defaultMember);
|
||||
@@ -97,13 +105,6 @@ const MyProfilePage: React.FC<SpeakerListProps> = ({ speakers, speakerSessions,
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getProfileById('2').then(({ data }) => {
|
||||
console.log({ data });
|
||||
setProfile(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!profile) return <>loading</>;
|
||||
|
||||
return (
|
||||
@@ -150,21 +151,24 @@ const MyProfilePage: React.FC<SpeakerListProps> = ({ speakers, speakerSessions,
|
||||
<IonAvatar>
|
||||
<img
|
||||
alt="Silhouette of a person's head"
|
||||
src="https://plus.unsplash.com/premium_photo-1683121126477-17ef068309bc"
|
||||
src={partyUserState?.meta?.avatarUrl ? partyUserState.meta.avatarUrl : ''}
|
||||
/>
|
||||
</IonAvatar>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
//
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{profile.name}</div>
|
||||
<div style={{ fontSize: '0.8rem' }}>{profile.rank}</div>
|
||||
<div style={{ fontSize: '0.8rem' }}>{profile.verified}</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>
|
||||
{partyUserState.meta?.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem' }}>{partyUserState.meta?.rank}</div>
|
||||
<div style={{ fontSize: '0.8rem' }}>
|
||||
{partyUserState.meta?.isVerified ? 'verified' : 'no'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -337,6 +341,8 @@ export default connect<OwnProps, StateProps, DispatchProps>({
|
||||
speakers: selectors.getSpeakers(state),
|
||||
speakerSessions: selectors.getSpeakerSessions(state),
|
||||
isLoggedin: state.user.isLoggedin,
|
||||
//
|
||||
partyUserState: selectors.getPartyUserState(state),
|
||||
}),
|
||||
component: React.memo(MyProfilePage),
|
||||
});
|
||||
|
@@ -13,17 +13,8 @@ import {
|
||||
IonContent,
|
||||
IonPage,
|
||||
IonButtons,
|
||||
IonMenuButton,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonDatetime,
|
||||
IonSelectOption,
|
||||
IonList,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonSelect,
|
||||
IonPopover,
|
||||
IonText,
|
||||
IonFooter,
|
||||
useIonRouter,
|
||||
} from '@ionic/react';
|
||||
@@ -33,12 +24,7 @@ import {
|
||||
chevronBackOutline,
|
||||
ellipsisHorizontal,
|
||||
ellipsisVertical,
|
||||
heart,
|
||||
logoIonic,
|
||||
} from 'ionicons/icons';
|
||||
import AboutPopover from '../../components/AboutPopover';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { TestContent } from './TestContent';
|
||||
import { Helloworld } from '../../api/Helloworld';
|
||||
import { getEventById } from '../../api/getEventById';
|
||||
|
||||
@@ -60,25 +46,15 @@ interface Event {
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
const EventDetail: React.FC<AboutProps> = () => {
|
||||
const NotImplemented: React.FC<AboutProps> = () => {
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [popoverEvent, setPopoverEvent] = useState<MouseEvent>();
|
||||
const [location, setLocation] = useState<'madison' | 'austin' | 'chicago' | 'seattle'>('madison');
|
||||
const [conferenceDate, setConferenceDate] = useState('2047-05-17T00:00:00-05:00');
|
||||
|
||||
const selectOptions = {
|
||||
header: 'Select a Location',
|
||||
};
|
||||
|
||||
const presentPopover = (e: React.MouseEvent) => {
|
||||
setPopoverEvent(e.nativeEvent);
|
||||
setShowPopover(true);
|
||||
};
|
||||
|
||||
function displayDate(date: string, dateFormat: string) {
|
||||
return format(parseISO(date), dateFormat);
|
||||
}
|
||||
|
||||
const [eventDetail, setEventDetail] = useState<Event | null>(null);
|
||||
useEffect(() => {
|
||||
Helloworld();
|
||||
@@ -155,4 +131,4 @@ const EventDetail: React.FC<AboutProps> = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(EventDetail);
|
||||
export default React.memo(NotImplemented);
|
||||
|
16
03_source/mobile/src/pages/PartyUserLogin/Login.scss
Normal file
16
03_source/mobile/src/pages/PartyUserLogin/Login.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
}
|
14
03_source/mobile/src/pages/PartyUserLogin/endpoints.ts
Normal file
14
03_source/mobile/src/pages/PartyUserLogin/endpoints.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { isDev } from '../../constants';
|
||||
|
||||
const CMS_BACKEND_URL = isDev ? 'http://localhost:7272' : 'https://pa_mobile.louislabs.com';
|
||||
|
||||
const endpoints = {
|
||||
auth: {
|
||||
me: `${CMS_BACKEND_URL}/api/auth/me`,
|
||||
signIn: `${CMS_BACKEND_URL}/api/auth/sign-in`,
|
||||
signUp: `${CMS_BACKEND_URL}/api/auth/sign-up`,
|
||||
//
|
||||
},
|
||||
};
|
||||
|
||||
export { endpoints };
|
209
03_source/mobile/src/pages/PartyUserLogin/index.tsx
Normal file
209
03_source/mobile/src/pages/PartyUserLogin/index.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonContent,
|
||||
IonPage,
|
||||
IonButtons,
|
||||
IonMenuButton,
|
||||
IonRow,
|
||||
IonCol,
|
||||
IonButton,
|
||||
IonList,
|
||||
IonItem,
|
||||
IonInput,
|
||||
IonText,
|
||||
IonIcon,
|
||||
useIonRouter,
|
||||
IonToast,
|
||||
} from '@ionic/react';
|
||||
import './Login.scss';
|
||||
import {
|
||||
setIsLoggedIn,
|
||||
setUsername,
|
||||
setData,
|
||||
setPartyUserMeta,
|
||||
} from '../../data/user/user.actions';
|
||||
import { connect } from '../../data/connect';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { chevronBackOutline } from 'ionicons/icons';
|
||||
import PATHS from '../../PATHS';
|
||||
import axios from 'axios';
|
||||
import * as selectors from '../../data/selectors';
|
||||
import { UserState } from '../../data/user/user.state';
|
||||
import constants from '../../constants';
|
||||
|
||||
interface OwnProps extends RouteComponentProps {}
|
||||
|
||||
interface StateProps {
|
||||
partyUserState: UserState;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
setIsLoggedIn: typeof setIsLoggedIn;
|
||||
setUsername: typeof setUsername;
|
||||
setData: typeof setData;
|
||||
setPartyUserMeta: typeof setPartyUserMeta;
|
||||
}
|
||||
|
||||
interface LoginProps extends OwnProps, DispatchProps {}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({
|
||||
setIsLoggedIn,
|
||||
history,
|
||||
setUsername: setUsernameAction,
|
||||
setData,
|
||||
setPartyUserMeta,
|
||||
}) => {
|
||||
const [username, setUsername] = useState('demo@minimals.cc');
|
||||
const [email, setEmail] = useState('demo@minimals.cc');
|
||||
const [password, setPassword] = useState('@2Minimal');
|
||||
const [formSubmitted, setFormSubmitted] = useState(false);
|
||||
//
|
||||
const [usernameError, setUsernameError] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState(false);
|
||||
const [emailError, setEmailError] = useState(false);
|
||||
|
||||
const login = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormSubmitted(true);
|
||||
|
||||
// if (!username) {
|
||||
// setUsernameError(true);
|
||||
// }
|
||||
// if (!password) {
|
||||
// setPasswordError(true);
|
||||
// }
|
||||
|
||||
const emailAndPassword = { email, password };
|
||||
const result = await axios.post(constants.SIGN_IN, emailAndPassword);
|
||||
const { data, status } = result;
|
||||
const { accessToken, user } = data;
|
||||
|
||||
if (status == 200) {
|
||||
// if username and password ok
|
||||
setData({ isLoggedin: true, accessToken });
|
||||
setPartyUserMeta(user);
|
||||
|
||||
await setIsLoggedIn(true);
|
||||
await setUsernameAction(username);
|
||||
|
||||
setShowLoginOkToast(true);
|
||||
|
||||
router.push(PATHS.PROFILE);
|
||||
} else {
|
||||
// if username or password failed
|
||||
console.log({ result });
|
||||
}
|
||||
};
|
||||
|
||||
const router = useIonRouter();
|
||||
function handleBackClick() {
|
||||
router.goBack();
|
||||
}
|
||||
|
||||
const [showLoginOkToast, setShowLoginOkToast] = useState(false);
|
||||
|
||||
return (
|
||||
<IonPage id="login-page">
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={handleBackClick}>
|
||||
<IonIcon icon={chevronBackOutline}></IonIcon>
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle>PartyUser Login Page</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent>
|
||||
<div className="login-logo">
|
||||
<img src="/assets/img/appicon.svg" alt="Ionic logo" />
|
||||
</div>
|
||||
|
||||
<form noValidate onSubmit={login}>
|
||||
<IonList>
|
||||
<IonItem>
|
||||
<IonInput
|
||||
label="Email"
|
||||
labelPlacement="stacked"
|
||||
color="primary"
|
||||
name="email"
|
||||
type="text"
|
||||
value={email}
|
||||
spellCheck={false}
|
||||
autocapitalize="off"
|
||||
onIonInput={(e) => setEmail(e.detail.value as string)}
|
||||
required
|
||||
>
|
||||
{formSubmitted && emailError && (
|
||||
<IonText color="danger" slot="error">
|
||||
<p>Email is required</p>
|
||||
</IonText>
|
||||
)}
|
||||
</IonInput>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonInput
|
||||
label="Password"
|
||||
labelPlacement="stacked"
|
||||
color="primary"
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onIonInput={(e) => setPassword(e.detail.value as string)}
|
||||
>
|
||||
{formSubmitted && passwordError && (
|
||||
<IonText color="danger" slot="error">
|
||||
<p>Password is required</p>
|
||||
</IonText>
|
||||
)}
|
||||
</IonInput>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
|
||||
<IonList>
|
||||
<IonItem>
|
||||
<div>email and password prefilled for demo</div>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
|
||||
<IonRow>
|
||||
<IonCol>
|
||||
<IonButton type="submit" expand="block">
|
||||
Login
|
||||
</IonButton>
|
||||
</IonCol>
|
||||
<IonCol>
|
||||
<IonButton routerLink={PATHS.PARTY_USER_SIGN_UP} color="light" expand="block">
|
||||
Signup
|
||||
</IonButton>
|
||||
</IonCol>
|
||||
</IonRow>
|
||||
</form>
|
||||
|
||||
<IonToast
|
||||
isOpen={showLoginOkToast}
|
||||
message="login ok"
|
||||
duration={2000}
|
||||
onDidDismiss={() => setShowLoginOkToast(false)}
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect<OwnProps, StateProps, DispatchProps>({
|
||||
mapDispatchToProps: {
|
||||
setIsLoggedIn,
|
||||
setUsername,
|
||||
setData,
|
||||
setPartyUserMeta,
|
||||
},
|
||||
mapStateToProps: (state) => ({
|
||||
partyUserState: selectors.getPartyUserState(state),
|
||||
}),
|
||||
component: Login,
|
||||
});
|
22
03_source/mobile/src/pages/PartyUserLogin/isValidToken.tsx
Normal file
22
03_source/mobile/src/pages/PartyUserLogin/isValidToken.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { jwtDecode } from './jwtDecode';
|
||||
|
||||
function isValidToken(accessToken: string) {
|
||||
if (!accessToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwtDecode(accessToken);
|
||||
|
||||
if (!decoded || !('exp' in decoded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentTime = Date.now() / 1000;
|
||||
|
||||
return decoded.exp > currentTime;
|
||||
} catch (error) {
|
||||
console.error('Error during token validation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
19
03_source/mobile/src/pages/PartyUserLogin/jwtDecode.tsx
Normal file
19
03_source/mobile/src/pages/PartyUserLogin/jwtDecode.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function jwtDecode(token: string) {
|
||||
try {
|
||||
if (!token) return null;
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) {
|
||||
throw new Error('Invalid token!');
|
||||
}
|
||||
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const decoded = JSON.parse(atob(base64));
|
||||
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
console.error('Error decoding token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
23
03_source/mobile/src/pages/PartyUserLogin/style.scss
Normal file
23
03_source/mobile/src/pages/PartyUserLogin/style.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
#login-page, #signup-page, #support-page {
|
||||
.login-logo {
|
||||
min-height: 200px;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-logo img {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
ion-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
32
03_source/mobile/src/pages/Signup/Login.scss
Normal file
32
03_source/mobile/src/pages/Signup/Login.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shared Authentication Styles (Duplicate)
|
||||
*
|
||||
* Note: This file is identical to ../Login/Login.scss
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
}
|
@@ -1,3 +1,18 @@
|
||||
/**
|
||||
* Signup Page Component
|
||||
*
|
||||
* Handles new user registration with:
|
||||
* - Username/password input fields
|
||||
* - Form validation
|
||||
* - Registration state management
|
||||
*
|
||||
* Note: Currently shares styling and some logic with Login page
|
||||
* (imports Login.scss and similar state management)
|
||||
*
|
||||
* Connects to Redux store for:
|
||||
* - Setting authentication state
|
||||
* - Storing username
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonHeader,
|
||||
@@ -16,8 +31,8 @@ import {
|
||||
IonText,
|
||||
} from '@ionic/react';
|
||||
import './Login.scss';
|
||||
import { setIsLoggedIn, setUsername } from '../data/user/user.actions';
|
||||
import { connect } from '../data/connect';
|
||||
import { setIsLoggedIn, setUsername } from '../../data/user/user.actions';
|
||||
import { connect } from '../../data/connect';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
|
||||
interface OwnProps extends RouteComponentProps {}
|
@@ -29,3 +29,6 @@ A: No, AI should not. Most of the time the user passing the update job. Unless u
|
||||
|
||||
Q: What should I do when user ask to update git staged files ?
|
||||
A: You only need to update the comment with same format and detail levels of the staged files. Do not change any other code. The sibling files in the same directory is a good reference.
|
||||
|
||||
Q: which command should i use for check git stage ?
|
||||
A: `git status . ` to avoid hanging in the terminal
|
||||
|
@@ -22,5 +22,17 @@
|
||||
"path": "98_AI_workspace"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
"settings": {
|
||||
"editor.fontSize": 15
|
||||
},
|
||||
"extensions": {
|
||||
"recommendations": [
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"onatm.open-in-new-window",
|
||||
"Prisma.prisma",
|
||||
"humao.rest-client",
|
||||
"Gruntfuggly.todo-tree",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user