3๋…„๊ฐ„ ํšŒ์‚ฌ์—์„œ ๊ฐœ๋ฐœํ•˜๋ฉด์„œ ๋‚˜๋ฆ„ ๊ฒฝํ—˜์„ ์Œ“์•˜๋‹ค๊ณ  ์ƒ๊ฐํ–ˆ์Šต๋‹ˆ๋‹ค.

 

ํ•˜์ง€๋งŒ ๋Œ์ด์ผœ๋ณด๋ฉด ๊ทธ ๊ฒฝํ—˜์€ ์ด๋ฏธ ์ž˜ ์งœ์ธ ๊ตฌ์กฐ ์•ˆ์—์„œ ์ฃผ์–ด์ง„ ์—ญํ• ๋งŒ ์ˆ˜ํ–‰ํ–ˆ์—ˆ๋˜ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.
๋””์ž์ด๋„ˆ์™€์˜ ํ˜‘์—… ๋ฐฉ์‹์€ ์ด๋ฏธ ์ •ํ˜•ํ™”๋˜์–ด ์žˆ์—ˆ๊ณ , ๋””์ž์ธ ์‹œ์Šคํ…œ๋„ ๊ตฌ์ถ•๋ผ ์žˆ์–ด์„œ ์‹œ์•ˆ์„ ๋ฐ›์•„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐ๋งŒ ์ง‘์ค‘ํ•˜๋ฉด ๋๊ฑฐ๋“ ์š”.

์•Œ๋ฆฌ๋‹ˆ ์‹์‚ฌ ํ™”๋ฉด

 

 

์ด๋ฒˆ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ๋ชจ๋“  ๊ฑธ ์ฒ˜์Œ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด์•ผ ํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ทธ ๊ณผ์ •์—์„œ ๋งŽ์€ ๊ฒƒ๋“ค์„ ์ƒˆ๋กญ๊ฒŒ ๊นจ๋‹ฌ์•˜๊ณ , ํŠนํžˆ ๋””์ž์ด๋„ˆ์™€์˜ ํ˜‘์—…์—์„œ ์—ฌ๋Ÿฌ ๋ฐฐ์›€๋“ค์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค!

 

๊ฐ‘์ž๊ธฐ ๋ณด์ด๊ธฐ ์‹œ์ž‘ํ•œ ๊ฒƒ๋“ค

์ƒํƒœ์— ๋Œ€ํ•œ ์ •์˜

๋””์ž์ด๋„ˆ๋‹˜๊ณผ ๊ฐ™์€ "์ƒํƒœ"๋ผ๋Š” ๋‹จ์–ด๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์—ˆ๋Š”๋ฐ, ์‹ค์ œ๋กœ๋Š” ๋‹ค๋ฅธ ๋ฐฉ์‹์„ ์ƒ๊ฐํ•˜๊ณ  ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

  • ๋””์ž์ด๋„ˆ๋‹˜์ด ๋งํ•˜๋Š” ์ƒํƒœ๋Š” ์ฃผ๋กœ ์‹œ๊ฐ์ ์ธ ๋ณ€ํ™” — ์˜ˆ๋ฅผ ๋“ค์–ด ์ƒ‰๊น”, ํ…Œ๋‘๋ฆฌ, ์•„์ด์ฝ˜ ๊ฐ™์€ UI ์š”์†Œ๋“ค์ด์—ˆ๊ณ ,
  • ์ œ๊ฐ€ ๋งํ•˜๋Š” ์ƒํƒœ๋Š” ๋ฐ์ดํ„ฐ์˜ ์ƒํƒœ ํ˜น์€ ์‚ฌ์šฉ์ž ์ƒํ˜ธ์ž‘์šฉ — ์˜ˆ๋ฅผ ๋“ค์–ด ์—๋Ÿฌ ์—ฌ๋ถ€, ์ž…๋ ฅ ์™„๋ฃŒ ์—ฌ๋ถ€, ๋„คํŠธ์›Œํฌ ์ƒํƒœ ๊ฐ™์€ ๊ฒƒ์ด์—ˆ์Šต๋‹ˆ๋‹ค.

์•„๋ž˜์—์„œ ์ž์„ธํžˆ ์„ค๋ช… ํ•˜๊ฒ ์ง€๋งŒ ๊ฐœ๋ฐœํ–ˆ๋˜ ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ๋กœ ์„ค๋ช…ํ•ด ๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

 

์ฒ˜์Œ ์‹œ์•ˆ์—๋Š” "๊ธฐ๋ณธ, ํฌ์ปค์Šค, ์—๋Ÿฌ, ๋น„ํ™œ์„ฑํ™”" ์ •๋„๋งŒ ์ •์˜๋ผ ์žˆ์—ˆ์–ด์š”.

๊ทธ๋Ÿฐ๋ฐ ์‹ค์ œ๋กœ ๊ตฌํ˜„์— ๋“ค์–ด๊ฐ€๋ฉด์„œ ๊ณ ๋ฏผ์ด ์ปค์กŒ์Šต๋‹ˆ๋‹ค.

 

  • ์ด๋ฉ”์ผ ํ•„๋“œ์—์„œ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๊ฐ€ ๋œจ๋Š” ๋™์‹œ์— ํฌ์ปค์Šค๊ฐ€ ๊ฐ€๋ฉด ์–ด๋–ป๊ฒŒ ๋ณด์—ฌ์ค˜์•ผ ํ•˜์ง€?
  • ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด์„œ ๋™์‹œ์— ๋กœ๋”ฉ ์ƒํƒœ๋„ ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค๋ฉด?
  • ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•„๋“œ์—์„œ๋Š” ํ˜ธ๋ฒ„ + ์—๋Ÿฌ ์ƒํƒœ๊ฐ€ ํ•จ๊ป˜ ์˜ค๋ฉด ์–ด๋–ค UI์—ฌ์•ผ ํ• ๊นŒ?

์ด๋ ‡๊ฒŒ ์ƒํƒœ ์กฐํ•ฉ ๋งŽ์•„์ง€๊ธฐ ์‹œ์ž‘ํ–ˆ์–ด์š”.
ํšŒ์‚ฌ๋Š” ์ด๋ฏธ ๋””์ž์ธ ์‹œ์Šคํ…œ๊ณผ ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ž˜ ์ •๋ฆฌ๋ผ ์žˆ์–ด์„œ, ์ด๋Ÿฐ ์กฐํ•ฉ๋“ค์€ ์‹ ๊ฒฝ ์“ธ ํ•„์š”๊ฐ€ ๊ฑฐ์˜ ์—†์—ˆ๊ฑฐ๋“ ์š”.
ํ•˜์ง€๋งŒ ์ด๋ฒˆ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ๋ชจ๋“  ๊ฒƒ์„ ์ง์ ‘ ๊ธฐํšํ•˜๊ณ , ๋””์ž์ด๋„ˆ๋‹˜๊ณผ ๋…ผ์˜ํ•˜๊ณ , ๊ตฌํ˜„๊นŒ์ง€ ํ•ด๋ณด๋ฉด์„œ "์ƒํƒœ"๋ฅผ ์ •์˜ํ•œ๋‹ค๋Š”๊ฒŒ ์–ผ๋งˆ๋‚˜ ๋ณต์žกํ•˜๊ณ  ์„ฌ์„ธํ•œ ์ผ์ธ์ง€ ์ฒด๊ฐํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

 

 

 

๊ฐ€์žฅ ์ฒ˜์Œ ์‹œ์ž‘๋œ ์†Œํ†ต์ด ์‹œ์ž‘๋œ ํ™”๋ฉด

๋กœ๊ทธ์ธ ํ™”๋ฉด

 

ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐ ๋””์ž์ด๋„ˆ๋‹˜์ด ๋กœ๊ทธ์ธ ํ™”๋ฉด ์‹œ์•ˆ์„ ๊ฐ€์ ธ์™”์Šต๋‹ˆ๋‹ค. ๊น”๋”ํ•˜๊ณ  ์ง๊ด€์ ์ธ ๋””์ž์ธ์ด์—ˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๊ตฌํ˜„์„ ์‹œ์ž‘ํ•˜๋ฉด์„œ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๋ช‡ ๊ฐ€์ง€ ๊ถ๊ธˆํ•œ ์ ๋“ค์ด ์ƒ๊ฒผ์–ด์š”.

 

"๋กœ๊ทธ์ธ ์‹คํŒจํ–ˆ์„ ๋•Œ๋Š” ์–ด๋–ป๊ฒŒ ํ‘œ์‹œํ• ๊นŒ์š”?"

 

๋””์ž์ด๋„ˆ๋‹˜๋„ "์•„, ๊ทธ๋Ÿฌ๊ฒŒ์š”. ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๊ฐ€ ํ•„์š”ํ•˜๊ฒ ๋„ค์š”"๋ผ๊ณ  ํ•ด์ฃผ์…จ๊ณ  ํ•จ๊ป˜ ๊ณ ๋ฏผํ•ด๋ดค์Šต๋‹ˆ๋‹ค.

 

 

๋กœ๊ทธ์ธ ์—๋Ÿฌ ํ™”๋ฉด ์˜ˆ์‹œ

 

์˜ˆ์‹œ ์ฝ”๋“œ๋Š” ์‹ค์ œ ๊ตฌํ˜„ ์ฝ”๋“œ์™€ ์ฐจ์ด๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

type LoginErrorType = 'INVALID_CREDENTIALS' | 'NETWORK_ERROR' | 'UNKNOWN_ERROR';

interface LoginError {
  type: LoginErrorType;
  message: string;
  field?: 'email' | 'password'; // ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋งŒ ์‚ฌ์šฉ
}

interface LoginState {
  status: 'idle' | 'loading' | 'success' | 'error';
  error: LoginError | null;
}

// ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์ƒ์ˆ˜ ๋ถ„๋ฆฌ
const LOGIN_ERROR_MESSAGES = {
  INVALID_CREDENTIALS: '์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ํ™•์ธํ•ด์ฃผ์„ธ์š”.',
  NETWORK_ERROR: '๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.',
  UNKNOWN_ERROR: '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.',
} as const;

// ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์šฉ ์ƒ์„ธ ๋ฉ”์‹œ์ง€
const DEV_ERROR_MESSAGES = {
  INVALID_EMAIL: '์•—! ๋“ฑ๋ก๋˜์ง€ ์•Š์€ ์ด๋ฉ”์ผ์ด๊ฑฐ๋‚˜ ์ž˜๋ชป ์ž…๋ ฅํ–ˆ์–ด์š”.',
  INVALID_PASSWORD: '์•—! ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‹€๋ ธ์–ด์š”. ๋‹ค์‹œ ํ•œ ๋ฒˆ ํ™•์ธํ•ด์ฃผ์„ธ์š”.',
} as const;

const useLogin = () => {
  const [state, setState] = useState<LoginState>({
    status: 'idle',
    error: null,
  });

  // ํ™˜๊ฒฝ๋ณ„ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ
  const getErrorMessage = (type: LoginErrorType, field?: 'email' | 'password'): LoginError => {
    const isDevelopment = process.env.NODE_ENV === 'development';
    
    if (isDevelopment && field) {
      return {
        type,
        message: field === 'email' ? DEV_ERROR_MESSAGES.INVALID_EMAIL : DEV_ERROR_MESSAGES.INVALID_PASSWORD,
        field,
      };
    }

    return {
      type,
      message: LOGIN_ERROR_MESSAGES[type],
    };
  };

  const login = async (email: string, password: string) => {
    setState({ status: 'loading', error: null });
    
    try {
      const user = await authService.findUserByEmail(email);
      if (!user) {
        setState({
          status: 'error',
          error: getErrorMessage('INVALID_CREDENTIALS', 'email'),
        });
        return;
      }

      const isValidPassword = await authService.validatePassword(email, password);
      if (!isValidPassword) {
        setState({
          status: 'error',
          error: getErrorMessage('INVALID_CREDENTIALS', 'password'),
        });
        return;
      }

      await authService.setSession(user);
      setState({ status: 'success', error: null });
    } catch (error) {
      setState({
        status: 'error',
        error: getErrorMessage('NETWORK_ERROR'),
      });
    }
  };

  return {
    ...state,
    login,
    isLoading: state.status === 'loading',
    isSuccess: state.status === 'success',
    isError: state.status === 'error',
  };
};

 

๋””์ž์ด๋„ˆ๋‹˜์€ ๋กœ๊ทธ์ธ ํ™”๋ฉด์—์„œ ์–ด๋–ค ๋ฐฉ์‹์œผ๋กœ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ๋„์šธ์ง€ ๊ณ ๋ฏผํ•˜๊ณ  ๊ณ„์…จ์–ด์š”.

 

ํ”ํžˆ ๋ณผ ์ˆ˜ ์žˆ๋Š” "์•„์ด๋””๋‚˜ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค"์™€ ๊ฐ™์€ ๋ชจํ˜ธํ•œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋Œ€์‹ , "์ด๋ฉ”์ผ์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค" ๋˜๋Š” "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค"์ฒ˜๋Ÿผ ๊ตฌ์ฒด์ ์œผ๋กœ ์•Œ๋ ค์ฃผ๋Š” ๊ฒŒ ์‚ฌ์šฉ์ž ์ž…์žฅ์—์„œ ๋” ์นœ์ ˆํ•œ UX๊ฐ€ ์•„๋‹๊นŒ์— ๋Œ€ํ•ด ๋…ผ์˜ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

 

๊ทธ๋ฆฌ๊ณ  ์™œ ๋งŽ์€ ์›น์‚ฌ์ดํŠธ๋“ค์ด ๋กœ๊ทธ์ธ ์‹คํŒจ ์‹œ "Incorrect username or password" ์ฒ˜๋Ÿผ ๋ชจํ˜ธํ•œ ๋ฉ”์‹œ์ง€๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฑธ๊นŒ์š”? ๋ผ๋Š” ์˜๋ฌธ์— ๋„๋‹ฌํ–ˆ์–ด์š”.

 

๊ด€๋ จํ•ด์„œ ์ฐพ์•„๋ณธ ๊ฒฐ๊ณผ, tarunbatra.com ๋ธ”๋กœ๊ทธ์—์„œ๋Š” ๋ณด์•ˆ์ ์ธ ์ด์œ ๋ฅผ ๊ฐ•์กฐํ–ˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๋ฅผ ๊ตฌ์ฒด์ ์œผ๋กœ ์ œ๊ณตํ•  ๊ฒฝ์šฐ, ์•…์˜์ ์ธ ์‚ฌ์šฉ์ž๊ฐ€ ํ•ด๋‹น ์ •๋ณด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์œ ํšจํ•œ ๊ณ„์ •์„ ์ถ”์ธกํ•˜๊ฑฐ๋‚˜ ๋ฐ˜๋ณต์ ์œผ๋กœ ์กฐํ•ฉ์„ ์‹œ๋„ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

 

์˜ˆ๋ฅผ ๋“ค์–ด, "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค"๋ผ๋Š” ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋ฉด ํ•ด๋‹น ์‚ฌ์šฉ์ž๋ช…์€ ์œ ํšจํ•˜๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, ๊ณต๊ฒฉ์ž์—๊ฒŒ ๋‹จ์„œ๋ฅผ ์ฃผ๋Š” ์…ˆ์ด์ฃ . ๋ฐ˜๋ฉด "์•„์ด๋”” ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค"์ฒ˜๋Ÿผ ๋ชจํ˜ธํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•˜๋ฉด ๊ณต๊ฒฉ์ž๋Š” ์–ด๋–ค ๋ถ€๋ถ„์ด ์ž˜๋ชป๋˜์—ˆ๋Š”์ง€ ์•Œ ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์— ๋ณด์•ˆ์ƒ ๋” ์•ˆ์ „ํ•ด์ง‘๋‹ˆ๋‹ค.

 

ํ•˜์ง€๋งŒ ๊ทธ ๋‹น์‹œ์—” ๋‚ด๋ถ€ ๋ฒ ํƒ€ ํ…Œ์ŠคํŠธ๋ฅผ ์ง„ํ–‰ํ•  ์˜ˆ์ •์ด์—ˆ๋˜ ๋™๋ฌผ๋ณ‘์› ๊ด€๊ณ„์ž๋ถ„๋“ค์˜ ์š”์ฒญ์ด ์žˆ์—ˆ๊ณ , ํ…Œ์ŠคํŠธ ํŽธ์˜๋ฅผ ์œ„ํ•ด ๋กœ๊ทธ์ธ ์˜ค๋ฅ˜ ์‹œ ์–ด๋–ค ํ•„๋“œ๊ฐ€ ์ž˜๋ชป๋˜์—ˆ๋Š”์ง€๋ฅผ ๋ถ„๋ฆฌํ•ด์„œ ๋ณด์—ฌ์ฃผ๋Š” UX๋ฅผ ์ฑ„ํƒํ•˜๊ธฐ๋กœ ํ–ˆ์Šต๋‹ˆ๋‹ค.

์‚ฌ์šฉ์ž๊ฐ€ ์–ด๋–ค ํ•„๋“œ์—์„œ ๋ฌธ์ œ๊ฐ€ ์ƒ๊ฒผ๋Š”์ง€ ์ง๊ด€์ ์œผ๋กœ ์•Œ ์ˆ˜ ์žˆ๋„๋ก, ํ•ด๋‹น ํ•„๋“œ ๋ฐ”๋กœ ์•„๋ž˜์— ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ๋„์šฐ๋Š” ๋ฐฉ์‹์œผ๋กœ ํ˜‘์˜ํ–ˆ๊ณ , ์‹ค์ œ ์‚ฌ์šฉ์ž ํ…Œ์ŠคํŠธ์—์„œ๋„ ๊ธ์ •์ ์ธ ๋ฐ˜์‘์„ ์–ป์„ ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค!

 

 

ํŽธํ•˜๊ฒŒ ์“ฐ๋˜ ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ

์•Œ๋ฆฌ๋‹ˆ์—์„œ๋Š” ๋‹ค์–‘ํ•œ ์ž…๋ ฅ ํ•„๋“œ๊ฐ€ ํ•„์š”ํ–ˆ์Šต๋‹ˆ๋‹ค.

์ด๋ฉ”์ผ, ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ „ํ™”๋ฒˆํ˜ธ, ์‚ฌ๋ฃŒ๋ช…, ๋ธŒ๋žœ๋“œ๋ช… ๋“ฑ๋“ฑ...

 

์ฒ˜์Œ์— ๋””์ž์ด๋„ˆ๋‹˜์ด ๋งŒ๋“  ํ…์ŠคํŠธํ•„๋“œ๋Š” ๊ธฐ๋ณธ์ ์ด ๋ชจ์Šต์ด์—ˆ๋Š”๋ฐ, ๊ตฌํ˜„ํ•˜๋ฉด์„œ ํ•จ๊ป˜ ๋‹ค์–‘ํ•œ ์ƒํƒœ๋ฅผ ๊ณ ๋ฏผํ•˜๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

 

"๋น„๋ฐ€๋ฒˆํ˜ธ ํ•„๋“œ์—์„œ ์ž…๋ ฅ ๋‚ด์šฉ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ๋ˆˆ ๋ชจ์–‘ ์•„์ด์ฝ˜์ด ์žˆ์œผ๋ฉด ์ข‹๊ฒ ์–ด์š”."

 

"๋งž์•„์š”! ๊ทธ๋ฆฌ๊ณ  ์‚ฌ๋ฃŒ ๋“ฑ๋กํ•  ๋•Œ ํ•„์ˆ˜ ์ž…๋ ฅ ํ•ญ๋ชฉ์€ ๋ผ๋ฒจ์— ๋ณ„ํ‘œ(*)๋ฅผ ํ‘œ์‹œํ•˜๋ฉด ์–ด๋–จ๊นŒ์š”?"

 

์ด์ฒ˜๋Ÿผ ํ•˜๋‚˜ํ•˜๋‚˜ ๊ธฐ๋Šฅ์ด ์ถ”๊ฐ€๋˜๋ฉฐ ํ•„์š”ํ•œ ์ƒํƒœ์˜ ์ˆ˜๋Š” ๊ธฐํ•˜๊ธ‰์ˆ˜์ ์œผ๋กœ ๋Š˜์–ด๋‚ฌ์Šต๋‹ˆ๋‹ค..

์˜ˆ๋ฅผ๋“ค์–ด ์•„๋ž˜์ฒ˜๋Ÿผ์š”. (์‹ค์ œ ์•Œ๋ฆฌ๋‹ˆ ๊ตฌํ˜„๊ณผ ์ฐจ์ด๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค)

  • ๊ธฐ๋ณธ ์ƒํƒœ 4๊ฐ€์ง€
  • ํ˜ธ๋ฒ„ ์ƒํƒœ ์ถ”๊ฐ€ → 4 × 2 = 8
  • ์ฝ๊ธฐ ์ „์šฉ(read-only) ๊ณ ๋ ค → 8 × 2 = 16
  • ํ•„์ˆ˜/์„ ํƒ ํ‘œ์‹œ → 16 × 2 = ์ด 32๊ฐ€์ง€ ์ƒํƒœ ์กฐํ•ฉ

๊ทธ๋Ÿฐ๋ฐ ๊ฐ€์žฅ ์–ด๋ ค์› ๋˜ ๊ฑด ์ด๋Ÿฐ ์กฐํ•ฉ๋“ค์ด ๋‹จ์ˆœํžˆ ์ถ”๊ฐ€๋งŒ ๋˜๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, ์„œ๋กœ ์ถฉ๋Œํ•˜๊ฑฐ๋‚˜ ์šฐ์„ ์ˆœ์œ„๊ฐ€ ์žˆ๋‹ค๋Š” ์ ์ด์—ˆ์–ด์š”.
์˜ˆ๋ฅผ ๋“ค์–ด disabled์™€ readOnly๋Š” ๋™์‹œ์— true์ผ ์ˆ˜ ์žˆ์ง€๋งŒ, ์‹ค์ œ๋กœ๋Š” disabled๊ฐ€ readOnly๋ฅผ ๋ฌด์‹œํ•ด์š”.

์ฆ‰, disabled > readOnly์ธ ๊ฑฐ์ฃ .
์ด๋Ÿฐ ์ƒํ˜ธ ์˜์กด์„ฑ๊ณผ ์šฐ์„ ์ˆœ์œ„๋Š” ์ฒ˜์Œ์—” ์ „ํ˜€ ์ธ์ง€ํ•˜์ง€ ๋ชปํ–ˆ๊ณ , ๋””์ž์ด๋„ˆ๋‹˜๊ณผ ํ•จ๊ป˜ ํ•œ์ฐธ์„ ๋…ผ์˜ํ•˜๋ฉด์„œ ๊ทœ์น™์„ ์ •๋ฆฌํ•ด๋‚˜๊ฐ”์Šต๋‹ˆ๋‹ค:

  • disabled > readOnly (์šฐ์„ ์ˆœ์œ„ ์กด์žฌ)
  • error์™€ success๋Š” ์ƒํ˜ธ ๋ฐฐํƒ€์ 
  • focus๋Š” ์‚ฌ์šฉ์ž์˜ ์ง์ ‘์ ์ธ ์ƒํ˜ธ์ž‘์šฉ์œผ๋กœ๋งŒ ๋ฐœ์ƒ

์ธํ’‹ ์˜ˆ์‹œ ํ™”๋ฉด

 

์˜ˆ์‹œ ์ฝ”๋“œ๋Š” ์‹ค์ œ ๊ตฌํ˜„ ์ฝ”๋“œ์™€ ์ฐจ์ด๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

import { forwardRef, useState, useCallback, useMemo, useId } from 'react';

// ๋””์ž์ธ ํ† ํฐ
const INPUT_COLORS = {
  default: {
    border: '#e1e5e9',
    background: '#ffffff',
  },
  focused: {
    border: '#00b896', // ๋ธŒ๋žœ๋“œ ์ปฌ๋Ÿฌ
    background: '#ffffff',
  },
  error: {
    border: '#ff6b6b', // ๋นจ๊ฐ„์ƒ‰
    background: '#ffffff',
  },
  disabled: {
    border: '#f4f4f4',
    background: '#f9f9f9',
  },
} as const;

// ์ƒํƒœ ์šฐ์„ ์ˆœ์œ„ ๋ช…์‹œ์  ์ •์˜
const INPUT_STATE_PRIORITY = {
  disabled: 4,
  error: 3,
  focused: 2,
  default: 1,
} as const;

type InputState = keyof typeof INPUT_STATE_PRIORITY;

// Props ํƒ€์ž… ๋ถ„๋ฆฌ
interface BaseInputProps {
  label?: string;
  required?: boolean;
  error?: string;
  disabled?: boolean;
  placeholder?: string;
  value?: string;
  onChange?: (value: string) => void;
  onFocus?: () => void;
  onBlur?: () => void;
}

interface TextInputProps extends BaseInputProps {
  type?: 'text' | 'email' | 'tel';
  leftIcon?: React.ReactNode;
  rightButton?: React.ReactNode;
}

interface PasswordInputProps extends BaseInputProps {
  type: 'password';
  showPasswordToggle?: boolean;
}

type InputProps = TextInputProps | PasswordInputProps;

// ์ƒํƒœ ๊ณ„์‚ฐ ๋กœ์ง ๋ถ„๋ฆฌ
const getInputState = (isFocused: boolean, error?: string, disabled?: boolean): InputState => {
  if (disabled) return 'disabled';
  if (error) return 'error';
  if (isFocused) return 'focused';
  return 'default';
};

const getInputStyles = (state: InputState) => {
  const colors = INPUT_COLORS[state];
  return {
    borderColor: colors.border,
    backgroundColor: colors.background,
  };
};

const TextInput = forwardRef<HTMLInputElement, InputProps>(({
  label,
  required = false,
  type = 'text',
  error,
  disabled = false,
  placeholder,
  value,
  onChange,
  onFocus,
  onBlur,
  ...props
}, ref) => {
  const [showPassword, setShowPassword] = useState(false);
  const [isFocused, setIsFocused] = useState(false);
  
  const inputId = useId();
  const errorId = useId();
  
  const inputType = useMemo(() => {
    if (type === 'password' && !showPassword) return 'password';
    return type === 'password' ? 'text' : type;
  }, [type, showPassword]);

  const currentState = useMemo(() => 
    getInputState(isFocused, error, disabled), 
    [isFocused, error, disabled]
  );

  const inputStyles = useMemo(() => 
    getInputStyles(currentState), 
    [currentState]
  );

  // ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๋“ค
  const handleFocus = useCallback(() => {
    if (disabled) return;
    setIsFocused(true);
    onFocus?.();
  }, [disabled, onFocus]);

  const handleBlur = useCallback(() => {
    setIsFocused(false);
    onBlur?.();
  }, [onBlur]);

  const togglePasswordVisibility = useCallback(() => {
    setShowPassword(prev => !prev);
  }, []);

  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    onChange?.(e.target.value);
  }, [onChange]);

  // ํŒจ์Šค์›Œ๋“œ ํ† ๊ธ€ ๋ฒ„ํŠผ
  const passwordToggleButton = type === 'password' && (
    <button
      type="button"
      onClick={togglePasswordVisibility}
      className="password-toggle"
      aria-label={showPassword ? '๋น„๋ฐ€๋ฒˆํ˜ธ ์ˆจ๊ธฐ๊ธฐ' : '๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณด๊ธฐ'}
      disabled={disabled}
      style={{ 
        opacity: disabled ? 0.5 : 1,
        cursor: disabled ? 'not-allowed' : 'pointer'
      }}
    >
      {showPassword ? '๐Ÿ™ˆ' : '๐Ÿ‘๏ธ'}
    </button>
  );

  return (
    <div className="text-input">
      {label && (
        <label htmlFor={inputId} className="input-label">
          {label}
          {required && (
            <span className="required-indicator" aria-label="ํ•„์ˆ˜" style={{ color: '#ff6b6b' }}>
              *
            </span>
          )}
        </label>
      )}
      
      <div 
        className={`input-container input-container--${currentState}`}
        style={{
          ...inputStyles,
          border: `1px solid ${inputStyles.borderColor}`,
          borderRadius: '8px',
          padding: '12px',
          display: 'flex',
          alignItems: 'center',
          gap: '8px',
        }}
      >
        {'leftIcon' in props && props.leftIcon && (
          <span className="input-icon" aria-hidden="true">
            {props.leftIcon}
          </span>
        )}
        
        <input
          ref={ref}
          id={inputId}
          type={inputType}
          disabled={disabled}
          placeholder={placeholder}
          value={value}
          onChange={handleChange}
          onFocus={handleFocus}
          onBlur={handleBlur}
          aria-describedby={error ? errorId : undefined}
          aria-invalid={!!error}
          style={{
            flex: 1,
            border: 'none',
            outline: 'none',
            backgroundColor: 'transparent',
            fontSize: '16px',
          }}
        />
        
        {passwordToggleButton}
        
        {'rightButton' in props && props.rightButton}
      </div>
      
      {error && (
        <span 
          id={errorId} 
          className="error-message" 
          role="alert"
          style={{
            color: '#ff6b6b',
            fontSize: '14px',
            marginTop: '4px',
            display: 'block',
          }}
        >
          {error}
        </span>
      )}
    </div>
  );
});

TextInput.displayName = 'TextInput';

// ์‚ฌ์šฉ ์˜ˆ์‹œ
const LoginForm = () => {
  const [formData, setFormData] = useState({
    email: '',
    password: '',
  });
  
  const { login, isLoading, error, status } = useLogin();
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await login(formData.email, formData.password);
  };
  
  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: '400px', margin: '0 auto' }}>
      <TextInput
        label="์ด๋ฉ”์ผ"
        type="email"
        required
        value={formData.email}
        onChange={(email) => setFormData(prev => ({ ...prev, email }))}
        error={error?.field === 'email' ? error.message : undefined}
        placeholder="์ด๋ฉ”์ผ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”"
        disabled={isLoading}
      />
      
      <div style={{ marginTop: '16px' }}>
        <TextInput
          label="๋น„๋ฐ€๋ฒˆํ˜ธ"
          type="password"
          required
          value={formData.password}
          onChange={(password) => setFormData(prev => ({ ...prev, password }))}
          error={error?.field === 'password' ? error.message : undefined}
          placeholder="๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”"
          disabled={isLoading}
          showPasswordToggle
        />
      </div>
      
      <button 
        type="submit" 
        disabled={isLoading}
        style={{
          width: '100%',
          padding: '12px',
          marginTop: '24px',
          backgroundColor: isLoading ? '#cccccc' : '#00b896',
          color: 'white',
          border: 'none',
          borderRadius: '8px',
          fontSize: '16px',
          cursor: isLoading ? 'not-allowed' : 'pointer',
        }}
      >
        {isLoading ? '๋กœ๊ทธ์ธ ์ค‘...' : '๋กœ๊ทธ์ธ'}
      </button>
      
      {error && !error.field && (
        <div style={{ 
          marginTop: '16px', 
          padding: '12px', 
          backgroundColor: '#ffebee',
          borderRadius: '8px',
          color: '#ff6b6b'
        }}>
          {error.message}
        </div>
      )}
    </form>
  );
};

 

"๋น„ํ™œ์„ฑํ™” ์ƒํƒœ์ผ ๋•Œ๋Š” ํšŒ์ƒ‰์œผ๋กœ, ์—๋Ÿฌ ์ƒํƒœ์ผ ๋•Œ๋Š” ๋นจ๊ฐ„์ƒ‰ ํ…Œ๋‘๋ฆฌ๋กœ ํ‰๋ฒ”ํ•˜๊ฒŒ ๊ฐ€๊ณ  ํฌ์ปค์Šค ์ƒํƒœ์—์„œ๋Š” ๋ธŒ๋žœ๋“œ ์ปฌ๋Ÿฌ์ธ ์ดˆ๋ก์ƒ‰์œผ๋กœ ๊ฐ•์กฐํ•˜๋ฉด ์ข‹๊ฒ ์–ด์š”"๋ผ๋Š” ์˜๊ฒฌ์œผ๋กœ ์ผ์น˜ํ–ˆ์Šต๋‹ˆ๋‹ค.

 

 

์•Œ๋ ˆ๋ฅด๊ธฐ ๊ธฐ๋ก์˜ ์‹œ๊ฐํ™”

์•Œ๋ฆฌ๋‹ˆ์˜ ํ•ต์‹ฌ ๊ธฐ๋Šฅ ์ค‘ ํ•˜๋‚˜๊ฐ€ ๋ฐ˜๋ ค๊ฒฌ์˜ ์•Œ๋ ˆ๋ฅด๊ธฐ ๋ฐ˜์‘์„ ๊ธฐ๋กํ•˜๊ณ  ๋ถ„์„ํ•˜๋Š” ๊ฑด๋ฐ, ์ด ๋ถ€๋ถ„์—์„œ ์ •๋ง ๋งŽ์€ ๋Œ€ํ™”๊ฐ€ ์˜ค๊ฐ”์Šต๋‹ˆ๋‹ค.

์‹œ๊ฐํ™” ์˜ˆ์‹œ ํ™”๋ฉด

 

 

ํ˜„์žฌ ๊ตฌํ˜„๋œ ์ƒํƒœ์™€๋Š” ๋‹ค๋ฅด์ง€๋งŒ ์•„๋ž˜์™€๊ฐ™์€ ๊ณ ๋ฏผ์„ ํ–ˆ์—ˆ์–ด์š”.

 

"์•Œ๋ ˆ๋ฅด๊ธฐ ์ •๋„๋ฅผ ์–ด๋–ป๊ฒŒ ํ‘œํ˜„ํ•˜๋ฉด ์ข‹์„๊นŒ์š”?"

 

"์ƒ‰๊น”๋กœ ๊ตฌ๋ถ„ํ•˜๋ฉด ์–ด๋–จ๊นŒ์š”? ๋นจ๊ฐ„์ƒ‰์€ ์‹ฌํ•จ, ์ฃผํ™ฉ์ƒ‰์€ ๋ณดํ†ต, ์ดˆ๋ก์ƒ‰์€ ๋‚ฎ์Œ?"

 

"์ƒ‰์•ฝ์ด ์žˆ๋Š” ์‚ฌ์šฉ์ž๋„ ๊ณ ๋ คํ•ด์•ผ ํ•  ๊ฒƒ ๊ฐ™์•„์š”. ์ƒ‰๊น”๊ณผ ํ•จ๊ป˜ ํ…์ŠคํŠธ๋‚˜ ์•„์ด์ฝ˜๋„ ๋„ฃ์œผ๋ฉด ์–ด๋–จ๊นŒ์š”?"

 

์˜ˆ์‹œ ์ฝ”๋“œ๋Š” ์‹ค์ œ ๊ตฌํ˜„ ์ฝ”๋“œ์™€ ์ฐจ์ด๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

// ๋””์ž์ธ ํ† ํฐ ์ •์˜
const COLORS = {
  success: {
    main: '#00b896',
    background: '#e6f8f5',
    light: '#b3f0e6',
  },
  warning: {
    main: '#ffa726',
    background: '#fff3e0',
    light: '#ffd699',
  },
  error: {
    main: '#ff6b6b',
    background: '#ffebee',
    light: '#ffb3b3',
  },
  gray: {
    600: '#666666',
    500: '#999999',
    100: '#f5f5f5',
  },
} as const;

// ์•Œ๋ ˆ๋ฅด๊ธฐ ๋ ˆ๋ฒจ ํƒ€์ž…
type AllergyLevel = 'low' | 'medium' | 'high';

// ์•Œ๋ ˆ๋ฅด๊ธฐ ๋ฐ์ดํ„ฐ ์ธํ„ฐํŽ˜์ด์Šค
interface AllergyData {
  level: AllergyLevel;
  percentage: number;
  ingredients: string[];
  timestamp?: Date;
}

// ์•„์ด์ฝ˜ ์ปดํฌ๋„ŒํŠธ๋“ค (์‹ค์ œ๋กœ๋Š” ์•„์ด์ฝ˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ)
const CheckCircleIcon = ({ size = 16 }: { size?: number }) => (
  <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
    <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
  </svg>
);

const AlertCircleIcon = ({ size = 16 }: { size?: number }) => (
  <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
    <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
  </svg>
);

const XCircleIcon = ({ size = 16 }: { size?: number }) => (
  <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
    <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11H7v-2h10v2z"/>
  </svg>
);

// ์•Œ๋ ˆ๋ฅด๊ธฐ ๋ ˆ๋ฒจ ์„ค์ •
interface AllergyLevelConfig {
  color: string;
  backgroundColor: string;
  text: string;
  icon: React.ReactNode;
  severity: number;
  description: string; // ์ ‘๊ทผ์„ฑ์„ ์œ„ํ•œ ์„ค๋ช… ์ถ”๊ฐ€
}

const ALLERGY_LEVEL_CONFIG: Record<AllergyLevel, AllergyLevelConfig> = {
  low: {
    color: COLORS.success.main,
    backgroundColor: COLORS.success.background,
    text: '์•Œ๋ ˆ๋ฅด๊ธฐ ๋‚ฎ์Œ',
    icon: <CheckCircleIcon size={16} />,
    severity: 1,
    description: '์•Œ๋ ˆ๋ฅด๊ธฐ ๋ฐ˜์‘์ด ๋‚ฎ์€ ์ˆ˜์ค€์ž…๋‹ˆ๋‹ค',
  },
  medium: {
    color: COLORS.warning.main,
    backgroundColor: COLORS.warning.background,
    text: '์•Œ๋ ˆ๋ฅด๊ธฐ ๋ณดํ†ต',
    icon: <AlertCircleIcon size={16} />,
    severity: 2,
    description: '์•Œ๋ ˆ๋ฅด๊ธฐ ๋ฐ˜์‘์ด ๋ณดํ†ต ์ˆ˜์ค€์ž…๋‹ˆ๋‹ค',
  },
  high: {
    color: COLORS.error.main,
    backgroundColor: COLORS.error.background,
    text: '์•Œ๋ ˆ๋ฅด๊ธฐ ๋†’์Œ',
    icon: <XCircleIcon size={16} />,
    severity: 3,
    description: '์•Œ๋ ˆ๋ฅด๊ธฐ ๋ฐ˜์‘์ด ๋†’์€ ์ˆ˜์ค€์ž…๋‹ˆ๋‹ค',
  },
} as const;

// Props ์ธํ„ฐํŽ˜์ด์Šค
interface AllergyItemProps {
  level: AllergyLevel;
  percentage: number;
  ingredients?: string[];
  showDetails?: boolean;
  className?: string;
  onToggleDetails?: () => void;
}

// ํผ์„ผํ‹ฐ์ง€ ํฌ๋งทํŒ… ํ•จ์ˆ˜
const formatPercentage = (value: number): string => {
  return `${Math.round(value)}%`;
};

// ๋ฉ”์ธ ์ปดํฌ๋„ŒํŠธ
export const AllergyItem: React.FC<AllergyItemProps> = ({
  level,
  percentage,
  ingredients = [],
  showDetails = false,
  className = '',
  onToggleDetails,
}) => {
  const config = ALLERGY_LEVEL_CONFIG[level];
  
  return (
    <div
      className={`allergy-item ${className}`}
      style={{
        backgroundColor: config.backgroundColor,
        border: `1px solid ${config.color}20`, // 20% ํˆฌ๋ช…๋„
        borderRadius: '12px',
        padding: '16px',
        marginBottom: '8px',
        transition: 'all 0.2s ease', // ๋ถ€๋“œ๋Ÿฌ์šด ์ „ํ™˜
      }}
      role="article"
      aria-label={`${config.description}, ${formatPercentage(percentage)}`}
    >
      <div 
        className="allergy-header"
        style={{
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'space-between',
          marginBottom: showDetails && ingredients.length > 0 ? '12px' : '0',
        }}
      >
        <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
          <span 
            className="allergy-icon" 
            style={{ color: config.color, display: 'flex', alignItems: 'center' }}
            aria-hidden="true"
          >
            {config.icon}
          </span>
          <span 
            className="allergy-text" 
            style={{ 
              color: config.color, 
              fontWeight: '600',
              fontSize: '14px',
            }}
          >
            {config.text}
          </span>
        </div>
        
        <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
          <span 
            className="allergy-percentage"
            style={{
              fontWeight: '700',
              fontSize: '16px',
              color: config.color,
            }}
          >
            {formatPercentage(percentage)}
          </span>
          
          {ingredients.length > 0 && (
            <button
              onClick={onToggleDetails}
              style={{
                background: 'none',
                border: 'none',
                cursor: 'pointer',
                padding: '4px',
                borderRadius: '4px',
                color: config.color,
                fontSize: '12px',
                transition: 'background-color 0.2s ease',
              }}
              aria-expanded={showDetails}
              aria-label={showDetails ? '์„ธ๋ถ€์‚ฌํ•ญ ์ˆจ๊ธฐ๊ธฐ' : '์„ธ๋ถ€์‚ฌํ•ญ ๋ณด๊ธฐ'}
            >
              {showDetails ? 'โ–ฒ' : 'โ–ผ'}
            </button>
          )}
        </div>
      </div>

      {/* ์ƒ‰์•ฝ ์‚ฌ์šฉ์ž๋ฅผ ์œ„ํ•œ ์ถ”๊ฐ€ ์‹œ๊ฐ์  ์š”์†Œ */}
      <div 
        style={{
          width: '100%',
          height: '4px',
          backgroundColor: `${config.color}30`,
          borderRadius: '2px',
          marginBottom: showDetails && ingredients.length > 0 ? '12px' : '0',
          overflow: 'hidden',
        }}
      >
        <div
          style={{
            width: `${percentage}%`,
            height: '100%',
            backgroundColor: config.color,
            borderRadius: '2px',
            transition: 'width 0.3s ease',
          }}
          role="progressbar"
          aria-valuenow={percentage}
          aria-valuemin={0}
          aria-valuemax={100}
          aria-label={`์•Œ๋ ˆ๋ฅด๊ธฐ ์ˆ˜์ค€ ${formatPercentage(percentage)}`}
        />
      </div>

      {showDetails && ingredients.length > 0 && (
        <div className="allergy-details">
          <h4 
            style={{ 
              margin: '0 0 8px 0',
              fontSize: '12px',
              color: COLORS.gray[600],
              fontWeight: '600',
            }}
          >
            ๊ด€๋ จ ์„ฑ๋ถ„ ({ingredients.length}๊ฐœ)
          </h4>
          <ul 
            className="allergy-ingredients"
            style={{
              margin: 0,
              padding: 0,
              listStyle: 'none',
              display: 'flex',
              flexWrap: 'wrap',
              gap: '6px',
            }}
          >
            {ingredients.map((ingredient, index) => (
              <li 
                key={`${ingredient}-${index}`}
                className="allergy-ingredient"
                style={{
                  backgroundColor: `${config.color}15`,
                  color: config.color,
                  padding: '4px 8px',
                  borderRadius: '12px',
                  fontSize: '12px',
                  fontWeight: '500',
                  border: `1px solid ${config.color}30`,
                }}
              >
                {ingredient}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
};

// ์•Œ๋ ˆ๋ฅด๊ธฐ ๋ชฉ๋ก ์ปดํฌ๋„ŒํŠธ
interface AllergyListProps {
  allergies: AllergyData[];
  title?: string;
}

export const AllergyList: React.FC<AllergyListProps> = ({ 
  allergies, 
  title = "์•Œ๋ ˆ๋ฅด๊ธฐ ๋ถ„์„ ๊ฒฐ๊ณผ" 
}) => {
  const [expandedItems, setExpandedItems] = React.useState<Set<number>>(new Set());

  const toggleDetails = (index: number) => {
    setExpandedItems(prev => {
      const newSet = new Set(prev);
      if (newSet.has(index)) {
        newSet.delete(index);
      } else {
        newSet.add(index);
      }
      return newSet;
    });
  };

  // ๋ฐ์ดํ„ฐ ์ •๋ ฌ (์‹ฌ๊ฐ๋„ ์ˆœ)
  const sortedAllergies = React.useMemo(() => {
    return [...allergies].sort((a, b) => {
      const configA = ALLERGY_LEVEL_CONFIG[a.level];
      const configB = ALLERGY_LEVEL_CONFIG[b.level];
      return configB.severity - configA.severity; // ์‹ฌ๊ฐํ•œ ๊ฒƒ๋ถ€ํ„ฐ
    });
  }, [allergies]);

  if (allergies.length === 0) {
    return (
      <div style={{ 
        textAlign: 'center', 
        padding: '40px 20px',
        color: COLORS.gray[500],
      }}>
        ์•Œ๋ ˆ๋ฅด๊ธฐ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
      </div>
    );
  }

  return (
    <div className="allergy-list" style={{ maxWidth: '600px', margin: '0 auto' }}>
      <h3 style={{ 
        fontSize: '18px', 
        fontWeight: '700', 
        marginBottom: '16px',
        color: COLORS.gray[600],
      }}>
        {title}
      </h3>
      
      {sortedAllergies.map((allergy, index) => (
        <AllergyItem
          key={index}
          level={allergy.level}
          percentage={allergy.percentage}
          ingredients={allergy.ingredients}
          showDetails={expandedItems.has(index)}
          onToggleDetails={() => toggleDetails(index)}
        />
      ))}
    </div>
  );
};

// ์‚ฌ์šฉ ์˜ˆ์‹œ
export const AllergyExample = () => {
  const sampleData: AllergyData[] = [
    {
      level: 'high',
      percentage: 85,
      ingredients: ['๋‹ญ๊ณ ๊ธฐ', '์Œ€', '์˜ฅ์ˆ˜์ˆ˜'],
    },
    {
      level: 'medium',
      percentage: 45,
      ingredients: ['์—ฐ์–ด', '๊ณ ๊ตฌ๋งˆ'],
    },
    {
      level: 'low',
      percentage: 15,
      ingredients: ['๋‹น๊ทผ', '๋ธŒ๋กœ์ฝœ๋ฆฌ', '์‚ฌ๊ณผ'],
    },
  ];

  return <AllergyList allergies={sampleData} title="์šฐ๋ฆฌ ๊ฐ•์•„์ง€ ์•Œ๋ ˆ๋ฅด๊ธฐ ๋ถ„์„" />;
};

 

"์›”๋ณ„ ํ†ต๊ณ„๋ฅผ ๋ณด์—ฌ์ค„ ๋•Œ ๊ทธ๋ž˜ํ”„๋กœ ํ‘œํ˜„ํ•˜๋ฉด ๋” ์ง๊ด€์ ์ผ ๊ฒƒ ๊ฐ™์•„์š”"๋ผ๋Š” ๋””์ž์ด๋„ˆ๋‹˜์˜ ์ œ์•ˆ์— ์–ด๋А์ •๋„ ํ™”๋ฉด์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!

 

๋งˆ์น˜๋ฉฐ

์ง„ํ–‰ํ•˜๋ฉฐ ๊ณ ๋ฏผํ•œ ๋””ํ…Œ์ผ๋“ค์€ ํšŒ์‚ฌ์—์„  ์ด๋ฏธ ๋””์ž์ธ ์‹œ์Šคํ…œ์ด๋‚˜ ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ์— ๋ฐ˜์˜๋˜์–ด ์žˆ์–ด์„œ, ๋ณ„๋‹ค๋ฅธ ๊ณ ๋ฏผ ์—†์ด ๊ฐ€์ ธ๋‹ค ์“ฐ๊ธฐ๋งŒ ํ–ˆ๋˜ ๊ฒƒ๋“ค์ด์—ˆ์–ด์š”.

์ด๋ฒˆ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ๊ทธ๋Ÿฐ ์š”์†Œ๋“ค์„ ์ง์ ‘ ๊ธฐํšํ•˜๊ณ , ๋””์ž์ธ์— ๋…น์—ฌ๋‚ด๊ณ , ์ปดํฌ๋„ŒํŠธ๋กœ ๊ตฌํ˜„ํ•˜๋Š” ๊ณผ์ •์„ ํ•˜๋‚˜ํ•˜๋‚˜ ๊ฒฝํ—˜ํ•ด๋ณด๋‹ˆ, ๊ฒ‰์œผ๋กœ๋งŒ ์•Œ๊ณ  ์žˆ์—ˆ๋˜ UI ๊ตฌ์„ฑ ์š”์†Œ๋“ค์ด ์–ผ๋งˆ๋‚˜ ๋งŽ์€ ์˜๋„์™€ ๊ณ ๋ฏผ ์†์—์„œ ๋งŒ๋“ค์–ด์กŒ๋Š”์ง€๋ฅผ ์ƒˆ์‚ผ ๋А๋‚„ ์ˆ˜ ์žˆ์—ˆ์–ด์š”.

 

๋ฌด์—‡๋ณด๋‹ค ๊ทธ๋Ÿฐ ๊ณผ์ •์ด ์ƒ๊ฐ๋ณด๋‹ค ๊ฝค ์žฌ๋ฐŒ์—ˆ์Šต๋‹ˆ๋‹ค!

 

๊ธ€ ์ดˆ๋ฐ˜์— ์ด์•ผ๊ธฐํ–ˆ๋˜ ‘์ƒํƒœ’์— ๋Œ€ํ•œ ์ •์˜๋ฅผ ์Šคํ„ฐ๋””์›๋“ค๊ณผ ํ•จ๊ป˜ ๊ณ ๋ฏผํ•˜๊ณ  ์ฐพ์•„๋ณด๋˜ ์ค‘, ์•„๋ž˜์˜ ๋ธ”๋กœ๊ทธ ๊ธ€์„ ์ ‘ํ•˜๊ฒŒ ๋˜์—ˆ๊ณ  ํฐ ๋„์›€์„ ๋ฐ›์•˜์Šต๋‹ˆ๋‹ค. ๐Ÿ‘‰ The Sorry State of States - velog.io/@tap_kim

 

์ €ํฌ๊ฐ€ ๊ฒช๊ณ  ์žˆ๋˜ ๊ณ ๋ฏผ๊ณผ ๊ฐ™์€ ๋‚ด์šฉ์„ ๋ณด๋ฉฐ ์ •๋ง ๋ฐ˜๊ฐ€์› ๊ณ ,
๋•๋ถ„์— ๋ง‰์—ฐํ–ˆ๋˜ ๋ฌธ์ œ๋“ค์„ ์กฐ๊ธˆ ๋” ๋น ๋ฅด๊ฒŒ ์ •๋ฆฌํ•˜๊ณ , ๋ช…ํ™•ํ•œ ๊ธฐ์ค€์„ ์„ธ์šธ ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

 

 

๐Ÿ“ƒ ์ฐธ๊ณ  ๋ฌธํ—Œ  
(๋ฒˆ์—ญ)์ƒํƒœ๋“ค์˜ ์ฐธ๋‹ดํ•œ ์ƒํƒœ
๋กœ๊ทธ์ธ ๊ณผ์ •์˜ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€: ๊ฐœ์ธ์ •๋ณด ๋ณดํ˜ธ ๋ฐ ๋ณด์•ˆ

 

+ Recent posts