From 879896581b94fe3e53e313ca5d8f9fad7585652e Mon Sep 17 00:00:00 2001 From: stakbucks Date: Sun, 3 Nov 2024 19:05:32 +0900 Subject: [PATCH] chore(utils): utils package --- apps/demo/package.json | 2 +- .../demo/src/app/bottle/[id]/BottleDetail.tsx | 43 +++++++++ apps/demo/src/app/bottle/[id]/HeaderArea.tsx | 16 ++++ apps/demo/src/app/bottle/[id]/layout.tsx | 11 +++ apps/demo/src/app/bottle/[id]/page.tsx | 16 ++++ apps/demo/src/app/bottles/Bottles.tsx | 10 +- apps/demo/src/app/bottles/page.tsx | 2 +- .../src/components/common/avatar/index.tsx | 15 ++- .../common/overlay-client-patch/index.tsx | 8 ++ .../common/selected-profile/index.tsx | 65 +++++++++++++ .../selectedProfileStyle.css.ts | 21 ++++ .../user-information/BasicInformationArea.tsx | 31 ++++++ .../user-information/IntroductionCard.tsx | 38 ++++++++ .../user-information/SelectedProfile.tsx | 23 +++++ .../common/user-information/index.tsx | 20 ++++ .../userInformationStyle.css.ts | 95 +++++++++++++++++++ .../src => apps/demo/src/server}/auth.ts | 0 .../demo/src/server}/clientSideTokens.ts | 0 .../src => apps/demo/src/server}/index.ts | 0 .../src => apps/demo/src/server}/log.ts | 0 apps/demo/src/server/serverSideTokens.ts | 14 +++ .../src => apps/demo/src/server}/types.ts | 0 .../src => apps/demo/src/server}/utils.ts | 0 packages/server-utils/src/serverSideTokens.ts | 10 -- packages/ui/src/components/asset/Asset.tsx | 30 +++--- .../components/asset/icons/icon_letter.svg | 48 ++++++++++ packages/{server-utils => utils}/.eslintrc.js | 0 packages/{server-utils => utils}/.gitignore | 0 packages/{server-utils => utils}/package.json | 12 ++- packages/utils/src/index.ts | 2 + packages/utils/src/server/auth.ts | 25 +++++ packages/utils/src/server/clientSideTokens.ts | 6 ++ packages/utils/src/server/index.ts | 3 + packages/utils/src/server/log.ts | 5 + packages/utils/src/server/serverSideTokens.ts | 14 +++ packages/utils/src/server/types.ts | 8 ++ packages/utils/src/server/utils.ts | 55 +++++++++++ packages/utils/src/user-agent/index.tsx | 49 ++++++++++ .../{server-utils => utils}/tsconfig.json | 2 +- .../tsconfig.lint.json | 0 pnpm-lock.yaml | 82 +++++++++------- 41 files changed, 717 insertions(+), 64 deletions(-) create mode 100644 apps/demo/src/app/bottle/[id]/BottleDetail.tsx create mode 100644 apps/demo/src/app/bottle/[id]/HeaderArea.tsx create mode 100644 apps/demo/src/app/bottle/[id]/layout.tsx create mode 100644 apps/demo/src/app/bottle/[id]/page.tsx create mode 100644 apps/demo/src/components/common/overlay-client-patch/index.tsx create mode 100644 apps/demo/src/components/common/selected-profile/index.tsx create mode 100644 apps/demo/src/components/common/selected-profile/selectedProfileStyle.css.ts create mode 100644 apps/demo/src/components/common/user-information/BasicInformationArea.tsx create mode 100644 apps/demo/src/components/common/user-information/IntroductionCard.tsx create mode 100644 apps/demo/src/components/common/user-information/SelectedProfile.tsx create mode 100644 apps/demo/src/components/common/user-information/index.tsx create mode 100644 apps/demo/src/components/common/user-information/userInformationStyle.css.ts rename {packages/server-utils/src => apps/demo/src/server}/auth.ts (100%) rename {packages/server-utils/src => apps/demo/src/server}/clientSideTokens.ts (100%) rename {packages/server-utils/src => apps/demo/src/server}/index.ts (100%) rename {packages/server-utils/src => apps/demo/src/server}/log.ts (100%) create mode 100644 apps/demo/src/server/serverSideTokens.ts rename {packages/server-utils/src => apps/demo/src/server}/types.ts (100%) rename {packages/server-utils/src => apps/demo/src/server}/utils.ts (100%) delete mode 100644 packages/server-utils/src/serverSideTokens.ts create mode 100644 packages/ui/src/components/asset/icons/icon_letter.svg rename packages/{server-utils => utils}/.eslintrc.js (100%) rename packages/{server-utils => utils}/.gitignore (100%) rename packages/{server-utils => utils}/package.json (64%) create mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/src/server/auth.ts create mode 100644 packages/utils/src/server/clientSideTokens.ts create mode 100644 packages/utils/src/server/index.ts create mode 100644 packages/utils/src/server/log.ts create mode 100644 packages/utils/src/server/serverSideTokens.ts create mode 100644 packages/utils/src/server/types.ts create mode 100644 packages/utils/src/server/utils.ts create mode 100644 packages/utils/src/user-agent/index.tsx rename packages/{server-utils => utils}/tsconfig.json (83%) rename packages/{server-utils => utils}/tsconfig.lint.json (100%) diff --git a/apps/demo/package.json b/apps/demo/package.json index 29279f1..79e2971 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@bottlesteam/ui": "workspace:*", - "@bottlesteam/server-utils": "workspace:*", + "@bottlesteam/utils": "workspace:*", "@tanstack/react-query": "^5.51.21", "cookies-next": "^4.2.1", "es-toolkit": "^1.26.1", diff --git a/apps/demo/src/app/bottle/[id]/BottleDetail.tsx b/apps/demo/src/app/bottle/[id]/BottleDetail.tsx new file mode 100644 index 0000000..8deb293 --- /dev/null +++ b/apps/demo/src/app/bottle/[id]/BottleDetail.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { UserInformation } from '@/components/common/user-information'; +import { Bottle } from '@/models/bottle'; +import { Asset, FixedBottomCTAButton, spacings } from '@bottlesteam/ui'; +import { useUserAgent } from '@bottlesteam/utils'; + +export function BottleDetail({ bottleDetail: user }: { bottleDetail: Bottle }) { + const userAgent = useUserAgent(); + + const handleInstall = () => { + if (!userAgent.isMobile) { + alert('모바일에서만 설치 가능합니다.'); + return; + } + if (userAgent.isAndroid) { + window.location.href = 'intent://main#Intent;scheme=bottle;package=com.team.bottles;end'; + } + if (userAgent.isIOS) { + // TODO: add iOS scheme + alert('iOS 대응 예정...ㅠ'); + } + }; + + return ( + <> + + + + + + + + 보틀 설치하고 대화 시작하기 + + + ); +} diff --git a/apps/demo/src/app/bottle/[id]/HeaderArea.tsx b/apps/demo/src/app/bottle/[id]/HeaderArea.tsx new file mode 100644 index 0000000..cdc5898 --- /dev/null +++ b/apps/demo/src/app/bottle/[id]/HeaderArea.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { Header } from '@bottlesteam/ui'; +import { useRouter } from 'next/navigation'; + +export function HeaderArea() { + const router = useRouter(); + + return ( +
{ + router.back(); + }} + /> + ); +} diff --git a/apps/demo/src/app/bottle/[id]/layout.tsx b/apps/demo/src/app/bottle/[id]/layout.tsx new file mode 100644 index 0000000..7809e52 --- /dev/null +++ b/apps/demo/src/app/bottle/[id]/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from 'react'; +import { HeaderArea } from './HeaderArea'; + +export default function BottleLayout({ children }: { children: ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/apps/demo/src/app/bottle/[id]/page.tsx b/apps/demo/src/app/bottle/[id]/page.tsx new file mode 100644 index 0000000..e3dc407 --- /dev/null +++ b/apps/demo/src/app/bottle/[id]/page.tsx @@ -0,0 +1,16 @@ +import { Bottle } from '@/models/bottle'; +import { GET, getServerSideTokens, createInit } from '@/server'; +import { UserAgentProvider } from '@bottlesteam/utils'; +import { BottleDetail } from './BottleDetail'; + +export default async function BottlePage({ params: { id } }: { params: { id: number } }) { + const tokens = getServerSideTokens(); + + const bottleDetail = await GET(`/api/v1/bottles/${id}`, tokens, createInit(tokens.accessToken)); + + return ( + + + + ); +} diff --git a/apps/demo/src/app/bottles/Bottles.tsx b/apps/demo/src/app/bottles/Bottles.tsx index c9966ce..343a523 100644 --- a/apps/demo/src/app/bottles/Bottles.tsx +++ b/apps/demo/src/app/bottles/Bottles.tsx @@ -5,6 +5,7 @@ import { BottleCard } from '@/components/common/bottle-card'; import { Fallback } from '@/components/common/fallback'; import { Layout, spacings } from '@bottlesteam/ui'; import { pick } from 'es-toolkit'; +import { useRouter } from 'next/navigation'; import { RandomBottlesQuery, UserInfo } from './page'; interface Props { @@ -13,6 +14,8 @@ interface Props { } export function Bottles({ bottles: { randomBottles }, userInfo }: Props) { + const router = useRouter(); + return ( {randomBottles.length > 0 ? ( @@ -20,7 +23,12 @@ export function Bottles({ bottles: { randomBottles }, userInfo }: Props) { {`${userInfo.name}님에게\n추천하는 분들이에요!`}
{randomBottles.map(bottle => ( - {}}> + { + router.push(`/bottle/${bottle.id}`); + }} + > {bottle.expiredAt} {bottle.introduction[0]?.answer} {props.src != null ? ( - user profile image + <> + user profile image +
+ ) : ( )} diff --git a/apps/demo/src/components/common/overlay-client-patch/index.tsx b/apps/demo/src/components/common/overlay-client-patch/index.tsx new file mode 100644 index 0000000..1a9539d --- /dev/null +++ b/apps/demo/src/components/common/overlay-client-patch/index.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { OverlayProvider } from 'overlay-kit'; +import { ReactNode } from 'react'; + +export function OverlayClientPatch({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/demo/src/components/common/selected-profile/index.tsx b/apps/demo/src/components/common/selected-profile/index.tsx new file mode 100644 index 0000000..6c3433e --- /dev/null +++ b/apps/demo/src/components/common/selected-profile/index.tsx @@ -0,0 +1,65 @@ +import { ProfileSelect } from '@/models/profile'; +import { Card, Chip, Paragraph, spacings } from '@bottlesteam/ui'; +import type { ReactNode } from 'react'; +import { chipWrapper, informationContainer, selectedProfileBlockStyle } from './selectedProfileStyle.css'; + +interface Props { + profile: ProfileSelect; + items: ({ + basicInformation, + personalities, + hobbies, + }: { + basicInformation: string[]; + personalities: string[]; + hobbies: string[]; + }) => ReactNode; +} + +export function SelectedProfileImpl({ + profile: { + job, + mbti, + region: { city }, + smoking, + alcohol, + keyword, + height, + interest: { culture, sports, entertainment, etc }, + }, + items, +}: Props) { + const basicInformation = [job, mbti, city, `${height}cm`, smoking, alcohol]; + const personalities = keyword; + const hobbies = [ + ...Object.values(culture), + ...Object.values(sports), + ...Object.values(entertainment), + ...Object.values(etc), + ]; + + return ( + +
{items({ basicInformation, personalities, hobbies })}
+
+ ); +} + +export function SelectedProfileBlock({ type, values }: { type: string; values: (string | number)[] }) { + return ( +
+ + {type} + +
+ {values.map(value => ( + {value} + ))} +
+
+ ); +} + +export const SelectedProfile = Object.assign(SelectedProfileImpl, { + Item: SelectedProfileBlock, +}); diff --git a/apps/demo/src/components/common/selected-profile/selectedProfileStyle.css.ts b/apps/demo/src/components/common/selected-profile/selectedProfileStyle.css.ts new file mode 100644 index 0000000..fc2a2d8 --- /dev/null +++ b/apps/demo/src/components/common/selected-profile/selectedProfileStyle.css.ts @@ -0,0 +1,21 @@ +import { spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; + +export const selectedProfileBlockStyle = style({ + display: 'flex', + flexDirection: 'column', + gap: spacings.sm, +}); + +export const informationContainer = style({ + display: 'flex', + flexDirection: 'column', + gap: spacings.xl, +}); + +export const chipWrapper = style({ + display: 'flex', + rowGap: spacings.sm, + columnGap: spacings.xs, + flexWrap: 'wrap', +}); diff --git a/apps/demo/src/components/common/user-information/BasicInformationArea.tsx b/apps/demo/src/components/common/user-information/BasicInformationArea.tsx new file mode 100644 index 0000000..744a70f --- /dev/null +++ b/apps/demo/src/components/common/user-information/BasicInformationArea.tsx @@ -0,0 +1,31 @@ +import { CurrentUser, OtherUser } from '@/models/user'; +import { Asset, Bubble, Paragraph, spacings } from '@bottlesteam/ui'; +import { Avatar } from '../avatar'; +import { basicInformationAreaStyle, nameAndAgeContainerStyle } from './userInformationStyle.css'; + +type Props = ( + | Pick + | Pick +) & { likeMessage?: string }; + +function isCurrentUser(props: Props): props is Pick { + return 'imageUrl' in props; +} + +export function BasicInformationArea(props: Props) { + return ( +
+ {props.likeMessage && {props.likeMessage}} + +
+ + {props.userName} + + + + {props.age}세 + +
+
+ ); +} diff --git a/apps/demo/src/components/common/user-information/IntroductionCard.tsx b/apps/demo/src/components/common/user-information/IntroductionCard.tsx new file mode 100644 index 0000000..8953aa8 --- /dev/null +++ b/apps/demo/src/components/common/user-information/IntroductionCard.tsx @@ -0,0 +1,38 @@ +import { Introduction } from '@/models/introduction'; +import { User } from '@/models/user'; +import { Card, Paragraph, spacings } from '@bottlesteam/ui'; +import { blockStyle, introductionStyle } from './userInformationStyle.css'; + +interface Props { + introduction: User['introduction']; + title: string; +} + +export function IntroductionCard(props: Props) { + return ( + + + {props.title} + +
+ {props.introduction.length > 0 ? ( + props.introduction.map(field => ) + ) : ( + + 아직 자기소개를 작성하지 않았아요. + + )} +
+
+ ); +} + +function IntroductionBlock({ field: { answer } }: { field: Introduction[number] }) { + return ( +
+ + {answer} + +
+ ); +} diff --git a/apps/demo/src/components/common/user-information/SelectedProfile.tsx b/apps/demo/src/components/common/user-information/SelectedProfile.tsx new file mode 100644 index 0000000..c112d57 --- /dev/null +++ b/apps/demo/src/components/common/user-information/SelectedProfile.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { ProfileSelect } from '@/models/profile'; +import { SelectedProfile } from '../selected-profile'; + +interface Props { + profile: ProfileSelect; +} + +export function UserInformationSelectedProfile({ profile }: Props) { + return ( + ( + <> + + + + + )} + /> + ); +} diff --git a/apps/demo/src/components/common/user-information/index.tsx b/apps/demo/src/components/common/user-information/index.tsx new file mode 100644 index 0000000..12bd85d --- /dev/null +++ b/apps/demo/src/components/common/user-information/index.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import { BasicInformationArea } from './BasicInformationArea'; +import { IntroductionCard } from './IntroductionCard'; +import { UserInformationSelectedProfile } from './SelectedProfile'; +import { layoutStyle } from './userInformationStyle.css'; + +interface RootProps { + children: ReactNode; + hasCTAButton: boolean; +} + +function UserInformationRoot({ children, hasCTAButton }: RootProps) { + return
{children}
; +} + +export const UserInformation = Object.assign(UserInformationRoot, { + BasicInformationArea, + IntroductionCard, + SelectedProfile: UserInformationSelectedProfile, +}); diff --git a/apps/demo/src/components/common/user-information/userInformationStyle.css.ts b/apps/demo/src/components/common/user-information/userInformationStyle.css.ts new file mode 100644 index 0000000..f265073 --- /dev/null +++ b/apps/demo/src/components/common/user-information/userInformationStyle.css.ts @@ -0,0 +1,95 @@ +import { colors, radius, spacings } from '@bottlesteam/ui'; +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; + +export const HEADER_HEIGHT = 48; +export const CTA_HEIGHT = 109; + +export const OVERLAP_HEIGHT = 20; +export const CONTAINER_OFFSET_HEIGHT = HEADER_HEIGHT + CTA_HEIGHT; + +export const layoutStyle = recipe({ + base: { + paddingTop: spacings.xxl, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + margin: '0 auto', + overflow: 'scroll', + msOverflowStyle: 'none', + scrollbarWidth: 'none', + padding: '0 16px', + '::-webkit-scrollbar': { + display: 'none', + }, + }, + variants: { + hasCTAButton: { + true: { height: `calc(100vh - ${CONTAINER_OFFSET_HEIGHT - OVERLAP_HEIGHT + 12}px - env(safe-area-inset-top))` }, + false: { height: '100vh' }, + }, + }, +}); + +export const basicInformationAreaStyle = style({ + display: 'flex', + flexDirection: 'column', + gap: spacings.sm, + width: '100%', + alignItems: 'center', + marginBottom: spacings.xl, +}); + +export const nameAndAgeContainerStyle = style({ + width: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: spacings.xs, +}); + +export const introductionStyle = style({ + backgroundColor: colors.purple100, + borderRadius: radius.sm, + padding: spacings.md, + display: 'flex', + flexDirection: 'column', + marginTop: spacings.xl, + gap: spacings.xxl, +}); + +export const blockStyle = style({ + display: 'flex', + flexDirection: 'column', + gap: spacings.xs, +}); + +export const gapStyle = style({ + height: spacings.sm, +}); + +export const nameContainer = style({ + display: 'flex', + alignItems: 'center', + gap: spacings.xs, + marginBottom: spacings.xl, +}); + +export const selectedProfileBlockStyle = style({ + display: 'flex', + flexDirection: 'column', + gap: spacings.sm, +}); + +export const informationContainer = style({ + display: 'flex', + flexDirection: 'column', + gap: spacings.xl, +}); + +export const chipWrapper = style({ + display: 'flex', + rowGap: spacings.sm, + columnGap: spacings.xs, + flexWrap: 'wrap', +}); diff --git a/packages/server-utils/src/auth.ts b/apps/demo/src/server/auth.ts similarity index 100% rename from packages/server-utils/src/auth.ts rename to apps/demo/src/server/auth.ts diff --git a/packages/server-utils/src/clientSideTokens.ts b/apps/demo/src/server/clientSideTokens.ts similarity index 100% rename from packages/server-utils/src/clientSideTokens.ts rename to apps/demo/src/server/clientSideTokens.ts diff --git a/packages/server-utils/src/index.ts b/apps/demo/src/server/index.ts similarity index 100% rename from packages/server-utils/src/index.ts rename to apps/demo/src/server/index.ts diff --git a/packages/server-utils/src/log.ts b/apps/demo/src/server/log.ts similarity index 100% rename from packages/server-utils/src/log.ts rename to apps/demo/src/server/log.ts diff --git a/apps/demo/src/server/serverSideTokens.ts b/apps/demo/src/server/serverSideTokens.ts new file mode 100644 index 0000000..a22002f --- /dev/null +++ b/apps/demo/src/server/serverSideTokens.ts @@ -0,0 +1,14 @@ +import { cookies } from 'next/headers'; +import { Tokens } from './auth'; + +/** + * @description can only be used on the server side + */ +export const getServerSideTokens = (): Tokens => { + const cookieStore = cookies(); + + return { + accessToken: cookieStore.get('accessToken')?.value ?? '', + refreshToken: cookieStore.get('refreshToken')?.value ?? '', + }; +}; diff --git a/packages/server-utils/src/types.ts b/apps/demo/src/server/types.ts similarity index 100% rename from packages/server-utils/src/types.ts rename to apps/demo/src/server/types.ts diff --git a/packages/server-utils/src/utils.ts b/apps/demo/src/server/utils.ts similarity index 100% rename from packages/server-utils/src/utils.ts rename to apps/demo/src/server/utils.ts diff --git a/packages/server-utils/src/serverSideTokens.ts b/packages/server-utils/src/serverSideTokens.ts deleted file mode 100644 index e8c3747..0000000 --- a/packages/server-utils/src/serverSideTokens.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { getCookie } from 'cookies-next'; -import { cookies } from 'next/headers'; - -/** - * @description can only be used on the server side - */ -export const getServerSideTokens = () => ({ - accessToken: getCookie('accessToken', { cookies }) ?? '', - refreshToken: getCookie('refreshToken', { cookies }) ?? '', -}); diff --git a/packages/ui/src/components/asset/Asset.tsx b/packages/ui/src/components/asset/Asset.tsx index c539294..14bf4e0 100644 --- a/packages/ui/src/components/asset/Asset.tsx +++ b/packages/ui/src/components/asset/Asset.tsx @@ -9,6 +9,7 @@ import IconClose from './icons/icon_close.svg'; import IconCloseWhite from './icons/icon_close_white.svg'; import DeleteIcon from './icons/icon_delete.svg'; import DownIcon from './icons/icon_down.svg'; +import LetterIcon from './icons/icon_letter.svg'; import PencilIcon from './icons/icon_pencil.svg'; import PlusIcon from './icons/icon_plus.svg'; import RightIcon from './icons/icon_right.svg'; @@ -32,7 +33,8 @@ type Type = | 'icon-plus' | 'icon-clock' | 'kakao-logo' - | 'bottle-logo'; + | 'bottle-logo' + | 'icon-letter'; export interface AssetProps extends ComponentProps<'svg'> { type: Type; @@ -46,29 +48,31 @@ export function Asset({ type, ...rest }: AssetProps) { ) : type === 'icon-right' ? ( ) : type === 'vector' ? ( - + ) : type === 'icon-check' ? ( - + ) : type === 'icon-check-colored' ? ( - + ) : type === 'icon-check-white' ? ( - + ) : type === 'icon-close' ? ( - + ) : type === 'icon-close-white' ? ( - + ) : type === 'icon-delete' ? ( - + ) : type === 'icon-pencil' ? ( - + ) : type === 'icon-clock' ? ( - + ) : type === 'icon-plus' ? ( - + ) : type === 'kakao-logo' ? ( - + ) : type === 'bottle-logo' ? ( - + + ) : type === 'icon-letter' ? ( + ) : ( ); diff --git a/packages/ui/src/components/asset/icons/icon_letter.svg b/packages/ui/src/components/asset/icons/icon_letter.svg new file mode 100644 index 0000000..b1fe278 --- /dev/null +++ b/packages/ui/src/components/asset/icons/icon_letter.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/server-utils/.eslintrc.js b/packages/utils/.eslintrc.js similarity index 100% rename from packages/server-utils/.eslintrc.js rename to packages/utils/.eslintrc.js diff --git a/packages/server-utils/.gitignore b/packages/utils/.gitignore similarity index 100% rename from packages/server-utils/.gitignore rename to packages/utils/.gitignore diff --git a/packages/server-utils/package.json b/packages/utils/package.json similarity index 64% rename from packages/server-utils/package.json rename to packages/utils/package.json index 4e377a2..608d5ef 100644 --- a/packages/server-utils/package.json +++ b/packages/utils/package.json @@ -1,5 +1,5 @@ { - "name": "@bottlesteam/server-utils", + "name": "@bottlesteam/utils", "version": "0.0.0", "private": true, "publishConfig": { @@ -17,10 +17,16 @@ "react": "^18.3.1", "react-dom": "^18" }, + "devDependencies": { + "typescript": "^5.3.3", + "@types/eslint": "^8.56.5", + "@types/node": "^20.11.24", + "@types/react": "^18.2.61", + "@types/react-dom": "^18.2.19" + }, "exports": { ".": { - "import": "./src/index.ts", - "require": "./src/index.ts" + "import": "./src/index.ts" } } } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 0000000..8d4ae1b --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,2 @@ +// export { POST, GET, createInit, getServerSideTokens, getClientSideTokens } from './server'; +export { UserAgentProvider, useUserAgent, type UserAgent } from './user-agent'; diff --git a/packages/utils/src/server/auth.ts b/packages/utils/src/server/auth.ts new file mode 100644 index 0000000..d92bbb8 --- /dev/null +++ b/packages/utils/src/server/auth.ts @@ -0,0 +1,25 @@ +import { setCookie } from 'cookies-next'; +import { createInit } from './utils'; + +export interface Tokens { + accessToken: string; + refreshToken: string; +} + +/** + * @description fetches a new set of tokens from the server with refresh token + */ +export async function refreshAuth(tokens: Tokens) { + const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_BASE_URL}/api/v1/auth/refresh`, { + method: 'POST', + ...createInit(tokens.refreshToken), + }); + + if (!response.ok) { + throw new Error('Unknown error occurred when handling tokens.'); + } + const { accessToken, refreshToken } = await response.json(); + setCookie('accessToken', accessToken); + setCookie('refreshToken', refreshToken); + return { accessToken, refreshToken }; +} diff --git a/packages/utils/src/server/clientSideTokens.ts b/packages/utils/src/server/clientSideTokens.ts new file mode 100644 index 0000000..b0250d0 --- /dev/null +++ b/packages/utils/src/server/clientSideTokens.ts @@ -0,0 +1,6 @@ +import { getCookie } from 'cookies-next'; + +export const getClientSideTokens = () => ({ + accessToken: getCookie('accessToken') ?? '', + refreshToken: getCookie('refreshToken') ?? '', +}); diff --git a/packages/utils/src/server/index.ts b/packages/utils/src/server/index.ts new file mode 100644 index 0000000..a088da8 --- /dev/null +++ b/packages/utils/src/server/index.ts @@ -0,0 +1,3 @@ +export { getServerSideTokens } from './serverSideTokens'; +export { getClientSideTokens } from './clientSideTokens'; +export { POST, GET, createInit } from './utils'; diff --git a/packages/utils/src/server/log.ts b/packages/utils/src/server/log.ts new file mode 100644 index 0000000..9bc634d --- /dev/null +++ b/packages/utils/src/server/log.ts @@ -0,0 +1,5 @@ +'use server'; + +export async function logAction(message: string): Promise { + console.log('[LOG]:', message); +} diff --git a/packages/utils/src/server/serverSideTokens.ts b/packages/utils/src/server/serverSideTokens.ts new file mode 100644 index 0000000..a22002f --- /dev/null +++ b/packages/utils/src/server/serverSideTokens.ts @@ -0,0 +1,14 @@ +import { cookies } from 'next/headers'; +import { Tokens } from './auth'; + +/** + * @description can only be used on the server side + */ +export const getServerSideTokens = (): Tokens => { + const cookieStore = cookies(); + + return { + accessToken: cookieStore.get('accessToken')?.value ?? '', + refreshToken: cookieStore.get('refreshToken')?.value ?? '', + }; +}; diff --git a/packages/utils/src/server/types.ts b/packages/utils/src/server/types.ts new file mode 100644 index 0000000..51e883f --- /dev/null +++ b/packages/utils/src/server/types.ts @@ -0,0 +1,8 @@ +export enum STATUS { + SUCCESS = 200, + UNKNOWN = 400, + UNAUTHORIZED = 401, + FORBIDDEN = 403, + NOT_FOUND = 404, + DUPLICATE = 409, +} diff --git a/packages/utils/src/server/utils.ts b/packages/utils/src/server/utils.ts new file mode 100644 index 0000000..8a331a3 --- /dev/null +++ b/packages/utils/src/server/utils.ts @@ -0,0 +1,55 @@ +import { refreshAuth, Tokens } from './auth'; +import { STATUS } from './types'; + +export function createInit( + token?: string, + body?: Body, + cache: RequestCache = 'no-store' +): RequestInit { + return { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + cache, + }; +} + +async function fetchWrapperWithTokenHandler(uri: string, tokens: Tokens, init?: RequestInit): Promise { + const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_BASE_URL}${uri}`, init); + + /** + * NOTE: handles ONLY Unauthorized status + */ + if (response.status === STATUS.UNAUTHORIZED) { + const newTokens = await refreshAuth(tokens); + + return await fetchWrapperWithTokenHandler(uri, newTokens, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${newTokens.accessToken}`, + }, + body: init?.body, + cache: init?.cache, + }); + } else if (!response.ok) { + // this error will be caught in useQuery hooks + throw new Error(''); + } + + try { + const data = await response.json(); + return data as Data; + } catch (error) { + return undefined as any; + } +} + +export function POST(input: string, tokens: Tokens, init?: RequestInit): Promise { + return fetchWrapperWithTokenHandler(input, tokens, { method: 'POST', ...init }); +} + +export function GET(input: string, tokens: Tokens, init?: RequestInit) { + return fetchWrapperWithTokenHandler(input, tokens, init); +} diff --git a/packages/utils/src/user-agent/index.tsx b/packages/utils/src/user-agent/index.tsx new file mode 100644 index 0000000..15b7f33 --- /dev/null +++ b/packages/utils/src/user-agent/index.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { ReactNode, createContext, useContext, useEffect, useState } from 'react'; + +export interface UserAgent { + rawUA: string; + isIOS: boolean; + isAndroid: boolean; + isMobile: boolean; +} + +const UserAgentContext = createContext(null); + +export function UserAgentProvider({ children }: { children: ReactNode }) { + const [userAgent, setUserAgent] = useState({ isAndroid: false, isIOS: true, rawUA: '', isMobile: true }); + + useEffect(() => { + const _userAgent = navigator.userAgent.toLowerCase(); + + const isMobile = _userAgent.indexOf('iphone') > -1 || _userAgent.indexOf('android') > -1; + + if (_userAgent.indexOf('android') > -1) { + setUserAgent({ + isIOS: false, + isAndroid: true, + rawUA: _userAgent, + isMobile, + }); + } else { + setUserAgent({ + isIOS: true, + isAndroid: false, + rawUA: _userAgent, + isMobile, + }); + } + }, []); + + return {children}; +} + +export function useUserAgent() { + const userAgent = useContext(UserAgentContext); + + if (userAgent == null) { + throw new Error('Wrap UserAgent Provider'); + } + return userAgent; +} diff --git a/packages/server-utils/tsconfig.json b/packages/utils/tsconfig.json similarity index 83% rename from packages/server-utils/tsconfig.json rename to packages/utils/tsconfig.json index df3917f..4855fa9 100644 --- a/packages/server-utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -3,6 +3,6 @@ "ts-node": { "esm": true }, - "include": ["src"], + "include": ["src/**/*"], "exclude": ["node_modules"] } diff --git a/packages/server-utils/tsconfig.lint.json b/packages/utils/tsconfig.lint.json similarity index 100% rename from packages/server-utils/tsconfig.lint.json rename to packages/utils/tsconfig.lint.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7490c8..204de73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,12 +206,12 @@ importers: apps/demo: dependencies: - '@bottlesteam/server-utils': - specifier: workspace:* - version: link:../../packages/server-utils '@bottlesteam/ui': specifier: workspace:* version: link:../../packages/ui + '@bottlesteam/utils': + specifier: workspace:* + version: link:../../packages/utils '@tanstack/react-query': specifier: ^5.51.21 version: 5.51.21(react@18.3.1) @@ -416,36 +416,6 @@ importers: specifier: ^5.5.2 version: 5.5.4 - packages/server-utils: - dependencies: - '@bottlesteam/bottle': - specifier: workspace:* - version: link:../../apps/bottle - '@bottlesteam/eslint-config': - specifier: workspace:* - version: link:../eslint-config - '@bottlesteam/typescript-config': - specifier: workspace:* - version: link:../typescript-config - cookies-next: - specifier: ^4.2.1 - version: 4.2.1 - dotenv: - specifier: ^16.4.5 - version: 16.4.5 - eslint: - specifier: ^8 - version: 8.57.0 - next: - specifier: 14.2.4 - version: 14.2.4(@babel/core@7.25.2)(react-dom@18.3.1)(react@18.3.1) - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18 - version: 18.3.1(react@18.3.1) - packages/typescript-config: {} packages/ui: @@ -503,6 +473,52 @@ importers: specifier: ^5.3.3 version: 5.5.4 + packages/utils: + dependencies: + '@bottlesteam/bottle': + specifier: workspace:* + version: link:../../apps/bottle + '@bottlesteam/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@bottlesteam/typescript-config': + specifier: workspace:* + version: link:../typescript-config + cookies-next: + specifier: ^4.2.1 + version: 4.2.1 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + eslint: + specifier: ^8 + version: 8.57.0 + next: + specifier: 14.2.4 + version: 14.2.4(@babel/core@7.25.2)(react-dom@18.3.1)(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/eslint': + specifier: ^8.56.5 + version: 8.56.11 + '@types/node': + specifier: ^20.11.24 + version: 20.14.14 + '@types/react': + specifier: ^18.2.61 + version: 18.3.3 + '@types/react-dom': + specifier: ^18.2.19 + version: 18.3.0 + typescript: + specifier: ^5.3.3 + version: 5.5.4 + packages/vitest-config: devDependencies: '@bottlesteam/typescript-config':