Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 15 additions & 4 deletions src/common/components/SelectButton/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import type { PropsWithChildren } from 'react';

import * as s from './style.css';
import type { TagType } from '@/libs/types/item';
import { Symbol } from '@/common/components/TagOptionBtn';

interface Props extends PropsWithChildren {
active: boolean;
onClick?: () => void;
selected?: TagType[];
}

const SelectButton = ({ children, active, onClick }: Props) => {
const SelectButton = ({ children, onClick, selected }: Props) => {
const isSelected = selected && selected.length > 0;
// TODO: 자연스럽게 바뀌게 애니메이션 추가
return (
<button className={s.Container({ active })} onClick={onClick}>
<button className={s.Container({ isSelected })} onClick={onClick}>
{children}
<span className={`mgc_down_line ${s.Icon({ direction: active ? 'up' : 'down' })}`} />
{isSelected ? (
<div className={s.SelectedIconContainer}>
{selected.map(type => (
<Symbol key={type} type={type} className={s.SelectedIcon} />
))}
</div>
) : (
<span className={`mgc_down_line ${s.Icon}`} />
)}
</button>
);
};
Expand Down
35 changes: 18 additions & 17 deletions src/common/components/SelectButton/style.css.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cva } from '@styled-system/css';
import { css, cva } from '@styled-system/css';

export const Container = cva({
base: {
Expand All @@ -17,7 +17,7 @@ export const Container = cva({
flexShrink: 0,
},
variants: {
active: {
isSelected: {
true: {
borderColor: 'main-54',
bgColor: 'main-26',
Expand All @@ -30,19 +30,20 @@ export const Container = cva({
},
});

export const Icon = cva({
base: {
color: '54',
fontSize: '0.875rem',
},
variants: {
direction: {
up: {
transform: 'rotate(180deg)',
},
down: {
transform: 'rotate(0deg)',
},
},
},
export const Icon = css({
color: '54',
fontSize: '0.875rem',
});

export const SelectedIconContainer = css({
display: 'flex',
alignItems: 'center',
gap: '0.125rem',
});

export const SelectedIcon = css({
fontSize: '0.75rem',
w: '0.75rem',
h: '0.75rem',
color: 'main',
});
13 changes: 9 additions & 4 deletions src/common/components/TagOptionBtn/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import * as s from './style.css';
import { COLOR_MAP } from '@/libs/constants/colorMap';
import { type TagType, TAG_TYPES_MAP, isIconType, isColorType } from '@/libs/types/item';
import { TagIcon } from '@/features/post/components/TagIcon';
import { cx } from '@styled-system/css';

const Symbol = ({ type }: { type: TagType }) => {
interface SymbolProps {
type: TagType;
className?: string;
}
export const Symbol = ({ type, className }: SymbolProps) => {
// 아이콘
if (isIconType(type)) return <TagIcon type={type} className={s.leftIcon} />;
if (isIconType(type)) return <TagIcon type={type} className={cx(s.leftIcon, className)} />;

// 컬러
if (isColorType(type)) {
Expand All @@ -15,7 +20,7 @@ const Symbol = ({ type }: { type: TagType }) => {
type === 'COLOR_OTHER'
? { backgroundImage: 'linear-gradient(180deg, #FF6164 0%, #FF9462 28.85%, #FFBE62 67.31%, #8BFF61 100%)' }
: { backgroundColor: colorValue };
return <span className={s.colorPalette} style={paletteStyle} />;
return <span className={cx(s.colorPalette, className)} style={paletteStyle} />;
}

// 아무 타입에도 속하지 않는 경우 : 아무 심볼 없음!
Expand All @@ -36,7 +41,7 @@ const TagOptionBtn = ({ isSelected = false, onClick, type }: Props) => {
<button className={s.Container({ isSelected })} onClick={onClick}>
<div className={s.row}>
<div className={s.iconLabel}>
<Symbol type={type} />
<Symbol type={type} className={s.SymbolStyle} />
{label}
</div>
<div className={`${rightIconClass} ${s.rightIcon({ isSelected })}`} />
Expand Down
8 changes: 6 additions & 2 deletions src/common/components/TagOptionBtn/style.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ export const Container = cva({
});
// style.css.ts
export const colorPalette = css({
width: '1.25rem',
height: '1.25rem',
borderRadius: '9999px',
padding: '1px',
borderColor: 'white',
Expand Down Expand Up @@ -82,3 +80,9 @@ export const rightIcon = cva({
},
},
});

export const SymbolStyle = css({
fontSize: '1.25rem',
w: '1.25rem',
h: '1.25rem',
});
3 changes: 2 additions & 1 deletion src/features/home/components/Filter/FilterContents/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as s from './style.css';
import { FilterTypeToList, type FilterType } from '@/features/home/types';
import { type TagType } from '@/libs/types/item';
import TagOptionBtn from '@/common/components/TagOptionBtn';
import PriceFilter from '@/features/home/components/Filter/PriceFilter';

interface Props {
type: FilterType;
Expand All @@ -25,7 +26,7 @@ const FilterContents = ({ type }: Props) => {
return (
<div className={s.Container}>
{type === 'price' ? (
<div>아 만들기 개귀찮네</div>
<PriceFilter />
) : (
<div key={type} className={s.TagOptionButtonWrapper}>
{FilterTypeToList[type].map(t => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { css } from '@styled-system/css';

export const Container = css({
width: 'full',
pt: '1.3125rem',
height: 'full',
mt: '1.3125rem',
height: '19rem',
});

export const TagOptionButtonWrapper = css({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import * as s from './style.css';
import { MAX_PRICE } from '@/libs/constants';
import { useSearchParams } from 'react-router';

interface Props {
active: boolean;
}
const CustomPriceInput = ({ active }: Props) => {
const [searchParams, setSearchParams] = useSearchParams();

const defaultStartPrice = searchParams.get('start-price') ? Number(searchParams.get('start-price')) : NaN;
const defaultEndPrice = searchParams.get('end-price') ? Number(searchParams.get('end-price')) : NaN;

const [startPrice, setStartPrice] = useState<number>(defaultStartPrice);
const [endPrice, setEndPrice] = useState<number>(defaultEndPrice);

useEffect(() => {
if (!active) {
setStartPrice(NaN);
setEndPrice(NaN);
}
}, [active]);

const handlePriceChange = (e: React.ChangeEvent<HTMLInputElement>, type: 'start' | 'end') => {
const handlePrice = type === 'start' ? setStartPrice : setEndPrice;
const value = e.target.value;
// 빈 문자열이면 NaN으로 설정
if (value === '') {
handlePrice(NaN);
return;
}
// 숫자가 아니거나 0으로 시작하는 경우 (단, '0' 자체는 허용) 무시
if (!/^\d+$/.test(value) || (value.length > 1 && value.startsWith('0'))) {
Comment thread
halionaz marked this conversation as resolved.
return;
}
const numValue = Number(value);
if (numValue > MAX_PRICE) return;
handlePrice(numValue);
};

const submitPrice = () => {
if (isNaN(startPrice) || isNaN(endPrice)) return;
if (startPrice > endPrice) {
alert('시작 가격이 끝 가격보다 클 수 없습니다.');
return;
}
Comment on lines +40 to +47

Copilot AI Aug 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using alert() is not a good user experience practice. Consider using a toast notification or inline error message instead.

Suggested change
};
const submitPrice = () => {
if (isNaN(startPrice) || isNaN(endPrice)) return;
if (startPrice > endPrice) {
alert('시작 가격이 끝 가격보다 클 수 없습니다.');
return;
}
setErrorMessage('');
};
const submitPrice = () => {
if (isNaN(startPrice) || isNaN(endPrice)) {
setErrorMessage('');
return;
}
if (startPrice > endPrice) {
setErrorMessage('시작 가격이 끝 가격보다 클 수 없습니다.');
return;
}
setErrorMessage('');

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토스트 알림 같은것도 하나 있으면 좋긴 하겠다

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토스트 알림 인정 정보 수정했을 때 띄워주면 좋을 듯

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

블루베리쨈 토스트 먹고 싶다

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전남친 토스트?

setSearchParams(prev => {
prev.set('start-price', startPrice.toString());
prev.set('end-price', endPrice.toString());
return prev;
});
};

return (
<div className={s.CustomPriceInputContainer({ active })}>
<div className={s.CustomPriceInput}>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={isNaN(startPrice) ? '' : startPrice.toString()}
onChange={e => handlePriceChange(e, 'start')}
disabled={!active}
onBlur={submitPrice}
/>
<span>원</span>
</div>
<span>-</span>
<div className={s.CustomPriceInput}>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={isNaN(endPrice) ? '' : endPrice.toString()}
onChange={e => handlePriceChange(e, 'end')}
disabled={!active}
onBlur={submitPrice}
/>
<span>원</span>
</div>
</div>
);
};
export default CustomPriceInput;
19 changes: 19 additions & 0 deletions src/features/home/components/Filter/PriceFilter/PriceButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as s from './style.css';

interface Props {
isSelected: boolean;
onClick: () => void;
label: string;
}
const PriceButton = ({ isSelected, onClick, label }: Props) => {
return (
<button className={s.PriceTagButton({ isSelected })} onClick={onClick}>
{label}
<div className={s.RightIcon({ isSelected })}>
<div />
</div>
</button>
);
};

export default PriceButton;
103 changes: 103 additions & 0 deletions src/features/home/components/Filter/PriceFilter/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { MAX_PRICE } from '@/libs/constants';
import { useSearchParams } from 'react-router';

import * as s from './style.css';
import { useMemo } from 'react';
import PriceButton from '@/features/home/components/Filter/PriceFilter/PriceButton';
import CustomPriceInput from '@/features/home/components/Filter/PriceFilter/CustomPriceInput';

const PriceOptions = [
{
label: '1만원 미만',
start: 0,
end: 9999,
},
{
label: '1만원 - 2만원',
start: 10000,
end: 19999,
},
{
label: '2만원 - 3만원',
start: 20000,
end: 29999,
},
{
label: '3만원 - 4만원',
start: 30000,
end: 39999,
},
{
label: '4만원 - 5만원',
start: 40000,
end: 49999,
},
{ label: '5만원 이상', start: 50000, end: MAX_PRICE },
Comment thread
halionaz marked this conversation as resolved.
];

const PriceFilter = () => {
const [searchParams, setSearchParams] = useSearchParams();
const startPrice = Number(searchParams.get('start-price') || 0);
const endPrice = Number(searchParams.get('end-price') || MAX_PRICE);

const resetPrice = () => {
setSearchParams(prev => {
prev.delete('start-price');
prev.delete('end-price');
return prev;
});
};

const handlePriceChange = (start: number, end: number) => {
setSearchParams(prev => {
prev.set('start-price', start.toString());
prev.set('end-price', end.toString());
return prev;
});
};

const isCustomPrice = useMemo(() => {
return (
searchParams.get('start-price') !== null &&
searchParams.get('end-price') !== null &&
!PriceOptions.some(option => option.start === startPrice && option.end === endPrice)
);
}, [searchParams, startPrice, endPrice]);

return (
<div className={s.Container}>
<div className={s.PriceWrapper}>
{PriceOptions.map(option => {
const isSelected = option.start === startPrice && option.end === endPrice;
return (
<PriceButton
key={option.start}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
resetPrice();
return;
}
handlePriceChange(option.start, option.end);
}}
label={option.label}
/>
);
})}
<PriceButton
isSelected={isCustomPrice}
onClick={() => {
if (isCustomPrice) {
resetPrice();
return;
}
handlePriceChange(0, MAX_PRICE);
}}
label="직접 입력"
/>
</div>
<CustomPriceInput active={isCustomPrice} />
</div>
);
};
export default PriceFilter;
Loading