Compare commits

..

14 Commits

Author SHA1 Message Date
louiscklaw
b80939c78d update eslint to disable export sorting, 2025-06-18 10:54:30 +08:00
louiscklaw
779984f65c feat: enhance party user authentication endpoint with improved error handling, logging, and PartyUser model integration 2025-06-18 10:52:00 +08:00
louiscklaw
8bb6c9e992 update FAQ, 2025-06-18 10:39:57 +08:00
louiscklaw
60ecca48b4 feat: update workspace settings with font size configuration and recommended extensions 2025-06-18 10:32:12 +08:00
louiscklaw
7a6014a115 feat: implement event joining flow with dummy payment page, including route configuration, Redux state management, and UI updates for event detail page 2025-06-18 04:06:16 +08:00
louiscklaw
37ace98e60 feat: add party payment flow with dummy pay page implementation and route configuration 2025-06-18 02:32:09 +08:00
louiscklaw
44091e0432 feat: add party user gender field to schema and implement event joining functionality with gender tracking 2025-06-18 02:28:29 +08:00
louiscklaw
279496ea38 feat: remove AppRoute component and adjust related route configurations 2025-06-18 02:18:18 +08:00
louiscklaw
a450747670 feat: add new pages for event detail, member profile and order detail with corresponding route configurations 2025-06-18 01:20:27 +08:00
louiscklaw
215476cfaa feat: add party user metadata storage and display support, including storage API, Redux actions, state management, and UI updates for profile page 2025-06-18 01:14:37 +08:00
louiscklaw
c93b31b2f6 feat: implement party user authentication system with signin/signup routes, JWT token validation, and frontend integration including mobile route configuration and API service updates 2025-06-18 01:14:05 +08:00
louiscklaw
4cf93f431e feat: refine Account model schema with consistent formatting and add default rank field to PartyUser model 2025-06-18 00:23:12 +08:00
louiscklaw
2b09261f0a feat: implement login and signup pages with shared authentication styles, including form validation and Redux integration for authentication state management 2025-06-17 23:47:18 +08:00
louiscklaw
1325a361dc feat: update requirement REQ0188 dependencies, add frontend/party-user-auth/trunk to development paths 2025-06-17 22:11:45 +08:00
54 changed files with 1669 additions and 170 deletions

View File

@@ -18,3 +18,4 @@ T.B.A.
develop/requirements/REQ0188
develop/frontend/party-user/trunk
develop/frontend/party-user-auth/trunk

View 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

View File

@@ -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,
{

View File

@@ -16,11 +16,11 @@ model Helloworld {
}
model Account {
id String @id @default(cuid())
userId String @map("user_id")
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String @map("provider_account_id")
providerAccountId String @map("provider_account_id")
refresh_token String?
access_token String?
expires_at Int?
@@ -30,10 +30,9 @@ 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?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
PartyUser PartyUser? @relation(fields: [partyUserId], references: [id])
partyUserId String?
@@unique([provider, providerAccountId])
}
@@ -1290,4 +1289,7 @@ model PartyUser {
city String @default("")
address String @default("")
zipCode String @default("")
//
rank String @default("user")
sex String @default("")
}

View File

@@ -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',
},
});
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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"
}
}

View File

@@ -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);
}
}

View File

@@ -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"
}

View File

@@ -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());
}

View File

@@ -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);
}
}

View File

@@ -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"
}

View File

@@ -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);
}
}

View File

@@ -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,
};

View File

@@ -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,
//

View File

@@ -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

View File

@@ -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</>;
}

View File

@@ -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;

View File

@@ -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',

View File

@@ -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} />
</>
);
};

View File

@@ -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) {

View File

@@ -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 });

View 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>

View 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 };
}
}

View File

@@ -0,0 +1,3 @@
export interface DummyState {
eventIdToJoin?: string;
}

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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>

View File

@@ -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 };
}

View File

@@ -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;
}

View 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',
};

View 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),
});

View 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;
}

View 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;
}

View 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,
});

View 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;
}

View File

@@ -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),
};

View File

@@ -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">

View 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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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),
});

View File

@@ -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);

View 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;
}
}

View 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 };

View 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,
});

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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"
]
}
}