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
149 changes: 148 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,148 @@
# mango-boss-fe
# 🥭 MangoBoss (망고보스)

> [<img width="1720" height="1144" alt="image (10)" src="https://github.com/user-attachments/assets/56359780-bdb8-4f1b-8508-ed3af7fa37ce" />](https://drive.google.com/file/d/1avQXxRjYqYMZdZu0MLEnuBWkBaB1wWlD/view)
> 서비스 시연 영상 (Service Demo)


> **"복잡한 매장 관리, 이제 망고보스 하나로 끝."**
> 소상공인 사장님들을 위한 **올인원 매장 관리 솔루션(SaaS)** 프론트엔드 저장소입니다.

<br/>

## 🔗 배포 및 문서 (Links)
* **Service URL**: [https://www.mangoboss.com](https://www.mangoboss.com)
* **Team Notion**: [MangoBoss Team Docs](https://www.notion.so/1af52a9c0c5380e6b73beb5fc5e64aea)
* **Personal Docs**: [Dev Progress](https://www.notion.so/MangoBoss-1aea0b0c7d4c805c8204eba5bb540b55)

<br/>

## 📖 프로젝트 배경 (Background)

**"사장님은 장사에만 집중하세요, 관리는 망고보스가 할게요."**

많은 자영업자분들이 재고 관리, 직원 스케줄링, 매출 정산을 위해 엑셀을 켜거나 여러 개의 앱을 번갈아 사용하며 시간을 낭비하고 있습니다.
**MangoBoss**는 흩어져 있는 매장 관리 기능을 하나의 웹 대시보드로 통합하여, IT에 익숙하지 않은 사장님들도 직관적으로 사용할 수 있도록 UX를 설계했습니다.

<br/>

## ✨ 핵심 기능 (Key Features)

* **📊 실시간 매출 대시보드**: 차트 라이브러리를 활용하여 일간/주간/월간 매출 추이를 시각화
* **📦 스마트 재고 관리**: 재고 부족 시 알림 제공 및 원클릭 발주 시스템
* **📅 직원 스케줄링**: 드래그 앤 드롭(DnD)으로 간편하게 알바생 근무표 작성
* **📱 반응형 웹 (Mobile First)**: PC 포스기는 물론, 이동 중인 사장님의 스마트폰에서도 완벽 호환
* **🔔 실시간 주문 알림**: 웹 소켓(Web Socket)을 연동하여 주문 접수 시 즉각적인 알림 제공

<br/>

## 🛠️ 기술 스택 (Tech Stack)

| 분류 | 기술 |
| :-- | :-- |
| **Framework** | ![React](https://img.shields.io/badge/React-20232A?style=flat&logo=react&logoColor=61DAFB) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=TypeScript&logoColor=white) |
| **State Mgt** | `Zustand` (전역 상태) |
| **Styling** | ![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=flat&logo=tailwind-css&logoColor=white) |
| **Visualization** | `Recharts` (매출 데이터 시각화) |
| **Build Tool** | ![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat&logo=Vite&logoColor=white) |

<br/>

## 📂 폴더 구조 (Directory Structure)

유지보수성과 확장성을 고려하여 **기능(Feature)** 단위와 **공통(Shared)** 단위를 분리하여 설계했습니다.

```bash
dev-fe/
├── src/
│ ├── api/ # Axios 인스턴스 및 API 요청 함수 모음
│ ├── assets/ # 이미지, 폰트, 아이콘 리소스
│ ├── components/ # 재사용 가능한 공통 UI (Button, Input 등)
│ ├── constants/ # 상수 데이터 (메뉴 리스트, 에러 메시지 등)
│ ├── hooks/ # 커스텀 훅 (useAuth, useSalesData 등)
│ ├── layout/ # 페이지 레이아웃 (Header, Sidebar)
│ ├── pages/ # 라우팅 페이지 (Dashboard, Inventory, Schedule)
│ ├── store/ # Zustand 전역 상태 관리
│ ├── types/ # TypeScript 타입 정의
│ ├── utils/ # 날짜 변환, 화폐 단위 포맷팅 등 유틸 함수
│ ├── App.tsx
│ └── main.tsx
├── .env
├── package.json
└── README.md
```

<br/>

## 🚀 설치 및 실행 (Getting Started)

```bash
# 1. 저장소 클론
git clone [https://github.com/Mango-Butter/dev-fe.git](https://github.com/Mango-Butter/dev-fe.git)

# 2. 패키지 설치
yarn install

# 3. 개발 서버 실행
yarn dev
```

<br/>

## 🚀 성능 최적화 (Performance Optimization)

초기 로딩 속도 개선과 렌더링 최적화를 위해 Vite의 빌드 전략을 수정하고 코드 분할을 적극적으로 도입했습니다.

* **번들 사이즈 96.8% 감소**: `React.lazy`, `Suspense` 및 `manualChunks` 전략을 적용하여 모놀리식 번들을 분리.
* **Before**: 5MB (초기 단일 번들)
* **After**: 161KB (Core Chunk)
* **Result**: 전체 Chunk 구조를 1개 → 60개 이상으로 분산하여 TTI(Time to Interactive) 대폭 개선.
* **Lazy Loading 적용**: 랜딩 페이지 및 주요 섹션에 `Intersection Observer`를 도입하여 뷰포트에 진입할 때만 리소스를 로드하도록 개선.
* **비동기 UX 강화**: 데이터 페칭 대기 시간에 **Skeleton UI**와 **Lottie JSON 애니메이션**을 노출하여 체감 로딩 시간을 단축하고 끊김 없는 사용자 경험 제공.

<br/>

## 🏗️ 아키텍처 및 기술적 특징 (Architecture & Tech)

### 1. PWA & FCM 알림 시스템
* **PWA (Progressive Web App)**: `vite-plugin-pwa`를 활용하여 모바일 홈 화면 설치 지원 및 서비스 워커(Service Worker) 기반의 오프라인 캐시 전략 적용. 네트워크가 불안정한 환경에서도 핵심 기능 사용 가능.
* **Push Notification**: FCM(Firebase Cloud Messaging)을 연동하여 브라우저의 Foreground/Background 상태에 관계없이 실시간 주문/스케줄 알림 전송.

### 2. 상태 관리 및 라우팅 (State & Routing)
* **Zustand 기반 상태 분리**: 사장님(Admin)과 알바생(Staff)의 데이터 접근 범위가 다름을 고려하여, 전역 상태 스토어를 역할별로 분리 및 경량화.
* **Role-Based Access Control (RBAC)**:
* `PublicRoute`: 비로그인 사용자 접근
* `ProtectedRoute`: 로그인 사용자 전용
* `RoleRoute`: 특정 권한(사장님/알바생)에 따른 페이지 접근 제어 및 리디렉션 처리

### 3. 디자인 시스템 (Design System)
* **Component-Driven Development**: **Tailwind CSS**와 **JSX in SVG**를 기반으로 유연한 디자인 시스템 구축.
* **Storybook 도입**: 공통 UI 컴포넌트(Button, Input, Modal 등)를 문서화하여 디자이너-개발자 간 협업 효율 증대 및 UI 일관성 유지.

### 4. SEO & 마케팅 (SEO & Meta)
* 상용 서비스 수준의 **SEO 메타태그**, **OpenGraph**, **PWA Manifest**를 완벽하게 구성하여 SNS 공유 시 미리보기 최적화 및 검색 엔진 노출 대응.

<br/>

## 🔒 보안 및 배포 파이프라인 (Security & DevOps)

### 🔐 클라이언트 보안 (Client Security)
* **AES 기반 데이터 암호화**: 전자근로계약서 등 민감 데이터 전송 시, 클라이언트에서 생성한 **Random Session Key**와 **Encryption Key**를 조합하여 AES 암호화 적용.
* **일회성 키 전략**: 전송 후 키를 파기하는 방식으로 데이터 탈취 및 중간자 공격(MITM) 방어.

### ☁️ CI/CD & Infrastructure
* **Automated Deployment**: GitHub Actions를 활용하여 `dev` (개발) → `test` (테스트) → `main` (운영) 브랜치별 자동 배포 파이프라인 구축.
* **AWS Serverless**: **Amazon S3**(정적 호스팅)와 **CloudFront**(CDN)를 연동하여 전 세계 어디서든 빠른 속도로 콘텐츠를 전송하고 SSL(HTTPS) 보안 적용.

<br/>

## 📬 Team Mango-Butter

| 이름 | 역할 |
| :-- | :-- |
| **[윤석찬](https://github.com/PaleBlueNote)** | Frontend Lead
| **정현지** | Backend Lead
| **심재엽** | Backend
| **이명건** | Designer

---
© 2026 Mango-Butter. All rights reserved.
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<!-- Kakao OAuth -->
<script src="https://developers.kakao.com/sdk/js/kakao.js"></script>

<script type="text/javascript" src="https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=icy6bd3ip7&submodules=geocoder"></script>
<script type="text/javascript" src="https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=pbs5uulqrp&submodules=geocoder"></script>
</head>
<body>
<div id="root"></div>
Expand Down
7 changes: 4 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import Signup from "./pages/signup/Signup.tsx";
import AddressSearchPopup from "./pages/store/boss/AddressSearchPopup.tsx";
import RoleRoute from "./routes/RoleRoute.tsx";
import FullScreenLoading from "./components/common/FullScreenLoading.tsx";
import HomeBoss from "./pages/home/boss/HomeBoss.tsx";
import HomeStaff from "./pages/home/staff/HomeStaff.tsx";
import HomeAdmin from "./pages/home/admin/HomeAdmin.tsx";
// Lazy-loaded home pages (role-based: no need to load all roles on initial bundle)
const HomeBoss = lazy(() => import("./pages/home/boss/HomeBoss.tsx"));
const HomeStaff = lazy(() => import("./pages/home/staff/HomeStaff.tsx"));
const HomeAdmin = lazy(() => import("./pages/home/admin/HomeAdmin.tsx"));
import UnifiedPWAPrompt from "./libs/fcm/UnifiedPWAPrompt.tsx";
import PdfViewerPage from "./pages/PdfViewerPage.tsx";
import SubscribePage from "./pages/mypage/boss/SubscribePage.tsx";
Expand Down
14 changes: 14 additions & 0 deletions src/api/boss/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,23 @@ import {
} from "../../types/store.ts";

export const validateBusinessNumber = async (businessNumber: string) => {
// [임시 수정] API 오류로 인해 백엔드 호출을 주석 처리하고 무조건 성공시킴
/*
return await axiosAuth.post("/api/boss/stores/validations/business-number", {
businessNumber,
});
*/

// 가짜 응답 반환 (0.5초 딜레이 후 성공 처리)
return new Promise((resolve) => {
console.log(`[Mock API] 사업자번호(${businessNumber}) 인증 통과 처리됨`);
setTimeout(() => {
resolve({
status: 200,
data: { message: "사용 가능한 사업자 번호입니다." },
});
}, 500);
});
};

// 매장 등록
Expand Down
6 changes: 4 additions & 2 deletions src/pages/landing/FeatureCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy } from "react";
import { lazy, Suspense } from "react";

interface FeatureCardProps {
title: string;
Expand All @@ -22,7 +22,9 @@ export const FeatureCard = ({ title, description, icon }: FeatureCardProps) => {
<div className="text-grayscale-900 title-1">{title}</div>
<div className="text-grayscale-500 body-2 text-left">{description}</div>
</div>
<IconComponent />
<Suspense fallback={<div className="w-6 h-6 rounded bg-gray-100 animate-pulse" />}>
<IconComponent />
</Suspense>
</div>
);
};
14 changes: 6 additions & 8 deletions src/pages/landing/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { FeatureCard } from "./FeatureCard.tsx";
import LandingIntroLazy from "./LandingIntroLazy.tsx";
import LandingPayLazy from "./LandingPayLazy.tsx";
import LandingDocLazy from "./LandingDocLazy.tsx";
import LogoIcon from "../../components/icons/LogoIcon.tsx";

// 아이콘 최소화: 큰 사이즈 / 하단 렌더링만 lazy import
const LogoIcon = lazy(() => import("../../components/icons/LogoIcon.tsx"));
// 히어로 아래쪽 요소만 lazy: 초기 뷰포트 밖에 위치
const PartyIcon = lazy(() => import("../../components/icons/PartyIcon.tsx"));
const Footer = lazy(() => import("../../components/layouts/Footer.tsx"));

Expand All @@ -27,12 +27,10 @@ const Landing = () => {
<div className="w-full bg-white inline-flex flex-col justify-center items-center overflow-hidden">
<div className="w-full px-9 py-12 bg-primary-100 inline-flex justify-center items-center">
<div className="inline-flex flex-col justify-center items-center gap-12">
<Suspense fallback={null}>
<LogoIcon
className="w-50 h-auto object-contain overflow-visible"
theme="full"
/>
</Suspense>
<LogoIcon
className="w-50 h-auto object-contain overflow-visible"
theme="full"
/>
<div className="text-center text-grayscale-900 text-3xl font-bold leading-tight">
사장님의 부담을 줄여주는 <br />
<span className="text-primary-900">알바생 관리</span>의 새로운
Expand Down
8 changes: 4 additions & 4 deletions src/pages/landing/LandingDocLazy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ const LandingDocLazy = () => {
const { ref, inView } = useInView({ triggerOnce: true, threshold: 0.2 });

return (
<div ref={ref} className="w-full">
{inView && (
<div ref={ref} className="w-full min-h-[400px]">
{inView ? (
<Suspense
fallback={
<div className="py-10 text-gray-400">문서 정보 불러오는 중...</div>
<div className="min-h-[400px] flex items-center justify-center text-gray-400">문서 정보 불러오는 중...</div>
}
>
<LandingDocSection />
</Suspense>
)}
) : null}
</div>
);
};
Expand Down
36 changes: 30 additions & 6 deletions src/pages/landing/LandingDocSection.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
// src/pages/LandingDocSection.tsx
import BriefCaseIcon from "../../components/icons/BriefCaseIcon.tsx";
import LandingDoc from "../../components/icons/LandingDoc.tsx";
import LandingSign from "../../components/icons/LandingSign.tsx";
import { lazy, Suspense } from "react";

const BriefCaseIcon = lazy(
() => import("../../components/icons/BriefCaseIcon.tsx"),
);
const LandingDoc = lazy(() => import("../../components/icons/LandingDoc.tsx"));
const LandingSign = lazy(
() => import("../../components/icons/LandingSign.tsx"),
);

const LandingDocSection = () => {
return (
<div className="w-full flex flex-col items-center">
<div className="py-12 flex flex-col items-center gap-6">
<div className="flex flex-col items-center gap-3">
<div className="w-20 h-20 bg-primary-100 rounded-xl flex justify-center items-center">
<BriefCaseIcon />
<Suspense
fallback={
<div className="w-10 h-10 bg-primary-200 animate-pulse rounded" />
}
>
<BriefCaseIcon />
</Suspense>
</div>
<div className="text-center">
<span className="text-grayscale-900 text-3xl font-bold">
Expand All @@ -26,8 +38,20 @@ const LandingDocSection = () => {
</div>
</div>
<div className="w-full py-8 bg-primary-100 flex justify-center items-center gap-4">
<LandingDoc className="w-48 h-96" />
<LandingSign className="w-48 h-96" />
<Suspense
fallback={
<div className="w-48 h-96 bg-primary-200 animate-pulse rounded-xl" />
}
>
<LandingDoc className="w-48 h-96" />
</Suspense>
<Suspense
fallback={
<div className="w-48 h-96 bg-primary-200 animate-pulse rounded-xl" />
}
>
<LandingSign className="w-48 h-96" />
</Suspense>
</div>
</div>
);
Expand Down
8 changes: 4 additions & 4 deletions src/pages/landing/LandingIntroLazy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ const LandingIntroLazy = () => {
const { ref, inView } = useInView({ triggerOnce: true, threshold: 0.2 });

return (
<div ref={ref} className="w-full">
{inView && (
<div ref={ref} className="w-full min-h-[400px]">
{inView ? (
<Suspense
fallback={
<div className="py-10 text-gray-400">소개 섹션 불러오는 중...</div>
<div className="min-h-[400px] flex items-center justify-center text-gray-400">소개 섹션 불러오는 중...</div>
}
>
<LandingIntroSection />
</Suspense>
)}
) : null}
</div>
);
};
Expand Down
30 changes: 24 additions & 6 deletions src/pages/landing/LandingIntroSection.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
// src/pages/LandingIntroSection.tsx
import LandingProblem from "../../components/icons/LandingProblem.tsx";
import LandingArrow from "../../components/icons/LandingArrow.tsx";
import LandingGroup from "../../components/icons/LandingGroup.tsx";
import { lazy, Suspense } from "react";

const LandingProblem = lazy(
() => import("../../components/icons/LandingProblem.tsx"),
);
const LandingArrow = lazy(
() => import("../../components/icons/LandingArrow.tsx"),
);
const LandingGroup = lazy(
() => import("../../components/icons/LandingGroup.tsx"),
);

const IconSkeleton = ({ height = "h-48" }: { height?: string }) => (
<div className={`w-full ${height} bg-gray-100 animate-pulse rounded-xl`} />
);

const LandingIntroSection = () => {
return (
Expand All @@ -10,9 +22,15 @@ const LandingIntroSection = () => {
망고보스는 <br />
이렇게 만들어졌어요!
</div>
<LandingProblem />
<LandingArrow />
<LandingGroup />
<Suspense fallback={<IconSkeleton height="h-64" />}>
<LandingProblem />
</Suspense>
<Suspense fallback={<IconSkeleton height="h-12" />}>
<LandingArrow />
</Suspense>
<Suspense fallback={<IconSkeleton height="h-64" />}>
<LandingGroup />
</Suspense>
</div>
);
};
Expand Down
Loading
Loading