Skip to content

Commit 6ef70c8

Browse files
authored
Merge branch 'main' into haram
2 parents 2dcb22b + d815287 commit 6ef70c8

19 files changed

Lines changed: 1049 additions & 706 deletions

README.md

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,146 @@
1-
# 23-5-team1-web
2-
와플스튜디오 23.5기 1조 web
1+
# 행샤(Hangsha): 교내 행사 캘린더 서비스
2+
3+
[<img width="250" height="115" alt="image" src="https://github.com/user-attachments/assets/046b4994-764e-40fd-9684-3d7d9dc11d76" />
4+
](https://hangsha.site/
5+
)
6+
7+
## 기획 의도
8+
[서울대 비교과관리시스템](https://extra.snu.ac.kr/)에 대해 알고 계셨나요?
9+
교내 곳곳에서 열리는 특강, 공연, 공모전, 토론회 등 각종 비교과 행사 정보를 볼 수 있는 사이트입니다.
10+
그러나 정보 접근을 위해 서울대 로그인 및 인증이 필요하며, 사용성이 떨어짐에 따라 실제 서울대 학생들의 이용 정도 또한 낮다는 한계가 있습니다.
11+
12+
이에 따라 저희는 **행샤**를 통해, **직관적인 캘린더 UI****개인화**된 행사 확인 서비스를 제공하고자 합니다.
13+
14+
## 핵심 기능
15+
### 1. 캘린더
16+
- 보다 더 직관적인 월/주/일 별 캘린더 UI
17+
- 행사 상세 정보 조회 및 실제 신청 링크 이동
18+
- 제목 및 다양한 조건 필터링을 통한 검색
19+
20+
### 2. 개인화
21+
- 소셜 로그인 + 아이디/비밀번호 로그인
22+
- 회원가입 온보딩 시 관심 행사 카테고리 설정
23+
- 제외 키워드 설정 (해당 키워드 포함 행사는 캘린더에서 숨김)
24+
- 관심 행사 북마크 및 마이페이지에서 북마크 모아보기
25+
26+
### 3. 시간표
27+
- 내 시간표 생성 및 저장
28+
- 주별 뷰에서 시간표와 겹치지 않는 행사 확인 가능
29+
- (확장 가능성) SNUTT 연동으로 시간표 자동 불러오기
30+
31+
### 4. 후기
32+
- 행사별 개인 후기(메모) 작성 가능
33+
- 태그로 작성한 메모 분류 가능
34+
35+
## 팀원
36+
| 이름 | Github ID | 분야 |
37+
|-|-|-|
38+
| 허서연(PM) | @h-seo-n | Frontend, PM, UI/UX Design |
39+
| 김하람 | @haram831 | Frontend, UI/UX Design |
40+
| 김도향 | @D-hyang | Backend |
41+
| 이승현 | @subir-sh | Backend, Data Scraping |
42+
| 정혜인 | @aystoe | Backend |
43+
44+
45+
## Tech Stack
46+
47+
| Category | Tools |
48+
|----------|-------|
49+
| Framework | React 19, TypeScript |
50+
| Routing | React Router DOM 7 |
51+
| Build | Vite 7 |
52+
| HTTP | Axios |
53+
| Date Handling | date-fns |
54+
| Linting & Formatting | Biome |
55+
| Unused Code Detection | Knip |
56+
| Deployment | AWS S3 + CloudFront |
57+
58+
<br />
59+
<hr />
60+
61+
## Getting Started
62+
63+
### Prerequisites
64+
65+
- **Node.js** ≥ 24 (see [CI config](.github/workflows/ci.yml))
66+
- **Yarn** package manager
67+
68+
### Installation
69+
70+
```bash
71+
git clone https://github.com/wafflestudio/23-5-team1-web.git
72+
cd 23-5-team1-web
73+
yarn install
74+
```
75+
76+
### Development
77+
78+
```bash
79+
yarn dev
80+
```
81+
82+
This starts the Vite dev server with hot module replacement. The dev server proxies `/api` requests to the backend automatically (configured in `vite.config.ts`).
83+
84+
### Environment Variables
85+
86+
Create a `.env.development` file (one is already included) or set these variables for production builds:
87+
88+
| Variable | Description |
89+
|----------|-------------|
90+
| `VITE_API_URL` | Base path for API requests (default: `/api/v1`) |
91+
| `VITE_KAKAO_REST_API_KEY` | Kakao OAuth API key |
92+
| `VITE_GOOGLE_CLIENT_ID` | Google OAuth client ID |
93+
| `VITE_NAVER_CLIENT_ID` | Naver OAuth client ID |
94+
| `VITE_KAKAO_REDIRECT_URI` | Kakao OAuth callback URL |
95+
| `VITE_GOOGLE_REDIRECT_URI` | Google OAuth callback URL |
96+
| `VITE_NAVER_REDIRECT_URI` | Naver OAuth callback URL |
97+
| `VITE_REST_REQUEST_URL` | Production API endpoint |
98+
99+
### Build for Production
100+
101+
```bash
102+
yarn build
103+
```
104+
105+
The output is written to `dist/` and can be served as static files.
106+
107+
## Project Structure
108+
109+
```
110+
src/
111+
├── api/ # API service layer (auth, events, users, timetables)
112+
├── contexts/ # React Context providers for global state
113+
├── pages/ # Page components (auth, calendar, timetable, search, etc.)
114+
├── router/ # Route definitions
115+
├── widgets/ # Reusable UI components
116+
├── util/ # Shared types, constants, and helpers
117+
└── styles/ # CSS modules
118+
```
119+
120+
## Available Scripts
121+
122+
| Command | Description |
123+
|---------|-------------|
124+
| `yarn dev` | Start dev server with hot reload |
125+
| `yarn build` | Type-check and build for production |
126+
| `yarn preview` | Preview the production build locally |
127+
| `yarn lint` | Run Biome linter |
128+
| `yarn check:format` | Check code formatting |
129+
| `yarn check:unused` | Detect unused exports with Knip |
130+
| `yarn check-all` | Run all checks (lint + unused code) |
131+
132+
## Contributing
133+
134+
1. Fork the repository
135+
2. Create a feature branch (`git checkout -b feature/my-feature`)
136+
3. Make your changes and ensure `yarn check-all` passes
137+
4. Open a pull request using the [PR template](.github/PULL_REQUEST_TEMPLATE.md)
138+
139+
## Getting Help
140+
141+
- Open an [issue](https://github.com/wafflestudio/23-5-team1-web/issues) using one of the provided [issue templates](.github/ISSUE_TEMPLATE)
142+
- Reach out to the team via [Waffle Studio](https://github.com/wafflestudio)
143+
144+
## Maintainers
145+
146+
Built and maintained by **Waffle Studio 23.5 Team 1**.

src/api/user.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,13 @@ export const removeBookmark = async (eventId: number) => {
5050

5151
// --- Interests ---
5252
export const getInterestCategories = async () => {
53-
const res = await api.get<Category[]>("/users/me/interest-categories");
54-
return res.data;
53+
const res = await api.get<{
54+
items: { category: Category; priority: number }[];
55+
}>("/users/me/interest-categories");
56+
const sortedCategories = res.data.items
57+
.sort((a, b) => a.priority - b.priority)
58+
.map((item) => item.category);
59+
return sortedCategories;
5560
};
5661

5762
export const addInterestCategories = async (

src/contexts/SearchContext.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface SearchContextType {
2020
setSize: Dispatch<SetStateAction<number>>;
2121
fetchSearchResult: (q: string, page: number, size: number) => Promise<void>;
2222
searchResults: SearchResult | null;
23+
emptySearchResults: ()=>void;
2324
// fetchSearchResult: (q: string, page: number, size: number) => Promise<void>;
2425
searchLoading: boolean;
2526
}
@@ -45,12 +46,16 @@ export const SearchProvider = ({ children }: { children: ReactNode }) => {
4546
setSearchResults(null);
4647
console.error("Error in getting search results", e);
4748
} finally {
48-
setSearchLoading(true);
49+
setSearchLoading(false);
4950
}
5051
},
5152
[],
5253
);
5354

55+
const emptySearchResults = () => {
56+
setSearchResults(null);
57+
}
58+
5459
// useEffect(() => {
5560
// fetchSearchResult(query, page, size);
5661
// }, [fetchSearchResult, query, page, size])
@@ -67,6 +72,7 @@ export const SearchProvider = ({ children }: { children: ReactNode }) => {
6772
searchResults,
6873
searchLoading,
6974
fetchSearchResult,
75+
emptySearchResults,
7076
}}
7177
>
7278
{children}

src/pages/MyPage.tsx

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,23 @@ import { useNavigate } from "react-router-dom";
77
import { useTimetable } from "@/contexts/TimetableContext";
88
import { useEffect, useState } from "react";
99
import { RiPencilFill } from "react-icons/ri";
10-
import { FaCamera } from "react-icons/fa6";
10+
import { FaCamera, FaStar } from "react-icons/fa6";
1111
import { IoMdDoneAll } from "react-icons/io";
12+
import { useUserData } from "@/contexts/UserDataContext";
13+
import Onboarding from "./auth/OnBoarding/Onboarding";
14+
import Modal from "@/widgets/Modal";
15+
import Loading from "@/widgets/Loading";
16+
import defaultProfile from "/assets/defaultProfile.png";
1217

13-
const ProfileCard = () => {
18+
const ProfileCard = ({ onClickInterest } : { onClickInterest: () => void }) => {
1419
const { user, updateUsername, setProfileImg } = useAuth();
20+
const { interestCategories } = useUserData();
1521
const { timetables } = useTimetable();
1622
const [profilePreviewUrl, setProfilePreviewUrl] = useState<string>(
17-
user ? user.profileImageUrl : "/assets/defaultProfile.png",
18-
);
23+
(user?.profileImageUrl && user.profileImageUrl !== "")
24+
? user.profileImageUrl
25+
: defaultProfile
26+
);
1927
const [imgFile, setImgFile] = useState<File | null>(null);
2028
const [, setIsDefaultProfile] = useState<boolean>(false);
2129
const [username, setUsername] = useState<string>(
@@ -25,7 +33,7 @@ const ProfileCard = () => {
2533
const navigate = useNavigate();
2634

2735
const handleImageError = () => {
28-
setProfilePreviewUrl("/assets/defaultProfile.png");
36+
setProfilePreviewUrl(defaultProfile);
2937
};
3038

3139
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -49,6 +57,13 @@ const ProfileCard = () => {
4957
setImgFile(null);
5058
};
5159

60+
useEffect(() => {
61+
if (user?.profileImageUrl && user.profileImageUrl.trim() !== "") {
62+
setProfilePreviewUrl(user.profileImageUrl);
63+
} else {
64+
setProfilePreviewUrl(defaultProfile);
65+
}
66+
}, [user?.profileImageUrl]);
5267
// profile image preview url cleanup (cleanup callback is executed before next effect / component unmount)
5368
useEffect(() => {
5469
return () => {
@@ -63,8 +78,8 @@ const ProfileCard = () => {
6378
<div className={styles.profileRow}>
6479
<div className={styles.profileImgWrapper}>
6580
<img
66-
src={profilePreviewUrl}
6781
alt="profile img"
82+
src={profilePreviewUrl}
6883
onError={handleImageError}
6984
/>
7085
{isEditmode && (
@@ -121,6 +136,26 @@ const ProfileCard = () => {
121136
/>
122137
)}
123138
</div>
139+
<button className={styles.preferenceCol} type='button' onClick={onClickInterest}>
140+
<div className={styles.preferenceHeader}>
141+
<FaStar size={24} color="#828282" style={{ marginRight: 12 }}/>
142+
<span>행사 보기 우선순위</span>
143+
</div>
144+
{(interestCategories && interestCategories.length > 0) ? (
145+
<div>
146+
<ul className={styles.preferenceChips}>
147+
{interestCategories.map((cat, idx) => (
148+
<li className={`${styles.preferenceChip} ${cat.groupId === 3 && styles.category} ${cat.groupId === 2 && styles.organization}`} key={cat.id}>
149+
{`${idx+1}순위: ${cat.name}`}
150+
</li>
151+
))}
152+
</ul>
153+
</div>
154+
) : (
155+
<span className={styles.notYetText}>클릭해서 우선순위로 확인할 행사를 설정해보세요!</span>
156+
)
157+
}
158+
</button>
124159
<button
125160
type="button"
126161
className={styles.timeTableBtn}
@@ -146,21 +181,42 @@ const ProfileCard = () => {
146181
};
147182

148183
const MyPage = () => {
149-
const { user } = useAuth();
184+
const { user, isLoading } = useAuth();
185+
const [isEditingInterest, setIsEditingInterest] = useState<boolean>(false);
186+
const navigate = useNavigate();
150187

151188
return (
152189
<div className={styles.main}>
153190
<Navigationbar />
154-
{user ? (
155-
<div className={styles.mypageContainer}>
156-
<ProfileCard />
157-
<div className={styles.widgetsWrapper}>
158-
<BookmarkWidget />
159-
<MemoWidget />
160-
</div>
161-
</div>
191+
{isLoading ?
192+
(
193+
<Loading />
194+
)
195+
: (
196+
isEditingInterest ? (
197+
<Onboarding isEditing={true} onFinishEdit={()=>setIsEditingInterest(false)} />
162198
) : (
163-
<div className={styles.notFound}>{/* Login modal */}</div>
199+
user ? (
200+
<div className={styles.mypageContainer}>
201+
<ProfileCard onClickInterest={() => setIsEditingInterest(()=>true)}/>
202+
<div className={styles.widgetsWrapper}>
203+
<BookmarkWidget />
204+
<MemoWidget />
205+
</div>
206+
</div>
207+
) : (
208+
<div className={styles.notFound}>
209+
<Modal
210+
content="마이페이지 이용을 위해서는 로그인을 해주세요."
211+
leftText="로그인"
212+
rightText="회원가입"
213+
onLeftClick={()=>navigate('/auth/login')}
214+
onRightClick={()=>navigate('/auth/signup')}
215+
onClose={null}
216+
/>
217+
</div>
218+
)
219+
)
164220
)}
165221
</div>
166222
);

src/pages/auth/OnBoarding/Onboarding.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { getCategoryGroups, getOrganizations } from "@api/event";
44
import { addInterestCategories } from "@api/user";
55
import type { Category } from "@types";
66
import styles from "@styles/Onboarding.module.css";
7+
import { useUserData } from "@/contexts/UserDataContext";
8+
9+
export default function Onboarding({ isEditing=false, onFinishEdit } : { isEditing?: boolean; onFinishEdit?: () => void }) {
10+
const { refreshUserData, interestCategories } = useUserData();
711

8-
export default function Onboarding() {
912
const [, setSearchParams] = useSearchParams();
1013

1114
const [categories, setCategories] = useState<Category[]>([]);
1215
const [selectedPreferences, setSelectedPreferences] = useState<Category[]>(
13-
[],
16+
interestCategories || [],
1417
);
1518
const [organizations, setOrganizations] = useState<Category[] | null>(null);
1619

@@ -65,6 +68,10 @@ export default function Onboarding() {
6568

6669
await addInterestCategories(items);
6770

71+
if (isEditing && onFinishEdit) {
72+
refreshUserData();
73+
onFinishEdit();
74+
}
6875
setSearchParams((prev) => {
6976
const next = new URLSearchParams(prev);
7077
next.set("step", "complete");
@@ -77,7 +84,7 @@ export default function Onboarding() {
7784
};
7885

7986
return (
80-
<div className={styles.onbPage}>
87+
<div className={`${styles.onbPage} ${isEditing ? styles.inMypage : ''}`}>
8188
<header className={styles.onbHeader}>
8289
<h1 className={styles.onbTitle}>관심사 설정</h1>
8390
<p className={styles.onbSubtitle}>

0 commit comments

Comments
 (0)