Compare commits

11 Commits

Author SHA1 Message Date
2efb13238c format(client): eslint
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 19:14:12 +08:00
50ad3888f5 feat(client): translate logout messages to Chinese
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 19:13:49 +08:00
f12e7ac3c1 feat(client): add KYC for event joining
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 19:12:57 +08:00
06f86cb8e3 chore(client): format
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-01 15:31:29 +08:00
094d02d203 feat(client): event list
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-01 14:26:35 +08:00
2df4d9aa49 feat(client): event card
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-01 09:54:19 +08:00
56ee572d6e fix(client): sidebar should be fullscreen
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-01 09:06:22 +08:00
d57a724940 refactor(sidebar): split nav views and add router decorator
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-01 09:04:50 +08:00
65d493b91b refactor(profile): split view/container and update nav state
Some checks failed
Client CMS Check Build (NixCN CMS) TeamCity build failed
Backend Check Build (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-31 18:30:34 +08:00
635b0fbb73 feat(client): add storybook and workbench profile flow
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-31 12:29:58 +08:00
6ea414bc88 fix(client): logout
Some checks failed
Backend Check Build (NixCN CMS) TeamCity build finished
Client CMS Check Build (NixCN CMS) TeamCity build failed
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-30 22:48:09 +08:00
76 changed files with 4641 additions and 495 deletions

View File

@@ -24,3 +24,6 @@ dist-ssr
*.sw? *.sw?
.direnv .direnv
*storybook.log
storybook-static

View File

@@ -0,0 +1,17 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@chromatic-com/storybook",
"@storybook/addon-vitest",
"@storybook/addon-a11y",
"@storybook/addon-docs",
"@storybook/addon-onboarding"
],
"framework": "@storybook/react-vite"
};
export default config;

View File

@@ -0,0 +1,36 @@
import type { Decorator, Preview } from '@storybook/react-vite';
import { ThemeProvider } from '../src/components/theme-provider';
import '../src/index.css';
import { createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router';
const RouterDecorator: Decorator = (Story) => {
const rootRoute = createRootRoute({ component: () => <Story /> });
const routeTree = rootRoute;
const router = createRouter({ routeTree });
return <RouterProvider router={router} />;
};
const ThemeDecorator: Decorator = (Story) => {
return <ThemeProvider defaultTheme="dark"><Story /></ThemeProvider>;
};
const preview: Preview = {
decorators: [RouterDecorator, ThemeDecorator],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo'
}
},
};
export default preview;

View File

@@ -0,0 +1,7 @@
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
import { setProjectAnnotations } from '@storybook/react-vite';
import * as projectAnnotations from './preview';
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);

View File

@@ -0,0 +1,18 @@
{
"file_scan_exclusions": [
"src/components/ui",
".tanstack",
"node_modules",
"dist",
// default values below
"**/.git",
"**/.svn",
"**/.hg",
"**/CVS",
"**/.DS_Store",
"**/Thumbs.db",
"**/.classpath",
"**/.settings"
]
}

View File

@@ -1,9 +1,10 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import antfu from '@antfu/eslint-config'; import antfu from '@antfu/eslint-config';
import pluginQuery from '@tanstack/eslint-plugin-query'; import pluginQuery from '@tanstack/eslint-plugin-query';
export default antfu({ export default antfu({
gitignore: true, gitignore: true,
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**', 'src/components/editor/**/*', 'src/client/**/*'], ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**', 'src/components/editor/**/*', 'src/client/**/*', 'openapi-ts.config.ts', 'vitest.shims.d.ts', '.storybook/**/*'],
react: true, react: true,
stylistic: { stylistic: {
semi: true, semi: true,

View File

@@ -5,7 +5,11 @@ export default defineConfig({
output: 'src/client', output: 'src/client',
plugins: [ plugins: [
'@hey-api/typescript', '@hey-api/typescript',
'@tanstack/react-query', {
name: '@tanstack/react-query',
infiniteQueryOptions: true,
infiniteQueryKeys: true,
},
'zod', 'zod',
{ {
name: '@hey-api/transformers', name: '@hey-api/transformers',

View File

@@ -9,7 +9,9 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"gen": "openapi-ts" "gen": "openapi-ts",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.1.0", "@base-ui/react": "^1.1.0",
@@ -51,29 +53,41 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"culori": "^4.0.2", "culori": "^4.0.2",
"dayjs": "^1.11.19",
"immer": "^11.1.0", "immer": "^11.1.0",
"lodash-es": "^4.17.22", "lodash-es": "^4.17.22",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-error-boundary": "^6.1.0",
"react-hook-form": "^7.69.0", "react-hook-form": "^7.69.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-spinners": "^0.17.0",
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"utf8": "^3.0.0", "utf8": "^3.0.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.2.1", "zod": "^3.25.76",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^6.7.1", "@antfu/eslint-config": "^6.7.1",
"@chromatic-com/storybook": "^5.0.0",
"@eslint-react/eslint-plugin": "^2.3.13", "@eslint-react/eslint-plugin": "^2.3.13",
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@hey-api/openapi-ts": "0.91.0", "@hey-api/openapi-ts": "0.91.0",
"@redux-devtools/extension": "^3.3.0",
"@storybook/addon-a11y": "^10.2.3",
"@storybook/addon-docs": "^10.2.3",
"@storybook/addon-onboarding": "^10.2.3",
"@storybook/addon-themes": "^10.2.3",
"@storybook/addon-vitest": "^10.2.3",
"@storybook/react-vite": "^10.2.3",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/eslint-plugin-query": "^5.91.2", "@tanstack/eslint-plugin-query": "^5.91.2",
"@tanstack/router-plugin": "^1.141.7", "@tanstack/router-plugin": "^1.141.7",
@@ -86,18 +100,24 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/utf8": "^3.0.3", "@types/utf8": "^3.0.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-storybook": "^10.2.3",
"globals": "^16.5.0", "globals": "^16.5.0",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"playwright": "^1.58.0",
"simple-git-hooks": "^2.13.1", "simple-git-hooks": "^2.13.1",
"storybook": "^10.2.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"type-fest": "^5.4.1", "type-fest": "^5.4.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4", "vite": "^7.2.4",
"vite-plugin-svgr": "^4.5.0" "vite-plugin-svgr": "^4.5.0",
"vitest": "^4.0.18"
}, },
"simple-git-hooks": { "simple-git-hooks": {
"pre-commit": "bun run lint-staged" "pre-commit": "bun run lint-staged"

2112
client/cms/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -3,8 +3,8 @@
import { type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; import { client } from '../client.gen';
import { getAuthRedirect, getEventCheckin, getEventCheckinQuery, getEventInfo, getUserFull, getUserInfo, getUserList, type Options, patchUserUpdate, postAuthExchange, postAuthMagic, postAuthRefresh, postAuthToken, postEventCheckinSubmit } from '../sdk.gen'; import { getAuthRedirect, getEventAttendance, getEventCheckin, getEventCheckinQuery, getEventInfo, getEventList, getUserInfo, getUserInfoByUserId, getUserList, type Options, patchUserUpdate, postAuthExchange, postAuthMagic, postAuthRefresh, postAuthToken, postEventCheckinSubmit, postEventJoin, postKycQuery, postKycSession } from '../sdk.gen';
import type { GetAuthRedirectData, GetAuthRedirectError, GetEventCheckinData, GetEventCheckinError, GetEventCheckinQueryData, GetEventCheckinQueryError, GetEventCheckinQueryResponse, GetEventCheckinResponse, GetEventInfoData, GetEventInfoError, GetEventInfoResponse, GetUserFullData, GetUserFullError, GetUserFullResponse, GetUserInfoData, GetUserInfoError, GetUserInfoResponse, GetUserListData, GetUserListError, GetUserListResponse, PatchUserUpdateData, PatchUserUpdateError, PatchUserUpdateResponse, PostAuthExchangeData, PostAuthExchangeError, PostAuthExchangeResponse, PostAuthMagicData, PostAuthMagicError, PostAuthMagicResponse, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshResponse, PostAuthTokenData, PostAuthTokenError, PostAuthTokenResponse, PostEventCheckinSubmitData, PostEventCheckinSubmitError, PostEventCheckinSubmitResponse } from '../types.gen'; import type { GetAuthRedirectData, GetAuthRedirectError, GetEventAttendanceData, GetEventAttendanceError, GetEventAttendanceResponse, GetEventCheckinData, GetEventCheckinError, GetEventCheckinQueryData, GetEventCheckinQueryError, GetEventCheckinQueryResponse, GetEventCheckinResponse, GetEventInfoData, GetEventInfoError, GetEventInfoResponse, GetEventListData, GetEventListError, GetEventListResponse, GetUserInfoByUserIdData, GetUserInfoByUserIdError, GetUserInfoByUserIdResponse, GetUserInfoData, GetUserInfoError, GetUserInfoResponse, GetUserListData, GetUserListError, GetUserListResponse, PatchUserUpdateData, PatchUserUpdateError, PatchUserUpdateResponse, PostAuthExchangeData, PostAuthExchangeError, PostAuthExchangeResponse, PostAuthMagicData, PostAuthMagicError, PostAuthMagicResponse, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshResponse, PostAuthTokenData, PostAuthTokenError, PostAuthTokenResponse, PostEventCheckinSubmitData, PostEventCheckinSubmitError, PostEventCheckinSubmitResponse, PostEventJoinData, PostEventJoinError, PostEventJoinResponse, PostKycQueryData, PostKycQueryError, PostKycQueryResponse, PostKycSessionData, PostKycSessionError, PostKycSessionResponse } from '../types.gen';
/** /**
* Exchange Auth Code * Exchange Auth Code
@@ -135,6 +135,26 @@ export const postAuthTokenMutation = (options?: Partial<Options<PostAuthTokenDat
return mutationOptions; return mutationOptions;
}; };
export const getEventAttendanceQueryKey = (options: Options<GetEventAttendanceData>) => createQueryKey('getEventAttendance', options);
/**
* Get Attendance List
*
* Retrieves the list of attendees, including user info and decrypted KYC data for a specified event.
*/
export const getEventAttendanceOptions = (options: Options<GetEventAttendanceData>) => queryOptions<GetEventAttendanceResponse, GetEventAttendanceError, GetEventAttendanceResponse, ReturnType<typeof getEventAttendanceQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getEventAttendance({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getEventAttendanceQueryKey(options)
});
export const getEventCheckinQueryKey = (options: Options<GetEventCheckinData>) => createQueryKey('getEventCheckin', options); export const getEventCheckinQueryKey = (options: Options<GetEventCheckinData>) => createQueryKey('getEventCheckin', options);
/** /**
@@ -214,16 +234,35 @@ export const getEventInfoOptions = (options: Options<GetEventInfoData>) => query
queryKey: getEventInfoQueryKey(options) queryKey: getEventInfoQueryKey(options)
}); });
export const getUserFullQueryKey = (options?: Options<GetUserFullData>) => createQueryKey('getUserFull', options); /**
* Join an Event
*
* Allows an authenticated user to join an event by providing the event ID. The user's role and state are initialized by the service.
*/
export const postEventJoinMutation = (options?: Partial<Options<PostEventJoinData>>): UseMutationOptions<PostEventJoinResponse, PostEventJoinError, Options<PostEventJoinData>> => {
const mutationOptions: UseMutationOptions<PostEventJoinResponse, PostEventJoinError, Options<PostEventJoinData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postEventJoin({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getEventListQueryKey = (options: Options<GetEventListData>) => createQueryKey('getEventList', options);
/** /**
* Get Full User Table * List Events
* *
* Fetches all user records without pagination. This is typically used for administrative overview or data export. * Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
*/ */
export const getUserFullOptions = (options?: Options<GetUserFullData>) => queryOptions<GetUserFullResponse, GetUserFullError, GetUserFullResponse, ReturnType<typeof getUserFullQueryKey>>({ export const getEventListOptions = (options: Options<GetEventListData>) => queryOptions<GetEventListResponse, GetEventListError, GetEventListResponse, ReturnType<typeof getEventListQueryKey>>({
queryFn: async ({ queryKey, signal }) => { queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserFull({ const { data } = await getEventList({
...options, ...options,
...queryKey[0], ...queryKey[0],
signal, signal,
@@ -231,47 +270,7 @@ export const getUserFullOptions = (options?: Options<GetUserFullData>) => queryO
}); });
return data; return data;
}, },
queryKey: getUserFullQueryKey(options) queryKey: getEventListQueryKey(options)
});
export const getUserInfoQueryKey = (options?: Options<GetUserInfoData>) => createQueryKey('getUserInfo', options);
/**
* Get My User Information
*
* Fetches the complete profile data for the user associated with the provided session/token.
*/
export const getUserInfoOptions = (options?: Options<GetUserInfoData>) => queryOptions<GetUserInfoResponse, GetUserInfoError, GetUserInfoResponse, ReturnType<typeof getUserInfoQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserInfo({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUserInfoQueryKey(options)
});
export const getUserListQueryKey = (options: Options<GetUserListData>) => createQueryKey('getUserList', options);
/**
* List Users
*
* Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
*/
export const getUserListOptions = (options: Options<GetUserListData>) => queryOptions<GetUserListResponse, GetUserListError, GetUserListResponse, ReturnType<typeof getUserListQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserList({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUserListQueryKey(options)
}); });
const createInfiniteParams = <K extends Pick<QueryKey<Options>[0], 'body' | 'headers' | 'path' | 'query'>>(queryKey: QueryKey<Options>, page: K) => { const createInfiniteParams = <K extends Pick<QueryKey<Options>[0], 'body' | 'headers' | 'path' | 'query'>>(queryKey: QueryKey<Options>, page: K) => {
@@ -303,6 +302,133 @@ const createInfiniteParams = <K extends Pick<QueryKey<Options>[0], 'body' | 'hea
return params as unknown as typeof page; return params as unknown as typeof page;
}; };
export const getEventListInfiniteQueryKey = (options: Options<GetEventListData>): QueryKey<Options<GetEventListData>> => createQueryKey('getEventList', options, true);
/**
* List Events
*
* Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
*/
export const getEventListInfiniteOptions = (options: Options<GetEventListData>) => infiniteQueryOptions<GetEventListResponse, GetEventListError, InfiniteData<GetEventListResponse>, QueryKey<Options<GetEventListData>>, number | Pick<QueryKey<Options<GetEventListData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
// @ts-ignore
{
queryFn: async ({ pageParam, queryKey, signal }) => {
// @ts-ignore
const page: Pick<QueryKey<Options<GetEventListData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : {
query: {
offset: pageParam
}
};
const params = createInfiniteParams(queryKey, page);
const { data } = await getEventList({
...options,
...params,
signal,
throwOnError: true
});
return data;
},
queryKey: getEventListInfiniteQueryKey(options)
});
/**
* Query KYC Status
*
* Checks the current state of a KYC session and updates local database if approved.
*/
export const postKycQueryMutation = (options?: Partial<Options<PostKycQueryData>>): UseMutationOptions<PostKycQueryResponse, PostKycQueryError, Options<PostKycQueryData>> => {
const mutationOptions: UseMutationOptions<PostKycQueryResponse, PostKycQueryError, Options<PostKycQueryData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postKycQuery({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Create KYC Session
*
* Initializes a KYC process (CNRid or Passport) and returns the status or redirect URI.
*/
export const postKycSessionMutation = (options?: Partial<Options<PostKycSessionData>>): UseMutationOptions<PostKycSessionResponse, PostKycSessionError, Options<PostKycSessionData>> => {
const mutationOptions: UseMutationOptions<PostKycSessionResponse, PostKycSessionError, Options<PostKycSessionData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postKycSession({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getUserInfoQueryKey = (options: Options<GetUserInfoData>) => createQueryKey('getUserInfo', options);
/**
* Get My User Information
*
* Fetches the complete profile data for the user associated with the provided session/token.
*/
export const getUserInfoOptions = (options: Options<GetUserInfoData>) => queryOptions<GetUserInfoResponse, GetUserInfoError, GetUserInfoResponse, ReturnType<typeof getUserInfoQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserInfo({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUserInfoQueryKey(options)
});
export const getUserInfoByUserIdQueryKey = (options: Options<GetUserInfoByUserIdData>) => createQueryKey('getUserInfoByUserId', options);
/**
* Get Other User Information
*
* Fetches the complete profile data for the user associated with the provided session/token.
*/
export const getUserInfoByUserIdOptions = (options: Options<GetUserInfoByUserIdData>) => queryOptions<GetUserInfoByUserIdResponse, GetUserInfoByUserIdError, GetUserInfoByUserIdResponse, ReturnType<typeof getUserInfoByUserIdQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserInfoByUserId({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUserInfoByUserIdQueryKey(options)
});
export const getUserListQueryKey = (options: Options<GetUserListData>) => createQueryKey('getUserList', options);
/**
* List Users
*
* Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
*/
export const getUserListOptions = (options: Options<GetUserListData>) => queryOptions<GetUserListResponse, GetUserListError, GetUserListResponse, ReturnType<typeof getUserListQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getUserList({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getUserListQueryKey(options)
});
export const getUserListInfiniteQueryKey = (options: Options<GetUserListData>): QueryKey<Options<GetUserListData>> => createQueryKey('getUserList', options, true); export const getUserListInfiniteQueryKey = (options: Options<GetUserListData>): QueryKey<Options<GetUserListData>> => createQueryKey('getUserList', options, true);
/** /**

View File

@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export { getAuthRedirect, getEventCheckin, getEventCheckinQuery, getEventInfo, getUserFull, getUserInfo, getUserList, type Options, patchUserUpdate, postAuthExchange, postAuthMagic, postAuthRefresh, postAuthToken, postEventCheckinSubmit } from './sdk.gen'; export { getAuthRedirect, getEventAttendance, getEventCheckin, getEventCheckinQuery, getEventInfo, getEventList, getUserInfo, getUserInfoByUserId, getUserList, type Options, patchUserUpdate, postAuthExchange, postAuthMagic, postAuthRefresh, postAuthToken, postEventCheckinSubmit, postEventJoin, postKycQuery, postKycSession } from './sdk.gen';
export type { ClientOptions, DataUser, DataUserSearchDoc, GetAuthRedirectData, GetAuthRedirectError, GetAuthRedirectErrors, GetEventCheckinData, GetEventCheckinError, GetEventCheckinErrors, GetEventCheckinQueryData, GetEventCheckinQueryError, GetEventCheckinQueryErrors, GetEventCheckinQueryResponse, GetEventCheckinQueryResponses, GetEventCheckinResponse, GetEventCheckinResponses, GetEventInfoData, GetEventInfoError, GetEventInfoErrors, GetEventInfoResponse, GetEventInfoResponses, GetUserFullData, GetUserFullError, GetUserFullErrors, GetUserFullResponse, GetUserFullResponses, GetUserInfoData, GetUserInfoError, GetUserInfoErrors, GetUserInfoResponse, GetUserInfoResponses, GetUserListData, GetUserListError, GetUserListErrors, GetUserListResponse, GetUserListResponses, PatchUserUpdateData, PatchUserUpdateError, PatchUserUpdateErrors, PatchUserUpdateResponse, PatchUserUpdateResponses, PostAuthExchangeData, PostAuthExchangeError, PostAuthExchangeErrors, PostAuthExchangeResponse, PostAuthExchangeResponses, PostAuthMagicData, PostAuthMagicError, PostAuthMagicErrors, PostAuthMagicResponse, PostAuthMagicResponses, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshErrors, PostAuthRefreshResponse, PostAuthRefreshResponses, PostAuthTokenData, PostAuthTokenError, PostAuthTokenErrors, PostAuthTokenResponse, PostAuthTokenResponses, PostEventCheckinSubmitData, PostEventCheckinSubmitError, PostEventCheckinSubmitErrors, PostEventCheckinSubmitResponse, PostEventCheckinSubmitResponses, ServiceAuthExchangeData, ServiceAuthExchangeResponse, ServiceAuthMagicData, ServiceAuthMagicResponse, ServiceAuthRefreshData, ServiceAuthTokenData, ServiceAuthTokenResponse, ServiceEventCheckinQueryResponse, ServiceEventCheckinResponse, ServiceEventCheckinSubmitData, ServiceEventInfoResponse, ServiceUserUserInfoData, ServiceUserUserTableResponse, UtilsRespStatus } from './types.gen'; export type { ClientOptions, DataEventIndexDoc, DataUserIndexDoc, GetAuthRedirectData, GetAuthRedirectError, GetAuthRedirectErrors, GetEventAttendanceData, GetEventAttendanceError, GetEventAttendanceErrors, GetEventAttendanceResponse, GetEventAttendanceResponses, GetEventCheckinData, GetEventCheckinError, GetEventCheckinErrors, GetEventCheckinQueryData, GetEventCheckinQueryError, GetEventCheckinQueryErrors, GetEventCheckinQueryResponse, GetEventCheckinQueryResponses, GetEventCheckinResponse, GetEventCheckinResponses, GetEventInfoData, GetEventInfoError, GetEventInfoErrors, GetEventInfoResponse, GetEventInfoResponses, GetEventListData, GetEventListError, GetEventListErrors, GetEventListResponse, GetEventListResponses, GetUserInfoByUserIdData, GetUserInfoByUserIdError, GetUserInfoByUserIdErrors, GetUserInfoByUserIdResponse, GetUserInfoByUserIdResponses, GetUserInfoData, GetUserInfoError, GetUserInfoErrors, GetUserInfoResponse, GetUserInfoResponses, GetUserListData, GetUserListError, GetUserListErrors, GetUserListResponse, GetUserListResponses, PatchUserUpdateData, PatchUserUpdateError, PatchUserUpdateErrors, PatchUserUpdateResponse, PatchUserUpdateResponses, PostAuthExchangeData, PostAuthExchangeError, PostAuthExchangeErrors, PostAuthExchangeResponse, PostAuthExchangeResponses, PostAuthMagicData, PostAuthMagicError, PostAuthMagicErrors, PostAuthMagicResponse, PostAuthMagicResponses, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshErrors, PostAuthRefreshResponse, PostAuthRefreshResponses, PostAuthTokenData, PostAuthTokenError, PostAuthTokenErrors, PostAuthTokenResponse, PostAuthTokenResponses, PostEventCheckinSubmitData, PostEventCheckinSubmitError, PostEventCheckinSubmitErrors, PostEventCheckinSubmitResponse, PostEventCheckinSubmitResponses, PostEventJoinData, PostEventJoinError, PostEventJoinErrors, PostEventJoinResponse, PostEventJoinResponses, PostKycQueryData, PostKycQueryError, PostKycQueryErrors, PostKycQueryResponse, PostKycQueryResponses, PostKycSessionData, PostKycSessionError, PostKycSessionErrors, PostKycSessionResponse, PostKycSessionResponses, ServiceAuthExchangeData, ServiceAuthExchangeResponse, ServiceAuthMagicData, ServiceAuthMagicResponse, ServiceAuthRefreshData, ServiceAuthTokenData, ServiceAuthTokenResponse, ServiceEventAttendanceListResponse, ServiceEventCheckinQueryResponse, ServiceEventCheckinResponse, ServiceEventCheckinSubmitData, ServiceEventEventJoinData, ServiceKycKycQueryData, ServiceKycKycQueryResponse, ServiceKycKycSessionData, ServiceKycKycSessionResponse, ServiceUserUserInfoData, UtilsRespStatus } from './types.gen';

View File

@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client'; import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { GetAuthRedirectData, GetAuthRedirectErrors, GetEventCheckinData, GetEventCheckinErrors, GetEventCheckinQueryData, GetEventCheckinQueryErrors, GetEventCheckinQueryResponses, GetEventCheckinResponses, GetEventInfoData, GetEventInfoErrors, GetEventInfoResponses, GetUserFullData, GetUserFullErrors, GetUserFullResponses, GetUserInfoData, GetUserInfoErrors, GetUserInfoResponses, GetUserListData, GetUserListErrors, GetUserListResponses, PatchUserUpdateData, PatchUserUpdateErrors, PatchUserUpdateResponses, PostAuthExchangeData, PostAuthExchangeErrors, PostAuthExchangeResponses, PostAuthMagicData, PostAuthMagicErrors, PostAuthMagicResponses, PostAuthRefreshData, PostAuthRefreshErrors, PostAuthRefreshResponses, PostAuthTokenData, PostAuthTokenErrors, PostAuthTokenResponses, PostEventCheckinSubmitData, PostEventCheckinSubmitErrors, PostEventCheckinSubmitResponses } from './types.gen'; import type { GetAuthRedirectData, GetAuthRedirectErrors, GetEventAttendanceData, GetEventAttendanceErrors, GetEventAttendanceResponses, GetEventCheckinData, GetEventCheckinErrors, GetEventCheckinQueryData, GetEventCheckinQueryErrors, GetEventCheckinQueryResponses, GetEventCheckinResponses, GetEventInfoData, GetEventInfoErrors, GetEventInfoResponses, GetEventListData, GetEventListErrors, GetEventListResponses, GetUserInfoByUserIdData, GetUserInfoByUserIdErrors, GetUserInfoByUserIdResponses, GetUserInfoData, GetUserInfoErrors, GetUserInfoResponses, GetUserListData, GetUserListErrors, GetUserListResponses, PatchUserUpdateData, PatchUserUpdateErrors, PatchUserUpdateResponses, PostAuthExchangeData, PostAuthExchangeErrors, PostAuthExchangeResponses, PostAuthMagicData, PostAuthMagicErrors, PostAuthMagicResponses, PostAuthRefreshData, PostAuthRefreshErrors, PostAuthRefreshResponses, PostAuthTokenData, PostAuthTokenErrors, PostAuthTokenResponses, PostEventCheckinSubmitData, PostEventCheckinSubmitErrors, PostEventCheckinSubmitResponses, PostEventJoinData, PostEventJoinErrors, PostEventJoinResponses, PostKycQueryData, PostKycQueryErrors, PostKycQueryResponses, PostKycSessionData, PostKycSessionErrors, PostKycSessionResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/** /**
@@ -81,6 +81,13 @@ export const postAuthToken = <ThrowOnError extends boolean = false>(options: Opt
} }
}); });
/**
* Get Attendance List
*
* Retrieves the list of attendees, including user info and decrypted KYC data for a specified event.
*/
export const getEventAttendance = <ThrowOnError extends boolean = false>(options: Options<GetEventAttendanceData, ThrowOnError>) => (options.client ?? client).get<GetEventAttendanceResponses, GetEventAttendanceErrors, ThrowOnError>({ url: '/event/attendance', ...options });
/** /**
* Generate Check-in Code * Generate Check-in Code
* *
@@ -117,18 +124,67 @@ export const postEventCheckinSubmit = <ThrowOnError extends boolean = false>(opt
export const getEventInfo = <ThrowOnError extends boolean = false>(options: Options<GetEventInfoData, ThrowOnError>) => (options.client ?? client).get<GetEventInfoResponses, GetEventInfoErrors, ThrowOnError>({ url: '/event/info', ...options }); export const getEventInfo = <ThrowOnError extends boolean = false>(options: Options<GetEventInfoData, ThrowOnError>) => (options.client ?? client).get<GetEventInfoResponses, GetEventInfoErrors, ThrowOnError>({ url: '/event/info', ...options });
/** /**
* Get Full User Table * Join an Event
* *
* Fetches all user records without pagination. This is typically used for administrative overview or data export. * Allows an authenticated user to join an event by providing the event ID. The user's role and state are initialized by the service.
*/ */
export const getUserFull = <ThrowOnError extends boolean = false>(options?: Options<GetUserFullData, ThrowOnError>) => (options?.client ?? client).get<GetUserFullResponses, GetUserFullErrors, ThrowOnError>({ url: '/user/full', ...options }); export const postEventJoin = <ThrowOnError extends boolean = false>(options: Options<PostEventJoinData, ThrowOnError>) => (options.client ?? client).post<PostEventJoinResponses, PostEventJoinErrors, ThrowOnError>({
url: '/event/join',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* List Events
*
* Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
*/
export const getEventList = <ThrowOnError extends boolean = false>(options: Options<GetEventListData, ThrowOnError>) => (options.client ?? client).get<GetEventListResponses, GetEventListErrors, ThrowOnError>({ url: '/event/list', ...options });
/**
* Query KYC Status
*
* Checks the current state of a KYC session and updates local database if approved.
*/
export const postKycQuery = <ThrowOnError extends boolean = false>(options: Options<PostKycQueryData, ThrowOnError>) => (options.client ?? client).post<PostKycQueryResponses, PostKycQueryErrors, ThrowOnError>({
url: '/kyc/query',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Create KYC Session
*
* Initializes a KYC process (CNRid or Passport) and returns the status or redirect URI.
*/
export const postKycSession = <ThrowOnError extends boolean = false>(options: Options<PostKycSessionData, ThrowOnError>) => (options.client ?? client).post<PostKycSessionResponses, PostKycSessionErrors, ThrowOnError>({
url: '/kyc/session',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/** /**
* Get My User Information * Get My User Information
* *
* Fetches the complete profile data for the user associated with the provided session/token. * Fetches the complete profile data for the user associated with the provided session/token.
*/ */
export const getUserInfo = <ThrowOnError extends boolean = false>(options?: Options<GetUserInfoData, ThrowOnError>) => (options?.client ?? client).get<GetUserInfoResponses, GetUserInfoErrors, ThrowOnError>({ url: '/user/info', ...options }); export const getUserInfo = <ThrowOnError extends boolean = false>(options: Options<GetUserInfoData, ThrowOnError>) => (options.client ?? client).get<GetUserInfoResponses, GetUserInfoErrors, ThrowOnError>({ url: '/user/info', ...options });
/**
* Get Other User Information
*
* Fetches the complete profile data for the user associated with the provided session/token.
*/
export const getUserInfoByUserId = <ThrowOnError extends boolean = false>(options: Options<GetUserInfoByUserIdData, ThrowOnError>) => (options.client ?? client).get<GetUserInfoByUserIdResponses, GetUserInfoByUserIdErrors, ThrowOnError>({ url: '/user/info/{user_id}', ...options });
/** /**
* List Users * List Users

View File

@@ -4,21 +4,21 @@ export type ClientOptions = {
baseUrl: 'http://localhost:8000/api/v1' | 'https://localhost:8000/api/v1' | (string & {}); baseUrl: 'http://localhost:8000/api/v1' | 'https://localhost:8000/api/v1' | (string & {});
}; };
export type DataUser = { export type DataEventIndexDoc = {
allow_public?: boolean; checkin_count?: number;
avatar?: string; description?: string;
bio?: string; enable_kyc?: boolean;
email?: string; end_time?: string;
id?: number; event_id?: string;
nickname?: string; is_joined?: boolean;
permission_level?: number; join_count?: number;
subtitle?: string; name?: string;
user_id?: string; start_time?: string;
username?: string; thumbnail?: string;
uuid?: string; type?: string;
}; };
export type DataUserSearchDoc = { export type DataUserIndexDoc = {
avatar?: string; avatar?: string;
email?: string; email?: string;
nickname?: string; nickname?: string;
@@ -64,6 +64,13 @@ export type ServiceAuthTokenResponse = {
refresh_token?: string; refresh_token?: string;
}; };
export type ServiceEventAttendanceListResponse = {
attendance_id?: string;
kyc_info?: unknown;
kyc_type?: string;
user_info?: ServiceUserUserInfoData;
};
export type ServiceEventCheckinQueryResponse = { export type ServiceEventCheckinQueryResponse = {
checkin_at?: string; checkin_at?: string;
}; };
@@ -76,10 +83,40 @@ export type ServiceEventCheckinSubmitData = {
checkin_code?: string; checkin_code?: string;
}; };
export type ServiceEventInfoResponse = { export type ServiceEventEventJoinData = {
end_time?: string; event_id?: string;
name?: string; kyc_id?: string;
start_time?: string; };
export type ServiceKycKycQueryData = {
kyc_id?: string;
};
export type ServiceKycKycQueryResponse = {
/**
* success | pending | failed
*/
status?: string;
};
export type ServiceKycKycSessionData = {
/**
* base64 json
*/
identity?: string;
/**
* cnrid | passport
*/
type?: string;
};
export type ServiceKycKycSessionResponse = {
kyc_id?: string;
redirect_uri?: string;
/**
* success | processing
*/
status?: string;
}; };
export type ServiceUserUserInfoData = { export type ServiceUserUserInfoData = {
@@ -94,10 +131,6 @@ export type ServiceUserUserInfoData = {
username?: string; username?: string;
}; };
export type ServiceUserUserTableResponse = {
user_table?: Array<DataUser>;
};
export type UtilsRespStatus = { export type UtilsRespStatus = {
code?: number; code?: number;
data?: unknown; data?: unknown;
@@ -110,6 +143,12 @@ export type PostAuthExchangeData = {
* Exchange Request Credentials * Exchange Request Credentials
*/ */
body: ServiceAuthExchangeData; body: ServiceAuthExchangeData;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never; path?: never;
query?: never; query?: never;
url: '/auth/exchange'; url: '/auth/exchange';
@@ -160,6 +199,12 @@ export type PostAuthMagicData = {
* Magic Link Request Data * Magic Link Request Data
*/ */
body: ServiceAuthMagicData; body: ServiceAuthMagicData;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never; path?: never;
query?: never; query?: never;
url: '/auth/magic'; url: '/auth/magic';
@@ -263,6 +308,12 @@ export type PostAuthRefreshData = {
* Refresh Token Body * Refresh Token Body
*/ */
body: ServiceAuthRefreshData; body: ServiceAuthRefreshData;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never; path?: never;
query?: never; query?: never;
url: '/auth/refresh'; url: '/auth/refresh';
@@ -313,6 +364,12 @@ export type PostAuthTokenData = {
* Token Request Body * Token Request Body
*/ */
body: ServiceAuthTokenData; body: ServiceAuthTokenData;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never; path?: never;
query?: never; query?: never;
url: '/auth/token'; url: '/auth/token';
@@ -358,8 +415,72 @@ export type PostAuthTokenResponses = {
export type PostAuthTokenResponse = PostAuthTokenResponses[keyof PostAuthTokenResponses]; export type PostAuthTokenResponse = PostAuthTokenResponses[keyof PostAuthTokenResponses];
export type GetEventAttendanceData = {
body?: never;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never;
query: {
/**
* Event UUID
*/
event_id: string;
};
url: '/event/attendance';
};
export type GetEventAttendanceErrors = {
/**
* Invalid Input
*/
400: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Unauthorized
*/
401: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Internal Server Error
*/
500: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
};
export type GetEventAttendanceError = GetEventAttendanceErrors[keyof GetEventAttendanceErrors];
export type GetEventAttendanceResponses = {
/**
* Successful retrieval
*/
200: UtilsRespStatus & {
data?: Array<ServiceEventAttendanceListResponse>;
};
};
export type GetEventAttendanceResponse = GetEventAttendanceResponses[keyof GetEventAttendanceResponses];
export type GetEventCheckinData = { export type GetEventCheckinData = {
body?: never; body?: never;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never; path?: never;
query: { query: {
/** /**
@@ -379,6 +500,14 @@ export type GetEventCheckinErrors = {
[key: string]: unknown; [key: string]: unknown;
}; };
}; };
/**
* Missing User ID / Unauthorized
*/
401: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/** /**
* Internal Server Error * Internal Server Error
*/ */
@@ -484,6 +613,12 @@ export type PostEventCheckinSubmitResponse = PostEventCheckinSubmitResponses[key
export type GetEventInfoData = { export type GetEventInfoData = {
body?: never; body?: never;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never; path?: never;
query: { query: {
/** /**
@@ -503,6 +638,14 @@ export type GetEventInfoErrors = {
[key: string]: unknown; [key: string]: unknown;
}; };
}; };
/**
* Missing User ID / Unauthorized
*/
401: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/** /**
* Event Not Found * Event Not Found
*/ */
@@ -528,22 +671,55 @@ export type GetEventInfoResponses = {
* Successful retrieval * Successful retrieval
*/ */
200: UtilsRespStatus & { 200: UtilsRespStatus & {
data?: ServiceEventInfoResponse; data?: DataEventIndexDoc;
}; };
}; };
export type GetEventInfoResponse = GetEventInfoResponses[keyof GetEventInfoResponses]; export type GetEventInfoResponse = GetEventInfoResponses[keyof GetEventInfoResponses];
export type GetUserFullData = { export type PostEventJoinData = {
body?: never; /**
* Event Join Details (UserId and EventId are required)
*/
body: ServiceEventEventJoinData;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never; path?: never;
query?: never; query?: never;
url: '/user/full'; url: '/event/join';
}; };
export type GetUserFullErrors = { export type PostEventJoinErrors = {
/** /**
* Internal Server Error (Database Error) * Invalid Input or UUID Parse Failed
*/
400: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Missing User ID / Unauthorized
*/
401: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Unauthorized / Missing User ID / Event Limit Exceeded
*/
403: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Internal Server Error / Database Error
*/ */
500: UtilsRespStatus & { 500: UtilsRespStatus & {
data?: { data?: {
@@ -552,21 +728,203 @@ export type GetUserFullErrors = {
}; };
}; };
export type GetUserFullError = GetUserFullErrors[keyof GetUserFullErrors]; export type PostEventJoinError = PostEventJoinErrors[keyof PostEventJoinErrors];
export type GetUserFullResponses = { export type PostEventJoinResponses = {
/** /**
* Successful retrieval of full user table * Successfully joined the event
*/ */
200: UtilsRespStatus & { 200: UtilsRespStatus & {
data?: ServiceUserUserTableResponse; data?: {
[key: string]: unknown;
};
}; };
}; };
export type GetUserFullResponse = GetUserFullResponses[keyof GetUserFullResponses]; export type PostEventJoinResponse = PostEventJoinResponses[keyof PostEventJoinResponses];
export type GetEventListData = {
body?: never;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never;
query?: {
/**
* Maximum number of events to return (default 20)
*/
limit?: number;
/**
* Number of events to skip
*/
offset?: number;
};
url: '/event/list';
};
export type GetEventListErrors = {
/**
* Invalid Input (Missing offset or malformed parameters)
*/
400: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Missing User ID / Unauthorized
*/
401: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Internal Server Error (Database query failed)
*/
500: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
};
export type GetEventListError = GetEventListErrors[keyof GetEventListErrors];
export type GetEventListResponses = {
/**
* Successful paginated list retrieval
*/
200: UtilsRespStatus & {
data?: Array<DataEventIndexDoc>;
};
};
export type GetEventListResponse = GetEventListResponses[keyof GetEventListResponses];
export type PostKycQueryData = {
/**
* KYC query data (KycId)
*/
body: ServiceKycKycQueryData;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never;
query?: never;
url: '/kyc/query';
};
export type PostKycQueryErrors = {
/**
* Invalid UUID or input
*/
400: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Unauthorized
*/
403: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Internal Server Error
*/
500: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
};
export type PostKycQueryError = PostKycQueryErrors[keyof PostKycQueryErrors];
export type PostKycQueryResponses = {
/**
* Query processed (success/pending/failed)
*/
200: UtilsRespStatus & {
data?: ServiceKycKycQueryResponse;
};
};
export type PostKycQueryResponse = PostKycQueryResponses[keyof PostKycQueryResponses];
export type PostKycSessionData = {
/**
* KYC session data (Type and Base64 Identity)
*/
body: ServiceKycKycSessionData;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never;
query?: never;
url: '/kyc/session';
};
export type PostKycSessionErrors = {
/**
* Invalid input or decode failed
*/
400: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Missing User ID
*/
403: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Internal Server Error / KYC Service Error
*/
500: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
};
export type PostKycSessionError = PostKycSessionErrors[keyof PostKycSessionErrors];
export type PostKycSessionResponses = {
/**
* Session created successfully
*/
200: UtilsRespStatus & {
data?: ServiceKycKycSessionResponse;
};
};
export type PostKycSessionResponse = PostKycSessionResponses[keyof PostKycSessionResponses];
export type GetUserInfoData = { export type GetUserInfoData = {
body?: never; body?: never;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never; path?: never;
query?: never; query?: never;
url: '/user/info'; url: '/user/info';
@@ -576,7 +934,7 @@ export type GetUserInfoErrors = {
/** /**
* Missing User ID / Unauthorized * Missing User ID / Unauthorized
*/ */
403: UtilsRespStatus & { 401: UtilsRespStatus & {
data?: { data?: {
[key: string]: unknown; [key: string]: unknown;
}; };
@@ -612,8 +970,80 @@ export type GetUserInfoResponses = {
export type GetUserInfoResponse = GetUserInfoResponses[keyof GetUserInfoResponses]; export type GetUserInfoResponse = GetUserInfoResponses[keyof GetUserInfoResponses];
export type GetUserInfoByUserIdData = {
body?: never;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path: {
/**
* Other user id
*/
user_id: string;
};
query?: never;
url: '/user/info/{user_id}';
};
export type GetUserInfoByUserIdErrors = {
/**
* Missing User ID / Unauthorized
*/
401: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* User Not Public
*/
403: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* User Not Found
*/
404: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/**
* Internal Server Error (UUID Parse Failed)
*/
500: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
};
export type GetUserInfoByUserIdError = GetUserInfoByUserIdErrors[keyof GetUserInfoByUserIdErrors];
export type GetUserInfoByUserIdResponses = {
/**
* Successful profile retrieval
*/
200: UtilsRespStatus & {
data?: ServiceUserUserInfoData;
};
};
export type GetUserInfoByUserIdResponse = GetUserInfoByUserIdResponses[keyof GetUserInfoByUserIdResponses];
export type GetUserListData = { export type GetUserListData = {
body?: never; body?: never;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never; path?: never;
query: { query: {
/** /**
@@ -637,6 +1067,14 @@ export type GetUserListErrors = {
[key: string]: unknown; [key: string]: unknown;
}; };
}; };
/**
* Missing User ID / Unauthorized
*/
401: UtilsRespStatus & {
data?: {
[key: string]: unknown;
};
};
/** /**
* Internal Server Error (Search Engine or Missing Offset) * Internal Server Error (Search Engine or Missing Offset)
*/ */
@@ -654,7 +1092,7 @@ export type GetUserListResponses = {
* Successful paginated list retrieval * Successful paginated list retrieval
*/ */
200: UtilsRespStatus & { 200: UtilsRespStatus & {
data?: Array<DataUserSearchDoc>; data?: Array<DataUserIndexDoc>;
}; };
}; };
@@ -665,6 +1103,12 @@ export type PatchUserUpdateData = {
* Updated User Profile Data * Updated User Profile Data
*/ */
body: ServiceUserUserInfoData; body: ServiceUserUserInfoData;
headers: {
/**
* latest
*/
'X-Api-Version': string;
};
path?: never; path?: never;
query?: never; query?: never;
url: '/user/update'; url: '/user/update';
@@ -682,7 +1126,7 @@ export type PatchUserUpdateErrors = {
/** /**
* Missing User ID / Unauthorized * Missing User ID / Unauthorized
*/ */
403: UtilsRespStatus & { 401: UtilsRespStatus & {
data?: { data?: {
[key: string]: unknown; [key: string]: unknown;
}; };

View File

@@ -2,175 +2,229 @@
import { z } from 'zod'; import { z } from 'zod';
export const zDataUser = z.object({ export const zDataEventIndexDoc = z.object({
allow_public: z.optional(z.boolean()), checkin_count: z.number().int().optional(),
avatar: z.optional(z.string()), description: z.string().optional(),
bio: z.optional(z.string()), enable_kyc: z.boolean().optional(),
email: z.optional(z.string()), end_time: z.string().optional(),
id: z.optional(z.int()), event_id: z.string().optional(),
nickname: z.optional(z.string()), is_joined: z.boolean().optional(),
permission_level: z.optional(z.int()), join_count: z.number().int().optional(),
subtitle: z.optional(z.string()), name: z.string().optional(),
user_id: z.optional(z.string()), start_time: z.string().optional(),
username: z.optional(z.string()), thumbnail: z.string().optional(),
uuid: z.optional(z.string()) type: z.string().optional()
}); });
export const zDataUserSearchDoc = z.object({ export const zDataUserIndexDoc = z.object({
avatar: z.optional(z.string()), avatar: z.string().optional(),
email: z.optional(z.string()), email: z.string().optional(),
nickname: z.optional(z.string()), nickname: z.string().optional(),
subtitle: z.optional(z.string()), subtitle: z.string().optional(),
type: z.optional(z.string()), type: z.string().optional(),
user_id: z.optional(z.string()), user_id: z.string().optional(),
username: z.optional(z.string()) username: z.string().optional()
}); });
export const zServiceAuthExchangeData = z.object({ export const zServiceAuthExchangeData = z.object({
client_id: z.optional(z.string()), client_id: z.string().optional(),
redirect_uri: z.optional(z.string()), redirect_uri: z.string().optional(),
state: z.optional(z.string()) state: z.string().optional()
}); });
export const zServiceAuthExchangeResponse = z.object({ export const zServiceAuthExchangeResponse = z.object({
redirect_uri: z.optional(z.string()) redirect_uri: z.string().optional()
}); });
export const zServiceAuthMagicData = z.object({ export const zServiceAuthMagicData = z.object({
client_id: z.optional(z.string()), client_id: z.string().optional(),
client_ip: z.optional(z.string()), client_ip: z.string().optional(),
email: z.optional(z.string()), email: z.string().optional(),
redirect_uri: z.optional(z.string()), redirect_uri: z.string().optional(),
state: z.optional(z.string()), state: z.string().optional(),
turnstile_token: z.optional(z.string()) turnstile_token: z.string().optional()
}); });
export const zServiceAuthMagicResponse = z.object({ export const zServiceAuthMagicResponse = z.object({
uri: z.optional(z.string()) uri: z.string().optional()
}); });
export const zServiceAuthRefreshData = z.object({ export const zServiceAuthRefreshData = z.object({
refresh_token: z.optional(z.string()) refresh_token: z.string().optional()
}); });
export const zServiceAuthTokenData = z.object({ export const zServiceAuthTokenData = z.object({
code: z.optional(z.string()) code: z.string().optional()
}); });
export const zServiceAuthTokenResponse = z.object({ export const zServiceAuthTokenResponse = z.object({
access_token: z.optional(z.string()), access_token: z.string().optional(),
refresh_token: z.optional(z.string()) refresh_token: z.string().optional()
}); });
export const zServiceEventCheckinQueryResponse = z.object({ export const zServiceEventCheckinQueryResponse = z.object({
checkin_at: z.optional(z.string()) checkin_at: z.string().optional()
}); });
export const zServiceEventCheckinResponse = z.object({ export const zServiceEventCheckinResponse = z.object({
checkin_code: z.optional(z.string()) checkin_code: z.string().optional()
}); });
export const zServiceEventCheckinSubmitData = z.object({ export const zServiceEventCheckinSubmitData = z.object({
checkin_code: z.optional(z.string()) checkin_code: z.string().optional()
}); });
export const zServiceEventInfoResponse = z.object({ export const zServiceEventEventJoinData = z.object({
end_time: z.optional(z.string()), event_id: z.string().optional(),
name: z.optional(z.string()), kyc_id: z.string().optional()
start_time: z.optional(z.string()) });
export const zServiceKycKycQueryData = z.object({
kyc_id: z.string().optional()
});
export const zServiceKycKycQueryResponse = z.object({
status: z.string().optional()
});
export const zServiceKycKycSessionData = z.object({
identity: z.string().optional(),
type: z.string().optional()
});
export const zServiceKycKycSessionResponse = z.object({
kyc_id: z.string().optional(),
redirect_uri: z.string().optional(),
status: z.string().optional()
}); });
export const zServiceUserUserInfoData = z.object({ export const zServiceUserUserInfoData = z.object({
allow_public: z.optional(z.boolean()), allow_public: z.boolean().optional(),
avatar: z.optional(z.string()), avatar: z.string().optional(),
bio: z.optional(z.string()), bio: z.string().optional(),
email: z.optional(z.string()), email: z.string().optional(),
nickname: z.optional(z.string()), nickname: z.string().optional(),
permission_level: z.optional(z.int()), permission_level: z.number().int().optional(),
subtitle: z.optional(z.string()), subtitle: z.string().optional(),
user_id: z.optional(z.string()), user_id: z.string().optional(),
username: z.optional(z.string()) username: z.string().optional()
}); });
export const zServiceUserUserTableResponse = z.object({ export const zServiceEventAttendanceListResponse = z.object({
user_table: z.optional(z.array(zDataUser)) attendance_id: z.string().optional(),
kyc_info: z.unknown().optional(),
kyc_type: z.string().optional(),
user_info: zServiceUserUserInfoData.optional()
}); });
export const zUtilsRespStatus = z.object({ export const zUtilsRespStatus = z.object({
code: z.optional(z.int()), code: z.number().int().optional(),
data: z.optional(z.unknown()), data: z.unknown().optional(),
error_id: z.optional(z.string()), error_id: z.string().optional(),
status: z.optional(z.string()) status: z.string().optional()
}); });
export const zPostAuthExchangeData = z.object({ export const zPostAuthExchangeData = z.object({
body: zServiceAuthExchangeData, body: zServiceAuthExchangeData,
path: z.optional(z.never()), path: z.never().optional(),
query: z.optional(z.never()) query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
}); });
/** /**
* Successful exchange * Successful exchange
*/ */
export const zPostAuthExchangeResponse = zUtilsRespStatus.and(z.object({ export const zPostAuthExchangeResponse = zUtilsRespStatus.and(z.object({
data: z.optional(zServiceAuthExchangeResponse) data: zServiceAuthExchangeResponse.optional()
})); }));
export const zPostAuthMagicData = z.object({ export const zPostAuthMagicData = z.object({
body: zServiceAuthMagicData, body: zServiceAuthMagicData,
path: z.optional(z.never()), path: z.never().optional(),
query: z.optional(z.never()) query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
}); });
/** /**
* Successful request * Successful request
*/ */
export const zPostAuthMagicResponse = zUtilsRespStatus.and(z.object({ export const zPostAuthMagicResponse = zUtilsRespStatus.and(z.object({
data: z.optional(zServiceAuthMagicResponse) data: zServiceAuthMagicResponse.optional()
})); }));
export const zGetAuthRedirectData = z.object({ export const zGetAuthRedirectData = z.object({
body: z.optional(z.never()), body: z.never().optional(),
path: z.optional(z.never()), path: z.never().optional(),
query: z.object({ query: z.object({
client_id: z.string(), client_id: z.string(),
redirect_uri: z.string(), redirect_uri: z.string(),
code: z.string(), code: z.string(),
state: z.optional(z.string()) state: z.string().optional()
}) })
}); });
export const zPostAuthRefreshData = z.object({ export const zPostAuthRefreshData = z.object({
body: zServiceAuthRefreshData, body: zServiceAuthRefreshData,
path: z.optional(z.never()), path: z.never().optional(),
query: z.optional(z.never()) query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
}); });
/** /**
* Successful rotation * Successful rotation
*/ */
export const zPostAuthRefreshResponse = zUtilsRespStatus.and(z.object({ export const zPostAuthRefreshResponse = zUtilsRespStatus.and(z.object({
data: z.optional(zServiceAuthTokenResponse) data: zServiceAuthTokenResponse.optional()
})); }));
export const zPostAuthTokenData = z.object({ export const zPostAuthTokenData = z.object({
body: zServiceAuthTokenData, body: zServiceAuthTokenData,
path: z.optional(z.never()), path: z.never().optional(),
query: z.optional(z.never()) query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
}); });
/** /**
* Successful token issuance * Successful token issuance
*/ */
export const zPostAuthTokenResponse = zUtilsRespStatus.and(z.object({ export const zPostAuthTokenResponse = zUtilsRespStatus.and(z.object({
data: z.optional(zServiceAuthTokenResponse) data: zServiceAuthTokenResponse.optional()
}));
export const zGetEventAttendanceData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
event_id: z.string()
}),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful retrieval
*/
export const zGetEventAttendanceResponse = zUtilsRespStatus.and(z.object({
data: z.array(zServiceEventAttendanceListResponse).optional()
})); }));
export const zGetEventCheckinData = z.object({ export const zGetEventCheckinData = z.object({
body: z.optional(z.never()), body: z.never().optional(),
path: z.optional(z.never()), path: z.never().optional(),
query: z.object({ query: z.object({
event_id: z.string() event_id: z.string()
}),
headers: z.object({
'X-Api-Version': z.string()
}) })
}); });
@@ -178,12 +232,12 @@ export const zGetEventCheckinData = z.object({
* Successfully generated code * Successfully generated code
*/ */
export const zGetEventCheckinResponse = zUtilsRespStatus.and(z.object({ export const zGetEventCheckinResponse = zUtilsRespStatus.and(z.object({
data: z.optional(zServiceEventCheckinResponse) data: zServiceEventCheckinResponse.optional()
})); }));
export const zGetEventCheckinQueryData = z.object({ export const zGetEventCheckinQueryData = z.object({
body: z.optional(z.never()), body: z.never().optional(),
path: z.optional(z.never()), path: z.never().optional(),
query: z.object({ query: z.object({
event_id: z.string() event_id: z.string()
}) })
@@ -193,27 +247,30 @@ export const zGetEventCheckinQueryData = z.object({
* Current attendance status * Current attendance status
*/ */
export const zGetEventCheckinQueryResponse = zUtilsRespStatus.and(z.object({ export const zGetEventCheckinQueryResponse = zUtilsRespStatus.and(z.object({
data: z.optional(zServiceEventCheckinQueryResponse) data: zServiceEventCheckinQueryResponse.optional()
})); }));
export const zPostEventCheckinSubmitData = z.object({ export const zPostEventCheckinSubmitData = z.object({
body: zServiceEventCheckinSubmitData, body: zServiceEventCheckinSubmitData,
path: z.optional(z.never()), path: z.never().optional(),
query: z.optional(z.never()) query: z.never().optional()
}); });
/** /**
* Attendance marked successfully * Attendance marked successfully
*/ */
export const zPostEventCheckinSubmitResponse = zUtilsRespStatus.and(z.object({ export const zPostEventCheckinSubmitResponse = zUtilsRespStatus.and(z.object({
data: z.optional(z.record(z.string(), z.unknown())) data: z.record(z.unknown()).optional()
})); }));
export const zGetEventInfoData = z.object({ export const zGetEventInfoData = z.object({
body: z.optional(z.never()), body: z.never().optional(),
path: z.optional(z.never()), path: z.never().optional(),
query: z.object({ query: z.object({
event_id: z.string() event_id: z.string()
}),
headers: z.object({
'X-Api-Version': z.string()
}) })
}); });
@@ -221,41 +278,119 @@ export const zGetEventInfoData = z.object({
* Successful retrieval * Successful retrieval
*/ */
export const zGetEventInfoResponse = zUtilsRespStatus.and(z.object({ export const zGetEventInfoResponse = zUtilsRespStatus.and(z.object({
data: z.optional(zServiceEventInfoResponse) data: zDataEventIndexDoc.optional()
})); }));
export const zGetUserFullData = z.object({ export const zPostEventJoinData = z.object({
body: z.optional(z.never()), body: zServiceEventEventJoinData,
path: z.optional(z.never()), path: z.never().optional(),
query: z.optional(z.never()) query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
}); });
/** /**
* Successful retrieval of full user table * Successfully joined the event
*/ */
export const zGetUserFullResponse = zUtilsRespStatus.and(z.object({ export const zPostEventJoinResponse = zUtilsRespStatus.and(z.object({
data: z.optional(zServiceUserUserTableResponse) data: z.record(z.unknown()).optional()
}));
export const zGetEventListData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
limit: z.number().int().optional(),
offset: z.number().int().optional()
}).optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful paginated list retrieval
*/
export const zGetEventListResponse = zUtilsRespStatus.and(z.object({
data: z.array(zDataEventIndexDoc).optional()
}));
export const zPostKycQueryData = z.object({
body: zServiceKycKycQueryData,
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Query processed (success/pending/failed)
*/
export const zPostKycQueryResponse = zUtilsRespStatus.and(z.object({
data: zServiceKycKycQueryResponse.optional()
}));
export const zPostKycSessionData = z.object({
body: zServiceKycKycSessionData,
path: z.never().optional(),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Session created successfully
*/
export const zPostKycSessionResponse = zUtilsRespStatus.and(z.object({
data: zServiceKycKycSessionResponse.optional()
})); }));
export const zGetUserInfoData = z.object({ export const zGetUserInfoData = z.object({
body: z.optional(z.never()), body: z.never().optional(),
path: z.optional(z.never()), path: z.never().optional(),
query: z.optional(z.never()) query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
}); });
/** /**
* Successful profile retrieval * Successful profile retrieval
*/ */
export const zGetUserInfoResponse = zUtilsRespStatus.and(z.object({ export const zGetUserInfoResponse = zUtilsRespStatus.and(z.object({
data: z.optional(zServiceUserUserInfoData) data: zServiceUserUserInfoData.optional()
}));
export const zGetUserInfoByUserIdData = z.object({
body: z.never().optional(),
path: z.object({
user_id: z.string()
}),
query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
});
/**
* Successful profile retrieval
*/
export const zGetUserInfoByUserIdResponse = zUtilsRespStatus.and(z.object({
data: zServiceUserUserInfoData.optional()
})); }));
export const zGetUserListData = z.object({ export const zGetUserListData = z.object({
body: z.optional(z.never()), body: z.never().optional(),
path: z.optional(z.never()), path: z.never().optional(),
query: z.object({ query: z.object({
limit: z.optional(z.string()), limit: z.string().optional(),
offset: z.string() offset: z.string()
}),
headers: z.object({
'X-Api-Version': z.string()
}) })
}); });
@@ -263,18 +398,21 @@ export const zGetUserListData = z.object({
* Successful paginated list retrieval * Successful paginated list retrieval
*/ */
export const zGetUserListResponse = zUtilsRespStatus.and(z.object({ export const zGetUserListResponse = zUtilsRespStatus.and(z.object({
data: z.optional(z.array(zDataUserSearchDoc)) data: z.array(zDataUserIndexDoc).optional()
})); }));
export const zPatchUserUpdateData = z.object({ export const zPatchUserUpdateData = z.object({
body: zServiceUserUserInfoData, body: zServiceUserUserInfoData,
path: z.optional(z.never()), path: z.never().optional(),
query: z.optional(z.never()) query: z.never().optional(),
headers: z.object({
'X-Api-Version': z.string()
})
}); });
/** /**
* Successful profile update * Successful profile update
*/ */
export const zPatchUserUpdateResponse = zUtilsRespStatus.and(z.object({ export const zPatchUserUpdateResponse = zUtilsRespStatus.and(z.object({
data: z.optional(z.record(z.string(), z.unknown())) data: z.record(z.unknown()).optional()
})); }));

View File

@@ -0,0 +1,40 @@
import { Calendar } from 'lucide-react';
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '../ui/badge';
import { Skeleton } from '../ui/skeleton';
export function EventCardSkeleton() {
return (
<Card className="relative mx-auto w-full max-w-sm pt-0">
<div className="absolute inset-0 z-30 aspect-video bg-black/10" />
<Skeleton
className="relative z-20 aspect-video w-full object-cover rounded-t-xl"
/>
<CardHeader>
<CardAction>
<Badge variant="secondary" className="bg-accent animate-pulse text-accent select-none">Official</Badge>
</CardAction>
<CardTitle>
<Skeleton className="h-4 max-w-48" />
</CardTitle>
<CardDescription className="flex flex-row items-center text-xs">
<Calendar className="size-4 mr-2" />
<Skeleton className="h-4 w-24" />
</CardDescription>
<CardDescription className="mt-1">
<Skeleton className="h-5 max-w-64" />
</CardDescription>
</CardHeader>
<CardFooter>
<Skeleton className="h-9 px-4 py-2 w-full"></Skeleton>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
import type { EventInfo } from './types';
import dayjs from 'dayjs';
import { Calendar } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Skeleton } from '../ui/skeleton';
export function EventCardView({ eventInfo, actionFooter }: { eventInfo: EventInfo; actionFooter: React.ReactNode }) {
const { type, coverImage, eventName, description, startTime, endTime } = eventInfo;
const startDayJs = dayjs(startTime);
const endDayJs = dayjs(endTime);
return (
<Card className="relative mx-auto w-full max-w-sm pt-0">
<div className="absolute inset-0 z-30 aspect-video bg-black/10" />
<img
src={coverImage}
alt="Event cover"
className="relative z-20 aspect-video w-full object-cover rounded-t-xl"
/>
<Skeleton
className="absolute z-15 aspect-video w-full object-cover rounded-t-xl"
/>
<CardHeader>
<CardAction>
{type === 'official' ? <Badge variant="secondary">Official</Badge> : <Badge variant="destructive">Party</Badge>}
</CardAction>
<CardTitle>{eventName}</CardTitle>
<CardDescription className="flex flex-row items-center text-xs">
<Calendar className="size-4 mr-2" />
{`${startDayJs.format('YYYY/MM/DD')} - ${endDayJs.format('YYYY/MM/DD')}`}
</CardDescription>
<CardDescription className="mt-1">
{description}
</CardDescription>
</CardHeader>
<CardFooter>
{actionFooter}
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,40 @@
import type { EventInfo } from './types';
import PlaceholderImage from '@/assets/event-placeholder.png';
import { useGetEvents } from '@/hooks/data/useGetEvents';
import { Button } from '../ui/button';
import { DialogTrigger } from '../ui/dialog';
import { EventGridView } from './event-grid.view';
import { KycDialogContainer } from './kyc/kyc.dialog.container';
export function EventGridContainer() {
const { data, isLoading } = useGetEvents();
const allEvents: EventInfo[] = isLoading
? []
: data.pages.flatMap(page => page.data!).map(it => ({
type: it.type! as EventInfo['type'],
eventId: it.event_id!,
isJoined: it.is_joined!,
requireKyc: it.enable_kyc!,
coverImage: it.thumbnail! || PlaceholderImage,
eventName: it.name!,
description: it.description!,
startTime: new Date(it.start_time!),
endTime: new Date(it.end_time!),
} satisfies EventInfo));
return (
<EventGridView
events={allEvents}
assembleFooter={eventInfo => (eventInfo.isJoined
? <Button className="w-full" disabled></Button>
: (
<KycDialogContainer eventIdToJoin={eventInfo.eventId}>
<DialogTrigger asChild>
<Button className="w-full"></Button>
</DialogTrigger>
</KycDialogContainer>
)
)}
/>
);
}

View File

@@ -0,0 +1,12 @@
import { EventCardSkeleton } from './event-card.skeleton';
export function EventGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<EventCardSkeleton key={i} />
))}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import type { EventInfo } from './types';
import { EventCardView } from './event-card.view';
export function EventGridView({ events, assembleFooter }: { events: EventInfo[]; assembleFooter: (event: EventInfo) => React.ReactNode }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{events.map(event => (
<EventCardView key={event.eventId} eventInfo={event} actionFooter={assembleFooter(event)} />
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { X } from 'lucide-react';
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
export function KycFailedDialogView() {
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<p></p>
<div className="flex justify-center my-12">
<X size={100} />
</div>
</DialogDescription>
</DialogHeader>
</DialogContent>
);
}

View File

@@ -0,0 +1,177 @@
import type { KycSubmission } from './kyc.types';
import { useForm } from '@tanstack/react-form';
import { useState } from 'react';
import { toast } from 'sonner';
import z from 'zod';
import {
Field,
FieldError,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '../../ui/button';
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
const CnridSchema = z.object({
cnrid: z.string().min(18, '身份证号应为18位').max(18, '身份证号应为18位'),
name: z.string().min(2, '姓名应至少2个字符').max(10, '姓名应不超过10个字符'),
});
function CnridForm({ onSubmit }: { onSubmit: OnSubmit }) {
const form = useForm({
defaultValues: {
cnrid: '',
name: '',
},
validators: {
onSubmit: CnridSchema,
},
onSubmit: async (values) => {
await onSubmit({
method: 'cnrid',
...values.value,
}).catch(() => {
toast('认证失败,请稍后再试');
});
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}}
className="flex flex-col gap-4"
>
<form.Field name="name">
{field => (
<Field>
<FieldLabel htmlFor="name"></FieldLabel>
<Input
id="name"
name="name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="cnrid">
{field => (
<Field>
<FieldLabel htmlFor="cnrid"></FieldLabel>
<Input
id="cnrid"
name="cnrid"
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<DialogFooter>
<form.Subscribe
selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]}
children={([canSubmit, isPristine, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? '...' : '开始认证'}</Button>
)}
/>
</DialogFooter>
</form>
);
}
const PassportSchema = z.object({
passportId: z.string().min(9, '护照号应为9个字符').max(9, '护照号应为9个字符'),
});
function PassportForm({ onSubmit }: { onSubmit: OnSubmit }) {
const form = useForm({
defaultValues: {
passportId: '',
},
validators: {
onSubmit: PassportSchema,
},
onSubmit: async (values) => {
await onSubmit({
method: 'passport',
...values.value,
}).catch(() => {
toast('认证失败,请稍后再试');
});
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}}
className="flex flex-col gap-4"
>
<form.Field name="passportId">
{field => (
<Field>
<FieldLabel htmlFor="passportId"></FieldLabel>
<Input
id="passportId"
name="passportId"
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<DialogFooter>
<form.Subscribe
selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]}
children={([canSubmit, isPristine, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? '...' : '开始认证'}</Button>
)}
/>
</DialogFooter>
</form>
);
}
type OnSubmit = (submission: KycSubmission) => Promise<void>;
export function KycMethodSelectionDialogView({ onSubmit }: { onSubmit: OnSubmit }) {
const [kycMethod, setKycMethod] = useState<string | null>(null);
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="prose">
<p></p>
</DialogDescription>
</DialogHeader>
<Label htmlFor="selection"></Label>
<Select onValueChange={setKycMethod}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="请选择..." />
</SelectTrigger>
<SelectContent>
<SelectGroup id="selection">
<SelectItem value="cnrid"></SelectItem>
<SelectItem value="passport"></SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{kycMethod === 'cnrid' && <CnridForm onSubmit={onSubmit} />}
{kycMethod === 'passport' && <PassportForm onSubmit={onSubmit} />}
</DialogContent>
);
}

View File

@@ -0,0 +1,18 @@
import { HashLoader } from 'react-spinners/esm';
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
export function KycPendingDialogView() {
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<p>...</p>
<div className="flex justify-center my-12">
<HashLoader color="#e0e0e0" size={100} />
</div>
</DialogDescription>
</DialogHeader>
</DialogContent>
);
}

View File

@@ -0,0 +1,24 @@
import { Button } from '../../ui/button';
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
export function KycPromptDialogView({ next }: { next: () => void }) {
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="prose">
<p></p>
<p></p>
<ul>
<li> AES-256 </li>
<li></li>
<li> 30 </li>
</ul>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={next}></Button>
</DialogFooter>
</DialogContent>
);
}

View File

@@ -0,0 +1,18 @@
import { Check } from 'lucide-react';
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
export function KycSuccessDialogView() {
return (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<p></p>
<div className="flex justify-center my-12">
<Check size={100} />
</div>
</DialogDescription>
</DialogHeader>
</DialogContent>
);
}

View File

@@ -0,0 +1,129 @@
import type { KycSubmission } from './kyc.types';
import { Dialog } from '@radix-ui/react-dialog';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useState } from 'react';
import { useStore } from 'zustand';
import { postEventJoin, postKycQuery } from '@/client';
import { getEventListInfiniteQueryKey } from '@/client/@tanstack/react-query.gen';
import { useCreateKycSession } from '@/hooks/data/useCreateKycSession';
import { ver } from '@/lib/apiVersion';
import { KycFailedDialogView } from './kyc-failed.dialog.view';
import { KycMethodSelectionDialogView } from './kyc-method-selection.dialog.view';
import { KycPendingDialogView } from './kyc-pending.dialog.view';
import { KycPromptDialogView } from './kyc-prompt.dialog.view';
import { KycSuccessDialogView } from './kyc-success.dialog.view';
import { createKycStore } from './kyc.state';
export function KycDialogContainer({ eventIdToJoin, children }: { eventIdToJoin: string; children: React.ReactNode }) {
const [store] = useState(() => createKycStore(eventIdToJoin));
const isDialogOpen = useStore(store, s => s.isDialogOpen);
const setIsDialogOpen = useStore(store, s => s.setIsDialogOpen);
const stage = useStore(store, s => s.stage);
const setStage = useStore(store, s => s.setStage);
const setKycId = useStore(store, s => s.setKycId);
const { mutateAsync } = useCreateKycSession();
const queryClient = useQueryClient();
const joinEvent = useCallback(async (eventId: string, kycId: string, abortSignal?: AbortSignal) => {
try {
await postEventJoin({
signal: abortSignal,
body: { event_id: eventId, kyc_id: kycId },
headers: ver('20260205'),
});
setStage('success');
}
catch (e) {
console.error('Error joining event:', e);
setStage('failed');
}
}, [setStage]);
const onKycSessionCreate = useCallback(async (submission: KycSubmission) => {
try {
const { data } = await mutateAsync(submission);
setKycId(data!.kyc_id!);
if (data!.status === 'success') {
await joinEvent(eventIdToJoin, data!.kyc_id!, undefined);
}
else if (data!.status === 'processing') {
window.open(data!.redirect_uri, '_blank');
setStage('pending');
}
}
catch (e) {
console.error(e);
setStage('failed');
}
}, [eventIdToJoin, joinEvent, mutateAsync, setKycId, setStage]);
useEffect(() => {
if (stage !== 'pending' || !isDialogOpen) {
return;
}
const controller = new AbortController();
let timer: NodeJS.Timeout;
const poll = async () => {
try {
const { data } = await postKycQuery({
signal: controller.signal,
body: { kyc_id: store.getState().kycId! },
headers: ver('20260205'),
});
const status = data?.data?.status;
if (status === 'success') {
void joinEvent(eventIdToJoin, store.getState().kycId!, controller.signal);
}
else if (status === 'failed') {
setStage('failed');
}
else if (status === 'pending') {
timer = setTimeout(() => void poll(), 1000);
}
else {
// What the fuck?
setStage('failed');
}
}
catch (e) {
if ((e as Error).name === 'AbortError')
return;
console.error('Error fetching KYC status:', e);
setStage('failed');
}
};
void poll();
return () => {
controller.abort();
clearTimeout(timer);
};
}, [stage, store, setStage, isDialogOpen, joinEvent, eventIdToJoin]);
return (
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
void queryClient.invalidateQueries({
queryKey: getEventListInfiniteQueryKey({ query: {}, headers: ver('20260205') }),
});
}
setIsDialogOpen(open);
}}
>
{children}
{stage === 'prompt' && <KycPromptDialogView next={() => setStage('methodSelection')} />}
{stage === 'methodSelection' && <KycMethodSelectionDialogView onSubmit={onKycSessionCreate} />}
{stage === 'pending' && <KycPendingDialogView />}
{stage === 'success' && <KycSuccessDialogView />}
{stage === 'failed' && <KycFailedDialogView />}
</Dialog>
);
}

View File

@@ -0,0 +1,34 @@
import { createStore } from 'zustand';
import { devtools } from 'zustand/middleware';
interface KycState {
isDialogOpen: boolean;
eventIdToJoin: string;
kycId: string | null;
stage: 'prompt' | 'methodSelection' | 'pending' | 'success' | 'failed';
setIsDialogOpen: (open: boolean) => void;
setStage: (stage: KycState['stage']) => void;
setKycId: (kycId: string) => void;
}
export function createKycStore(eventIdToJoin: string) {
const initialState = {
isDialogOpen: false,
eventIdToJoin,
kycId: null,
stage: 'prompt' as const,
};
return createStore<KycState>()(devtools(set => ({
...initialState,
setIsDialogOpen: (open: boolean) => set(() =>
open
? { ...initialState, isDialogOpen: true }
: { ...initialState, isDialogOpen: false },
),
setStage: (stage: KycState['stage']) => set(() => ({ stage })),
setKycId: (kycId: string) => set(() => ({ kycId })),
})));
}
export type KycStore = ReturnType<typeof createKycStore>;

View File

@@ -0,0 +1,8 @@
export type KycSubmission = {
method: 'cnrid';
cnrid: string;
name: string;
} | {
method: 'passport';
passportId: string;
};

View File

@@ -0,0 +1,11 @@
export interface EventInfo {
type: 'official' | 'party';
eventId: string;
isJoined: boolean;
requireKyc: boolean;
coverImage: string;
eventName: string;
description: string;
startTime: Date;
endTime: Date;
}

View File

@@ -1,20 +0,0 @@
import type { ReactNode } from 'react';
import React, { Suspense } from 'react';
export function withFallback<P extends object>(
Component: React.ComponentType<P>,
fallback: ReactNode,
) {
const Wrapped: React.FC<P> = (props) => {
return (
<Suspense fallback={fallback}>
<Component {...props} />
</Suspense>
);
};
Wrapped.displayName = `withFallback(${Component.displayName! || Component.name || 'Component'
})`;
return Wrapped;
}

View File

@@ -0,0 +1,15 @@
import type { ServiceUserUserInfoData } from '@/client';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { EditProfileDialogView } from './edit-profile.dialog.view';
export function EditProfileDialogContainer({ data }: { data: ServiceUserUserInfoData }) {
const { mutateAsync } = useUpdateUser();
return (
<EditProfileDialogView
user={data}
updateProfile={async (data) => {
await mutateAsync({ body: data });
}}
/>
);
}

View File

@@ -1,5 +1,9 @@
import type { ServiceUserUserInfoData } from '@/client';
import { useForm } from '@tanstack/react-form'; import { useForm } from '@tanstack/react-form';
import { useState } from 'react'; import {
useEffect,
useState,
} from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import z from 'zod'; import z from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -20,22 +24,16 @@ import {
import { import {
Input, Input,
} from '@/components/ui/input'; } from '@/components/ui/input';
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { Switch } from '../ui/switch'; import { Switch } from '../ui/switch';
const formSchema = z.object({ const formSchema = z.object({
username: z.string().min(5), username: z.string().min(5),
nickname: z.string(), nickname: z.string(),
subtitle: z.string(), subtitle: z.string(),
avatar: z.url().or(z.literal('')), avatar: z.string().url().or(z.literal('')),
allow_public: z.boolean(), allow_public: z.boolean(),
}); });
export function EditProfileDialog() { export function EditProfileDialogView({ user, updateProfile }: { user: ServiceUserUserInfoData; updateProfile: (data: ServiceUserUserInfoData) => Promise<void> }) {
const { data } = useUserInfo();
const user = data.data!;
const { mutateAsync } = useUpdateUser();
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
avatar: user.avatar, avatar: user.avatar,
@@ -51,7 +49,7 @@ export function EditProfileDialog() {
value, value,
}) => { }) => {
try { try {
await mutateAsync({ body: value }); await updateProfile(value);
toast.success('个人资料更新成功'); toast.success('个人资料更新成功');
} }
catch (error) { catch (error) {
@@ -63,11 +61,14 @@ export function EditProfileDialog() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (!open) { useEffect(() => {
setTimeout(() => { if (!open) {
form.reset(); const id = setTimeout(() => {
}, 200); form.reset();
} }, 200);
return () => clearTimeout(id);
}
}, [open, form]);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -79,7 +80,7 @@ export function EditProfileDialog() {
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
form.handleSubmit().then(() => setOpen(false)); void form.handleSubmit().then(() => setOpen(false));
}} }}
className="grid gap-4" className="grid gap-4"
> >

View File

@@ -0,0 +1,17 @@
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
import { useOtherUserInfo } from '@/hooks/data/useUserInfo';
import { utf8ToBase64 } from '@/lib/utils';
import { ProfileView } from './profile.view';
export function ProfileContainer({ userId }: { userId: string }) {
const { data } = useOtherUserInfo(userId);
const { mutateAsync } = useUpdateUser();
return (
<ProfileView
user={data.data!}
onSaveBio={async (bio) => {
await mutateAsync({ body: { bio: utf8ToBase64(bio) } });
}}
/>
);
}

View File

@@ -0,0 +1,30 @@
import { Mail } from 'lucide-react';
import { Skeleton } from '../ui/skeleton';
export function ProfileError({ reason }: { reason: string }) {
return (
<div className="flex flex-col justify-center w-full lg:w-auto h-full lg:h-auto lg:flex-row lg:gap-8">
<div className="flex w-full flex-row mt-2 lg:mt-0 lg:flex-col lg:w-max">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-row gap-3 w-full lg:flex-col">
<Skeleton className="size-16 rounded-full border-2 border-muted lg:size-64" />
<div className="flex flex-1 flex-col justify-center lg:mt-3 gap-2">
<Skeleton className="w-32 h-8" />
<Skeleton className="w-20 h-6" />
</div>
</div>
<div className="flex flex-row gap-2 items-center text-sm px-1 lg:px-0">
<Mail className="h-4 w-4 stroke-muted-foreground" />
<Skeleton className="w-32 h-4" />
</div>
</div>
<Skeleton className="w-64 h-[40px]" />
</div>
</div>
<Skeleton className="relative rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 prose dark:prose-invert max-w-[1012px] self-center flex items-center justify-center">
{reason}
</Skeleton>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Mail } from 'lucide-react';
import { Skeleton } from '../ui/skeleton';
export function ProfileSkeleton() {
return (
<div className="flex flex-col justify-center w-full lg:w-auto h-full lg:h-auto lg:flex-row lg:gap-8">
<div className="flex w-full flex-row mt-2 lg:mt-0 lg:flex-col lg:w-max">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-row gap-3 w-full lg:flex-col">
<Skeleton className="size-16 rounded-full border-2 border-muted lg:size-64" />
<div className="flex flex-1 flex-col justify-center lg:mt-3 gap-2">
<Skeleton className="w-32 h-8" />
<Skeleton className="w-20 h-6" />
</div>
</div>
<div className="flex flex-row gap-2 items-center text-sm px-1 lg:px-0">
<Mail className="h-4 w-4 stroke-muted-foreground" />
<Skeleton className="w-32 h-4" />
</div>
</div>
<Skeleton className="w-64 h-[40px]" />
</div>
</div>
<Skeleton className="relative rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 prose dark:prose-invert max-w-[1012px] self-center">
</Skeleton>
</div>
);
}

View File

@@ -1,24 +1,23 @@
import type { ServiceUserUserInfoData } from '@/client';
import { identicon } from '@dicebear/collection'; import { identicon } from '@dicebear/collection';
import { createAvatar } from '@dicebear/core'; import { createAvatar } from '@dicebear/core';
import MDEditor from '@uiw/react-md-editor'; import MDEditor from '@uiw/react-md-editor';
import { isNil } from 'lodash-es'; import {
isEmpty,
isNil,
} from 'lodash-es';
import { Mail, Pencil } from 'lucide-react'; import { Mail, Pencil } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarImage } from '@/components/ui/avatar';
import { useUpdateUser } from '@/hooks/data/useUpdateUser'; import { base64ToUtf8 } from '@/lib/utils';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { base64ToUtf8, utf8ToBase64 } from '@/lib/utils';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { EditProfileDialog } from './edit-profile-dialog'; import { EditProfileDialogContainer } from './edit-profile.dialog.container';
export function MainProfile() { export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData; onSaveBio: (bio: string) => Promise<void> }) {
const { data } = useUserInfo();
const user = data.data!;
const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio ?? '')); const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio ?? ''));
const [enableBioEdit, setEnableBioEdit] = useState(false); const [enableBioEdit, setEnableBioEdit] = useState(false);
const { mutateAsync } = useUpdateUser();
const IdentIcon = useMemo(() => { const IdentIcon = useMemo(() => {
const avatar = createAvatar(identicon, { const avatar = createAvatar(identicon, {
@@ -35,7 +34,7 @@ export function MainProfile() {
<div className="flex flex-col w-full gap-3"> <div className="flex flex-col w-full gap-3">
<div className="flex flex-row gap-3 w-full lg:flex-col"> <div className="flex flex-row gap-3 w-full lg:flex-col">
<Avatar className="size-16 rounded-full border-2 border-muted lg:size-64"> <Avatar className="size-16 rounded-full border-2 border-muted lg:size-64">
{user.avatar ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon} {!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
</Avatar> </Avatar>
<div className="flex flex-1 flex-col justify-center lg:mt-3"> <div className="flex flex-1 flex-col justify-center lg:mt-3">
<span className="font-semibold text-2xl" aria-hidden="true">{user.nickname}</span> <span className="font-semibold text-2xl" aria-hidden="true">{user.nickname}</span>
@@ -47,7 +46,7 @@ export function MainProfile() {
{user.email} {user.email}
</div> </div>
</div> </div>
<EditProfileDialog /> <EditProfileDialogContainer data={user} />
</div> </div>
</div> </div>
<section className="relative rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 prose dark:prose-invert max-w-[1012px] self-center"> <section className="relative rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 prose dark:prose-invert max-w-[1012px] self-center">
@@ -71,7 +70,7 @@ export function MainProfile() {
else { else {
if (!isNil(bio)) { if (!isNil(bio)) {
try { try {
await mutateAsync({ body: { bio: utf8ToBase64(bio) } }); await onSaveBio(bio);
setEnableBioEdit(false); setEnableBioEdit(false);
} }
catch (error) { catch (error) {

View File

@@ -1,7 +1,8 @@
import type { NavData } from '@/lib/navData';
import * as React from 'react'; import * as React from 'react';
import NixOSLogo from '@/assets/nixos.svg?react'; import NixOSLogo from '@/assets/nixos.svg?react';
import { NavMain } from '@/components/sidebar/nav-main'; import { NavMain } from '@/components/sidebar/nav-main.view';
import { NavSecondary } from '@/components/sidebar/nav-secondary'; import { NavSecondary } from '@/components/sidebar/nav-secondary.view';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -11,10 +12,8 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
import { NavUser } from './nav-user';
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ navData, footerWidget, ...props }: React.ComponentProps<typeof Sidebar> & { navData: NavData; footerWidget: React.ReactNode }) {
return ( return (
<Sidebar collapsible="offcanvas" {...props}> <Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader> <SidebarHeader>
@@ -37,7 +36,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<NavSecondary items={navData.navSecondary} className="mt-auto" /> <NavSecondary items={navData.navSecondary} className="mt-auto" />
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<NavUser /> {footerWidget}
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
); );

View File

@@ -0,0 +1,11 @@
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { NavUserView } from './nav-user.view';
export function NavUserContainer() {
const { data } = useUserInfo();
return (
<NavUserView
user={data.data!}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { IconDotsVertical } from '@tabler/icons-react';
import { SidebarMenuButton } from '../ui/sidebar';
import { Skeleton } from '../ui/skeleton';
export function NavUserSkeleton() {
return (
<SidebarMenuButton
size="lg"
>
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex flex-col flex-1 gap-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
);
}

View File

@@ -1,10 +1,12 @@
import { identicon } from '@dicebear/collection'; import type { ServiceUserUserInfoData } from '@/client';
import { identicon } from '@dicebear/collection';
import { createAvatar } from '@dicebear/core'; import { createAvatar } from '@dicebear/core';
import { import {
IconDotsVertical, IconDotsVertical,
IconLogout, IconLogout,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { isEmpty } from 'lodash-es';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
Avatar, Avatar,
@@ -24,16 +26,10 @@ import {
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { useUserInfo } from '@/hooks/data/useUserInfo'; import { logout } from '@/lib/token';
import { useLogout } from '@/hooks/useLogout';
import { withFallback } from '../hoc/with-fallback';
import { Skeleton } from '../ui/skeleton';
function NavUser_() { export function NavUserView({ user }: { user: ServiceUserUserInfoData }) {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
const { data } = useUserInfo();
const user = data.data!;
const { logout } = useLogout();
const IdentIcon = useMemo(() => { const IdentIcon = useMemo(() => {
const avatar = createAvatar(identicon, { const avatar = createAvatar(identicon, {
@@ -53,7 +49,7 @@ function NavUser_() {
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<Avatar className="h-8 w-8 rounded-lg"> <Avatar className="h-8 w-8 rounded-lg">
{user.avatar ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon} {!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.nickname}</span> <span className="truncate font-medium">{user.nickname}</span>
@@ -73,7 +69,7 @@ function NavUser_() {
<DropdownMenuLabel className="p-0 font-normal"> <DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg"> <Avatar className="h-8 w-8 rounded-lg">
{user.avatar ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon} {!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.nickname}</span> <span className="truncate font-medium">{user.nickname}</span>
@@ -84,7 +80,7 @@ function NavUser_() {
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}> <DropdownMenuItem onClick={_e => logout()}>
<IconLogout /> <IconLogout />
</DropdownMenuItem> </DropdownMenuItem>
@@ -94,20 +90,3 @@ function NavUser_() {
</SidebarMenu> </SidebarMenu>
); );
} }
function NavUserSkeleton() {
return (
<SidebarMenuButton
size="lg"
>
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex flex-col flex-1 gap-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
);
}
export const NavUser = withFallback(NavUser_, <NavUserSkeleton />);

View File

@@ -1,18 +1,7 @@
import { useRouterState } from '@tanstack/react-router';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar'; import { SidebarTrigger } from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
export function SiteHeader() {
const pathname = useRouterState({ select: state => state.location.pathname });
const allNavItems = [...navData.navMain, ...navData.navSecondary];
const currentTitle
= allNavItems.find(item =>
item.url === '/'
? pathname === '/'
: pathname.startsWith(item.url),
)?.title ?? '工作台';
export function SiteHeader({ title }: { title: string }) {
return ( return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"> <header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6"> <div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
@@ -21,7 +10,7 @@ export function SiteHeader() {
orientation="vertical" orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4" className="mx-2 data-[orientation=vertical]:h-4"
/> />
<h1 className="text-base font-medium">{currentTitle}</h1> <h1 className="text-base font-medium">{title}</h1>
</div> </div>
</header> </header>
); );

View File

@@ -1,9 +0,0 @@
import { Skeleton } from '../ui/skeleton';
export function CardSkeleton() {
return (
<Skeleton
className="gap-6 rounded-xl py-6 h-full"
/>
);
}

View File

@@ -0,0 +1,45 @@
import type { KycSubmission } from '@/components/events/kyc/kyc.types';
import { useMutation } from '@tanstack/react-query';
import { postKycSessionMutation } from '@/client/@tanstack/react-query.gen';
import { ver } from '@/lib/apiVersion';
import { utf8ToBase64 } from '@/lib/utils';
type CreateKycSessionBase64Payload = {
// Cnrid
legal_name: string;
resident_id: string;
} | {
// Passport
id: string;
};
export function useCreateKycSession() {
const mutation = useMutation({
...postKycSessionMutation(),
});
return {
...mutation,
mutate: null, // Don't ever use this
mutateAsync: async (data: KycSubmission) => {
const payload = utf8ToBase64(JSON.stringify(
data.method === 'cnrid'
? {
legal_name: data.name,
resident_id: data.cnrid,
} satisfies CreateKycSessionBase64Payload
: {
id: data.passportId,
} satisfies CreateKycSessionBase64Payload,
));
const response = await mutation.mutateAsync({
body: {
identity: payload,
type: data.method,
},
headers: ver('20260205'),
});
return response;
},
};
}

View File

@@ -6,7 +6,7 @@ export function useExchangeToken() {
return useMutation({ return useMutation({
...postAuthExchangeMutation(), ...postAuthExchangeMutation(),
onSuccess: (data) => { onSuccess: (data) => {
window.location.href = data.data?.redirect_uri!; window.location.href = data.data!.redirect_uri!;
}, },
onError: (error) => { onError: (error) => {
console.error(error); console.error(error);

View File

@@ -0,0 +1,21 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { isNil } from 'lodash-es';
import { getEventListInfiniteOptions } from '@/client/@tanstack/react-query.gen';
import { ver } from '@/lib/apiVersion';
export function useGetEvents() {
return useInfiniteQuery({
...getEventListInfiniteOptions({
query: {},
headers: ver('20260205'),
}),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const currentData = lastPage?.data;
if (!isNil(currentData) && currentData.length === 20) {
return allPages.length * 20;
}
return undefined;
},
});
}

View File

@@ -1,12 +1,18 @@
import type { ServiceUserUserInfoData } from '@/client';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getUserInfoQueryKey, patchUserUpdateMutation } from '@/client/@tanstack/react-query.gen'; import { getUserInfoByUserIdQueryKey, getUserInfoQueryKey, patchUserUpdateMutation } from '@/client/@tanstack/react-query.gen';
import { ver } from '@/lib/apiVersion';
export function useUpdateUser() { export function useUpdateUser() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const data: { data: ServiceUserUserInfoData | undefined } | undefined = queryClient.getQueryData(getUserInfoQueryKey({ headers: ver('20260205') }));
return useMutation({ return useMutation({
...patchUserUpdateMutation(), ...patchUserUpdateMutation(),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: getUserInfoQueryKey() }); await queryClient.invalidateQueries({ queryKey: getUserInfoQueryKey({ headers: ver('20260205') }) });
if ((data?.data?.user_id) != null) {
await queryClient.invalidateQueries({ queryKey: getUserInfoByUserIdQueryKey({ path: { user_id: data.data.user_id }, headers: ver('20260205') }) });
}
}, },
}); });
} }

View File

@@ -1,9 +1,21 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { getUserInfoOptions } from '@/client/@tanstack/react-query.gen'; import {
getUserInfoByUserIdOptions,
getUserInfoOptions,
} from '@/client/@tanstack/react-query.gen';
import { ver } from '@/lib/apiVersion';
export function useUserInfo() { export function useUserInfo() {
return useSuspenseQuery({ return useSuspenseQuery({
...getUserInfoOptions(), ...getUserInfoOptions({ headers: ver('20260205') }),
staleTime: 10 * 60 * 1000, staleTime: 10 * 60 * 1000,
}); });
} }
export function useOtherUserInfo(userId: string) {
return useSuspenseQuery({
...getUserInfoByUserIdOptions({ path: { user_id: userId }, headers: ver('20260205') }),
staleTime: 10 * 60 * 1000,
retry: (_failureCount, error) => error.code !== 403,
});
}

View File

@@ -11,6 +11,7 @@ export function useIsMobile() {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
}; };
mql.addEventListener('change', onChange); mql.addEventListener('change', onChange);
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange); return () => mql.removeEventListener('change', onChange);
}, []); }, []);

View File

@@ -1,14 +0,0 @@
import { useNavigate } from '@tanstack/react-router';
import { useCallback } from 'react';
import { clearTokens } from '@/lib/token';
export function useLogout() {
const navigate = useNavigate();
const logout = useCallback(() => {
clearTokens();
void navigate({ to: '/authorize' });
}, [navigate]);
return { logout };
}

View File

@@ -0,0 +1,5 @@
export function ver(version: string) {
return {
'X-Api-Version': version,
};
}

View File

@@ -1,13 +1,12 @@
import { isEmpty, isNil } from 'lodash-es'; import { isEmpty, isNil } from 'lodash-es';
import { client } from '@/client/client.gen'; import { client } from '@/client/client.gen';
import { router } from './router';
import { import {
clearTokens,
doRefreshToken, doRefreshToken,
getAccessToken,
getRefreshToken, getRefreshToken,
getToken, logout,
setAccessToken,
setRefreshToken, setRefreshToken,
setToken,
} from './token'; } from './token';
export function configInternalApiClient() { export function configInternalApiClient() {
@@ -19,8 +18,8 @@ export function configInternalApiClient() {
}); });
client.interceptors.request.use((request) => { client.interceptors.request.use((request) => {
const token = getToken(); const token = getAccessToken();
if (token) { if (!isNil(token) && !isEmpty(token)) {
request.headers.set('Authorization', `Bearer ${token}`); request.headers.set('Authorization', `Bearer ${token}`);
} }
return request; return request;
@@ -28,14 +27,21 @@ export function configInternalApiClient() {
client.interceptors.response.use(async (response, request, options) => { client.interceptors.response.use(async (response, request, options) => {
if (response.status === 401) { if (response.status === 401) {
const refreshToken = getRefreshToken();
// Avoid infinite loop if the refresh token request itself fails // Avoid infinite loop if the refresh token request itself fails
if (!request.url.includes('/auth/refresh') && !isNil(refreshToken)) { if (request.url.includes('/auth/refresh')) {
try { // Refresh token failed, clear tokens and redirect to login page
const refreshResponse = await doRefreshToken(); logout('会话已过期');
}
else {
const refreshToken = getRefreshToken();
if (isNil(refreshToken) || isEmpty(refreshToken)) {
logout('未登录');
}
else {
const refreshResponse = await doRefreshToken(refreshToken);
if (!isEmpty(refreshResponse)) { if (!isEmpty(refreshResponse)) {
const { access_token, refresh_token } = refreshResponse; const { access_token, refresh_token } = refreshResponse;
setToken(access_token!); setAccessToken(access_token!);
setRefreshToken(refresh_token!); setRefreshToken(refresh_token!);
const fetchFn = options.fetch ?? globalThis.fetch; const fetchFn = options.fetch ?? globalThis.fetch;
@@ -50,11 +56,6 @@ export function configInternalApiClient() {
}); });
} }
} }
catch (e) {
clearTokens();
await router.navigate({ to: '/authorize' });
return response;
}
} }
} }
return response; return response;

View File

@@ -1,4 +1,5 @@
import { import {
IconCalendarEvent,
IconDashboard, IconDashboard,
IconUser, IconUser,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@@ -10,6 +11,11 @@ export const navData = {
url: '/', url: '/',
icon: IconDashboard, icon: IconDashboard,
}, },
{
title: '活动列表',
url: '/events',
icon: IconCalendarEvent,
},
], ],
navSecondary: [ navSecondary: [
{ {
@@ -19,3 +25,4 @@ export const navData = {
}, },
], ],
}; };
export type NavData = typeof navData;

View File

@@ -1,40 +1,52 @@
import type { ServiceAuthTokenResponse } from '@/client'; import type { ServiceAuthTokenResponse } from '@/client';
import { toast } from 'sonner';
import { postAuthRefresh } from '@/client'; import { postAuthRefresh } from '@/client';
import { router } from './router';
export function setToken(token: string) { const ACCESS_TOKEN_LOCALSTORAGE_KEY = 'token';
localStorage.setItem('token', token); const REFRESH_TOKEN_LOCALSTORAGE_KEY = 'refreshToken';
export function setAccessToken(token: string) {
localStorage.setItem(ACCESS_TOKEN_LOCALSTORAGE_KEY, token);
} }
export function getToken() { export function getAccessToken() {
return localStorage.getItem('token'); return localStorage.getItem(ACCESS_TOKEN_LOCALSTORAGE_KEY);
} }
export function removeToken() { export function removeAccessToken() {
localStorage.removeItem('token'); localStorage.removeItem(ACCESS_TOKEN_LOCALSTORAGE_KEY);
}
export function hasToken() {
return getToken() !== null;
} }
export function setRefreshToken(refreshToken: string) { export function setRefreshToken(refreshToken: string) {
localStorage.setItem('refreshToken', refreshToken); localStorage.setItem(REFRESH_TOKEN_LOCALSTORAGE_KEY, refreshToken);
} }
export function getRefreshToken() { export function getRefreshToken() {
return localStorage.getItem('refreshToken'); return localStorage.getItem(REFRESH_TOKEN_LOCALSTORAGE_KEY);
}
export function removeRefreshToken() {
localStorage.removeItem(REFRESH_TOKEN_LOCALSTORAGE_KEY);
} }
export function clearTokens() { export function clearTokens() {
removeToken(); removeAccessToken();
setRefreshToken(''); removeRefreshToken();
} }
export async function doRefreshToken(): Promise<ServiceAuthTokenResponse | undefined> { export async function doRefreshToken(refreshToken: string): Promise<ServiceAuthTokenResponse | undefined> {
const { data } = await postAuthRefresh({ const { data } = await postAuthRefresh({
body: { body: {
refresh_token: getRefreshToken()!, refresh_token: refreshToken,
}, },
}); });
return data?.data; return data?.data;
} }
export function logout(message: string = '已登出') {
clearTokens();
void router.navigate({ to: '/authorize' }).then(() => {
toast.info(message);
});
}

View File

@@ -1,3 +1,4 @@
import type { Query } from '@tanstack/react-query';
import type { ClassValue } from 'clsx'; import type { ClassValue } from 'clsx';
// eslint-disable-next-line unicorn/prefer-node-protocol // eslint-disable-next-line unicorn/prefer-node-protocol
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
@@ -17,3 +18,12 @@ export function base64ToUtf8(base64: string): string {
export function utf8ToBase64(utf8: string): string { export function utf8ToBase64(utf8: string): string {
return Buffer.from(utf8, 'utf-8').toString('base64'); return Buffer.from(utf8, 'utf-8').toString('base64');
} }
export function invalidateBlurry(id: string) {
return {
predicate: (query: Query<unknown, Error, unknown, readonly unknown[]>) => {
const key = query.queryKey[0] as { _id: string };
return key?._id === id;
},
};
}

View File

@@ -12,9 +12,11 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as TokenRouteImport } from './routes/token' import { Route as TokenRouteImport } from './routes/token'
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent' import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
import { Route as AuthorizeRouteImport } from './routes/authorize' import { Route as AuthorizeRouteImport } from './routes/authorize'
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout' import { Route as WorkbenchLayoutRouteImport } from './routes/_workbenchLayout'
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index' import { Route as WorkbenchLayoutIndexRouteImport } from './routes/_workbenchLayout/index'
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile' import { Route as WorkbenchLayoutEventsRouteImport } from './routes/_workbenchLayout/events'
import { Route as WorkbenchLayoutProfileIndexRouteImport } from './routes/_workbenchLayout/profile.index'
import { Route as WorkbenchLayoutProfileUserIdRouteImport } from './routes/_workbenchLayout/profile.$userId'
const TokenRoute = TokenRouteImport.update({ const TokenRoute = TokenRouteImport.update({
id: '/token', id: '/token',
@@ -31,61 +33,95 @@ const AuthorizeRoute = AuthorizeRouteImport.update({
path: '/authorize', path: '/authorize',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({ const WorkbenchLayoutRoute = WorkbenchLayoutRouteImport.update({
id: '/_sidebarLayout', id: '/_workbenchLayout',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({ const WorkbenchLayoutIndexRoute = WorkbenchLayoutIndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
getParentRoute: () => SidebarLayoutRoute, getParentRoute: () => WorkbenchLayoutRoute,
} as any) } as any)
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({ const WorkbenchLayoutEventsRoute = WorkbenchLayoutEventsRouteImport.update({
id: '/profile', id: '/events',
path: '/profile', path: '/events',
getParentRoute: () => SidebarLayoutRoute, getParentRoute: () => WorkbenchLayoutRoute,
} as any) } as any)
const WorkbenchLayoutProfileIndexRoute =
WorkbenchLayoutProfileIndexRouteImport.update({
id: '/profile/',
path: '/profile/',
getParentRoute: () => WorkbenchLayoutRoute,
} as any)
const WorkbenchLayoutProfileUserIdRoute =
WorkbenchLayoutProfileUserIdRouteImport.update({
id: '/profile/$userId',
path: '/profile/$userId',
getParentRoute: () => WorkbenchLayoutRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof SidebarLayoutIndexRoute '/': typeof WorkbenchLayoutIndexRoute
'/authorize': typeof AuthorizeRoute '/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute '/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute '/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute '/events': typeof WorkbenchLayoutEventsRoute
'/profile/$userId': typeof WorkbenchLayoutProfileUserIdRoute
'/profile/': typeof WorkbenchLayoutProfileIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/authorize': typeof AuthorizeRoute '/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute '/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute '/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute '/events': typeof WorkbenchLayoutEventsRoute
'/': typeof SidebarLayoutIndexRoute '/': typeof WorkbenchLayoutIndexRoute
'/profile/$userId': typeof WorkbenchLayoutProfileUserIdRoute
'/profile': typeof WorkbenchLayoutProfileIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren '/_workbenchLayout': typeof WorkbenchLayoutRouteWithChildren
'/authorize': typeof AuthorizeRoute '/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute '/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute '/token': typeof TokenRoute
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute '/_workbenchLayout/events': typeof WorkbenchLayoutEventsRoute
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute '/_workbenchLayout/': typeof WorkbenchLayoutIndexRoute
'/_workbenchLayout/profile/$userId': typeof WorkbenchLayoutProfileUserIdRoute
'/_workbenchLayout/profile/': typeof WorkbenchLayoutProfileIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/authorize' | '/magicLinkSent' | '/token' | '/profile' fullPaths:
fileRoutesByTo: FileRoutesByTo | '/'
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
id:
| '__root__'
| '/_sidebarLayout'
| '/authorize' | '/authorize'
| '/magicLinkSent' | '/magicLinkSent'
| '/token' | '/token'
| '/_sidebarLayout/profile' | '/events'
| '/_sidebarLayout/' | '/profile/$userId'
| '/profile/'
fileRoutesByTo: FileRoutesByTo
to:
| '/authorize'
| '/magicLinkSent'
| '/token'
| '/events'
| '/'
| '/profile/$userId'
| '/profile'
id:
| '__root__'
| '/_workbenchLayout'
| '/authorize'
| '/magicLinkSent'
| '/token'
| '/_workbenchLayout/events'
| '/_workbenchLayout/'
| '/_workbenchLayout/profile/$userId'
| '/_workbenchLayout/profile/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren WorkbenchLayoutRoute: typeof WorkbenchLayoutRouteWithChildren
AuthorizeRoute: typeof AuthorizeRoute AuthorizeRoute: typeof AuthorizeRoute
MagicLinkSentRoute: typeof MagicLinkSentRoute MagicLinkSentRoute: typeof MagicLinkSentRoute
TokenRoute: typeof TokenRoute TokenRoute: typeof TokenRoute
@@ -114,46 +150,64 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthorizeRouteImport preLoaderRoute: typeof AuthorizeRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_sidebarLayout': { '/_workbenchLayout': {
id: '/_sidebarLayout' id: '/_workbenchLayout'
path: '' path: ''
fullPath: '/' fullPath: '/'
preLoaderRoute: typeof SidebarLayoutRouteImport preLoaderRoute: typeof WorkbenchLayoutRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_sidebarLayout/': { '/_workbenchLayout/': {
id: '/_sidebarLayout/' id: '/_workbenchLayout/'
path: '/' path: '/'
fullPath: '/' fullPath: '/'
preLoaderRoute: typeof SidebarLayoutIndexRouteImport preLoaderRoute: typeof WorkbenchLayoutIndexRouteImport
parentRoute: typeof SidebarLayoutRoute parentRoute: typeof WorkbenchLayoutRoute
} }
'/_sidebarLayout/profile': { '/_workbenchLayout/events': {
id: '/_sidebarLayout/profile' id: '/_workbenchLayout/events'
path: '/events'
fullPath: '/events'
preLoaderRoute: typeof WorkbenchLayoutEventsRouteImport
parentRoute: typeof WorkbenchLayoutRoute
}
'/_workbenchLayout/profile/': {
id: '/_workbenchLayout/profile/'
path: '/profile' path: '/profile'
fullPath: '/profile' fullPath: '/profile/'
preLoaderRoute: typeof SidebarLayoutProfileRouteImport preLoaderRoute: typeof WorkbenchLayoutProfileIndexRouteImport
parentRoute: typeof SidebarLayoutRoute parentRoute: typeof WorkbenchLayoutRoute
}
'/_workbenchLayout/profile/$userId': {
id: '/_workbenchLayout/profile/$userId'
path: '/profile/$userId'
fullPath: '/profile/$userId'
preLoaderRoute: typeof WorkbenchLayoutProfileUserIdRouteImport
parentRoute: typeof WorkbenchLayoutRoute
} }
} }
} }
interface SidebarLayoutRouteChildren { interface WorkbenchLayoutRouteChildren {
SidebarLayoutProfileRoute: typeof SidebarLayoutProfileRoute WorkbenchLayoutEventsRoute: typeof WorkbenchLayoutEventsRoute
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute WorkbenchLayoutIndexRoute: typeof WorkbenchLayoutIndexRoute
WorkbenchLayoutProfileUserIdRoute: typeof WorkbenchLayoutProfileUserIdRoute
WorkbenchLayoutProfileIndexRoute: typeof WorkbenchLayoutProfileIndexRoute
} }
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = { const WorkbenchLayoutRouteChildren: WorkbenchLayoutRouteChildren = {
SidebarLayoutProfileRoute: SidebarLayoutProfileRoute, WorkbenchLayoutEventsRoute: WorkbenchLayoutEventsRoute,
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute, WorkbenchLayoutIndexRoute: WorkbenchLayoutIndexRoute,
WorkbenchLayoutProfileUserIdRoute: WorkbenchLayoutProfileUserIdRoute,
WorkbenchLayoutProfileIndexRoute: WorkbenchLayoutProfileIndexRoute,
} }
const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren( const WorkbenchLayoutRouteWithChildren = WorkbenchLayoutRoute._addFileChildren(
SidebarLayoutRouteChildren, WorkbenchLayoutRouteChildren,
) )
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
SidebarLayoutRoute: SidebarLayoutRouteWithChildren, WorkbenchLayoutRoute: WorkbenchLayoutRouteWithChildren,
AuthorizeRoute: AuthorizeRoute, AuthorizeRoute: AuthorizeRoute,
MagicLinkSentRoute: MagicLinkSentRoute, MagicLinkSentRoute: MagicLinkSentRoute,
TokenRoute: TokenRoute, TokenRoute: TokenRoute,

View File

@@ -12,11 +12,9 @@ const queryClient = new QueryClient({
const status const status
// eslint-disable-next-line ts/no-unsafe-member-access // eslint-disable-next-line ts/no-unsafe-member-access
= error?.response?.status ?? error?.status; = error?.response?.status ?? error?.status;
if (status >= 400 && status < 500) { if (status >= 400 && status < 500) {
return false; return false;
} }
return failureCount < 3; return failureCount < 3;
}, },
}, },

View File

@@ -1,31 +0,0 @@
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { AppSidebar } from '@/components/sidebar/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
export const Route = createFileRoute('/_sidebarLayout')({
component: RouteComponent,
});
function RouteComponent() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<Outlet />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -1,14 +0,0 @@
import { createFileRoute } from '@tanstack/react-router';
import { MainProfile } from '@/components/profile/main-profile';
export const Route = createFileRoute('/_sidebarLayout/profile')({
component: RouteComponent,
});
function RouteComponent() {
return (
<div className="flex h-full flex-col gap-6 px-4 py-6">
<MainProfile />
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { createFileRoute, Outlet, useRouterState } from '@tanstack/react-router';
import { Suspense } from 'react';
import { AppSidebar } from '@/components/sidebar/app-sidebar.view';
import { NavUserContainer } from '@/components/sidebar/nav-user.container';
import { NavUserSkeleton } from '@/components/sidebar/nav-user.skeletion';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
export const Route = createFileRoute('/_workbenchLayout')({
component: RouteComponent,
});
function RouteComponent() {
const pathname = useRouterState({ select: state => state.location.pathname });
const allNavItems = [...navData.navMain, ...navData.navSecondary];
const title
= allNavItems.find(item =>
item.url === '/'
? pathname === '/'
: pathname.startsWith(item.url),
)?.title ?? '工作台';
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar
navData={navData}
footerWidget={(
<Suspense fallback={<NavUserSkeleton />}>
<NavUserContainer />
</Suspense>
)}
variant="inset"
/>
<SidebarInset>
<SiteHeader title={title} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<Outlet />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,14 @@
import { createFileRoute } from '@tanstack/react-router';
import { EventGridContainer } from '@/components/events/event-grid.container';
export const Route = createFileRoute('/_workbenchLayout/events')({
component: RouteComponent,
});
function RouteComponent() {
return (
<div className="py-4 px-6 md:gap-6 md:py-6">
<EventGridContainer />
</div>
);
}

View File

@@ -1,15 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { hasToken } from '@/lib/token';
export const Route = createFileRoute('/_sidebarLayout/')({ export const Route = createFileRoute('/_workbenchLayout/')({
component: Index, component: Index,
loader: async () => {
if (!hasToken()) {
throw redirect({
to: '/authorize',
});
}
},
}); });
function Index() { function Index() {

View File

@@ -0,0 +1,28 @@
import { createFileRoute } from '@tanstack/react-router';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ProfileContainer } from '@/components/profile/profile.container';
import { ProfileError } from '@/components/profile/profile.error';
import { ProfileSkeleton } from '@/components/profile/profile.skeleton';
export const Route = createFileRoute('/_workbenchLayout/profile/$userId')({
component: RouteComponent,
});
function RouteComponent() {
const { userId } = Route.useParams();
return (
<div className="flex h-full flex-col gap-6 px-4 py-6">
<ErrorBoundary fallbackRender={(error) => {
if ((error.error as { code: number }).code === 403)
return <ProfileError reason="用户个人资料未公开" />;
else return <ProfileError reason="获取用户个人资料失败" />;
}}
>
<Suspense fallback={<ProfileSkeleton />}>
<ProfileContainer userId={userId} />
</Suspense>
</ErrorBoundary>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { createFileRoute, Navigate } from '@tanstack/react-router';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ProfileError } from '@/components/profile/profile.error';
import { ProfileSkeleton } from '@/components/profile/profile.skeleton';
import { useUserInfo } from '@/hooks/data/useUserInfo';
export const Route = createFileRoute('/_workbenchLayout/profile/')({
component: RouteComponent,
});
function RouteComponent() {
const { data } = useUserInfo();
return (
<ErrorBoundary fallback={<ProfileError reason="获取用户个人资料失败" />}>
<Suspense fallback={<ProfileSkeleton />}>
<Navigate to="/profile/$userId" params={{ userId: data.data!.user_id! }} />
</Suspense>
</ErrorBoundary>
);
}

View File

@@ -6,7 +6,7 @@ import z from 'zod';
import { LoginForm } from '@/components/login-form'; import { LoginForm } from '@/components/login-form';
import { useExchangeToken } from '@/hooks/data/useExchangeToken'; import { useExchangeToken } from '@/hooks/data/useExchangeToken';
import { generateOAuthState } from '@/lib/random'; import { generateOAuthState } from '@/lib/random';
import { getToken } from '@/lib/token'; import { getAccessToken } from '@/lib/token';
const baseUrl = import.meta.env.VITE_APP_BASE_URL; const baseUrl = import.meta.env.VITE_APP_BASE_URL;
@@ -25,7 +25,7 @@ export const Route = createFileRoute('/authorize')({
}); });
function RouteComponent() { function RouteComponent() {
const token = getToken(); const token = getAccessToken();
const oauthParams = Route.useSearch(); const oauthParams = Route.useSearch();
const mutation = useExchangeToken(); const mutation = useExchangeToken();
/** /**
@@ -41,7 +41,7 @@ function RouteComponent() {
}, },
}); });
} }
}, [token, mutation.isIdle]); }, [token, mutation.isIdle, mutation, oauthParams.client_id, oauthParams.redirect_uri, oauthParams.state]);
return ( return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">

View File

@@ -6,7 +6,7 @@ import {
} from 'react'; } from 'react';
import z from 'zod'; import z from 'zod';
import { postAuthTokenMutation } from '@/client/@tanstack/react-query.gen'; import { postAuthTokenMutation } from '@/client/@tanstack/react-query.gen';
import { setRefreshToken, setToken } from '@/lib/token'; import { setAccessToken, setRefreshToken } from '@/lib/token';
const tokenCodeSchema = z.object({ const tokenCodeSchema = z.object({
code: z.string().nonempty(), code: z.string().nonempty(),
@@ -25,8 +25,8 @@ function RouteComponent() {
const mutation = useMutation({ const mutation = useMutation({
...postAuthTokenMutation(), ...postAuthTokenMutation(),
onSuccess: (data) => { onSuccess: (data) => {
setToken(data.data?.access_token!); setAccessToken(data.data!.access_token!);
setRefreshToken(data.data?.refresh_token!); setRefreshToken(data.data!.refresh_token!);
void navigate({ to: '/' }); void navigate({ to: '/' });
}, },
onError: () => { onError: () => {
@@ -38,6 +38,7 @@ function RouteComponent() {
if (mutation.isIdle) { if (mutation.isIdle) {
mutation.mutate({ body: { code } }); mutation.mutate({ body: { code } });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return <div>{status}</div>; return <div>{status}</div>;

View File

@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { EventCardSkeleton } from '@/components/events/event-card.skeleton';
import { EventCardView } from '@/components/events/event-card.view';
import { Button } from '@/components/ui/button';
const meta = {
title: 'Events/EventCard',
component: EventCardView,
} satisfies Meta<typeof EventCardView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
eventInfo: {
eventId: '1',
type: 'official',
requireKyc: true,
isJoined: false,
coverImage: 'https://github.com/NixOS/nixos-artwork/blob/master/wallpapers/nix-wallpaper-watersplash.png?raw=true',
eventName: 'Nix CN Conference 26.05',
description: 'Event Description',
startTime: new Date('2026-06-13T04:00:00.000Z'),
endTime: new Date('2026-06-14T04:00:00.000Z'),
},
actionFooter: <Button className="w-full"></Button>,
},
};
export const Loading: Story = {
render: () => <EventCardSkeleton />,
args: {
eventInfo: {
eventId: '1',
type: 'official',
requireKyc: true,
coverImage: '',
isJoined: false,
eventName: '',
description: '',
startTime: new Date(0),
endTime: new Date(0),
},
actionFooter: <Button className="w-full"></Button>,
},
};

View File

@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { EventGridSkeleton } from '@/components/events/event-grid.skeleton';
import { EventGridView } from '@/components/events/event-grid.view';
import { Button } from '@/components/ui/button';
import { Skeleton as UiSkeleton } from '@/components/ui/skeleton';
const meta = {
title: 'Events/EventGrid',
component: EventGridView,
} satisfies Meta<typeof EventGridView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
events: [
{
eventId: '1',
requireKyc: true,
isJoined: false,
type: 'official',
coverImage: 'https://github.com/NixOS/nixos-artwork/blob/master/wallpapers/nix-wallpaper-watersplash.png?raw=true',
eventName: 'Nix CN Conference 26.05',
description: 'Event Description',
startTime: new Date('2026-06-13T04:00:00.000Z'),
endTime: new Date('2026-06-14T04:00:00.000Z'),
},
{
eventId: '2',
requireKyc: true,
isJoined: false,
type: 'official',
coverImage: 'https://github.com/NixOS/nixos-artwork/blob/master/wallpapers/nix-wallpaper-moonscape.png?raw=true',
eventName: 'Nix CN Conference 26.05',
description: 'Event Description',
startTime: new Date('2026-06-13T04:00:00.000Z'),
endTime: new Date('2026-06-14T04:00:00.000Z'),
},
{
eventId: '3',
requireKyc: true,
isJoined: false,
type: 'official',
coverImage: 'https://github.com/NixOS/nixos-artwork/blob/master/wallpapers/nix-wallpaper-nineish-catppuccin-latte.png?raw=true',
eventName: 'Nix CN Conference 26.05',
description: 'Event Description',
startTime: new Date('2026-06-13T04:00:00.000Z'),
endTime: new Date('2026-06-14T04:00:00.000Z'),
},
{
eventId: '4',
requireKyc: true,
isJoined: false,
type: 'official',
coverImage: 'https://github.com/NixOS/nixos-artwork/blob/master/wallpapers/nixos-wallpaper-catppuccin-macchiato.png?raw=true',
eventName: 'Nix CN Conference 26.05',
description: 'Event Description',
startTime: new Date('2026-06-13T04:00:00.000Z'),
endTime: new Date('2026-06-14T04:00:00.000Z'),
},
],
assembleFooter: () => <Button className="w-full"></Button>,
},
};
export const Skeleton: Story = {
render: () => <EventGridSkeleton />,
args: {
events: [],
assembleFooter: () => <UiSkeleton className="w-full" />,
},
};

View File

@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { KycFailedDialogView } from '@/components/events/kyc/kyc-failed.dialog.view';
import { KycMethodSelectionDialogView } from '@/components/events/kyc/kyc-method-selection.dialog.view';
import { KycPendingDialogView } from '@/components/events/kyc/kyc-pending.dialog.view';
import { KycPromptDialogView } from '@/components/events/kyc/kyc-prompt.dialog.view';
import { KycSuccessDialogView } from '@/components/events/kyc/kyc-success.dialog.view';
import { Dialog } from '@/components/ui/dialog';
const meta = {
title: 'Events/KycDialog',
component: KycPromptDialogView,
decorators: [
Story => (
<Dialog open={true}>
<Story />
</Dialog>
),
],
} satisfies Meta<typeof KycPromptDialogView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Prompt: Story = {
args: {
},
};
export const MethodSelection: Story = {
render: () => <KycMethodSelectionDialogView onSubmit={async () => Promise.resolve()} />,
args: {
},
};
export const Pending: Story = {
render: () => <KycPendingDialogView />,
args: {
},
};
export const Success: Story = {
render: () => <KycSuccessDialogView />,
args: {
},
};
export const Failed: Story = {
render: () => <KycFailedDialogView />,
args: {
},
};

View File

@@ -0,0 +1,8 @@
export const user = {
username: 'nvirellia',
nickname: 'Noa Virellia',
subtitle: '天生骄傲',
email: 'noa@requiem.garden',
bio: '',
avatar: 'https://avatars.githubusercontent.com/u/54884471?v=4',
};

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AppSidebar } from '@/components/sidebar/app-sidebar.view';
import { NavUserSkeleton } from '@/components/sidebar/nav-user.skeletion';
import { NavUserView } from '@/components/sidebar/nav-user.view';
import { SidebarProvider } from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
import { user } from '../exampleUser';
const meta = {
title: 'Layout/Sidebar',
component: AppSidebar,
decorators: [
Story => (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<Story />
</SidebarProvider>
),
],
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof AppSidebar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
navData,
footerWidget: <NavUserView user={user} />,
},
};
export const Loading: Story = {
args: {
navData,
footerWidget: <NavUserSkeleton />,
},
};

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { EditProfileDialogView } from '@/components/profile/edit-profile.dialog.view';
import { user } from '../exampleUser';
const meta = {
title: 'Profile/EditDialog',
component: EditProfileDialogView,
decorators: [
Story => (
<div style={{
height: '100vh',
maxWidth: '256px',
margin: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Story />
</div>
),
],
} satisfies Meta<typeof EditProfileDialogView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
user,
updateProfile: async () => { },
},
parameters: {
layout: 'fullscreen',
},
};

View File

@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ProfileError } from '@/components/profile/profile.error';
import { ProfileSkeleton } from '@/components/profile/profile.skeleton';
import { ProfileView } from '@/components/profile/profile.view';
import { user } from '../exampleUser';
const queryClient = new QueryClient();
const meta = {
title: 'Profile/View',
component: ProfileView,
decorators: [
Story => (
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
),
],
} satisfies Meta<typeof ProfileView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
user,
onSaveBio: async () => Promise.resolve(),
},
};
export const Loading: Story = {
render: () => <ProfileSkeleton />,
args: {
user: {},
onSaveBio: async () => Promise.resolve(),
},
};
export const Error: Story = {
render: () => <ProfileError reason="用户个人资料未公开" />,
args: {
user: {
allow_public: false,
},
onSaveBio: async () => Promise.resolve(),
},
};

View File

@@ -1,21 +1,24 @@
/// <reference types="vitest/config" />
import path from 'node:path'; import path from 'node:path';
// https://vite.dev/config/
import { fileURLToPath } from 'node:url';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { tanstackRouter } from '@tanstack/router-plugin/vite'; import { tanstackRouter } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import svgr from 'vite-plugin-svgr'; import svgr from 'vite-plugin-svgr';
// https://vite.dev/config/ const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [tanstackRouter({
tanstackRouter({ target: 'react',
target: 'react', autoCodeSplitting: true,
autoCodeSplitting: true, }), react(), tailwindcss(), svgr()],
}),
react(),
tailwindcss(),
svgr(),
],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
@@ -29,4 +32,28 @@ export default defineConfig({
port: 5173, port: 5173,
allowedHosts: ['nix.org.cn', 'nixos.party'], allowedHosts: ['nix.org.cn', 'nixos.party'],
}, },
test: {
projects: [{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, '.storybook'),
}),
],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [{
browser: 'chromium',
}],
},
setupFiles: ['.storybook/vitest.setup.ts'],
},
}],
},
}); });

1
client/cms/vitest.shims.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@vitest/browser-playwright" />