Compare commits

..

8 Commits

44 changed files with 1286 additions and 123 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

@@ -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,68 @@
import type { User } from '@prisma/client';
import type { NextRequest } from 'next/server';
import { headers } from 'next/headers';
import { verify } from 'src/utils/jwt';
import { STATUS, response, handleError } from 'src/utils/response';
import { JWT_SECRET } from 'src/_mock/_auth';
import { getUserById } from 'src/app/services/user.service';
import { createAccessLog } from 'src/app/services/access-log.service';
import { flattenNextjsRequest } from '../sign-in/flattenNextjsRequest';
// ----------------------------------------------------------------------
// export const runtime = 'edge';
/**
* This API is used for demo purpose only
* You should use a real database
* You should hash the password before saving to database
* You should not save the password in the database
* You should not expose the JWT_SECRET in the client side
*/
const USER_TOKEN_CHECK_FAILED = 'user token check failed';
const INVALID_AUTH_TOKEN = 'Invalid authorization token';
const USER_ID_NOT_FOUND = 'userId not found';
const USER_TOKEN_OK = 'user token check ok';
const AUTHORIZATION_TOKEN_MISSING_OR_INVALID = 'Authorization token missing or invalid';
export async function GET(req: NextRequest) {
const debug = { 'req.headers': flattenNextjsRequest(req) };
try {
const headersList = headers();
const authorization = headersList.get('authorization');
if (!authorization || !authorization.startsWith('Bearer ')) {
return response({ message: AUTHORIZATION_TOKEN_MISSING_OR_INVALID }, STATUS.UNAUTHORIZED);
}
const accessToken = `${authorization}`.split(' ')[1];
const data = await verify(accessToken, JWT_SECRET);
console.log(data.userId);
if (data.userId) {
// TODO: remove me
// const currentUser = _users.find((user) => user.id === data.userId);
const currentUser: User | null = await getUserById(data.userId);
if (!currentUser) {
createAccessLog('', USER_TOKEN_CHECK_FAILED, debug);
return response({ message: INVALID_AUTH_TOKEN }, STATUS.UNAUTHORIZED);
}
createAccessLog(currentUser.id, USER_TOKEN_OK, debug);
return response({ user: currentUser }, STATUS.OK);
} else {
return response({ message: USER_ID_NOT_FOUND }, STATUS.ERROR);
}
} catch (error) {
return handleError('[Auth] - Me', error);
}
}

View File

@@ -0,0 +1,25 @@
###
# username and password ok
GET http://localhost:7272/api/auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbWJnbnUyengwMDBjaHEzaGZ3dmtjejlvIiwiaWF0IjoxNzQ4OTY0ODkyLCJleHAiOjE3NTAxNzQ0OTJ9.lo04laCxtm0IVeYaETEV3hXKyDmXPEn7SyWtY2VR4dI
###
# There is no user corresponding to the email address.
POST http://localhost:7272/api/auth/sign-in
content-type: application/json
{
"email": "demo@minimals1.cc",
"password": "@2Minimal"
}
###
# Wrong password
POST http://localhost:7272/api/auth/sign-in
content-type: application/json
{
"email": "demo@minimals.cc",
"password": "@2Min111imal"
}

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

@@ -41,6 +41,12 @@ async function getPartyUser(partyUserId: string): Promise<PartyUser | null> {
});
}
async function getPartyUserByEmail(email: string): Promise<PartyUser | null> {
return prisma.partyUser.findUnique({
where: { email },
});
}
async function getUserById(id: string): Promise<User | null> {
return prisma.user.findFirst({ where: { id } });
}
@@ -70,6 +76,8 @@ export {
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,24 @@ 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';
setupIonicReact();
@@ -84,6 +92,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 +113,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 +124,20 @@ 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="/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
path="/logout"
@@ -124,6 +145,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 +174,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,13 @@ const PATHS = {
FAVOURITES_LIST: `/tabs/favourites`,
PROFILE: '/tabs/my_profile',
// partyUser
PARTY_USER_SIGN_IN: '/partyUserlogin',
PARTY_USER_SIGN_UP: '/partyUserSignUp',
//
TABS_DEBUG: '/tabs/debug',
//
// DEMO_WEATHER_APP: '/demo-weather-app',
DEMO_WEATHER_APP_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,16 @@
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`,
};
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

@@ -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,6 @@ export const mapCenter = (state: AppState) => {
}
return item;
};
export const getPartyUserUsername = (state: AppState) => state.user.username;
export const getPartyUserState = (state: AppState) => state.user;

View File

@@ -1,9 +1,19 @@
import { combineReducers } from './combineReducers';
// state.ts - Defines the Redux store state shape and reducers
//
// Initial state structure:
// - data: Contains app data like sessions, speakers, events etc.
// - user: User preferences and authentication state
// - locations: Location data for maps and navigation
// - order: Order and transaction related state
import { combineReducers } from './combineReducers';
// Main feature reducers
import { sessionsReducer } from './sessions/sessions.reducer';
import { userReducer } from './user/user.reducer';
import { locationsReducer } from './locations/locations.reducer';
//
// Additional feature reducers
import { orderReducer } from './sessions/orders.reducer';
export const initialState: AppState = {

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

@@ -73,7 +73,7 @@ interface StateProps {}
interface DispatchProps {}
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 +90,7 @@ const showJoinedMembers = (joinMembers: Record<string, any>[]) => {
);
};
const EventDetail: React.FC<EventDetailProps> = ({ event_detail }) => {
const EventDetail: React.FC<PageProps> = ({ event_detail }) => {
const router = useIonRouter();
const [showPopover, setShowPopover] = useState(false);

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