Skip to content
Open
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_API_URL=/
VITE_API_PROXY_TARGET=https://snuclear.wafflestudio.com
2 changes: 2 additions & 0 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Header } from '@widgets/header';
import { Footer } from '@widgets/footer';
import { SideMenu } from '@widgets/side-menu';
import { WarningModal } from '@shared/ui/Warning';
import { TourController } from '@features/tour';
import { AppRoutes } from './routes';
import './styles/global.css';

Expand Down Expand Up @@ -96,6 +97,7 @@ export default function App() {
setShowLoginWarningOpen={setShowLoginWarningOpen}
/>
)}
<TourController />
</div>
);
}
Expand Down
3 changes: 3 additions & 0 deletions src/features/course-search/ui/SearchCourseItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ export interface SearchCourseItemProps {
isSelected: boolean;
cartCount?: number;
onSelect: () => void;
checkDataTourId?: string;
}

export function SearchCourseItem({
course,
isSelected,
cartCount = 0,
onSelect,
checkDataTourId,
}: SearchCourseItemProps) {
const schedule = useMemo(
() => formatSchedule(course.placeAndTime, '시간 미정'),
Expand All @@ -35,6 +37,7 @@ export function SearchCourseItem({
<button
type="button"
className={`customCheckBtn ${isSelected ? 'checked' : ''}`}
data-tour-id={checkDataTourId}
>
<svg
className="checkIcon"
Expand Down
3 changes: 3 additions & 0 deletions src/features/registration-practice/ui/SortableCourseItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ export interface SortableCourseItemProps {
courseData: CourseData;
isSelected: boolean;
onSelect: () => void;
checkDataTourId?: string;
}

export function SortableCourseItem({
courseData,
isSelected,
onSelect,
checkDataTourId,
}: SortableCourseItemProps) {
const {
attributes,
Expand Down Expand Up @@ -58,6 +60,7 @@ export function SortableCourseItem({
type="button"
className={`customCheckBtn ${isSelected ? 'checked' : ''}`}
aria-label={`${c.course.courseTitle} 선택`}
data-tour-id={checkDataTourId}
>
<svg
viewBox="0 0 24 24"
Expand Down
10 changes: 10 additions & 0 deletions src/features/tour/api/tourApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { api } from '@shared/api/fetch';
import type { TourCompletionRequest, TourStatusResponse } from '../model/types';

export const getTourStatusApi = async () => {
return api.get<TourStatusResponse>('/api/tour/status');
};

export const completeTourApi = async (data: TourCompletionRequest) => {
return api.post<void>('/api/tour/completion', data);
};
9 changes: 9 additions & 0 deletions src/features/tour/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export { TourController } from './ui/TourController';
export { useTourStore } from './model/tourStore';
export { useIsDesktop } from './model/useIsDesktop';
export {
TOUR_KEYWORD,
tutorialCourse,
createTutorialPreEnroll,
} from './model/tutorialCourse';
export type { TourStep } from './model/types';
91 changes: 91 additions & 0 deletions src/features/tour/model/tourStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { create } from 'zustand';
import type { TourStep } from './types';
import { getTutorialOverQuotaCount } from './tutorialCourse';

interface TourStoreState {
isActive: boolean;
currentStep: TourStep | null;
publishedAt: string | null;
hasPrompted: boolean;
isManuallyStopped: boolean;
cartCount: number;
hasSelectedSearchCourse: boolean;
hasSelectedRegistrationCourse: boolean;
captchaInput: string;
isPracticeReady: boolean;
isRegistrationSucceeded: boolean;
start: (publishedAt?: string | null) => void;
startAtStep: (step: TourStep, publishedAt?: string | null) => void;
stop: () => void;
complete: () => void;
setStep: (step: TourStep) => void;
markPrompted: () => void;
setPublishedAt: (publishedAt: string | null) => void;
selectSearchCourse: () => void;
setCartOverQuota: () => void;
selectRegistrationCourse: () => void;
setCaptchaInput: (value: string) => void;
setPracticeReady: () => void;
markRegistrationSucceeded: () => void;
}

const initialRuntimeState = {
cartCount: 0,
hasSelectedSearchCourse: false,
hasSelectedRegistrationCourse: false,
captchaInput: '',
isPracticeReady: false,
isRegistrationSucceeded: false,
};

export const useTourStore = create<TourStoreState>((set) => ({
isActive: false,
currentStep: null,
publishedAt: null,
hasPrompted: false,
isManuallyStopped: false,
...initialRuntimeState,

start: (publishedAt) =>
set((state) => ({
...initialRuntimeState,
isActive: true,
currentStep: 'searchIntro',
publishedAt: publishedAt ?? state.publishedAt,
isManuallyStopped: false,
})),

startAtStep: (step, publishedAt) =>
set((state) => ({
...initialRuntimeState,
isActive: true,
currentStep: step,
publishedAt: publishedAt ?? state.publishedAt,
isManuallyStopped: false,
})),

stop: () =>
set({
isActive: false,
currentStep: null,
isManuallyStopped: true,
}),

complete: () =>
set({
isActive: false,
currentStep: null,
isRegistrationSucceeded: true,
isManuallyStopped: false,
}),

setStep: (step) => set({ currentStep: step }),
markPrompted: () => set({ hasPrompted: true }),
setPublishedAt: (publishedAt) => set({ publishedAt }),
selectSearchCourse: () => set({ hasSelectedSearchCourse: true }),
setCartOverQuota: () => set({ cartCount: getTutorialOverQuotaCount() }),
selectRegistrationCourse: () => set({ hasSelectedRegistrationCourse: true }),
setCaptchaInput: (value) => set({ captchaInput: value.replace(/\D/g, '').slice(0, 2) }),
setPracticeReady: () => set({ isPracticeReady: true }),
markRegistrationSucceeded: () => set({ isRegistrationSucceeded: true }),
}));
38 changes: 38 additions & 0 deletions src/features/tour/model/tutorialCourse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { CourseDetailResponse } from '@entities/course';
import type { PreEnrollCourseResponse } from '@features/cart-management';

export const TOUR_KEYWORD = '미시경제이론';
export const TOUR_PRE_ENROLL_ID = 9260720;

export const tutorialCourse: CourseDetailResponse = {
id: 260720,
year: 2026,
semester: 'SPRING',
classification: '전공선택',
college: '사회과학대학',
department: '경제학부',
academicCourse: '학사',
academicYear: '2학년',
courseNumber: 'M1314.000100',
lectureNumber: '001',
courseTitle: TOUR_KEYWORD,
credit: 3,
instructor: '경제학부',
placeAndTime: JSON.stringify({
time: '월(09:30~10:45)/수(09:30~10:45)',
place: '16동 349호',
}),
quota: 60,
freshmanQuota: 0,
registrationCount: 59,
};

export const createTutorialPreEnroll = (
cartCount: number
): PreEnrollCourseResponse => ({
preEnrollId: TOUR_PRE_ENROLL_ID,
course: tutorialCourse,
cartCount,
});

export const getTutorialOverQuotaCount = () => tutorialCourse.quota + 1;
26 changes: 26 additions & 0 deletions src/features/tour/model/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export type TourStep =
| 'searchIntro'
| 'searchType'
| 'searchCheck'
| 'searchAddToCart'
| 'searchGoToCart'
| 'cartCount'
| 'cartGoRegistration'
| 'registrationTimer'
| 'registrationStart'
| 'registrationWaiting'
| 'registrationCheck'
| 'registrationCaptcha'
| 'registrationSubmit'
| 'registrationGoHistory'
| 'historyResult';

export interface TourStatusResponse {
shouldShow: boolean;
publishedAt: string;
completedAt?: string | null;
}

export interface TourCompletionRequest {
publishedAt: string;
}
23 changes: 23 additions & 0 deletions src/features/tour/model/useIsDesktop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';

export function useIsDesktop() {
const [isDesktop, setIsDesktop] = useState(() =>
typeof window === 'undefined'
? true
: window.matchMedia('(min-width: 769px)').matches
);

useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 769px)');
const handleChange = () => setIsDesktop(mediaQuery.matches);

handleChange();
mediaQuery.addEventListener('change', handleChange);

return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, []);

return isDesktop;
}
Loading
Loading