Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions apps/app/apis/postSocialLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,14 @@ export const postSocialLogin = async (
provider: SocialProviderT,
body: PostSocialLoginRequestT
): Promise<SocialLoginSuccessPayloadT> => {
const response = await fetch(
`${process.env.EXPO_PUBLIC_API_URL}/api/v1/auth/login/${provider}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Type': 'app',
},
body: JSON.stringify(body),
}
);
const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/api/v1/auth/login/${provider}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Type': 'app',
},
body: JSON.stringify(body),
});

const data = await response.json();
if (!response.ok) throw new Error(data.detail ?? '로그인에 실패했습니다.');
Expand Down
12 changes: 10 additions & 2 deletions apps/app/hooks/useWebviewCookieSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,19 @@ export const useWebviewCookieSync = () => {
const useWebKit = Platform.OS === 'ios';

if (accessToken) {
await CookieManager.set(WEB_URL, { name: 'access_token', value: accessToken, path: '/' }, useWebKit);
await CookieManager.set(
WEB_URL,
{ name: 'access_token', value: accessToken, path: '/' },
useWebKit
);
}

if (refreshToken) {
await CookieManager.set(WEB_URL, { name: 'refresh_token', value: refreshToken, path: '/' }, useWebKit);
await CookieManager.set(
WEB_URL,
{ name: 'refresh_token', value: refreshToken, path: '/' },
useWebKit
);
}

setIsSynced(true);
Expand Down
5 changes: 4 additions & 1 deletion apps/app/utils/handleImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ export const handleOpenImagePicker = async ({ requestId, maxCount }: OpenImagePi
});

if (result.canceled || result.assets.length === 0) {
WebBridge.postMessage({ type: WEBBRIDGE_MESSAGE_TYPE.APP_RES_IMAGE_PICKER_CANCEL, payload: { requestId } });
WebBridge.postMessage({
type: WEBBRIDGE_MESSAGE_TYPE.APP_RES_IMAGE_PICKER_CANCEL,
payload: { requestId },
});
return;
}

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/apis/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ clientApi.interceptors.response.use(
isRefreshing = true;

try {
/** 토큰 갱신 */
/** 토큰 갱신 — body 없는 POST 라도 빈 객체로 보내야 일부 백엔드가 415 안 던진다. */
const { data } = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}${ENDPOINTS.AUTH_TOKEN_REFRESH}`,
null,
{},
{
withCredentials: true,
}
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/apis/getNicknameCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { environmentManager } from '@tanstack/react-query';

import { ENDPOINTS } from '@/consts/api';
import type { ApiResponseT } from '@/types/api';
import type { GetNicknameCheckResponseT } from '@/types/user';

import { clientApi } from './client';
import { serverApi } from './server';

/**
* 닉네임 중복 체크.
* 본인의 현재 닉네임은 서버가 자동으로 통과시킨다 (재확인 흐름 호환).
*/
export const getNicknameCheck = async (nickname: string) => {
if (environmentManager.isServer()) {
const { data } = await serverApi.get<ApiResponseT<GetNicknameCheckResponseT>>(
ENDPOINTS.USERS_NICKNAME_CHECK,
{ params: { nickname } }
);
return data.data;
}

const { data } = await clientApi.get<ApiResponseT<GetNicknameCheckResponseT>>(
ENDPOINTS.USERS_NICKNAME_CHECK,
{ params: { nickname } }
);
return data.data;
};
1 change: 1 addition & 0 deletions apps/web/src/app/archive/_components/WishlistContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from 'react';

import { useGetWishlist } from '@/hooks/useGetWishlist';

import { useWishlistDelete } from '../_hooks/useDeleteWishes';
import { useShareIntentWish } from '../_hooks/useShareIntentWish';
import WishAddDialog from './WishAddDialog';
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/app/archive/_components/wish-grid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }:
if (item.status === 'FAILED') return <WishFailedCard key={item.id} wishId={item.id} />;
else if (item.status === 'PROCESSING') return <WishProcessingCard key={item.id} />;

const card = <WishCard key={item.id} name={item.name} price={item.price} imageUrl={item.imageUrl} />;
const card = (
<WishCard key={item.id} name={item.name} price={item.price} imageUrl={item.imageUrl} />
);

if (isDeleteMode) {
const isSelected = selectedIds?.has(item.id) ?? false;
Expand All @@ -31,7 +33,7 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }:
aria-pressed={isSelected}
className="relative text-left transition-opacity active:opacity-80"
>
{card}
<WishCard name={item.name} price={item.price} imageUrl={item.imageUrl} />
<span className="pointer-events-none absolute top-3 left-3 z-10 size-6">
{isSelected ? (
<CheckboxSelectedIconFill className="size-6 text-uac-light" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import BottomCta from '@/components/bottom-cta';
import Button from '@/components/button';
import Input from '@/components/input';
import Spacing from '@/components/spacing';

import type { ItemStatusT } from '@/types/item';
import formatPrice from '@/utils/formatPrice';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { SocialProviderT } from '@piki/core';

import { clientApi } from '@/apis/client';
import { ENDPOINTS } from '@/consts/api';
import type { ApiResponseT } from '@/types/api';
import type { SocialProviderT } from '@piki/core';

import type { PostSocialLoginResponseT } from '@/types/auth';

type PostSocialLoginRequestT = {
Expand Down
13 changes: 5 additions & 8 deletions apps/web/src/app/auth/callback/apple/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,11 @@ export async function POST(request: NextRequest) {
const redirectUri = `${request.nextUrl.origin}/auth/callback/apple`;

try {
const apiResponse = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/auth/login/apple`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri, state }),
}
);
const apiResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/auth/login/apple`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri, state }),
});

if (!apiResponse.ok) {
return NextResponse.redirect(loginUrl, { status: 302 });
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app/home/_assets/sad-face.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions apps/web/src/app/home/_components/InviteTournamentButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client';

import { useState } from 'react';

import { LoginIconOutline } from '@/assets/icons';

import InviteCodeDialog from './invite-code-dialog/InviteCodeDialog';

function InviteTournamentButton() {
const [isOpen, setIsOpen] = useState(false);

const handleOpen = () => setIsOpen(true);

return (
<>
<button
type="button"
onClick={handleOpen}
className="flex h-[54px] w-full cursor-pointer items-center justify-center gap-2 rounded-[12px] bg-bg-layer-default px-9"
>
<LoginIconOutline className="size-6 text-icon-neutral-secondary" />
<span className="body-1-semibold text-text-neutral-primary">초대 토너먼트 입장</span>
</button>

<InviteCodeDialog open={isOpen} onOpenChange={setIsOpen} />
</>
);
}

export default InviteTournamentButton;
7 changes: 5 additions & 2 deletions apps/web/src/app/home/_components/TournamentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import TournamentCard from '@/components/tournament-card';
import type { UserT } from '@/components/user-profile-group/userProfile.const';
import { useGetTournamentList } from '@/hooks/useGetTournamentList';

// TODO: 백엔드가 정식 프사 처리 도입 시 imageUrl 반영
// 비회원(GUEST)은 서버가 dicebear 자동 아바타를 내려주는데,
// 디자인상 비회원은 기본 SVG 프로필을 노출하므로 무시한다.
const isGeneratedAvatar = (url: string) => url.includes('api.dicebear.com');
const toUsers = (imageUrls: string[]): UserT[] =>
imageUrls.map((_, index) => ({
imageUrls.map((url, index) => ({
id: index,
profileType: index % 2 === 0 ? 'blue' : 'yellow',
...(isGeneratedAvatar(url) ? {} : { imageUrl: url }),
}));

function TorunamentList() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use client';

import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/dialog';

import SadFace from '../../_assets/sad-face.svg';

type InvalidCodeDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};

function InvalidCodeDialog({ open, onOpenChange }: InvalidCodeDialogProps) {
const handleClose = () => onOpenChange(false);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} className="flex flex-col items-center gap-5 p-6">
<SadFace className="size-7.75" aria-hidden />

<div className="flex flex-col items-center gap-1">
<DialogTitle className="text-center heading-1 text-text-neutral-primary">
코드가 유효하지 않아요
</DialogTitle>
<DialogDescription className="text-center body-2-medium text-text-neutral-tertiary">
입력한 코드와 일치하는 토너먼트가 없어요.
<br />
코드를 다시 확인해주세요.
</DialogDescription>
</div>

<button
type="button"
onClick={handleClose}
className="flex h-13 w-full cursor-pointer items-center justify-center rounded-xl bg-bg-neutral-primary body-1-semibold text-text-neutral-inverse"
>
닫기
</button>
</DialogContent>
</Dialog>
);
}

export default InvalidCodeDialog;
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
'use client';

import { useMutation } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import { useRouter } from 'next/navigation';
import { useState } from 'react';

import { getInvitePreviewByCode } from '@/app/tournament/join/_apis/getInvitePreviewByCode';
import {
CODE_LENGTH,
isValidInviteCodeFormat,
} from '@/app/tournament/join/_utils/verifyInviteCode';
import Button from '@/components/button';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/dialog';
import Input from '@/components/input';
import Spinner from '@/components/spinner';

import InvalidCodeDialog from './InvalidCodeDialog';

type InviteCodeDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};

function InviteCodeDialog({ open, onOpenChange }: InviteCodeDialogProps) {
const router = useRouter();
const [code, setCode] = useState('');
const [showFormatError, setShowFormatError] = useState(false);
const [isInvalidDialogOpen, setIsInvalidDialogOpen] = useState(false);

const { mutate: previewMutation, isPending: isPreviewPending } = useMutation({
mutationFn: getInvitePreviewByCode,
// mutation 진행 중 사용자가 input 을 바꿀 수 있으므로
// 검증에 사용한 변수 (variables) 를 그대로 라우팅에 쓴다 — state 의 code 를 다시 읽지 않는다.
onSuccess: (data, enteredCode) => {
onOpenChange(false);
reset();
router.push(`/tournament/join/${data.tournamentId}?code=${enteredCode}`);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onError: error => {
// 400: 코드 없음 / 409: 만료 — 둘 다 사용자에게 InvalidCodeDialog 안내
if (isAxiosError(error)) {
const status = error.response?.status;
if (status === 400 || status === 409) {
setIsInvalidDialogOpen(true);
return;
}
}
setIsInvalidDialogOpen(true);
},
});

const isComplete = code.length === CODE_LENGTH;
const canSubmit = isComplete && !isPreviewPending;

const reset = () => {
setCode('');
setShowFormatError(false);
};

const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};

const handleChange = (next: string) => {
setCode(next.slice(0, CODE_LENGTH).toUpperCase());
if (showFormatError) setShowFormatError(false);
};

const handleSubmit = () => {
if (!canSubmit) return;

if (!isValidInviteCodeFormat(code)) {
setShowFormatError(true);
return;
}

previewMutation(code);
};

return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent showCloseButton={false} className="flex flex-col gap-5 p-6">
<DialogTitle className="text-center heading-1 text-text-neutral-primary">
초대받은 토너먼트
</DialogTitle>
<DialogDescription className="sr-only">초대 코드를 입력해 입장합니다.</DialogDescription>

<Input
label="초대 코드"
placeholder="ex. ABC123"
value={code}
onChange={event => handleChange(event.target.value)}
aria-invalid={showFormatError}
{...(showFormatError
? { helperText: '영문 대문자 3자 + 숫자 3자로 입력해주세요.' }
: {})}
maxLength={CODE_LENGTH}
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
autoFocus
/>

<Button size="lg" variant="primary" disabled={!canSubmit} onClick={handleSubmit}>
{isPreviewPending ? <Spinner size={20} /> : '입장하기'}
</Button>
</DialogContent>
</Dialog>

<InvalidCodeDialog open={isInvalidDialogOpen} onOpenChange={setIsInvalidDialogOpen} />
</>
);
}

export default InviteCodeDialog;
Loading
Loading