diff --git a/.github/actions/notify-slack-deploy/action.yaml b/.github/actions/notify-slack-deploy/action.yaml new file mode 100644 index 00000000..068fd35a --- /dev/null +++ b/.github/actions/notify-slack-deploy/action.yaml @@ -0,0 +1,46 @@ +name: Notify Slack of deploy result +description: Send a Slack chat.postMessage reflecting the calling job's status. The status emoji ('성공 :tada:', '취소 :no_entry:', '실패 :rotating_light:') is auto-appended to the header — the caller supplies the prefix. + +inputs: + slack-token: + description: Slack bot token (xoxb-...) + required: true + slack-channel: + description: Slack channel ID + required: true + header-prefix: + description: Header text shown before the auto-appended status emoji (e.g. "frontend → infrastructure (dev)") + required: true + section-text: + description: Section body in mrkdwn. Caller can reference job.status / step outputs to vary by outcome. + required: false + default: GitHub Action 바로가기 + +runs: + using: composite + steps: + - name: Notify Slack + uses: slackapi/slack-github-action@v3 + with: + method: chat.postMessage + token: ${{ inputs.slack-token }} + payload: | + channel: "${{ inputs.slack-channel }}" + blocks: + - type: header + text: + type: plain_text + text: "${{ inputs.header-prefix }} ${{ job.status == 'success' && '성공 :tada:' || job.status == 'cancelled' && '취소 :no_entry:' || '실패 :rotating_light:' }}" + emoji: true + - type: section + text: + type: mrkdwn + text: "${{ inputs.section-text }}" + accessory: + type: button + text: + type: plain_text + text: "${{ github.run_id }}" + value: github_action + url: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + action_id: button-action diff --git a/.github/workflows/deploy-infra.yml b/.github/workflows/deploy-infra.yml new file mode 100644 index 00000000..243e6057 --- /dev/null +++ b/.github/workflows/deploy-infra.yml @@ -0,0 +1,150 @@ +name: Deploy frontend to infrastructure + +concurrency: + group: deploy-infra-${{ github.ref }}-${{ inputs.WORKFLOW_PHASE || 'dev' }} + cancel-in-progress: true + +on: + workflow_dispatch: + inputs: + WORKFLOW_PHASE: + description: "Phase to deploy" + required: true + default: dev + type: choice + options: + - dev + - prd + push: + branches: + - main + +permissions: + contents: read + +jobs: + config: + runs-on: ubuntu-latest + outputs: + phase: ${{ steps.set.outputs.phase }} + matrix: ${{ steps.set.outputs.matrix }} + keys: ${{ steps.set.outputs.keys }} + keys_display: ${{ steps.set.outputs.keys_display }} + env: + PHASE: ${{ github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev' }} + steps: + - id: set + run: | + set -euo pipefail + # Per-phase {key, app} list. `key` matches infra repo's vars.yaml + # `frontends.` and is also the upload-artifact name suffix. + # `mode` is derived from PHASE at build time, not encoded here. + case "$PHASE" in + dev) + INCLUDE='[ + {"key":"admin-dev","app":"pyconkr-admin"}, + {"key":"participant-dev","app":"pyconkr-participant-portal"}, + {"key":"pyconkr-dev","app":"pyconkr-2026"} + ]' ;; + prd) + INCLUDE='[ + {"key":"admin","app":"pyconkr-admin"}, + {"key":"participant","app":"pyconkr-participant-portal"}, + {"key":"pyconkr-2026","app":"pyconkr-2026"}, + {"key":"pyconkr-2025","app":"pyconkr-2025"} + ]' ;; + esac + MATRIX=$(jq -nc --argjson inc "$INCLUDE" '{include: $inc}') + KEYS=$(echo "$MATRIX" | jq -c '[.include[].key]') + KEYS_DISPLAY=$(echo "$MATRIX" | jq -r '[.include[].key] | join(" & ")') + { + echo "phase=$PHASE" + echo "matrix=$MATRIX" + echo "keys=$KEYS" + echo "keys_display=$KEYS_DISPLAY" + } >> "$GITHUB_OUTPUT" + + build: + needs: config + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.config.outputs.matrix) }} + env: + VITE_MODE: ${{ needs.config.outputs.phase == 'prd' && 'production' || 'development' }} + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: "pnpm-lock.yaml" + + - run: pnpm install --frozen-lockfile + + - name: Build ${{ matrix.app }} (${{ env.VITE_MODE }}) + run: pnpm build:@apps/${{ matrix.app }} --mode ${{ env.VITE_MODE }} + + # Artifact name = `frontend-` (infra repo vars.yaml `frontends.`). + - uses: actions/upload-artifact@v7 + with: + name: frontend-${{ matrix.key }} + path: apps/${{ matrix.app }}/dist/ + retention-days: 7 + if-no-files-found: error + include-hidden-files: true + + trigger: + if: always() + needs: [config, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: .github/actions + + - name: Generate App token (cross-repo dispatch) + id: app-token + if: needs.build.result == 'success' + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }} + private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ secrets.INFRA_REPO_NAME }} + + - name: Dispatch deploy-frontend to infrastructure + if: needs.build.result == 'success' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + INFRA_REPO: ${{ github.repository_owner }}/${{ secrets.INFRA_REPO_NAME }} + PHASE: ${{ needs.config.outputs.phase }} + KEYS: ${{ needs.config.outputs.keys }} + run: | + set -euo pipefail + # Build payload via jq so `keys` stays a real JSON array (gh api -f + # would coerce values to strings). + jq -n \ + --arg phase "$PHASE" \ + --arg src "${{ github.repository }}" \ + --arg run "${{ github.run_id }}" \ + --argjson keys "$KEYS" \ + '{event_type:"deploy-frontend",client_payload:{phase:$phase,source_repo:$src,source_run_id:$run,keys:$keys}}' \ + | gh api "repos/$INFRA_REPO/dispatches" --input - + + # If build failed/cancelled, dispatch is skipped — fail this job so + # job.status reflects the real outcome for the slack step. + - name: Propagate upstream status + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 + + - uses: ./.github/actions/notify-slack-deploy + if: always() + with: + slack-token: ${{ secrets.SLACK_BOT_TOKEN }} + slack-channel: ${{ vars.SLACK_DEPLOYMENT_ALERT_CHANNEL }} + header-prefix: "frontend → infrastructure (${{ needs.config.outputs.phase || '?' }})" + section-text: "${{ needs.config.outputs.keys_display || '?' }} 빌드" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 11574372..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: Deploy to AWS S3 - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ github.event.inputs.WORKFLOW_PHASE || 'dev' }} - cancel-in-progress: true - -on: - workflow_dispatch: - inputs: - WORKFLOW_PHASE: - description: "Environment to deploy to" - required: true - default: dev - type: choice - options: - - dev - - prod - push: - branches: - - "main" - -permissions: - id-token : write - contents: read - -jobs: - build: - runs-on: ubuntu-latest - - env: - API_STAGE: ${{ github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev' }} - BUMP_RULE: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && '--mode development' || '' }} - AWS_S3_PYCONKR_FRONTEND_BUCKET: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && secrets.AWS_S3_PYCONKR_FRONTEND_BUCKET_DEV || secrets.AWS_S3_PYCONKR_FRONTEND_BUCKET_PROD }} - AWS_S3_PYCONKR_ADMIN_BUCKET: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && secrets.AWS_S3_PYCONKR_ADMIN_BUCKET_DEV || secrets.AWS_S3_PYCONKR_ADMIN_BUCKET_PROD }} - AWS_CLOUDFRONT_PYCONKR_FRONTEND_DISTRIBUTION_ID: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && secrets.AWS_CLOUDFRONT_PYCONKR_FRONTEND_DISTRIBUTION_ID_DEV || secrets.AWS_CLOUDFRONT_PYCONKR_FRONTEND_DISTRIBUTION_ID_PROD }} - AWS_CLOUDFRONT_PYCONKR_ADMIN_DISTRIBUTION_ID: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && secrets.AWS_CLOUDFRONT_PYCONKR_ADMIN_DISTRIBUTION_ID_DEV || secrets.AWS_CLOUDFRONT_PYCONKR_ADMIN_DISTRIBUTION_ID_PROD }} - - strategy: - matrix: - application: [pyconkr, pyconkr-admin] - include: - - application: pyconkr - aws_s3_bucket_key: AWS_S3_PYCONKR_FRONTEND_BUCKET - aws_cloudfront_distribution_key: AWS_CLOUDFRONT_PYCONKR_FRONTEND_DISTRIBUTION_ID - - application: pyconkr-admin - aws_s3_bucket_key: AWS_S3_PYCONKR_ADMIN_BUCKET - aws_cloudfront_distribution_key: AWS_CLOUDFRONT_PYCONKR_ADMIN_DISTRIBUTION_ID - - steps: - - uses: actions/checkout@master - - - uses: aws-actions/configure-aws-credentials@master - with: - role-session-name: ${{ github.run_id }} - role-to-assume: ${{ secrets.AWS_FRONTEND_DEPLOYMENT_ROLE_ARN }} - aws-region: ${{ vars.AWS_REGION }} - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - cache-dependency-path: 'pnpm-lock.yaml' - - - name: Get current repo name - id: info - run: echo "::set-output name=repository_name::$(echo ${{ github.repository }} | sed -e 's/${{ github.repository_owner }}\///')" - - - name: Install project dependencies - run: pnpm install --frozen-lockfile - - - run: mkdir -p dist - - - run: pnpm build:@apps/${{ matrix.application }} ${{ env.BUMP_RULE }} && cp -r apps/${{ matrix.application }}/dist/* dist/ - - - run: aws s3 cp --recursive ./dist s3://${{ env[matrix.aws_s3_bucket_key] }}/ - - - run: aws cloudfront create-invalidation --distribution-id ${{ env[matrix.aws_cloudfront_distribution_key] }} --paths "/*" - - # Notify to Slack (Success) - - name: Notify deployment to Slack - if: failure() || cancelled() - uses: slackapi/slack-github-action@v1.26.0 - with: - channel-id: ${{ vars.SLACK_DEPLOYMENT_ALERT_CHANNEL }} - payload: | - { - "blocks": [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": "${{ steps.info.outputs.repository_name }} ${{ matrix.application }} (${{ env.API_STAGE }}) 배포 실패 :rotating_light: (${{ job.status }})", - "emoji": true - } - }, - { - "type": "section", - "text": {"type": "mrkdwn", "text": "GitHub Action 바로가기"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "${{ github.run_id }}"}, - "value": "github_action", - "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", - "action_id": "button-action" - } - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - - # Notify to Slack (Failure) - - name: Notify deployment to Slack - uses: slackapi/slack-github-action@v1.26.0 - with: - channel-id: ${{ vars.SLACK_DEPLOYMENT_ALERT_CHANNEL }} - payload: | - { - "blocks": [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": "${{ steps.info.outputs.repository_name }} ${{ matrix.application }} (${{ env.API_STAGE }}) 배포 성공 :tada:", - "emoji": true - } - }, - { - "type": "section", - "text": {"type": "mrkdwn", "text": "GitHub Action 바로가기"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "${{ github.run_id }}"}, - "value": "github_action", - "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", - "action_id": "button-action" - } - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore index a547bf36..17bc032a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# TypeScript incremental build cache +*.tsbuildinfo + # Editor directories and files .vscode/* !.vscode/extensions.json @@ -22,3 +25,10 @@ dist-ssr *.njsproj *.sln *.sw? +.claude + +# MDX Components information file +**/mdx-components.json + +# Scratch files +tmp diff --git a/README.md b/README.md index 40ede56e..e023324d 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,77 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default tseslint.config({ - extends: [ - // Remove ...tseslint.configs.recommended and replace with this - ...tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - ...tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - ...tseslint.configs.stylisticTypeChecked, - ], - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) +# PyCon Korea Frontend (2025 - ) + +2025년 이후부터 PyCon Korea 사이트들의 모노레포입니다. + +## 구성 + +- `apps/pyconkr-2025` — 2025 행사 사이트 +- `apps/pyconkr-2026` — 2026 행사 사이트 +- `apps/pyconkr-admin` — 관리자 페이지 +- `apps/pyconkr-participant-portal` — 참가자 포털 +- `packages/common`, `packages/shop` — 앱들이 공유하는 코드 +- `dotenv/` — 모든 앱이 공유하는 환경변수 파일 + +## 요구 사항 + +- Node.js 22+ +- pnpm (`package.json`의 `packageManager` 필드 버전. `corepack enable`로 자동 설치 가능) +- 첫 실행 시 mkcert가 로컬 CA를 시스템 신뢰 저장소에 자동 설치합니다 (관리자 비밀번호 요구될 수 있음) + +## 설치 + +```bash +pnpm install ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default tseslint.config({ - plugins: { - // Add the react-x and react-dom plugins - 'react-x': reactX, - 'react-dom': reactDom, - }, - rules: { - // other rules... - // Enable its recommended typescript rules - ...reactX.configs['recommended-typescript'].rules, - ...reactDom.configs.recommended.rules, - }, -}) +## 환경변수 + +기본값은 `dotenv/.env.development`(원격 dev 백엔드)와 `dotenv/.env.production`에 들어 있고, 그대로 둬도 dev는 동작합니다. + +| 키 | 설명 | +|---|---| +| `VITE_PYCONKR_BACKEND_API_DOMAIN` | 백엔드 API 도메인. dev 서버에서는 vite proxy(`/v1`, `/api`)의 target으로 쓰이고, prod 빌드에서는 브라우저가 직접 호출합니다. | +| `VITE_PYCONKR_BACKEND_CSRF_COOKIE_NAME` | 백엔드 CSRF 토큰 쿠키 이름. 환경(prod / dev / local)별로 prefix가 달라서 분리되어 있습니다. | +| `VITE_PYCONKR_FRONTEND_DOMAIN` | 프론트엔드의 외부 도메인. 관리자 페이지의 외부 링크 생성 등에 사용됩니다. | +| `VITE_PYCONKR_SHOP_IMP_ACCOUNT_ID` | PortOne(아임포트) 가맹점 식별자. 결제 모듈 초기화에 사용됩니다. | + +로컬에서 값을 덮어쓰고 싶다면 **`dotenv/.env.development.local`**(gitignored)을 만들어 사용하세요. 예: 로컬에서 직접 띄운 백엔드를 쓰고 싶을 때 — + +```bash +VITE_PYCONKR_BACKEND_API_DOMAIN=http://localhost:8000 +VITE_PYCONKR_BACKEND_CSRF_COOKIE_NAME=LOCAL_PYCONKR_BACKEND_csrftoken ``` + +## 개발 서버 실행 + +각 앱은 `https://localhost:`로 뜹니다. 포트는 vite가 비어 있는 것을 자동 선택 (보통 5173부터). + +```bash +pnpm dev:@apps/pyconkr-2025 +pnpm dev:@apps/pyconkr-2026 +pnpm dev:@apps/pyconkr-admin +pnpm dev:@apps/pyconkr-participant-portal +``` + +백엔드 호출은 vite proxy(`/v1`, `/api`)로 forward되므로 별도 `/etc/hosts` 설정은 필요 없습니다. CORS와 쿠키(`Secure`, `Domain` 속성)도 proxy 단에서 자동으로 처리합니다. + +## 빌드 / 프리뷰 + +```bash +pnpm build:@apps/pyconkr-2025 +pnpm preview:@apps/pyconkr-2025 +``` + +다른 앱도 동일한 패턴 (`build:@apps/`, `preview:@apps/`). + +## 린트 / 포맷 + +```bash +pnpm lint +pnpm format # 자동 수정 +pnpm format:check # 검사만 +``` + +## 자주 마주치는 이슈 + +- **TS 에러가 IDE에 떠 있는데 빌드/dev는 잘 됨**: 패키지 버전 변경 후 `node_modules/.pnpm/`에 orphan이 남아 IDE TS 서비스가 헷갈리는 경우입니다. `rm -rf node_modules && pnpm install` 후 VS Code의 TypeScript 서버를 재시작하세요. +- **mkcert 인증서 오류**: `mkcert -install`을 한 번 직접 실행해보세요. diff --git a/apps/pyconkr/index.html b/apps/pyconkr-2025/index.html similarity index 100% rename from apps/pyconkr/index.html rename to apps/pyconkr-2025/index.html diff --git a/apps/pyconkr/package.json b/apps/pyconkr-2025/package.json similarity index 89% rename from apps/pyconkr/package.json rename to apps/pyconkr-2025/package.json index 88bd428c..8e646334 100644 --- a/apps/pyconkr/package.json +++ b/apps/pyconkr-2025/package.json @@ -1,5 +1,5 @@ { - "name": "@apps/pyconkr", + "name": "@apps/pyconkr-2025", "dependencies": { "@frontend/common": "workspace:*", "@frontend/shop": "workspace:*" diff --git a/apps/pyconkr/public/favicon-180.png b/apps/pyconkr-2025/public/favicon-180.png similarity index 100% rename from apps/pyconkr/public/favicon-180.png rename to apps/pyconkr-2025/public/favicon-180.png diff --git a/apps/pyconkr/public/favicon-192.png b/apps/pyconkr-2025/public/favicon-192.png similarity index 100% rename from apps/pyconkr/public/favicon-192.png rename to apps/pyconkr-2025/public/favicon-192.png diff --git a/apps/pyconkr/public/favicon-512.png b/apps/pyconkr-2025/public/favicon-512.png similarity index 100% rename from apps/pyconkr/public/favicon-512.png rename to apps/pyconkr-2025/public/favicon-512.png diff --git a/apps/pyconkr/public/favicon.ico b/apps/pyconkr-2025/public/favicon.ico similarity index 100% rename from apps/pyconkr/public/favicon.ico rename to apps/pyconkr-2025/public/favicon.ico diff --git a/apps/pyconkr/public/favicon.svg b/apps/pyconkr-2025/public/favicon.svg similarity index 100% rename from apps/pyconkr/public/favicon.svg rename to apps/pyconkr-2025/public/favicon.svg diff --git a/apps/pyconkr/public/site.webmanifest b/apps/pyconkr-2025/public/site.webmanifest similarity index 100% rename from apps/pyconkr/public/site.webmanifest rename to apps/pyconkr-2025/public/site.webmanifest diff --git a/apps/pyconkr-2025/src/App.tsx b/apps/pyconkr-2025/src/App.tsx new file mode 100644 index 00000000..46df35dc --- /dev/null +++ b/apps/pyconkr-2025/src/App.tsx @@ -0,0 +1,44 @@ +import { useBackendClient, useFlattenSiteMapQuery, useSponsorQuery } from "@frontend/common/hooks/useAPI"; +import { NestedSiteMapSchema } from "@frontend/common/schemas/backendAPI"; +import { buildNestedSiteMap } from "@frontend/common/utils"; +import { FC, useEffect } from "react"; +import { Outlet, ScrollRestoration, useLocation } from "react-router-dom"; +import { isEmpty, isNullish } from "remeda"; + +import { EVENT_NAME } from "./consts"; +import { useAppContext } from "./contexts/app_context"; + +export const App: FC = () => { + const backendAPIClient = useBackendClient(); + const { data: sponsorTiers } = useSponsorQuery(backendAPIClient, { event: EVENT_NAME }); + const { data: flatSiteMap } = useFlattenSiteMapQuery(backendAPIClient); + const siteMapNode = buildNestedSiteMap(flatSiteMap)?.[""]; + + const location = useLocation(); + const { setAppContext, language } = useAppContext(); + + useEffect(() => { + (async () => { + const currentRouteCodes = ["", ...location.pathname.split("/").filter((code) => !isEmpty(code))]; + const currentSiteMapDepth: (NestedSiteMapSchema | undefined)[] = [siteMapNode]; + + for (const routeCode of currentRouteCodes.splice(1)) { + const childrenMap = currentSiteMapDepth + .at(-1) + ?.children?.reduce((acc, child) => ({ ...acc, [child.route_code]: child }), {} as Record); + currentSiteMapDepth.push(childrenMap?.[routeCode]); + if (isNullish(currentSiteMapDepth.at(-1))) break; + } + + setAppContext((ps) => ({ ...ps, siteMapNode, sponsorTiers, currentSiteMapDepth })); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location, language, flatSiteMap, sponsorTiers]); + + return ( + <> + + + + ); +}; diff --git a/apps/pyconkr-2025/src/assets/pyconkr2025_logo.png b/apps/pyconkr-2025/src/assets/pyconkr2025_logo.png new file mode 100755 index 00000000..a4b1f567 Binary files /dev/null and b/apps/pyconkr-2025/src/assets/pyconkr2025_logo.png differ diff --git a/apps/pyconkr/src/assets/sponsorExample.svg b/apps/pyconkr-2025/src/assets/sponsorExample.svg similarity index 100% rename from apps/pyconkr/src/assets/sponsorExample.svg rename to apps/pyconkr-2025/src/assets/sponsorExample.svg diff --git a/apps/pyconkr/src/assets/thirdparty/flickr.svg b/apps/pyconkr-2025/src/assets/thirdparty/flickr.svg similarity index 100% rename from apps/pyconkr/src/assets/thirdparty/flickr.svg rename to apps/pyconkr-2025/src/assets/thirdparty/flickr.svg diff --git a/apps/pyconkr/src/components/layout/BreadCrumb/index.tsx b/apps/pyconkr-2025/src/components/layout/BreadCrumb/index.tsx similarity index 82% rename from apps/pyconkr/src/components/layout/BreadCrumb/index.tsx rename to apps/pyconkr-2025/src/components/layout/BreadCrumb/index.tsx index 464b7af9..caa71985 100644 --- a/apps/pyconkr/src/components/layout/BreadCrumb/index.tsx +++ b/apps/pyconkr-2025/src/components/layout/BreadCrumb/index.tsx @@ -1,23 +1,21 @@ +import { NestedSiteMapSchema } from "@frontend/common/schemas/backendAPI"; import { Stack, styled } from "@mui/material"; -import * as React from "react"; +import { FC } from "react"; import { Link } from "react-router-dom"; -import * as R from "remeda"; - -import BackendAPISchemas from "../../../../../../packages/common/src/schemas/backendAPI"; - +import { isNonNullish } from "remeda"; type BreadCrumbPropType = { title: string; - parentSiteMaps: (BackendAPISchemas.NestedSiteMapSchema | undefined)[]; + parentSiteMaps: (NestedSiteMapSchema | undefined)[]; }; -export const BreadCrumb: React.FC = ({ title, parentSiteMaps }) => { +export const BreadCrumb: FC = ({ title, parentSiteMaps }) => { let route = "/"; return ( {parentSiteMaps .slice(1, -1) - .filter((routeInfo) => R.isNonNullish(routeInfo)) + .filter((routeInfo) => isNonNullish(routeInfo)) .map(({ route_code, name }, index) => { route += `${route_code}/`; return ( diff --git a/apps/pyconkr/src/components/layout/CartBadgeButton/index.tsx b/apps/pyconkr-2025/src/components/layout/CartBadgeButton/index.tsx similarity index 58% rename from apps/pyconkr/src/components/layout/CartBadgeButton/index.tsx rename to apps/pyconkr-2025/src/components/layout/CartBadgeButton/index.tsx index c18e20c6..6755b535 100644 --- a/apps/pyconkr/src/components/layout/CartBadgeButton/index.tsx +++ b/apps/pyconkr-2025/src/components/layout/CartBadgeButton/index.tsx @@ -1,40 +1,39 @@ -import * as Shop from "@frontend/shop"; +import { useCart, useShopClient } from "@frontend/shop/hooks"; import { ShoppingCart } from "@mui/icons-material"; import { Badge, badgeClasses, IconButton, styled } from "@mui/material"; import { ErrorBoundary, Suspense } from "@suspensive/react"; -import * as React from "react"; -import { useNavigate } from "react-router-dom"; +import { FC } from "react"; +import { Link as RouterLink } from "react-router-dom"; type InnerCartBadgeButtonPropType = { loading?: boolean; count?: number; }; +// `as typeof IconButton`으로 styled가 잃어버린 polymorphic 타입(component/to)을 복원한다. const ColoredIconButton = styled(IconButton)(({ theme }) => ({ color: theme.palette.primary.nonFocus, "&:hover": { color: theme.palette.primary.dark }, "&:active": { color: theme.palette.primary.main }, transition: "color 0.4s ease, background-color 0.4s ease", -})); +})) as typeof IconButton; const InnerCartBadge = styled(Badge)({ [`& .${badgeClasses.badge}`]: { top: "-12px", right: "-3px" } }); -const InnerCartBadgeButton: React.FC = ({ loading, count }) => { - const navigate = useNavigate(); - +const InnerCartBadgeButton: FC = ({ loading, count }) => { return ( - navigate("/store/cart")}> + {count !== undefined && count > 0 && } ); }; -export const CartBadgeButton: React.FC = Suspense.with( +export const CartBadgeButton: FC = Suspense.with( { fallback: }, ErrorBoundary.with({ fallback: }, () => { - const shopAPIClient = Shop.Hooks.useShopClient(); - const { data: cart } = Shop.Hooks.useCart(shopAPIClient); - return ; + const shopAPIClient = useShopClient(); + const { data: cart } = useCart(shopAPIClient); + return ; }) ); diff --git a/apps/pyconkr-2025/src/components/layout/Footer/Mobile/MobileFooter.tsx b/apps/pyconkr-2025/src/components/layout/Footer/Mobile/MobileFooter.tsx new file mode 100644 index 00000000..d32cfe0d --- /dev/null +++ b/apps/pyconkr-2025/src/components/layout/Footer/Mobile/MobileFooter.tsx @@ -0,0 +1,188 @@ +import styled from "@emotion/styled"; +import { useEmail } from "@frontend/common/hooks/useEmail"; +import { Article, Email, Facebook, GitHub, Instagram, LinkedIn, X, YouTube } from "@mui/icons-material"; +import { FC } from "react"; + +import FlickrIcon from "@apps/pyconkr-2025/assets/thirdparty/flickr.svg?react"; +import { useAppContext } from "@apps/pyconkr-2025/contexts/app_context"; + +interface IconItem { + icon: FC<{ width?: number; height?: number }>; + alt: string; + href: string; +} + +const defaultIcons: IconItem[] = [ + { + icon: Facebook, + alt: "facebook", + href: "https://www.facebook.com/pyconkorea/", + }, + { + icon: YouTube, + alt: "YouTube", + href: "https://www.youtube.com/c/PyConKRtube", + }, + { icon: X, alt: "X", href: "https://x.com/PyConKR" }, + { icon: GitHub, alt: "github", href: "https://github.com/pythonkr" }, + { + icon: Instagram, + alt: "Instagram", + href: "https://www.instagram.com/pycon_korea/", + }, + { + icon: LinkedIn, + alt: "LinkedIn", + href: "https://www.linkedin.com/company/pyconkorea/", + }, + { icon: Article, alt: "blog", href: "https://blog.pycon.kr/" }, + { + icon: FlickrIcon, + alt: "Flickr", + href: "https://www.flickr.com/photos/126829363@N08/", + }, +]; + +export default function MobileFooter() { + const { sendEmail } = useEmail(); + const { language } = useAppContext(); + + const title = language === "ko" ? "Weave with Python, 파이콘 한국 2025" : "Weave with Python, Pycon KR 2025"; + const committeeTitle = + language === "ko" + ? "파이콘 한국 2025는 파이콘 한국 준비위원회가 만들고 있습니다" + : "PyCon Korea 2025 is organized by the PyCon Korea Organizing Committee"; + const djangoTitle = language === "ko" ? "파이썬 웹 프레임워크 Django로 만들었습니다" : "Built with the Django web framework for Python"; + + const links = [ + { + text: language === "ko" ? "파이콘 한국 행동 강령(CoC)" : "PyCon Korea Code of Conduct", + href: "https://pythonkr.github.io/pycon-code-of-conduct/ko/coc/a_intent_and_purpose.html", + }, + { + text: language === "ko" ? "서비스 이용 약관" : "Terms of Service", + href: "/about/terms-of-service", + }, + { + text: language === "ko" ? "개인 정보 처리 방침" : "Privacy Policy", + href: "/about/privacy-policy", + }, + ]; + + return ( + + + +
+ +
+ +
+ +
+
+ + {links.map((link, index) => ( + + + {link.text} + + {index < links.length - 1 && |} + + ))} + + + + + {defaultIcons.map((icon) => ( + + + ))} + +
+
+ ); +} + +const FooterContainer = styled.footer` + background: linear-gradient(to bottom, #ffffff 0%, #e4fdff 25%, #92c9cc 50%, #5cadb3 75%, #095a5f 100%); + color: ${({ theme }) => theme.palette.common.white}; + font-size: 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + max-height: 16rem; + padding: 5rem 0 1rem 0; +`; + +const FooterContent = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; +`; + +const FooterBoldText = styled.text` + font-weight: 600; +`; + +const FooterNormalText = styled.text` + font-weight: 400; +`; + +const FooterSlogan = styled.div` + text-align: center; +`; + +const FooterLinkSlogan = styled.div` + display: flex; + gap: 0.3rem; +`; + +const FooterLinks = styled.div` + display: flex; + align-items: center; + gap: 0.3rem; +`; + +const FooterIcons = styled.div` + display: flex; + align-items: center; + gap: 9px; +`; + +const Link = styled.a` + color: ${({ theme }) => theme.palette.common.white}; + text-decoration: none; + &:hover { + text-decoration: underline; + } +`; + +const Separator = styled.span` + color: ${({ theme }) => theme.palette.common.white}; + opacity: 0.5; + margin: 0.05rem 0; +`; + +const IconLink = styled.a` + display: flex; + align-items: center; + justify-content: center; + + cursor: pointer; + + &:hover { + opacity: 0.8; + } + + img { + width: 20px; + height: 20px; + } +`; diff --git a/apps/pyconkr-2025/src/components/layout/Footer/index.tsx b/apps/pyconkr-2025/src/components/layout/Footer/index.tsx new file mode 100644 index 00000000..6d8f337a --- /dev/null +++ b/apps/pyconkr-2025/src/components/layout/Footer/index.tsx @@ -0,0 +1,240 @@ +import styled from "@emotion/styled"; +import { useEmail } from "@frontend/common/hooks/useEmail"; +import { Article, Email, Facebook, GitHub, Instagram, LinkedIn, OpenInNew, X, YouTube } from "@mui/icons-material"; +import { Button, useMediaQuery, useTheme } from "@mui/material"; +import { FC, Fragment } from "react"; + +import FlickrIcon from "@apps/pyconkr-2025/assets/thirdparty/flickr.svg?react"; +import { useAppContext } from "@apps/pyconkr-2025/contexts/app_context"; + +import MobileFooter from "./Mobile/MobileFooter"; + +interface IconItem { + icon: FC<{ width?: number; height?: number }>; + alt: string; + href: string; +} + +const defaultIcons: IconItem[] = [ + { + icon: Facebook, + alt: "facebook", + href: "https://www.facebook.com/pyconkorea/", + }, + { + icon: YouTube, + alt: "YouTube", + href: "https://www.youtube.com/c/PyConKRtube", + }, + { icon: X, alt: "X", href: "https://x.com/PyConKR" }, + { icon: GitHub, alt: "github", href: "https://github.com/pythonkr" }, + { + icon: Instagram, + alt: "Instagram", + href: "https://www.instagram.com/pycon_korea/", + }, + { + icon: LinkedIn, + alt: "LinkedIn", + href: "https://www.linkedin.com/company/pyconkorea/", + }, + { icon: Article, alt: "blog", href: "https://blog.pycon.kr/" }, + { + icon: FlickrIcon, + alt: "Flickr", + href: "https://www.flickr.com/photos/126829363@N08/", + }, +]; + +const Bar: FC = () =>
|
; + +export default function Footer() { + const { sendEmail } = useEmail(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + + const { language } = useAppContext(); + + const corpPasamoStr = language === "ko" ? "사단법인 파이썬사용자모임" : "Python Korea"; + const corpAddressStr = + language === "ko" ? "서울특별시 강남구 강남대로84길 24-4" : "24-4, Gangnam-daero 84-gil, Gangnam-gu, Seoul, Republic of Korea"; + const corpRepresentatorStr = language === "ko" ? "대표자명 : 배권한" : "Representator : Kwon-Han Bae"; + const corpPhoneStr = + language === "ko" + ? "대표 전화 번호 : 031-261-2203, 010-5298-6622, 010-8259-3013 (문자)" + : "Phone Number : 031-261-2203, 010-5298-6622, 010-8259-3013 (SMS)"; + const corpCompanyNumberStr = language === "ko" ? "사업자 등록 번호 : 338-82-00046" : "Business Registration Number : 338-82-00046"; + const corpCheckBtnStr = language === "ko" ? "사업자 정보 확인" : "Check Business Registration Information"; + const corpMailOrderSalesRegistrationNumberStr = + language === "ko" ? "통신 판매 번호 : 2023-서울강남-03501" : "Mail Order Sales Registration Number : 2023-SEOUL-GANGNAM-03501"; + const hostingProviderStr = language === "ko" ? "호스팅 제공자 : (주) 스마일서브 (iwinv)" : "Hosting Provider : SMILESERV Co., Ltd. (iwinv)"; + const contractEmailStr = language === "ko" ? "문의: " : "Contact: "; + const copyrightStr = language === "ko" ? "© 2025, 사단법인 파이썬사용자모임, All rights reserved." : "© 2025, Python Korea, All rights reserved."; + + const links = [ + { + text: language === "ko" ? "파이콘 한국 행동 강령(CoC)" : "PyCon Korea Code of Conduct", + href: "https://pythonkr.github.io/pycon-code-of-conduct/ko/coc/a_intent_and_purpose.html", + }, + { + text: language === "ko" ? "서비스 이용 약관" : "Terms of Service", + href: "/about/terms-of-service", + }, + { + text: language === "ko" ? "개인 정보 처리 방침" : "Privacy Policy", + href: "/about/privacy-policy", + }, + ]; + + if (isMobile) { + return ; + } else { + return ( + + + + {corpPasamoStr} +
+ {corpAddressStr} + + {corpRepresentatorStr} + + {corpPhoneStr} + + {corpCompanyNumberStr} + + + +
+ {corpMailOrderSalesRegistrationNumberStr} + + {hostingProviderStr} + + {contractEmailStr} + pyconkr@pycon.kr +
+ + {links.map((link, index) => ( + + + {link.text} + + {index < links.length - 1 && |} + + ))} + + + + + {defaultIcons.map((icon) => ( + + + ))} + + {copyrightStr} +
+
+ ); + } +} + +const FooterContainer = styled.footer` + background-color: ${({ theme }) => theme.palette.primary.main}; + color: ${({ theme }) => theme.palette.common.white}; + font-size: 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + max-height: 16rem; + padding: 1rem 0; +`; + +const FooterContent = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; +`; + +const FooterText = styled.div` + padding: 0 2rem; + margin: 0.1rem; + + font-size: 9pt; + + a > button { + margin-left: 0.25rem; + padding: 0.05rem 0.25rem; + font-size: 8pt; + color: ${({ theme }) => theme.palette.common.white}; + border-color: ${({ theme }) => theme.palette.common.white}; + + gap: 0.25rem; + + & span { + margin-left: -2px; + margin-right: 0; + + & svg { + font-size: 12pt !important; + } + } + } + + strong { + font-size: 12pt; + } +`; + +const FooterSlogan = styled.div` + text-align: center; +`; + +const FooterLinks = styled.div` + display: flex; + align-items: center; + gap: 0.625rem; +`; + +const FooterIcons = styled.div` + display: flex; + align-items: center; + gap: 9px; +`; + +const Link = styled.a` + color: ${({ theme }) => theme.palette.common.white}; + text-decoration: none; + &:hover { + text-decoration: underline; + } +`; + +const Separator = styled.span` + color: ${({ theme }) => theme.palette.common.white}; + opacity: 0.5; +`; + +const IconLink = styled.a` + display: flex; + align-items: center; + justify-content: center; + + cursor: pointer; + + &:hover { + opacity: 0.8; + } + + img { + width: 20px; + height: 20px; + } +`; diff --git a/apps/pyconkr-2025/src/components/layout/Header/Mobile/HamburgerButton.tsx b/apps/pyconkr-2025/src/components/layout/Header/Mobile/HamburgerButton.tsx new file mode 100644 index 00000000..1f811bde --- /dev/null +++ b/apps/pyconkr-2025/src/components/layout/Header/Mobile/HamburgerButton.tsx @@ -0,0 +1,45 @@ +import { IconButton, styled } from "@mui/material"; +import { FC } from "react"; +interface HamburgerButtonProps { + isOpen: boolean; + onClick: () => void; + isMainPath?: boolean; +} + +export const HamburgerButton: FC = ({ isOpen, onClick, isMainPath = true }) => { + return ( + + + + + + + + ); +}; + +const StyledIconButton = styled(IconButton)<{ isMainPath: boolean }>(({ theme, isMainPath }) => ({ + padding: 0, + width: 26, + height: 18, + color: isMainPath ? theme.palette.mobileHeader.main.text : theme.palette.mobileHeader.sub.text, +})); + +const HamburgerIcon = styled("div")<{ isOpen: boolean; isMainPath: boolean }>(({ isOpen, theme, isMainPath }) => ({ + width: 26, + height: 18, + position: "relative", + cursor: "pointer", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + + "& span": { + display: "block", + height: isOpen ? 3 : 2, + width: "100%", + backgroundColor: isMainPath ? theme.palette.mobileHeader.main.text : theme.palette.mobileHeader.sub.text, + borderRadius: 1, + transition: "height 0.3s ease", + }, +})); diff --git a/apps/pyconkr-2025/src/components/layout/Header/Mobile/MobileHeader.tsx b/apps/pyconkr-2025/src/components/layout/Header/Mobile/MobileHeader.tsx new file mode 100644 index 00000000..34b6ca7c --- /dev/null +++ b/apps/pyconkr-2025/src/components/layout/Header/Mobile/MobileHeader.tsx @@ -0,0 +1,91 @@ +import { PythonKorea } from "@frontend/common/components"; +import { Box, Stack, styled, Typography } from "@mui/material"; +import { FC, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; + +import { useAppContext } from "@apps/pyconkr-2025/contexts/app_context"; + +import { HamburgerButton } from "./HamburgerButton"; +import { MobileLanguageToggle } from "./MobileLanguageToggle"; +import { MobileNavigation } from "./MobileNavigation"; + +interface MobileHeaderProps { + isNavigationOpen?: boolean; + onToggleNavigation?: () => void; +} + +export const MobileHeader: FC = ({ isNavigationOpen = false, onToggleNavigation }) => { + const { siteMapNode } = useAppContext(); + const location = useLocation(); + const [internalNavigationOpen, setInternalNavigationOpen] = useState(false); + + const navigationOpen = onToggleNavigation ? isNavigationOpen : internalNavigationOpen; + const toggleNavigation = onToggleNavigation || (() => setInternalNavigationOpen(!internalNavigationOpen)); + + const isMainPath = location.pathname === "/"; + + return ( + <> + + + + + + + + + 파이콘 한국 2025 + + + + + + + + + + toggleNavigation()} siteMapNode={siteMapNode} /> + + ); +}; + +const MobileHeaderContainer = styled("header")<{ isOpen: boolean; isMainPath: boolean }>(({ theme, isOpen, isMainPath }) => ({ + position: isMainPath ? "fixed" : "sticky", + top: 0, + left: 0, + right: 0, + + display: isOpen ? "none" : "flex", + alignItems: "center", + justifyContent: "space-between", + + width: "100%", + height: 60, + + padding: "15px 23px", + + backgroundColor: isMainPath ? "rgba(182, 216, 215, 0.1)" : "#B6D8D7", + backdropFilter: isMainPath ? "blur(8px)" : "none", + WebkitBackdropFilter: isMainPath ? "blur(8px)" : "none", + color: isMainPath ? "white" : "rgba(18, 109, 127, 0.6)", + + zIndex: isMainPath ? theme.zIndex.appBar + 100000 : theme.zIndex.appBar, +})); + +const LeftContent = styled(Box)({ + display: "flex", + alignItems: "center", + gap: 17, +}); + +const LogoAndTextContainer = styled(Box)({ + display: "flex", + alignItems: "center", +}); diff --git a/apps/pyconkr-2025/src/components/layout/Header/Mobile/MobileLanguageToggle.tsx b/apps/pyconkr-2025/src/components/layout/Header/Mobile/MobileLanguageToggle.tsx new file mode 100644 index 00000000..3d695288 --- /dev/null +++ b/apps/pyconkr-2025/src/components/layout/Header/Mobile/MobileLanguageToggle.tsx @@ -0,0 +1,74 @@ +import { ButtonBase, styled } from "@mui/material"; +import { FC } from "react"; + +import { LOCAL_STORAGE_LANGUAGE_KEY } from "@apps/pyconkr-2025/consts/local_stroage"; +import { useAppContext } from "@apps/pyconkr-2025/contexts/app_context"; + +interface MobileLanguageToggleProps { + isMainPath?: boolean; +} + +export const MobileLanguageToggle: FC = ({ isMainPath = true }) => { + const { language, setAppContext } = useAppContext(); + + const toggleLanguage = () => { + const newLanguage = language === "ko" ? "en" : "ko"; + localStorage.setItem(LOCAL_STORAGE_LANGUAGE_KEY, newLanguage); + setAppContext((ps) => ({ ...ps, language: newLanguage })); + }; + + return ( + + + + + ); +}; + +const ToggleContainer = styled("div")<{ isMainPath: boolean }>(({ theme, isMainPath }) => ({ + display: "flex", + width: "4rem", + height: "1.5rem", + border: "1px solid white", + borderRadius: 15, + padding: 2, + gap: 2, + backgroundColor: isMainPath + ? theme.palette.mobileNavigation.main.languageToggle.background + : theme.palette.mobileNavigation.sub.languageToggle.background, +})); + +const LanguageButton = styled(ButtonBase)<{ isActive: boolean; isMainPath: boolean }>(({ theme, isActive, isMainPath }) => ({ + flex: 1, + height: "100%", + borderRadius: 13, + fontSize: 12, + fontWeight: 400, + transition: "all 0.2s ease", + + color: isMainPath ? theme.palette.mobileHeader.main.text : theme.palette.mobileHeader.sub.text, + backgroundColor: "transparent", + + ...(isActive && { + backgroundColor: isMainPath + ? theme.palette.mobileNavigation.main.languageToggle.active.background + : theme.palette.mobileNavigation.sub.languageToggle.active.background, + color: isMainPath ? theme.palette.mobileHeader.main.activeLanguage : theme.palette.mobileHeader.sub.activeLanguage, + fontWeight: 600, + }), + + "&:hover": { + backgroundColor: isActive + ? isMainPath + ? theme.palette.mobileNavigation.main.languageToggle.active.hover + : theme.palette.mobileNavigation.sub.languageToggle.active.hover + : isMainPath + ? theme.palette.mobileNavigation.main.languageToggle.inactive.hover + : theme.palette.mobileNavigation.sub.languageToggle.inactive.hover, + }, + + WebkitFontSmoothing: "antialiased", + MozOsxFontSmoothing: "grayscale", + textRendering: "optimizeLegibility", + WebkitTextStroke: "0.5px transparent", +})); diff --git a/apps/pyconkr-2025/src/components/layout/Header/Mobile/MobileNavigation.tsx b/apps/pyconkr-2025/src/components/layout/Header/Mobile/MobileNavigation.tsx new file mode 100644 index 00000000..9203179f --- /dev/null +++ b/apps/pyconkr-2025/src/components/layout/Header/Mobile/MobileNavigation.tsx @@ -0,0 +1,358 @@ +import { PythonKorea } from "@frontend/common/components"; +import { NestedSiteMapSchema } from "@frontend/common/schemas/backendAPI"; +import { ArrowBack, ArrowForward } from "@mui/icons-material"; +import { Box, Button, Chip, Drawer, IconButton, Stack, styled, Typography } from "@mui/material"; +import { FC, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { isEmpty } from "remeda"; + +import { SignInButton } from "@apps/pyconkr-2025/components/layout/SignInButton"; + +import { HamburgerButton } from "./HamburgerButton"; +import { MobileLanguageToggle } from "./MobileLanguageToggle"; + +type MenuType = NestedSiteMapSchema; + +interface MobileNavigationProps { + isOpen: boolean; + onClose: () => void; + siteMapNode?: MenuType; +} + +type NavigationLevel = "depth1" | "depth2" | "depth3"; + +interface NavigationState { + level: NavigationLevel; + depth1?: MenuType; + depth2?: MenuType; + breadcrumbs: { name: string; level: NavigationLevel }[]; +} + +export const MobileNavigation: FC = ({ isOpen, onClose, siteMapNode }) => { + const location = useLocation(); + const [navState, setNavState] = useState({ + level: "depth1", + breadcrumbs: [], + }); + + const isMainPath = location.pathname === "/"; + + const resetNavigation = () => { + setNavState({ + level: "depth1", + breadcrumbs: [], + }); + }; + + const navigateToDepth2 = (depth1: MenuType) => { + setNavState({ + level: "depth2", + depth1, + breadcrumbs: [{ name: depth1.name, level: "depth1" }], + }); + }; + + const navigateToDepth3 = (depth2: MenuType) => { + setNavState((prev) => ({ + ...prev, + level: "depth3", + depth2, + breadcrumbs: [...prev.breadcrumbs, { name: depth2.name, level: "depth2" }], + })); + }; + + const goBack = () => { + if (navState.level === "depth3") { + setNavState((prev) => ({ + ...prev, + level: "depth2", + depth2: undefined, + breadcrumbs: prev.breadcrumbs.slice(0, -1), + })); + } else if (navState.level === "depth2") { + resetNavigation(); + } + }; + + const handleClose = () => { + onClose(); + resetNavigation(); + }; + + const renderDepth1Menu = () => { + if (!siteMapNode) return null; + + return ( + + {Object.values(siteMapNode.children) + .filter((s) => !s.hide) + .map((menu) => ( + + {!isEmpty(menu.children) && Object.values(menu.children).some((child) => !child.hide) ? ( + navigateToDepth2(menu)}> + {menu.name} + + ) : ( + + {menu.name} + + )} + {!isEmpty(menu.children) && Object.values(menu.children).some((child) => !child.hide) && ( + navigateToDepth2(menu)}> + + + )} + + ))} + + ); + }; + + const renderDepth2Menu = () => { + if (!navState.depth1) return null; + + return ( + + + + + + {navState.depth1.name} + + + + + + {Object.values(navState.depth1.children) + .filter((s) => !s.hide) + .map((menu) => ( + + + + + {!isEmpty(menu.children) && Object.values(menu.children).some((child) => !child.hide) && ( + navigateToDepth3(menu)}> + + + )} + + ))} + + + ); + }; + + const renderDepth3Menu = () => { + if (!navState.depth2) return null; + + return ( + + + + + + {navState.depth2.name} + + + + + + {Object.values(navState.depth2.children) + .filter((s) => !s.hide) + .map((menu) => ( + + + + ))} + + + ); + }; + + return ( + + + + + + + + + + 파이콘 한국 2025 + + + + + + + {navState.level === "depth1" && renderDepth1Menu()} + {navState.level === "depth2" && renderDepth2Menu()} + {navState.level === "depth3" && renderDepth3Menu()} + + + + + + + + + + + + + ); +}; + +const StyledDrawer = styled(Drawer)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + "& .MuiDrawer-paper": { + width: "70vw", + background: isMainPath ? theme.palette.mobileNavigation.main.background : theme.palette.mobileNavigation.sub.background, + backdropFilter: isMainPath ? "blur(10px)" : "none", + WebkitBackdropFilter: isMainPath ? "blur(10px)" : "none", + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + borderTopRightRadius: 15, + borderBottomRightRadius: 15, + }, +})); + +const DrawerContent = styled(Box)({ + height: "100%", + display: "flex", + flexDirection: "column", +}); + +const NavigationHeader = styled(Box)<{ isMainPath: boolean }>({ + display: "flex", + alignItems: "center", + padding: "23px 23px 10px 23px", + position: "relative", + gap: 17, +}); + +const NavigationContent = styled(Box)({ + flex: 1, + overflow: "auto", +}); + +const MenuContainer = styled(Stack)({ + padding: "20px 0", + gap: "25px", +}); + +const MenuItem = styled(Box)<{ isMainPath?: boolean }>({ + display: "flex", + alignItems: "center", + padding: "0 23px", + gap: 23, +}); + +const MenuLink = styled(Link)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + textDecoration: "none", + fontSize: "20px", + fontWeight: 600, +})); + +const MenuButton = styled(Button)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + textTransform: "none", + fontSize: "20px", + fontWeight: 600, + padding: 0, + minWidth: "auto", + minHeight: "auto", + justifyContent: "flex-start", +})); + +const MenuArrowButton = styled(IconButton)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + padding: 8, +})); + +const BackButton = styled(Button)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + display: "flex", + alignItems: "center", + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + textTransform: "none", + padding: "0 15px 0 0", + minWidth: "auto", + minHeight: "auto", +})); + +const MenuChip = styled(Chip)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + backgroundColor: isMainPath ? theme.palette.mobileNavigation.main.chip.background : theme.palette.mobileNavigation.sub.chip.background, + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + height: 40, + borderRadius: 15, + padding: "10px 13px", + fontSize: "16px", + fontWeight: 600, + + "& .MuiChip-label": { + padding: 0, + }, + + "&:hover": { + backgroundColor: isMainPath ? theme.palette.mobileNavigation.main.chip.hover : theme.palette.mobileNavigation.sub.chip.hover, + }, +})); + +const HeaderTitle = styled(Typography)<{ isMainPath: boolean }>(({ theme, isMainPath }) => ({ + color: isMainPath ? theme.palette.mobileHeader.main.text : theme.palette.mobileHeader.sub.text, + fontSize: 18, + fontWeight: 600, +})); + +const LogoAndTextContainer = styled(Box)({ + display: "flex", + alignItems: "center", +}); + +const NavigationMenuSection = styled(Box)({ + padding: "20px 23px", +}); + +const Depth2Header = styled(Box)<{ isMainPath: boolean }>({ + display: "flex", + alignItems: "center", + height: "auto", + marginBottom: 10, +}); + +const Depth2Title = styled(Typography)<{ isMainPath: boolean }>(({ theme, isMainPath }) => ({ + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + fontSize: 20, + fontWeight: 800, +})); + +const Depth2Divider = styled(Box)<{ isMainPath: boolean }>(({ theme, isMainPath }) => ({ + height: 1, + backgroundColor: isMainPath ? theme.palette.mobileNavigation.main.divider : theme.palette.mobileNavigation.sub.divider, + marginBottom: 21, +})); + +const Depth2MenuList = styled(Stack)({ + gap: 15, +}); + +const Depth2MenuItem = styled(Box)({ + display: "flex", + alignItems: "center", + gap: 10, +}); + +const Depth3MenuGrid = styled(Box)({ + height: 260, + display: "flex", + flexDirection: "column", + flexWrap: "wrap", + alignContent: "flex-start", + gap: 15, + overflow: "hidden", +}); diff --git a/apps/pyconkr/src/components/layout/Header/index.tsx b/apps/pyconkr-2025/src/components/layout/Header/index.tsx similarity index 75% rename from apps/pyconkr/src/components/layout/Header/index.tsx rename to apps/pyconkr-2025/src/components/layout/Header/index.tsx index 6219cf23..7a24c44c 100644 --- a/apps/pyconkr/src/components/layout/Header/index.tsx +++ b/apps/pyconkr-2025/src/components/layout/Header/index.tsx @@ -1,18 +1,20 @@ -import * as Common from "@frontend/common"; +import { PythonKorea } from "@frontend/common/components"; +import { NestedSiteMapSchema } from "@frontend/common/schemas/backendAPI"; import { ArrowForwardIos } from "@mui/icons-material"; -import { Box, Button, CircularProgress, Divider, Stack, styled, SxProps, Theme, Typography } from "@mui/material"; +import { Box, Button, CircularProgress, Divider, Stack, styled, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material"; import { MUIStyledCommonProps } from "@mui/system"; -import * as React from "react"; +import { CSSProperties, Fragment, useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import * as R from "remeda"; +import { isEmpty, isNonNullish, isString } from "remeda"; -import BackendAPISchemas from "../../../../../../packages/common/src/schemas/backendAPI"; -import { useAppContext } from "../../../contexts/app_context"; -import { CartBadgeButton } from "../CartBadgeButton"; -import LanguageSelector from "../LanguageSelector"; -import { SignInButton } from "../SignInButton"; +import { CartBadgeButton } from "@apps/pyconkr-2025/components/layout/CartBadgeButton"; +import LanguageSelector from "@apps/pyconkr-2025/components/layout/LanguageSelector"; +import { SignInButton } from "@apps/pyconkr-2025/components/layout/SignInButton"; +import { useAppContext } from "@apps/pyconkr-2025/contexts/app_context"; -type MenuType = BackendAPISchemas.NestedSiteMapSchema; +import { MobileHeader } from "./Mobile/MobileHeader"; + +type MenuType = NestedSiteMapSchema; type MenuOrUndefinedType = MenuType | undefined; type NavigationStateType = { @@ -21,12 +23,14 @@ type NavigationStateType = { depth3?: MenuType; }; -const HeaderHeight: React.CSSProperties["height"] = "3.625rem"; -const BreadCrumbHeight: React.CSSProperties["height"] = "4.5rem"; +const HeaderHeight: CSSProperties["height"] = "3.625rem"; +const BreadCrumbHeight: CSSProperties["height"] = "4.5rem"; -const Header: React.FC = () => { +export default function Header() { const { title, language, siteMapNode, currentSiteMapDepth, shouldShowTitleBanner } = useAppContext(); - const [navState, setNavState] = React.useState({}); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const [navState, setNavState] = useState({}); const resetDepths = () => setNavState({}); const setDepth1 = (depth1: MenuOrUndefinedType) => setNavState({ depth1 }); @@ -36,11 +40,15 @@ const Header: React.FC = () => { const getDepth2Route = (nextRoute?: string) => (navState.depth1?.route_code || "") + `/${nextRoute || ""}`; const getDepth3Route = (nextRoute?: string) => getDepth2Route(navState.depth2?.route_code) + `/${nextRoute || ""}`; - React.useEffect(resetDepths, [language]); + useEffect(resetDepths, [language]); + + if (isMobile) { + return ; + } let breadCrumbRoute = ""; let breadCrumbArray = currentSiteMapDepth.slice(1, -1); - if (R.isEmpty(breadCrumbArray)) breadCrumbArray = currentSiteMapDepth.slice(0, -1); + if (isEmpty(breadCrumbArray)) breadCrumbArray = currentSiteMapDepth.slice(0, -1); const headerContainerStyle: SxProps = shouldShowTitleBanner ? {} @@ -55,7 +63,7 @@ const Header: React.FC = () => { - + @@ -65,7 +73,13 @@ const Header: React.FC = () => { {Object.values(siteMapNode.children) .filter((s) => !s.hide) .map((r) => ( - + @@ -91,16 +105,17 @@ const Header: React.FC = () => { key={r.id} onClick={resetDepths} onMouseEnter={() => setDepth2(r)} - // 하위 depth가 있는 경우, 하위 depth를 선택할 수 있도록 유지하기 위해 depth2도 유지합니다. - onMouseLeave={() => R.isEmpty(navState.depth2?.children ?? {}) && setDepth2(undefined)} - to={getDepth2Route(r.route_code)} + onMouseLeave={() => isEmpty(navState.depth2?.children ?? {}) && setDepth2(undefined)} + target={isString(r.external_link) ? "_blank" : undefined} + rel={isString(r.external_link) ? "noopener noreferrer" : undefined} + to={r.external_link || getDepth2Route(r.route_code)} /> ))} - {navState.depth2 && !R.isEmpty(navState.depth2.children) && ( + {navState.depth2 && !isEmpty(navState.depth2.children) && ( <> - {!R.isEmpty(navState.depth2.children) && } + {!isEmpty(navState.depth2.children) && } {Object.values(navState.depth2.children) @@ -113,7 +128,9 @@ const Header: React.FC = () => { onClick={resetDepths} onMouseEnter={() => setDepth3(r)} onMouseLeave={() => setDepth3(undefined)} - to={getDepth3Route(r?.route_code)} + target={isString(r.external_link) ? "_blank" : undefined} + rel={isString(r.external_link) ? "noopener noreferrer" : undefined} + to={r.external_link || getDepth3Route(r?.route_code)} /> ))} @@ -140,14 +157,14 @@ const Header: React.FC = () => { {breadCrumbArray - .filter((routeInfo) => R.isNonNullish(routeInfo)) + .filter((routeInfo) => isNonNullish(routeInfo)) .map(({ route_code, name }, index) => { breadCrumbRoute += `${route_code}/`; return ( - + {index > 0 && } - + ); })} @@ -161,7 +178,7 @@ const Header: React.FC = () => { )} ); -}; +} const ResponsivePaddingDefinition = ({ theme }: MUIStyledCommonProps) => ({ paddingRight: theme!.spacing(16), @@ -291,5 +308,3 @@ const BreadCrumbContainer = styled(Stack)(({ theme }) => ({ fontSize: "0.75rem", }, })); - -export default Header; diff --git a/apps/pyconkr/src/components/layout/LanguageSelector/index.tsx b/apps/pyconkr-2025/src/components/layout/LanguageSelector/index.tsx similarity index 87% rename from apps/pyconkr/src/components/layout/LanguageSelector/index.tsx rename to apps/pyconkr-2025/src/components/layout/LanguageSelector/index.tsx index 5535802d..62a13f1d 100644 --- a/apps/pyconkr/src/components/layout/LanguageSelector/index.tsx +++ b/apps/pyconkr-2025/src/components/layout/LanguageSelector/index.tsx @@ -1,8 +1,8 @@ import { Language } from "@mui/icons-material"; import { Button, Stack, styled } from "@mui/material"; -import { LOCAL_STORAGE_LANGUAGE_KEY } from "../../../consts/local_stroage"; -import { useAppContext } from "../../../contexts/app_context"; +import { LOCAL_STORAGE_LANGUAGE_KEY } from "@apps/pyconkr-2025/consts/local_stroage"; +import { useAppContext } from "@apps/pyconkr-2025/contexts/app_context"; export default function LanguageSelector() { const { language, setAppContext } = useAppContext(); diff --git a/apps/pyconkr-2025/src/components/layout/PageLayout/index.tsx b/apps/pyconkr-2025/src/components/layout/PageLayout/index.tsx new file mode 100644 index 00000000..322bb1c9 --- /dev/null +++ b/apps/pyconkr-2025/src/components/layout/PageLayout/index.tsx @@ -0,0 +1,23 @@ +import { Stack, styled } from "@mui/material"; + +export const PageLayout = styled(Stack)(({ theme }) => ({ + height: "75%", + width: "100%", + maxWidth: "1200px", + + justifyContent: "flex-start", + alignItems: "center", + + paddingTop: theme.spacing(8), + paddingBottom: theme.spacing(8), + + paddingRight: theme.spacing(16), + paddingLeft: theme.spacing(16), + + [theme.breakpoints.down("lg")]: { + padding: theme.spacing(4), + }, + [theme.breakpoints.down("sm")]: { + padding: theme.spacing(2), + }, +})); diff --git a/apps/pyconkr-2025/src/components/layout/SignInButton/index.tsx b/apps/pyconkr-2025/src/components/layout/SignInButton/index.tsx new file mode 100644 index 00000000..e2dacddc --- /dev/null +++ b/apps/pyconkr-2025/src/components/layout/SignInButton/index.tsx @@ -0,0 +1,99 @@ +import { useShopClient, useSignOutMutation, useUserStatus } from "@frontend/shop/hooks"; +import { Login, Logout } from "@mui/icons-material"; +import { Button, Stack } from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import { Link as RouterLink } from "react-router-dom"; + +import { useAppContext } from "@apps/pyconkr-2025/contexts/app_context"; + +type InnerSignInButtonImplPropType = { + loading?: boolean; + signedIn?: boolean; + onSignOut?: () => void; + isMobile?: boolean; + isMainPath?: boolean; + onClose?: () => void; +}; + +const InnerSignInButtonImpl: React.FC = ({ + loading, + signedIn, + onSignOut, + isMobile = false, + isMainPath = true, + onClose, +}) => { + const { language } = useAppContext(); + + const signInBtnStr = language === "ko" ? "로그인" : "Sign In"; + const signOutBtnStr = language === "ko" ? "로그아웃" : "Sign Out"; + + // 로그인 상태에 따라: 로그아웃은 클릭 액션, 로그인은 Ctrl+클릭(새 탭)이 동작하도록 Link로 이동. + const navProps = signedIn + ? { onClick: () => onSignOut?.() } + : ({ component: RouterLink, to: "/account/sign-in", onClick: () => onClose?.() } as const); + + if (isMobile) { + return ( + + ); + } + + return ( + diff --git a/apps/pyconkr-2025/src/consts/index.ts b/apps/pyconkr-2025/src/consts/index.ts new file mode 100644 index 00000000..fa58fc96 --- /dev/null +++ b/apps/pyconkr-2025/src/consts/index.ts @@ -0,0 +1,3 @@ +export const IS_DEBUG_ENV = import.meta.env.MODE === "development"; + +export const EVENT_NAME = "파이콘 한국 2025"; diff --git a/apps/pyconkr/src/consts/local_stroage.ts b/apps/pyconkr-2025/src/consts/local_stroage.ts similarity index 100% rename from apps/pyconkr/src/consts/local_stroage.ts rename to apps/pyconkr-2025/src/consts/local_stroage.ts diff --git a/apps/pyconkr-2025/src/consts/mdx_components.ts b/apps/pyconkr-2025/src/consts/mdx_components.ts new file mode 100644 index 00000000..59d24fe4 --- /dev/null +++ b/apps/pyconkr-2025/src/consts/mdx_components.ts @@ -0,0 +1,323 @@ +// 후대의 개발자님께 : 컴포넌트 맨 첫글자가 대문자로 시작하지 않으면 JSX 컴포넌트가 아니라 일반 HTML 태그로 인식합니다. 제발 대문자로 시작해주세요. +import PyCon2025HostLogoBig from "@frontend/common/assets/pyconkr2025_hostlogo_big.png"; +import PyCon2025HostLogoSmall from "@frontend/common/assets/pyconkr2025_hostlogo_small.png"; +import PyCon2025MobileLogoImage from "@frontend/common/assets/pyconkr2025_main_cover_image.png"; +import PyCon2025MobileLogoTitle from "@frontend/common/assets/pyconkr2025_main_cover_title.png"; +import { LottiePlayer, NetworkLottiePlayer } from "@frontend/common/components"; +import { + Confetti, + FAQAccordion, + Map as MDXMap, + MobileAccordion, + MobileCover, + PrimaryStyledDetails, + SecondaryStyledDetails, + SessionList, + SessionTimeTable, + StyledFullWidthButton, +} from "@frontend/common/components/mdx_components"; +import { PriceDisplay, ShopContextProvider, SignInGuard, UserSignInAccount, UserSignInMethod } from "@frontend/shop/components/common"; +import { CartStatus, OrderList, PatronList, ProductImageCardList, ProductList, UserInfo } from "@frontend/shop/components/features"; +import { + Accordion, + AccordionActions, + AccordionDetails, + AccordionSummary, + Alert, + AlertTitle, + AppBar, + Autocomplete, + Avatar, + AvatarGroup, + Backdrop, + Badge, + BottomNavigation, + BottomNavigationAction, + Box, + Breadcrumbs, + Button, + ButtonBase, + ButtonGroup, + Card, + CardActionArea, + CardActions, + CardContent, + CardHeader, + CardMedia, + Checkbox, + Chip, + CircularProgress, + Collapse, + Container, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + Drawer, + Fab, + Fade, + FilledInput, + FormControl, + FormControlLabel, + FormGroup, + FormHelperText, + FormLabel, + Grid, + Grow, + Icon, + IconButton, + ImageList, + ImageListItem, + ImageListItemBar, + Input, + InputAdornment, + InputBase, + InputLabel, + LinearProgress, + Link, + List, + ListItem, + ListItemAvatar, + ListItemButton, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + ListSubheader, + Menu, + MenuItem, + MenuList, + MobileStepper, + Modal, + NativeSelect, + NoSsr, + OutlinedInput, + Pagination, + PaginationItem, + Paper, + Popover, + Popper, + Portal, + Radio, + RadioGroup, + Rating, + Select, + Skeleton, + Slide, + Slider, + Snackbar, + SnackbarContent, + SpeedDial, + SpeedDialAction, + SpeedDialIcon, + Stack, + Step, + StepButton, + StepConnector, + StepContent, + StepIcon, + StepLabel, + Stepper, + SvgIcon, + SwipeableDrawer, + Switch, + Tab, + TabScrollButton, + Table, + TableBody, + TableCell, + TableContainer, + TableFooter, + TableHead, + TablePagination, + TableRow, + TableSortLabel, + Tabs, + TextField, + TextareaAutosize, + ToggleButton, + ToggleButtonGroup, + Toolbar, + Tooltip, + Typography, + Zoom, +} from "@mui/material"; +import type { MDXComponents } from "mdx/types.js"; +import { FC, createElement } from "react"; + +const MUIMDXComponents: MDXComponents = { + Mui__material__Accordion: Accordion, + Mui__material__AccordionActions: AccordionActions, + Mui__material__AccordionDetails: AccordionDetails, + Mui__material__AccordionSummary: AccordionSummary, + Mui__material__Alert: Alert, + Mui__material__AlertTitle: AlertTitle, + Mui__material__AppBar: AppBar, + Mui__material__Autocomplete: Autocomplete, + Mui__material__Avatar: Avatar, + Mui__material__AvatarGroup: AvatarGroup, + Mui__material__Backdrop: Backdrop, + Mui__material__Badge: Badge, + Mui__material__BottomNavigation: BottomNavigation, + Mui__material__BottomNavigationAction: BottomNavigationAction, + Mui__material__Box: Box, + Mui__material__Breadcrumbs: Breadcrumbs, + Mui__material__Button: Button, + Mui__material__ButtonBase: ButtonBase, + Mui__material__ButtonGroup: ButtonGroup, + Mui__material__Card: Card, + Mui__material__CardActionArea: CardActionArea, + Mui__material__CardActions: CardActions, + Mui__material__CardContent: CardContent, + Mui__material__CardHeader: CardHeader, + Mui__material__CardMedia: CardMedia, + Mui__material__Checkbox: Checkbox, + Mui__material__Chip: Chip, + Mui__material__CircularProgress: CircularProgress, + Mui__material__Collapse: Collapse, + Mui__material__Container: Container, + Mui__material__Dialog: Dialog, + Mui__material__DialogActions: DialogActions, + Mui__material__DialogContent: DialogContent, + Mui__material__DialogContentText: DialogContentText, + Mui__material__DialogTitle: DialogTitle, + Mui__material__Divider: Divider, + Mui__material__Drawer: Drawer, + Mui__material__Fab: Fab, + Mui__material__Fade: Fade, + Mui__material__FilledInput: FilledInput, + Mui__material__FormControl: FormControl, + Mui__material__FormControlLabel: FormControlLabel, + Mui__material__FormGroup: FormGroup, + Mui__material__FormHelperText: FormHelperText, + Mui__material__FormLabel: FormLabel, + Mui__material__Grid: Grid, + Mui__material__Grow: Grow, + Mui__material__Icon: Icon, + Mui__material__IconButton: IconButton, + Mui__material__ImageList: ImageList, + Mui__material__ImageListItem: ImageListItem, + Mui__material__ImageListItemBar: ImageListItemBar, + Mui__material__Input: Input, + Mui__material__InputAdornment: InputAdornment, + Mui__material__InputBase: InputBase, + Mui__material__InputLabel: InputLabel, + Mui__material__LinearProgress: LinearProgress, + Mui__material__Link: Link, + Mui__material__List: List, + Mui__material__ListItem: ListItem, + Mui__material__ListItemAvatar: ListItemAvatar, + Mui__material__ListItemButton: ListItemButton, + Mui__material__ListItemIcon: ListItemIcon, + Mui__material__ListItemSecondaryAction: ListItemSecondaryAction, + Mui__material__ListItemText: ListItemText, + Mui__material__ListSubheader: ListSubheader, + Mui__material__Menu: Menu, + Mui__material__MenuItem: MenuItem, + Mui__material__MenuList: MenuList, + Mui__material__MobileStepper: MobileStepper, + Mui__material__Modal: Modal, + Mui__material__NativeSelect: NativeSelect, + Mui__material__NoSsr: NoSsr, + Mui__material__OutlinedInput: OutlinedInput, + Mui__material__Pagination: Pagination, + Mui__material__PaginationItem: PaginationItem, + Mui__material__Paper: Paper, + Mui__material__Popover: Popover, + Mui__material__Popper: Popper, + Mui__material__Portal: Portal, + Mui__material__Radio: Radio, + Mui__material__RadioGroup: RadioGroup, + Mui__material__Rating: Rating, + Mui__material__Select: Select, + Mui__material__Skeleton: Skeleton, + Mui__material__Slide: Slide, + Mui__material__Slider: Slider, + Mui__material__Snackbar: Snackbar, + Mui__material__SnackbarContent: SnackbarContent, + Mui__material__SpeedDial: SpeedDial, + Mui__material__SpeedDialAction: SpeedDialAction, + Mui__material__SpeedDialIcon: SpeedDialIcon, + Mui__material__Stack: Stack, + Mui__material__Step: Step, + Mui__material__StepButton: StepButton, + Mui__material__StepConnector: StepConnector, + Mui__material__StepContent: StepContent, + Mui__material__StepIcon: StepIcon, + Mui__material__StepLabel: StepLabel, + Mui__material__Stepper: Stepper, + Mui__material__SvgIcon: SvgIcon, + Mui__material__SwipeableDrawer: SwipeableDrawer, + Mui__material__Switch: Switch, + Mui__material__Tab: Tab, + Mui__material__Table: Table, + Mui__material__TableBody: TableBody, + Mui__material__TableCell: TableCell, + Mui__material__TableContainer: TableContainer, + Mui__material__TableFooter: TableFooter, + Mui__material__TableHead: TableHead, + Mui__material__TablePagination: TablePagination, + Mui__material__TableRow: TableRow, + Mui__material__TableSortLabel: TableSortLabel, + Mui__material__Tabs: Tabs, + Mui__material__TabScrollButton: TabScrollButton, + Mui__material__TextField: TextField, + Mui__material__TextareaAutosize: TextareaAutosize, + Mui__material__ToggleButton: ToggleButton, + Mui__material__ToggleButtonGroup: ToggleButtonGroup, + Mui__material__Toolbar: Toolbar, + Mui__material__Tooltip: Tooltip, + Mui__material__Typography: Typography, + Mui__material__Zoom: Zoom, +}; + +const PyConKR2025MobileAccordion: FC = () => + createElement(MobileAccordion, { + marqueeText: "AUG 15 - 17", + marqueeLogoSrc: PyCon2025HostLogoSmall, + hostLogoBigSrc: PyCon2025HostLogoBig, + venueKo: "서울특별시 중구 필동로 1길 30 동국대학교 신공학관", + venueEnLines: ["New Engineering Building, Dongguk University", "Pildong-ro 1-gil, Jung-gu, Seoul, Republic of Korea"], + }); + +const PyConKR2025MobileCover: FC = () => + createElement(MobileCover, { + coverImageSrc: PyCon2025MobileLogoImage, + coverTitleSrc: PyCon2025MobileLogoTitle, + }); + +const PyConKRCommonMDXComponents: MDXComponents = { + Common__Components__Lottie: LottiePlayer, + Common__Components__NetworkLottie: NetworkLottiePlayer, + Common__Components__MDX__Confetti: Confetti, + Common__Components__MDX__PrimaryStyledDetails: PrimaryStyledDetails, + Common__Components__MDX__SecondaryStyledDetails: SecondaryStyledDetails, + Common__Components__MDX__Map: MDXMap, + Common__Components__MDX__FAQAccordion: FAQAccordion, + Common__Components__MDX__FullWidthStyledButton: StyledFullWidthButton, + Common__Components__Session__List: SessionList, + Common__Components__Session__TimeTable: SessionTimeTable, + Common__Components__MDX__MobileAccordion: PyConKR2025MobileAccordion, + Common__Components__MDX__MobileCover: PyConKR2025MobileCover, +}; + +const PythonKRShopMDXComponents: MDXComponents = { + Shop__Common__PriceDisplay: PriceDisplay, + Shop__Common__SignInGuard: SignInGuard, + Shop__Common__ContextProvider: ShopContextProvider, + Shop__Common__UserSignInMethod: UserSignInMethod, + Shop__Common__UserSignInAccount: UserSignInAccount, + Shop__Feature__CartStatus: CartStatus, + Shop__Feature__ProductList: ProductList, + Shop__Feature__ProductImageCardList: ProductImageCardList, + Shop__Feature__OrderList: OrderList, + Shop__Feature__UserInfo: UserInfo, + Shop__Feature__PatronList: PatronList, +}; + +export const PyConKRMDXComponents = { + ...MUIMDXComponents, + ...PyConKRCommonMDXComponents, + ...PythonKRShopMDXComponents, +}; diff --git a/apps/pyconkr/src/consts/mdx_help_text.ts b/apps/pyconkr-2025/src/consts/mdx_help_text.ts similarity index 100% rename from apps/pyconkr/src/consts/mdx_help_text.ts rename to apps/pyconkr-2025/src/consts/mdx_help_text.ts diff --git a/apps/pyconkr-2025/src/contexts/app_context.tsx b/apps/pyconkr-2025/src/contexts/app_context.tsx new file mode 100644 index 00000000..3a200550 --- /dev/null +++ b/apps/pyconkr-2025/src/contexts/app_context.tsx @@ -0,0 +1,26 @@ +import { NestedSiteMapSchema, SponsorTierSchema } from "@frontend/common/schemas/backendAPI"; +import { Dispatch, SetStateAction, createContext, useContext } from "react"; +type LanguageType = "ko" | "en"; + +export type AppContextType = { + language: LanguageType; + shouldShowTitleBanner: boolean; + shouldShowSponsorBanner: boolean; + + siteMapNode?: NestedSiteMapSchema; + sponsorTiers?: SponsorTierSchema[]; + title: string; + currentSiteMapDepth: (NestedSiteMapSchema | undefined)[]; + + setAppContext: Dispatch>>; +}; + +export const AppContext = createContext(undefined); + +export const useAppContext = (): AppContextType => { + const context = useContext(AppContext); + if (!context) { + throw new Error("useAppContext must be used within an AppContextProvider"); + } + return context; +}; diff --git a/apps/pyconkr/src/debug/page/component_test.tsx b/apps/pyconkr-2025/src/debug/page/component_test.tsx similarity index 85% rename from apps/pyconkr/src/debug/page/component_test.tsx rename to apps/pyconkr-2025/src/debug/page/component_test.tsx index 0635c2f4..0eb0e3b7 100644 --- a/apps/pyconkr/src/debug/page/component_test.tsx +++ b/apps/pyconkr-2025/src/debug/page/component_test.tsx @@ -1,8 +1,7 @@ -import * as Common from "@frontend/common"; +import { PrimaryStyledDetails, SecondaryStyledDetails } from "@frontend/common/components/mdx_components"; import { Chip, Stack, Table, TableBody, TableCell, TableRow } from "@mui/material"; -import * as React from "react"; - -const HighlightedChip: React.FC<{ label: string }> = ({ label }) => ( +import { FC } from "react"; +const HighlightedChip: FC<{ label: string }> = ({ label }) => ( ({ @@ -14,10 +13,10 @@ const HighlightedChip: React.FC<{ label: string }> = ({ label }) => ( /> ); -export const ComponentTestPage: React.FC = () => { +export const ComponentTestPage: FC = () => { return ( - + 모든 자동차의 출입은 동국대 정문으로만 가능 @@ -47,9 +46,9 @@ export const ComponentTestPage: React.FC = () => {
-
+ - + 모든 자동차의 출입은 동국대 정문으로만 가능 @@ -79,7 +78,7 @@ export const ComponentTestPage: React.FC = () => {
-
+
); }; diff --git a/apps/pyconkr/src/debug/page/map_test.tsx b/apps/pyconkr-2025/src/debug/page/map_test.tsx similarity index 76% rename from apps/pyconkr/src/debug/page/map_test.tsx rename to apps/pyconkr-2025/src/debug/page/map_test.tsx index 6f5d097d..a67ed286 100644 --- a/apps/pyconkr/src/debug/page/map_test.tsx +++ b/apps/pyconkr-2025/src/debug/page/map_test.tsx @@ -1,13 +1,13 @@ -import * as Common from "@frontend/common"; +import { Map as MDXMap, MapPropType } from "@frontend/common/components/mdx_components"; +import { getFormValue, isFormValid } from "@frontend/common/utils"; import { Box, Button, FormControlLabel, Stack, Switch, TextField } from "@mui/material"; -import * as React from "react"; - +import { FC, useRef, useState } from "react"; type MapTestPageStateType = { checked: boolean; - mapProps: Common.Components.MDX.MapPropType; + mapProps: MapPropType; }; -const INITIAL_DATA: Common.Components.MDX.MapPropType = { +const INITIAL_DATA: MapPropType = { geo: { lat: 37.5580918, lng: 126.9982178 }, placeName: { ko: "동국대학교 신공학관", @@ -22,13 +22,13 @@ const INITIAL_DATA: Common.Components.MDX.MapPropType = { "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3162.871473157695!2d126.99821779999999!3d37.5580918!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x357ca302befa0c31%3A0xbc66c66731962172!2z64-Z6rWt64yA7ZWZ6rWQIOyLoOqzte2Vmeq0gA!5e0!3m2!1sko!2sen!4v1748768615566!5m2!1sko!2sen", }; -export const MapTestPage: React.FC = () => { - const geoFormRef = React.useRef(null); - const placeNameFormRef = React.useRef(null); - const placeCodeFormRef = React.useRef(null); - const gMapIframeUrlInputRef = React.useRef(null); +export const MapTestPage: FC = () => { + const geoFormRef = useRef(null); + const placeNameFormRef = useRef(null); + const placeCodeFormRef = useRef(null); + const gMapIframeUrlInputRef = useRef(null); - const [state, setState] = React.useState({ checked: false, mapProps: INITIAL_DATA }); + const [state, setState] = useState({ checked: false, mapProps: INITIAL_DATA }); const setChecked = (checked: boolean) => setState((ps) => ({ ...ps, checked })); const language = state.checked ? "en" : "ko"; @@ -41,21 +41,21 @@ export const MapTestPage: React.FC = () => { [geoForm, pNameForm, pCodeForm, gMapIframeUrl].forEach((formOrInput, index) => { if (!formOrInput) throw new Error(`${formOrInput}[${index}] is not defined.`); - if (formOrInput instanceof HTMLFormElement && !Common.Utils.isFormValid(formOrInput)) throw new Error(`${formOrInput}[${index}] is not valid.`); + if (formOrInput instanceof HTMLFormElement && !isFormValid(formOrInput)) throw new Error(`${formOrInput}[${index}] is not valid.`); if (formOrInput instanceof HTMLInputElement && !formOrInput.checkValidity()) throw new Error(`${formOrInput}[${index}] is not valid.`); }); if (!(geoForm && pNameForm && pCodeForm && gMapIframeUrl)) return; - const strGeo = Common.Utils.getFormValue<{ lat: string; lng: string }>({ form: geoForm }); + const strGeo = getFormValue<{ lat: string; lng: string }>({ form: geoForm }); if (!strGeo.lat || !strGeo.lng || isNaN(parseFloat(strGeo.lat)) || isNaN(parseFloat(strGeo.lng))) { alert("위도와 경도를 올바르게 입력해주세요."); return; } const geo = { lat: parseFloat(strGeo.lat), lng: parseFloat(strGeo.lng) }; const googleMapIframeSrc = gMapIframeUrl.value.trim(); - const placeCode = Common.Utils.getFormValue<{ kakao: string; naver: string; google: string }>({ form: pCodeForm }); - const placeName = Common.Utils.getFormValue<{ ko: string; en: string }>({ form: pNameForm }); + const placeCode = getFormValue<{ kakao: string; naver: string; google: string }>({ form: pCodeForm }); + const placeName = getFormValue<{ ko: string; en: string }>({ form: pNameForm }); placeName.ko = placeName.ko.trim().replace("\\n", "\n"); placeName.en = placeName.en.trim().replace("\\n", "\n"); @@ -89,7 +89,7 @@ export const MapTestPage: React.FC = () => { - + ); diff --git a/apps/pyconkr/src/debug/page/mdi_test.tsx b/apps/pyconkr-2025/src/debug/page/mdi_test.tsx similarity index 67% rename from apps/pyconkr/src/debug/page/mdi_test.tsx rename to apps/pyconkr-2025/src/debug/page/mdi_test.tsx index 33cca026..2ff46fd7 100644 --- a/apps/pyconkr/src/debug/page/mdi_test.tsx +++ b/apps/pyconkr-2025/src/debug/page/mdi_test.tsx @@ -1,10 +1,12 @@ -import * as Common from "@frontend/common"; +import { MDXEditor, MDXRenderer } from "@frontend/common/components"; +import { useCommonContext } from "@frontend/common/hooks/useCommonContext"; import { Box, Stack } from "@mui/material"; import React from "react"; const HalfWidthStyle: React.CSSProperties = { width: "50%", maxWidth: "50%" }; export const MdiTestPage: React.FC = () => { + const { baseUrl, mdxComponents } = useCommonContext(); const [state, setState] = React.useState<{ text: string; resetKey: number }>({ text: "", resetKey: Math.random(), @@ -25,10 +27,10 @@ export const MdiTestPage: React.FC = () => { }} > - + - + ); diff --git a/apps/pyconkr/src/debug/page/shop_test.tsx b/apps/pyconkr-2025/src/debug/page/shop_test.tsx similarity index 69% rename from apps/pyconkr/src/debug/page/shop_test.tsx rename to apps/pyconkr-2025/src/debug/page/shop_test.tsx index bb5beee1..5bfbe1a5 100644 --- a/apps/pyconkr/src/debug/page/shop_test.tsx +++ b/apps/pyconkr-2025/src/debug/page/shop_test.tsx @@ -1,4 +1,4 @@ -import * as Shop from "@frontend/shop"; +import { CartStatus, OrderList, ProductImageCardList, ProductList, UserInfo } from "@frontend/shop/components/features"; import { Divider, Stack, Typography } from "@mui/material"; import React from "react"; @@ -11,26 +11,26 @@ export const ShopTestPage: React.FC = () => ( 계정 상태 - + 상품 목록 - + 상품 목록 (이미지 카드) - + 장바구니 - + 주문 내역 - + ); diff --git a/apps/pyconkr-2025/src/main.tsx b/apps/pyconkr-2025/src/main.tsx new file mode 100644 index 00000000..e7a69bf2 --- /dev/null +++ b/apps/pyconkr-2025/src/main.tsx @@ -0,0 +1,126 @@ +import { Global } from "@emotion/react"; +import { CenteredPage, CommonContextProvider, ErrorFallback } from "@frontend/common/components"; +import type { ContextOptions } from "@frontend/common/contexts"; +import { captureSessionTokenFromURL, initFaro, registerChunkLoadErrorReloadHandler } from "@frontend/common/utils"; +import { ShopContextProvider } from "@frontend/shop/components/common"; +import { ContextOptions as ShopContextOptions } from "@frontend/shop/contexts"; +import { CircularProgress, CssBaseline, ThemeProvider } from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import { matchQuery, MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { SnackbarProvider } from "notistack"; +import { FC, StrictMode, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { RouterProvider } from "react-router-dom"; + +import { IS_DEBUG_ENV } from "./consts"; +import { LOCAL_STORAGE_LANGUAGE_KEY } from "./consts/local_stroage.ts"; +import { PyConKRMDXComponents } from "./consts/mdx_components.ts"; +import { AppContext, AppContextType } from "./contexts/app_context.tsx"; +import { router } from "./router.tsx"; +import { globalStyles, muiTheme } from "./styles/globalStyles.ts"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + gcTime: 5 * 60 * 1000, + retry: 1, + }, + }, + mutationCache: new MutationCache({ + onMutate: (_variables, mutation) => { + queryClient.resetQueries({ + predicate: (query) => mutation.meta?.invalidates?.some((queryKey) => matchQuery({ queryKey }, query)) ?? true, + }); + queryClient.cancelQueries({ + predicate: (query) => mutation.meta?.invalidates?.some((queryKey) => matchQuery({ queryKey }, query)) ?? true, + }); + }, + onSuccess: (_data, _variables, _context, mutation) => { + queryClient.resetQueries({ + predicate: (query) => mutation.meta?.invalidates?.some((queryKey) => matchQuery({ queryKey }, query)) ?? true, + }); + }, + }), +}); + +// dev 서버에서는 vite proxy(/v1, /api)로 백엔드 호출 → relative URL 사용 (same-origin이라 CORS/쿠키 문제 회피) +const backendApiDomain = import.meta.env.DEV ? "" : import.meta.env.VITE_PYCONKR_BACKEND_API_DOMAIN; + +const CommonOptions: ContextOptions = { + appType: "main", + language: "ko", + debug: IS_DEBUG_ENV, + baseUrl: ".", + backendApiDomain, + backendApiAbsoluteDomain: import.meta.env.VITE_PYCONKR_BACKEND_API_DOMAIN, + accountsDomain: import.meta.env.VITE_PYCONKR_ACCOUNTS_DOMAIN, + backendApiCSRFCookieName: import.meta.env.VITE_PYCONKR_BACKEND_CSRF_COOKIE_NAME, + backendApiSessionCookieName: import.meta.env.VITE_PYCONKR_BACKEND_SESSION_COOKIE_NAME, + backendApiTimeout: 10000, + mdxComponents: PyConKRMDXComponents, +}; + +const ShopOptions: ShopContextOptions = { + language: "ko", + shopImpAccountId: import.meta.env.VITE_PYCONKR_SHOP_IMP_ACCOUNT_ID, +}; + +const SuspenseFallback = ( + + + +); + +export const MainApp: FC = () => { + const [appState, setAppContext] = useState>({ + language: (localStorage.getItem(LOCAL_STORAGE_LANGUAGE_KEY) as "ko" | "en" | null) ?? "ko", + shouldShowTitleBanner: true, + shouldShowSponsorBanner: false, + + currentSiteMapDepth: [], + + title: "PyCon Korea 2025", + }); + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +initFaro({ + enabled: import.meta.env.PROD, + tracing: false, + url: import.meta.env.VITE_FARO_COLLECTOR_URL, + app: { + name: "pyconkr-2025", + version: import.meta.env.VITE_APP_VERSION, + environment: import.meta.env.MODE as "development" | "production", + }, +}); +registerChunkLoadErrorReloadHandler(); +captureSessionTokenFromURL(import.meta.env.VITE_PYCONKR_BACKEND_SESSION_COOKIE_NAME); + +createRoot(document.getElementById("root")!).render(); diff --git a/apps/pyconkr-2025/src/router.tsx b/apps/pyconkr-2025/src/router.tsx new file mode 100644 index 00000000..65548481 --- /dev/null +++ b/apps/pyconkr-2025/src/router.tsx @@ -0,0 +1,31 @@ +import { createBrowserRouter } from "react-router-dom"; + +import { App } from "./App.tsx"; +import MainLayout from "./components/layout/index.tsx"; +import { PageIdParamRenderer, RouteRenderer } from "./components/pages/dynamic_route.tsx"; +import { MDXPreviewPage } from "./components/pages/mdx_preview.tsx"; +import { PresentationDetailPage } from "./components/pages/presentation_detail.tsx"; +import { ShopSignInPage } from "./components/pages/sign_in.tsx"; +import { SponsorDetailPage } from "./components/pages/sponsor_detail.tsx"; +import { Test } from "./components/pages/test.tsx"; +import { IS_DEBUG_ENV } from "./consts"; + +export const router = createBrowserRouter([ + { + element: , + children: [ + { + element: , + children: [ + ...(IS_DEBUG_ENV ? [{ path: "/debug", element: }] : []), + { path: "/preview", element: }, + { path: "/account/sign-in", element: }, + { path: "/sponsors/:id", element: }, + { path: "/presentations/:id", element: }, + { path: "/pages/:id", element: }, + { path: "*", element: }, + ], + }, + ], + }, +]); diff --git a/apps/pyconkr/src/styles/globalStyles.ts b/apps/pyconkr-2025/src/styles/globalStyles.ts similarity index 59% rename from apps/pyconkr/src/styles/globalStyles.ts rename to apps/pyconkr-2025/src/styles/globalStyles.ts index f90c19e3..0f60b97f 100644 --- a/apps/pyconkr/src/styles/globalStyles.ts +++ b/apps/pyconkr-2025/src/styles/globalStyles.ts @@ -24,6 +24,59 @@ export const muiTheme = createTheme({ dark: "#C66900", contrastText: "#FFFFFF", }, + mobileHeader: { + main: { + background: "rgba(182, 216, 215, 0.1)", + text: "#FFFFFF", + activeLanguage: "#888888", + }, + sub: { + background: "#B6D8D7", + text: "rgba(18, 109, 127, 0.6)", + activeLanguage: "#126D7F", + }, + }, + mobileNavigation: { + main: { + background: + "linear-gradient(0deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)), linear-gradient(0deg, rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15))", + text: "#FFFFFF", + chip: { + background: "rgba(212, 212, 212, 0.5)", + hover: "rgba(212, 212, 212, 0.7)", + }, + divider: "rgba(255, 255, 255, 0.3)", + languageToggle: { + background: "transparent", + active: { + background: "rgba(255, 255, 255, 0.7)", + hover: "rgba(255, 255, 255, 0.8)", + }, + inactive: { + hover: "rgba(255, 255, 255, 0.1)", + }, + }, + }, + sub: { + background: "#B6D8D7", + text: "rgba(18, 109, 127, 0.9)", + chip: { + background: "rgba(18, 109, 127, 0.2)", + hover: "rgba(18, 109, 127, 0.3)", + }, + divider: "rgba(18, 109, 127, 0.3)", + languageToggle: { + background: "rgba(255, 255, 255, 0.1)", + active: { + background: "rgba(255, 255, 255, 0.9)", + hover: "rgba(255, 255, 255, 1)", + }, + inactive: { + hover: "rgba(255, 255, 255, 0.3)", + }, + }, + }, + }, text: { primary: "#000000", secondary: "#666666", @@ -83,11 +136,7 @@ export const globalStyles = css` -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + overscroll-behavior: none; word-break: keep-all; overflow-wrap: break-all; diff --git a/apps/pyconkr-2025/tsconfig.json b/apps/pyconkr-2025/tsconfig.json new file mode 100644 index 00000000..ff99f6ed --- /dev/null +++ b/apps/pyconkr-2025/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.mts", "vite-env.d.ts", "../../types"] +} diff --git a/apps/pyconkr/vite-env.d.ts b/apps/pyconkr-2025/vite-env.d.ts similarity index 76% rename from apps/pyconkr/vite-env.d.ts rename to apps/pyconkr-2025/vite-env.d.ts index f89a311a..e841c542 100644 --- a/apps/pyconkr/vite-env.d.ts +++ b/apps/pyconkr-2025/vite-env.d.ts @@ -6,9 +6,9 @@ interface ViteTypeOptions { interface ImportMetaEnv { readonly VITE_PYCONKR_BACKEND_API_DOMAIN: string; - readonly VITE_PYCONKR_SHOP_API_DOMAIN: string; - readonly VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME: string; readonly VITE_PYCONKR_SHOP_IMP_ACCOUNT_ID: string; + readonly VITE_FARO_COLLECTOR_URL: string; + readonly VITE_APP_VERSION: string; } interface ImportMeta { diff --git a/apps/pyconkr-2025/vite.config.mts b/apps/pyconkr-2025/vite.config.mts new file mode 100644 index 00000000..c39f8d95 --- /dev/null +++ b/apps/pyconkr-2025/vite.config.mts @@ -0,0 +1,61 @@ +import path from "path"; + +import mdx from "@mdx-js/rollup"; +import react from "@vitejs/plugin-react"; +import { defineConfig, loadEnv } from "vite"; +import mkcert from "vite-plugin-mkcert"; +import svgr from "vite-plugin-svgr"; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, path.resolve(__dirname, "../../dotenv"), ""); + const backendApiDomain = env.VITE_PYCONKR_BACKEND_API_DOMAIN ?? ""; + + // Faro 릴리스 식별: 빌드 시각(KST, YYYY-MM-DD_HH-mm-ss) + git short SHA. 예: 2026-06-17_14-30-12+abc1234 + const sha = process.env.GITHUB_SHA?.slice(0, 7) ?? "local"; + const t = Object.fromEntries( + new Intl.DateTimeFormat("en-US", { + timeZone: "Asia/Seoul", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }) + .formatToParts(new Date()) + .map((p) => [p.type, p.value]) + ); + const version = `${t.year}-${t.month}-${t.day}_${t.hour}-${t.minute}-${t.second}+${sha}`; + + // 백엔드 응답 쿠키의 Domain 속성(예: pycon.kr) 제거 — localhost origin에서 브라우저가 저장 가능하도록. + const proxyOptions = { + target: backendApiDomain, + changeOrigin: true, + cookieDomainRewrite: "", + headers: { "X-Frontend-Domain": "2025.pycon.kr" }, + }; + + return { + base: "/", + envDir: "../../dotenv", + define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify(version) }, + plugins: [react(), mdx(), mkcert({ hosts: ["localhost"] }), svgr()], + resolve: { + alias: { + "@frontend/common": path.resolve(__dirname, "../../packages/common/src"), + "@frontend/shop": path.resolve(__dirname, "../../packages/shop/src"), + "@apps/pyconkr-2025": path.resolve(__dirname, "./src"), + }, + }, + server: { + host: "localhost", + proxy: { + "/v1": proxyOptions, + "/api": proxyOptions, + "/authn": proxyOptions, + }, + }, + }; +}); diff --git a/apps/pyconkr-2026/index.html b/apps/pyconkr-2026/index.html new file mode 100644 index 00000000..5aa8bd94 --- /dev/null +++ b/apps/pyconkr-2026/index.html @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PyCon Korea 2026 + + +
+ + + diff --git a/apps/pyconkr-2026/package.json b/apps/pyconkr-2026/package.json new file mode 100644 index 00000000..0351ea98 --- /dev/null +++ b/apps/pyconkr-2026/package.json @@ -0,0 +1,13 @@ +{ + "name": "@apps/pyconkr-2026", + "dependencies": { + "@frontend/common": "workspace:*", + "@frontend/shop": "workspace:*" + }, + "devDependencies": { + "vite": "^6.3.5", + "vite-plugin-mdx": "^3.6.1", + "vite-plugin-mkcert": "^1.17.8", + "vite-plugin-svgr": "^4.3.0" + } +} diff --git a/apps/pyconkr-2026/public/favicon-180.png b/apps/pyconkr-2026/public/favicon-180.png new file mode 100755 index 00000000..d4101bbf Binary files /dev/null and b/apps/pyconkr-2026/public/favicon-180.png differ diff --git a/apps/pyconkr-2026/public/favicon-192.png b/apps/pyconkr-2026/public/favicon-192.png new file mode 100755 index 00000000..ca83853c Binary files /dev/null and b/apps/pyconkr-2026/public/favicon-192.png differ diff --git a/apps/pyconkr-2026/public/favicon-512.png b/apps/pyconkr-2026/public/favicon-512.png new file mode 100755 index 00000000..456bd3e2 Binary files /dev/null and b/apps/pyconkr-2026/public/favicon-512.png differ diff --git a/apps/pyconkr-2026/public/favicon.ico b/apps/pyconkr-2026/public/favicon.ico new file mode 100755 index 00000000..cc576c9f Binary files /dev/null and b/apps/pyconkr-2026/public/favicon.ico differ diff --git a/apps/pyconkr-2026/public/favicon.svg b/apps/pyconkr-2026/public/favicon.svg new file mode 100755 index 00000000..625fbed4 --- /dev/null +++ b/apps/pyconkr-2026/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/pyconkr-2026/public/site.webmanifest b/apps/pyconkr-2026/public/site.webmanifest new file mode 100755 index 00000000..c97b4816 --- /dev/null +++ b/apps/pyconkr-2026/public/site.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "PyCon Korea", + "icons": [ + { + "src": "favicon-192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "favicon-512.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + }, + { + "src": "favicon-512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "id": "/", + "start_url": "/", + "scope": "/", + "display": "standalone" +} diff --git a/apps/pyconkr-2026/public/slogan_logo.png b/apps/pyconkr-2026/public/slogan_logo.png new file mode 100644 index 00000000..c8bfc6db Binary files /dev/null and b/apps/pyconkr-2026/public/slogan_logo.png differ diff --git a/apps/pyconkr-2026/src/App.tsx b/apps/pyconkr-2026/src/App.tsx new file mode 100644 index 00000000..46df35dc --- /dev/null +++ b/apps/pyconkr-2026/src/App.tsx @@ -0,0 +1,44 @@ +import { useBackendClient, useFlattenSiteMapQuery, useSponsorQuery } from "@frontend/common/hooks/useAPI"; +import { NestedSiteMapSchema } from "@frontend/common/schemas/backendAPI"; +import { buildNestedSiteMap } from "@frontend/common/utils"; +import { FC, useEffect } from "react"; +import { Outlet, ScrollRestoration, useLocation } from "react-router-dom"; +import { isEmpty, isNullish } from "remeda"; + +import { EVENT_NAME } from "./consts"; +import { useAppContext } from "./contexts/app_context"; + +export const App: FC = () => { + const backendAPIClient = useBackendClient(); + const { data: sponsorTiers } = useSponsorQuery(backendAPIClient, { event: EVENT_NAME }); + const { data: flatSiteMap } = useFlattenSiteMapQuery(backendAPIClient); + const siteMapNode = buildNestedSiteMap(flatSiteMap)?.[""]; + + const location = useLocation(); + const { setAppContext, language } = useAppContext(); + + useEffect(() => { + (async () => { + const currentRouteCodes = ["", ...location.pathname.split("/").filter((code) => !isEmpty(code))]; + const currentSiteMapDepth: (NestedSiteMapSchema | undefined)[] = [siteMapNode]; + + for (const routeCode of currentRouteCodes.splice(1)) { + const childrenMap = currentSiteMapDepth + .at(-1) + ?.children?.reduce((acc, child) => ({ ...acc, [child.route_code]: child }), {} as Record); + currentSiteMapDepth.push(childrenMap?.[routeCode]); + if (isNullish(currentSiteMapDepth.at(-1))) break; + } + + setAppContext((ps) => ({ ...ps, siteMapNode, sponsorTiers, currentSiteMapDepth })); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location, language, flatSiteMap, sponsorTiers]); + + return ( + <> + + + + ); +}; diff --git a/apps/pyconkr-2026/src/assets/fonts/exqt_extra_height.ttf b/apps/pyconkr-2026/src/assets/fonts/exqt_extra_height.ttf new file mode 100644 index 00000000..436de545 Binary files /dev/null and b/apps/pyconkr-2026/src/assets/fonts/exqt_extra_height.ttf differ diff --git a/apps/pyconkr-2026/src/assets/fonts/galmuri11.woff2 b/apps/pyconkr-2026/src/assets/fonts/galmuri11.woff2 new file mode 100644 index 00000000..659863b5 Binary files /dev/null and b/apps/pyconkr-2026/src/assets/fonts/galmuri11.woff2 differ diff --git a/apps/pyconkr-2026/src/assets/pyconkr2026_main_cover_image.png b/apps/pyconkr-2026/src/assets/pyconkr2026_main_cover_image.png new file mode 100644 index 00000000..97f37df5 Binary files /dev/null and b/apps/pyconkr-2026/src/assets/pyconkr2026_main_cover_image.png differ diff --git a/apps/pyconkr-2026/src/assets/pythonkorea_dongguk_logo.png b/apps/pyconkr-2026/src/assets/pythonkorea_dongguk_logo.png new file mode 100644 index 00000000..a25ab2be Binary files /dev/null and b/apps/pyconkr-2026/src/assets/pythonkorea_dongguk_logo.png differ diff --git a/apps/pyconkr-2026/src/assets/thirdparty/flickr.svg b/apps/pyconkr-2026/src/assets/thirdparty/flickr.svg new file mode 100644 index 00000000..cae2f99f --- /dev/null +++ b/apps/pyconkr-2026/src/assets/thirdparty/flickr.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/pyconkr-2026/src/components/layout/BreadCrumb/index.tsx b/apps/pyconkr-2026/src/components/layout/BreadCrumb/index.tsx new file mode 100644 index 00000000..caa71985 --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/BreadCrumb/index.tsx @@ -0,0 +1,88 @@ +import { NestedSiteMapSchema } from "@frontend/common/schemas/backendAPI"; +import { Stack, styled } from "@mui/material"; +import { FC } from "react"; +import { Link } from "react-router-dom"; +import { isNonNullish } from "remeda"; +type BreadCrumbPropType = { + title: string; + parentSiteMaps: (NestedSiteMapSchema | undefined)[]; +}; + +export const BreadCrumb: FC = ({ title, parentSiteMaps }) => { + let route = "/"; + return ( + + + {parentSiteMaps + .slice(1, -1) + .filter((routeInfo) => isNonNullish(routeInfo)) + .map(({ route_code, name }, index) => { + route += `${route_code}/`; + return ( + + {index > 0 && >} + + + ); + })} + + {title} + + ); +}; + +const BreadCrumbContainer = styled(Stack)(({ theme }) => ({ + position: "fixed", + + top: "3.625rem", + width: "100%", + height: "4.5rem", + background: "linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.45))", + boxShadow: "0 1px 10px rgba(0, 0, 0, 0.1)", + backdropFilter: "blur(10px)", + + gap: "0.25rem", + justifyContent: "center", + alignItems: "flex-start", + + zIndex: theme.zIndex.appBar - 1, + + paddingRight: "8rem", + paddingLeft: "8rem", + + [theme.breakpoints.down("lg")]: { + paddingRight: "2rem", + paddingLeft: "2rem", + }, + [theme.breakpoints.down("sm")]: { + paddingRight: "1rem", + paddingLeft: "1rem", + }, +})); + +const BreadcrumbPathContainer = styled(Stack)` + font-size: 9.75px; + font-weight: 300; + color: #000000; + + a { + color: #000000; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .separator { + color: #4e869d; + margin: 0 5px; + } +`; + +const PageTitle = styled("h1")` + font-size: 27px; + font-weight: 600; + color: #000000; + margin: 0; +`; diff --git a/apps/pyconkr-2026/src/components/layout/CartBadgeButton/index.tsx b/apps/pyconkr-2026/src/components/layout/CartBadgeButton/index.tsx new file mode 100644 index 00000000..c5f0d9df --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/CartBadgeButton/index.tsx @@ -0,0 +1,48 @@ +import { useCart, useShopClient } from "@frontend/shop/hooks"; +import { ShoppingCart } from "@mui/icons-material"; +import { Badge, badgeClasses, IconButton, styled } from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import { FC } from "react"; +import { Link as RouterLink } from "react-router-dom"; + +type CartBadgeButtonProps = { onClose?: () => void }; + +type InnerCartBadgeButtonPropType = CartBadgeButtonProps & { + loading?: boolean; + count?: number; +}; + +// `as typeof IconButton`으로 styled가 잃어버린 polymorphic 타입(component/to)을 복원한다. +const ColoredIconButton = styled(IconButton)(({ theme }) => ({ + color: theme.palette.primary.nonFocus, + "&:hover": { color: theme.palette.primary.dark }, + "&:active": { color: theme.palette.primary.main }, + transition: "color 0.4s ease, background-color 0.4s ease", +})) as typeof IconButton; + +const InnerCartBadge = styled(Badge)({ [`& .${badgeClasses.badge}`]: { top: "-12px", right: "-3px" } }); + +const InnerCartBadgeButton: FC = ({ loading, count, onClose }) => { + if (!loading && (count === undefined || count <= 0)) return null; + + return ( + onClose?.()}> + + {count !== undefined && count > 0 && } + + ); +}; + +const CartBadgeButtonContent: FC = ({ onClose }) => { + const shopAPIClient = useShopClient(); + const { data: cart } = useCart(shopAPIClient); + return ; +}; + +export const CartBadgeButton: FC = ({ onClose }) => ( + }> + }> + + + +); diff --git a/apps/pyconkr-2026/src/components/layout/Footer/Mobile/MobileFooter.tsx b/apps/pyconkr-2026/src/components/layout/Footer/Mobile/MobileFooter.tsx new file mode 100644 index 00000000..b1d6fa25 --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/Footer/Mobile/MobileFooter.tsx @@ -0,0 +1,166 @@ +import styled from "@emotion/styled"; +import { useEmail } from "@frontend/common/hooks/useEmail"; +import { Article, Email, Facebook, GitHub, Instagram, LinkedIn, X, YouTube } from "@mui/icons-material"; +import { FC } from "react"; + +import FlickrIcon from "@apps/pyconkr-2026/assets/thirdparty/flickr.svg?react"; +import { useAppContext } from "@apps/pyconkr-2026/contexts/app_context"; + +interface IconItem { + icon: FC<{ width?: number; height?: number }>; + alt: string; + href: string; +} + +const defaultIcons: IconItem[] = [ + { icon: Facebook, alt: "facebook", href: "https://www.facebook.com/pyconkorea/" }, + { icon: YouTube, alt: "YouTube", href: "https://www.youtube.com/c/PyConKRtube" }, + { icon: X, alt: "X", href: "https://x.com/PyConKR" }, + { icon: GitHub, alt: "github", href: "https://github.com/pythonkr" }, + { icon: Instagram, alt: "Instagram", href: "https://www.instagram.com/pycon_korea/" }, + { icon: LinkedIn, alt: "LinkedIn", href: "https://www.linkedin.com/company/pyconkorea/" }, + { icon: Article, alt: "blog", href: "https://blog.pycon.kr/" }, + { icon: FlickrIcon, alt: "Flickr", href: "https://www.flickr.com/photos/126829363@N08/" }, +]; + +export default function MobileFooter() { + const { sendEmail } = useEmail(); + const { language } = useAppContext(); + + const title = language === "ko" ? "파이콘 한국 2026" : "PyCon Korea 2026"; + const committeeTitle = + language === "ko" + ? "파이콘 한국 2026은 파이콘 한국 준비위원회가 만들고 있습니다" + : "PyCon Korea 2026 is organized by the PyCon Korea Organizing Team"; + const djangoTitle = language === "ko" ? "파이썬 웹 프레임워크 Django로 만들었습니다" : "Built with the Django web framework for Python"; + + const links = [ + { + text: language === "ko" ? "파이콘 한국 행동 강령(CoC)" : "PyCon Korea Code of Conduct", + href: "https://pythonkr.github.io/pycon-code-of-conduct/ko/coc/a_intent_and_purpose.html", + }, + { text: language === "ko" ? "서비스 이용 약관" : "Terms of Service", href: "/about/terms-of-service" }, + { text: language === "ko" ? "개인 정보 처리 방침" : "Privacy Policy", href: "/about/privacy-policy" }, + ]; + + return ( + + + +
+ +
+ +
+ +
+
+ + {links.map((link, index) => ( + + {link.text} + {index < links.length - 1 && |} + + ))} + + + + + {defaultIcons.map((icon) => ( + + + ))} + +
+
+ ); +} + +const FooterContainer = styled.footer` + background: linear-gradient( + to bottom, + ${({ theme }) => theme.palette.background.default} 0%, + ${({ theme }) => theme.palette.background.paper} 25%, + ${({ theme }) => theme.palette.primary.dark} 75%, + ${({ theme }) => theme.palette.primary.main} 100% + ); + color: ${({ theme }) => theme.palette.text.primary}; + font-size: 0.8rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + max-height: 16rem; + padding: 5rem 0 1rem 0; +`; + +const FooterContent = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; +`; + +const FooterBoldText = styled.text` + font-weight: 600; +`; + +const FooterNormalText = styled.text` + font-weight: 400; +`; + +const FooterSlogan = styled.div` + text-align: center; +`; + +const FooterLinkSlogan = styled.div` + display: flex; + gap: 0.3rem; +`; + +const FooterLinks = styled.div` + display: flex; + align-items: center; + gap: 0.3rem; +`; + +const FooterIcons = styled.div` + display: flex; + align-items: center; + gap: 9px; +`; + +const Link = styled.a` + color: ${({ theme }) => theme.palette.text.primary}; + text-decoration: none; + &:hover { + text-decoration: underline; + } +`; + +const Separator = styled.span` + color: ${({ theme }) => theme.palette.text.primary}; + opacity: 0.5; + margin: 0.05rem 0; +`; + +const IconLink = styled.a` + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: ${({ theme }) => theme.palette.text.primary}; + + &:hover { + opacity: 0.8; + } + + img { + width: 20px; + height: 20px; + } +`; diff --git a/apps/pyconkr/src/components/layout/Footer/index.tsx b/apps/pyconkr-2026/src/components/layout/Footer/index.tsx similarity index 77% rename from apps/pyconkr/src/components/layout/Footer/index.tsx rename to apps/pyconkr-2026/src/components/layout/Footer/index.tsx index 3f940f42..3a813902 100644 --- a/apps/pyconkr/src/components/layout/Footer/index.tsx +++ b/apps/pyconkr-2026/src/components/layout/Footer/index.tsx @@ -1,15 +1,16 @@ import styled from "@emotion/styled"; -import * as Common from "@frontend/common"; +import { useEmail } from "@frontend/common/hooks/useEmail"; import { Article, Email, Facebook, GitHub, Instagram, LinkedIn, OpenInNew, X, YouTube } from "@mui/icons-material"; -import { Button } from "@mui/material"; -import * as React from "react"; +import { Button, useMediaQuery, useTheme } from "@mui/material"; +import { FC, Fragment } from "react"; -import FlickrIcon from "@apps/pyconkr/assets/thirdparty/flickr.svg?react"; +import FlickrIcon from "@apps/pyconkr-2026/assets/thirdparty/flickr.svg?react"; +import { useAppContext } from "@apps/pyconkr-2026/contexts/app_context"; -import { useAppContext } from "../../../contexts/app_context"; +import MobileFooter from "./Mobile/MobileFooter"; interface IconItem { - icon: React.FC<{ width?: number; height?: number }>; + icon: FC<{ width?: number; height?: number }>; alt: string; href: string; } @@ -45,12 +46,18 @@ const defaultIcons: IconItem[] = [ }, ]; -const Bar: React.FC = () =>
|
; +const Bar: FC = () =>
|
; export default function Footer() { - const { sendEmail } = Common.Hooks.Common.useEmail(); + const { sendEmail } = useEmail(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const muiMd = theme.breakpoints.values.md; + const { language } = useAppContext(); + if (isMobile) return ; + const corpPasamoStr = language === "ko" ? "사단법인 파이썬사용자모임" : "Python Korea"; const corpAddressStr = language === "ko" ? "서울특별시 강남구 강남대로84길 24-4" : "24-4, Gangnam-daero 84-gil, Gangnam-gu, Seoul, Republic of Korea"; @@ -63,10 +70,9 @@ export default function Footer() { const corpCheckBtnStr = language === "ko" ? "사업자 정보 확인" : "Check Business Registration Information"; const corpMailOrderSalesRegistrationNumberStr = language === "ko" ? "통신 판매 번호 : 2023-서울강남-03501" : "Mail Order Sales Registration Number : 2023-SEOUL-GANGNAM-03501"; - const hostingProviderStr = - language === "ko" ? "호스팅 제공자 : Amazon Web Services(Korea LLC)" : "Hosting Provider : Amazon Web Services(Korea LLC)"; + const hostingProviderStr = language === "ko" ? "호스팅 제공자 : (주) 스마일서브 (iwinv)" : "Hosting Provider : SMILESERV Co., Ltd. (iwinv)"; const contractEmailStr = language === "ko" ? "문의: " : "Contact: "; - const copyrightStr = language === "ko" ? "© 2025, 사단법인 파이썬사용자모임, All rights reserved." : "© 2025, Python Korea, All rights reserved."; + const copyrightStr = language === "ko" ? "© 2026, 사단법인 파이썬사용자모임, All rights reserved." : "© 2026, Python Korea, All rights reserved."; const links = [ { @@ -86,7 +92,7 @@ export default function Footer() { return ( - + {corpPasamoStr}
{corpAddressStr} @@ -111,12 +117,12 @@ export default function Footer() {
{links.map((link, index) => ( - + {link.text} {index < links.length - 1 && |} - + ))} @@ -136,16 +142,21 @@ export default function Footer() { } const FooterContainer = styled.footer` - background-color: ${({ theme }) => theme.palette.primary.main}; - color: ${({ theme }) => theme.palette.common.white}; + background: linear-gradient( + to bottom, + ${({ theme }) => theme.palette.background.default} 0%, + ${({ theme }) => theme.palette.background.paper} 25%, + ${({ theme }) => theme.palette.primary.dark} 75%, + ${({ theme }) => theme.palette.primary.main} 100% + ); + color: ${({ theme }) => theme.palette.text.primary}; font-size: 0.75rem; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; - max-height: 16rem; - padding: 1rem 0; + padding: 5rem 0 1rem; `; const FooterContent = styled.div` @@ -156,7 +167,7 @@ const FooterContent = styled.div` gap: 0.75rem; `; -const FooterText = styled.div` +const FooterText = styled.div<{ muiMd: number }>` padding: 0 2rem; margin: 0.1rem; @@ -184,6 +195,18 @@ const FooterText = styled.div` strong { font-size: 12pt; } + + @media (min-width: ${(props) => props.muiMd}px) { + font-size: 9pt; + + a > button { + font-size: 8pt; + } + + strong { + font-size: 12pt; + } + } `; const FooterSlogan = styled.div` @@ -195,6 +218,7 @@ const FooterLinks = styled.div` align-items: center; gap: 0.625rem; `; + const FooterIcons = styled.div` display: flex; align-items: center; diff --git a/apps/pyconkr-2026/src/components/layout/Header/Mobile/HamburgerButton.tsx b/apps/pyconkr-2026/src/components/layout/Header/Mobile/HamburgerButton.tsx new file mode 100644 index 00000000..39ea37b7 --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/Header/Mobile/HamburgerButton.tsx @@ -0,0 +1,44 @@ +import { IconButton, styled } from "@mui/material"; +import { FC } from "react"; +interface HamburgerButtonProps { + isOpen: boolean; + onClick: () => void; +} + +export const HamburgerButton: FC = ({ isOpen, onClick }) => { + return ( + + + + + + + + ); +}; + +const StyledIconButton = styled(IconButton)({ + padding: 0, + width: 26, + height: 18, + color: "#ededde", +}); + +const HamburgerIcon = styled("div")<{ isOpen: boolean }>(({ isOpen }) => ({ + width: 26, + height: 18, + position: "relative", + cursor: "pointer", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + + "& span": { + display: "block", + height: isOpen ? 3 : 2, + width: "100%", + backgroundColor: "#ededde", + borderRadius: 1, + transition: "height 0.3s ease", + }, +})); diff --git a/apps/pyconkr-2026/src/components/layout/Header/Mobile/MobileHeader.tsx b/apps/pyconkr-2026/src/components/layout/Header/Mobile/MobileHeader.tsx new file mode 100644 index 00000000..2f73c007 --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/Header/Mobile/MobileHeader.tsx @@ -0,0 +1,62 @@ +import { Box, Stack, styled, Typography } from "@mui/material"; +import { FC, useState } from "react"; +import { Link } from "react-router-dom"; + +import LanguageSelector from "@apps/pyconkr-2026/components/layout/LanguageSelector"; +import { useAppContext } from "@apps/pyconkr-2026/contexts/app_context"; + +import { HamburgerButton } from "./HamburgerButton"; +import { MobileNavigation } from "./MobileNavigation"; +import { PyConLogo } from "@apps/pyconkr-2026/components/pycon_logo"; + +export const MobileHeader: FC = () => { + const { siteMapNode } = useAppContext(); + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + setIsOpen(!isOpen)} /> + + + + + PyCon Korea 2026 + + + + + + + + setIsOpen(false)} siteMapNode={siteMapNode} /> + + ); +}; + +const MobileHeaderContainer = styled("header")<{ isOpen: boolean }>(({ theme, isOpen }) => ({ + display: isOpen ? "none" : "flex", + alignItems: "center", + justifyContent: "space-between", + + position: "sticky", + top: 0, + left: 0, + right: 0, + width: "100%", + height: 60, + padding: "15px 20px", + + backgroundColor: "rgba(18, 9, 30, 0.9)", + backdropFilter: "blur(10px)", + WebkitBackdropFilter: "blur(10px)", + borderBottom: "1px solid rgba(237, 94, 189, 0.2)", + + zIndex: theme.zIndex.appBar, +})); + +const LeftContent = styled(Box)({ display: "flex", alignItems: "center", gap: 17 }); diff --git a/apps/pyconkr-2026/src/components/layout/Header/Mobile/MobileNavigation.tsx b/apps/pyconkr-2026/src/components/layout/Header/Mobile/MobileNavigation.tsx new file mode 100644 index 00000000..f8589d29 --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/Header/Mobile/MobileNavigation.tsx @@ -0,0 +1,271 @@ +import { NestedSiteMapSchema } from "@frontend/common/schemas/backendAPI"; +import { ArrowBack, ArrowForward } from "@mui/icons-material"; +import { Box, Button, Chip, Drawer, IconButton, Stack, styled } from "@mui/material"; +import { FC, useState } from "react"; +import { Link } from "react-router-dom"; +import { isEmpty } from "remeda"; + +import { CartBadgeButton } from "@apps/pyconkr-2026/components/layout/CartBadgeButton"; +import LanguageSelector from "@apps/pyconkr-2026/components/layout/LanguageSelector"; +import { UserMenuButton } from "@apps/pyconkr-2026/components/layout/UserMenuButton"; + +import { HamburgerButton } from "./HamburgerButton"; +import { PyConLogo } from "@apps/pyconkr-2026/components/pycon_logo"; + +type MenuType = NestedSiteMapSchema; + +interface MobileNavigationProps { + isOpen: boolean; + onClose: () => void; + siteMapNode?: MenuType; +} + +type NavigationLevel = "depth1" | "depth2" | "depth3"; + +interface NavigationState { + level: NavigationLevel; + depth1?: MenuType; + depth2?: MenuType; + breadcrumbs: { name: string; level: NavigationLevel }[]; +} + +export const MobileNavigation: FC = ({ isOpen, onClose, siteMapNode }) => { + const [navState, setNavState] = useState({ level: "depth1", breadcrumbs: [] }); + + const resetNavigation = () => setNavState({ level: "depth1", breadcrumbs: [] }); + + const navigateToDepth2 = (depth1: MenuType) => { + setNavState({ level: "depth2", depth1, breadcrumbs: [{ name: depth1.name, level: "depth1" }] }); + }; + + const navigateToDepth3 = (depth2: MenuType) => { + setNavState((prev) => ({ + ...prev, + level: "depth3", + depth2, + breadcrumbs: [...prev.breadcrumbs, { name: depth2.name, level: "depth2" }], + })); + }; + + const goBack = () => { + if (navState.level === "depth3") { + setNavState((prev) => ({ ...prev, level: "depth2", depth2: undefined, breadcrumbs: prev.breadcrumbs.slice(0, -1) })); + } else if (navState.level === "depth2") { + resetNavigation(); + } + }; + + const handleClose = () => { + onClose(); + resetNavigation(); + }; + + const renderDepth1Menu = () => { + if (!siteMapNode) return null; + return ( + + {Object.values(siteMapNode.children) + .filter((s) => !s.hide) + .map((menu) => ( + + {!isEmpty(menu.children) && Object.values(menu.children).some((c) => !c.hide) ? ( + navigateToDepth2(menu)}>{menu.name} + ) : ( + + {menu.name} + + )} + {!isEmpty(menu.children) && Object.values(menu.children).some((c) => !c.hide) && ( + navigateToDepth2(menu)}> + + + )} + + ))} + + ); + }; + + const renderDepth2Menu = () => { + if (!navState.depth1) return null; + return ( + + + + + + {navState.depth1.name} + + + + {Object.values(navState.depth1.children) + .filter((s) => !s.hide) + .map((menu) => ( + + + + + {!isEmpty(menu.children) && Object.values(menu.children).some((c) => !c.hide) && ( + navigateToDepth3(menu)}> + + + )} + + ))} + + + ); + }; + + const renderDepth3Menu = () => { + if (!navState.depth2) return null; + return ( + + + + + + {navState.depth2.name} + + + + {Object.values(navState.depth2.children) + .filter((s) => !s.hide) + .map((menu) => ( + + + + ))} + + + ); + }; + + return ( + + + + + + + + PyCon Korea 2026 + + + + + + {navState.level === "depth1" && renderDepth1Menu()} + {navState.level === "depth2" && renderDepth2Menu()} + {navState.level === "depth3" && renderDepth3Menu()} + + + + + + + + + + + + ); +}; + +const StyledDrawer = styled(Drawer)({ + "& .MuiDrawer-paper": { + width: "70vw", + background: "rgba(18, 9, 30, 0.97)", + backdropFilter: "blur(12px)", + WebkitBackdropFilter: "blur(12px)", + color: "#ededde", + borderTopRightRadius: 15, + borderBottomRightRadius: 15, + borderRight: "1px solid rgba(237, 94, 189, 0.2)", + }, +}); + +const DrawerContent = styled(Box)({ height: "100%", display: "flex", flexDirection: "column" }); + +const NavigationHeader = styled(Box)({ + display: "flex", + alignItems: "center", + padding: "23px 23px 10px 23px", + gap: 17, +}); + +const HeaderTitle = styled("span")({ + color: "#ededde", + fontSize: "16px", + fontWeight: 600, +}); + +const NavigationContent = styled(Box)({ flex: 1, overflow: "auto" }); + +const MenuContainer = styled(Stack)({ padding: "20px 0", gap: "25px" }); + +const MenuItem = styled(Box)({ display: "flex", alignItems: "center", padding: "0 23px", gap: 23 }); + +const MenuLink = styled(Link)({ + color: "#ededde", + textDecoration: "none", + fontSize: "20px", + fontWeight: 600, +}); + +const MenuButton = styled(Button)({ + color: "#ededde", + textTransform: "none", + fontSize: "20px", + fontWeight: 600, + padding: 0, + minWidth: "auto", + justifyContent: "flex-start", +}); + +const MenuArrowButton = styled(IconButton)({ color: "#ededde", padding: 8 }); + +const NavigationMenuSection = styled(Box)({ padding: "20px 0" }); + +const DepthHeader = styled(Box)({ display: "flex", alignItems: "center", padding: "0 23px 12px", gap: 8 }); + +const BackButton = styled(Button)({ + color: "#ed5ebd", + textTransform: "none", + padding: "0 15px 0 0", + minWidth: "auto", +}); + +const DepthTitle = styled("span")({ color: "#ededde", fontSize: "18px", fontWeight: 700 }); + +const DepthDivider = styled("div")({ + margin: "0 23px 16px", + height: 3, + width: "3rem", + borderRadius: 2, + backgroundColor: "#f5c73d", +}); + +const DepthMenuList = styled(Stack)({ padding: "0 23px", gap: "12px" }); + +const Depth2MenuItem = styled(Box)({ display: "flex", alignItems: "center", gap: 8 }); + +const DepthMenuGrid = styled(Box)({ padding: "0 23px", display: "flex", flexWrap: "wrap", gap: "10px" }); + +const MenuChip = styled(Chip)({ + backgroundColor: "rgba(237, 94, 189, 0.15)", + color: "#ededde", + height: 40, + borderRadius: 15, + padding: "10px 4px", + fontSize: "15px", + fontWeight: 600, + border: "1px solid rgba(237, 94, 189, 0.3)", + "&:hover": { backgroundColor: "rgba(237, 94, 189, 0.3)" }, + "& .MuiChip-label": { padding: "0 10px" }, +}); diff --git a/apps/pyconkr-2026/src/components/layout/Header/index.tsx b/apps/pyconkr-2026/src/components/layout/Header/index.tsx new file mode 100644 index 00000000..f1d41ea7 --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/Header/index.tsx @@ -0,0 +1,356 @@ +import { NestedSiteMapSchema } from "@frontend/common/schemas/backendAPI"; +import { ArrowForwardIos, OpenInNew } from "@mui/icons-material"; +import { Box, Button, CircularProgress, Divider, Stack, styled, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material"; +import { MUIStyledCommonProps } from "@mui/system"; +import { CSSProperties, Fragment, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { isEmpty, isNonNullish, isString } from "remeda"; + +import { CartBadgeButton } from "@apps/pyconkr-2026/components/layout/CartBadgeButton"; +import LanguageSelector from "@apps/pyconkr-2026/components/layout/LanguageSelector"; +import { UserMenuButton } from "@apps/pyconkr-2026/components/layout/UserMenuButton"; +import { useAppContext } from "@apps/pyconkr-2026/contexts/app_context"; + +import { PyConLogo } from "../../pycon_logo"; +import { MobileHeader } from "./Mobile/MobileHeader"; + +type MenuType = NestedSiteMapSchema; +type MenuOrUndefinedType = MenuType | undefined; + +type NavigationStateType = { + depth1?: MenuType; + depth2?: MenuType; + depth3?: MenuType; +}; + +const HeaderHeight: CSSProperties["height"] = "3.625rem"; +const BreadCrumbHeight: CSSProperties["height"] = "4.5rem"; +const MaxContentWidth: CSSProperties["maxWidth"] = "1366px"; + +export default function Header() { + const { title, language, siteMapNode, currentSiteMapDepth, shouldShowTitleBanner } = useAppContext(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("lg")); + const [navState, setNavState] = useState({}); + + const resetDepths = () => setNavState({}); + const setDepth1 = (depth1: MenuOrUndefinedType) => setNavState({ depth1 }); + const setDepth2 = (depth2: MenuOrUndefinedType) => setNavState((ps) => ({ ...ps, depth2, depth3: undefined })); + const setDepth3 = (depth3: MenuOrUndefinedType) => setNavState((ps) => ({ ...ps, depth3 })); + + const getDepth2Route = (nextRoute?: string) => (navState.depth1?.route_code || "") + `/${nextRoute || ""}`; + const getDepth3Route = (nextRoute?: string) => getDepth2Route(navState.depth2?.route_code) + `/${nextRoute || ""}`; + + useEffect(resetDepths, [language]); + + if (isMobile) { + return ; + } + + let breadCrumbRoute = ""; + let breadCrumbArray = currentSiteMapDepth.slice(1, -1); + if (isEmpty(breadCrumbArray)) breadCrumbArray = currentSiteMapDepth.slice(0, -1); + + const headerStyle: SxProps = shouldShowTitleBanner ? {} : { backgroundColor: "transparent" }; + + return ( + + + + + + + + + PyCon Korea 2026 + + + + + {siteMapNode ? ( + + {Object.values(siteMapNode.children) + .filter((s) => !s.hide) + .map((r) => ( + + setDepth1(r)} + isActive={navState.depth1?.id === r.id} + endIcon={isString(r.external_link) ? : undefined} + > + {r.name} + + + ))} + + ) : ( + + )} + + + + + + + + + + {navState.depth1 && ( + + + + {navState.depth1.name} + + + + + {Object.values(navState.depth1.children) + .filter((s) => !s.hide) + .map((r) => ( + setDepth2(r)} + onMouseLeave={() => isEmpty(navState.depth2?.children ?? {}) && setDepth2(undefined)} + target={isString(r.external_link) ? "_blank" : undefined} + rel={isString(r.external_link) ? "noopener noreferrer" : undefined} + to={r.external_link || getDepth2Route(r.route_code)} + > + {r.name} + {isString(r.external_link) && } + + ))} + + + {navState.depth2 && !isEmpty(navState.depth2.children) && ( + <> + {!isEmpty(navState.depth2.children) && } + + + {Object.values(navState.depth2.children) + .filter((s) => !s.hide) + .map((r) => ( + setDepth3(r)} + onMouseLeave={() => setDepth3(undefined)} + target={isString(r.external_link) ? "_blank" : undefined} + rel={isString(r.external_link) ? "noopener noreferrer" : undefined} + to={r.external_link || getDepth3Route(r?.route_code)} + > + {r.name} + {isString(r.external_link) && } + + ))} + + + )} + + + + )} + {shouldShowTitleBanner ? ( + <> + + + + {breadCrumbArray + .filter((routeInfo) => isNonNullish(routeInfo)) + .map(({ route_code, name }, index) => { + breadCrumbRoute += `${route_code}/`; + return ( + + {index > 0 && } + + + ); + })} + + + {title} + + + + {/* Spacer for fixed header */} + + + ) : ( + + )} + + ); +} + +const ResponsivePaddingDefinition = ({ theme }: MUIStyledCommonProps) => ({ + paddingRight: theme!.spacing(16), + paddingLeft: theme!.spacing(16), + + [theme!.breakpoints.down("lg")]: { + paddingRight: theme!.spacing(4), + paddingLeft: theme!.spacing(4), + }, + [theme!.breakpoints.down("sm")]: { + paddingRight: theme!.spacing(2), + paddingLeft: theme!.spacing(2), + }, +}); + +const HeaderContainer = styled("header")(({ theme }) => ({ + position: "fixed", + width: "100%", + height: HeaderHeight, + backgroundColor: "rgba(18, 9, 30, 0.85)", + backdropFilter: "blur(12px)", + WebkitBackdropFilter: "blur(12px)", + borderBottom: "1px solid rgba(237, 94, 189, 0.2)", + color: theme.palette.text.primary, + + fontWeight: 500, + + zIndex: theme.zIndex.appBar, + transition: "background-color 0.3s ease-in-out", + "& .header-title-text": { + opacity: 1, + transition: "opacity 0.2s ease", + }, + "&:hover .header-title-text": { + opacity: 1, + }, +})); + +const HeaderInner = styled("div")(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "auto minmax(0, 1fr) auto", + alignItems: "center", + columnGap: theme.spacing(2), + width: "100%", + height: "100%", + maxWidth: MaxContentWidth, + marginInline: "auto", + paddingRight: theme.spacing(2), + paddingLeft: theme.spacing(2), + whiteSpace: "nowrap", +})); + +const NavButton = styled(Button)<{ isActive?: boolean }>(({ theme, isActive }) => ({ + color: isActive ? theme.palette.primary.main : theme.palette.text.primary, + minWidth: 0, + paddingInline: theme.spacing(0.75), + textTransform: "none", + fontSize: "0.75rem", + fontWeight: isActive ? 700 : 400, + whiteSpace: "nowrap", + transition: "color 0.2s ease", + "&:hover": { color: theme.palette.primary.main, backgroundColor: "transparent" }, +})); + +const NavSideElementContainer = styled(Stack)({ + flexDirection: "row", + alignItems: "center", +}); + +const NavOuterContainer = styled(Stack)(({ theme }) => ({ + width: "100vw", + + position: "fixed", + left: 0, + top: HeaderHeight, + + zIndex: theme.zIndex.appBar + 1, + + backgroundColor: "rgba(18, 9, 30, 0.95)", + boxShadow: "0 5px 20px rgba(0, 0, 0, 0.4)", + backdropFilter: "blur(12px)", + + WebkitBackdropFilter: "blur(12px)", + borderBottom: "1px solid rgba(237, 94, 189, 0.2)", +})); + +const NavInnerContainer = styled(Stack)(({ theme }) => ({ + width: "100%", + maxWidth: MaxContentWidth, + marginInline: "auto", + minHeight: "10rem", + overflowY: "auto", + gap: "1rem", + + paddingTop: "1.5rem", + paddingBottom: "2rem", + + ...ResponsivePaddingDefinition({ theme }), +})); + +const Depth1to2Divider = styled(Divider)(({ theme }) => ({ + width: "3rem", + borderBottom: `4px solid ${theme.palette.highlight.main}`, +})); + +const Depth2Item = styled(Link)(({ theme }) => ({ + color: theme.palette.text.primary, + fontWeight: 300, + textDecoration: "none", + width: "fit-content", + borderBottom: "2px solid transparent", + + "&.active": { + fontWeight: 700, + borderBottom: `2px solid ${theme.palette.primary.main}`, + color: theme.palette.primary.main, + }, + "&:hover": { + color: theme.palette.primary.main, + }, +})); + +const Depth2to3Divider = styled(Divider)({ borderColor: "rgba(237, 94, 189, 0.3)" }); + +const Depth3Item = styled(Depth2Item)({ fontSize: "0.75rem" }); + +const BreadCrumbContainer = styled(Stack)(({ theme }) => ({ + position: "fixed", + + top: HeaderHeight, + width: "100%", + height: BreadCrumbHeight, + background: "rgba(18, 9, 30, 0.8)", + boxShadow: "0 1px 10px rgba(0, 0, 0, 0.3)", + backdropFilter: "blur(10px)", + WebkitBackdropFilter: "blur(10px)", + borderBottom: "1px solid rgba(237, 94, 189, 0.15)", + zIndex: theme.zIndex.appBar - 1, +})); + +const BreadCrumbInner = styled(Stack)(({ theme }) => ({ + width: "100%", + height: "100%", + maxWidth: MaxContentWidth, + marginInline: "auto", + gap: "0.25rem", + justifyContent: "center", + alignItems: "flex-start", + + ...ResponsivePaddingDefinition({ theme }), + + "& a": { + color: theme.palette.highlight.main, + fontWeight: 300, + fontSize: "0.75rem", + textDecoration: "none", + + "&:hover": { + textDecoration: "underline", + }, + }, +})); diff --git a/apps/pyconkr-2026/src/components/layout/LanguageSelector/index.tsx b/apps/pyconkr-2026/src/components/layout/LanguageSelector/index.tsx new file mode 100644 index 00000000..c9fa4f4a --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/LanguageSelector/index.tsx @@ -0,0 +1,33 @@ +import { Language } from "@mui/icons-material"; +import { Button, Stack, styled } from "@mui/material"; + +import { LOCAL_STORAGE_LANGUAGE_KEY } from "@apps/pyconkr-2026/consts/local_stroage"; +import { useAppContext } from "@apps/pyconkr-2026/contexts/app_context"; + +export default function LanguageSelector() { + const { language, setAppContext } = useAppContext(); + const toggleLanguage = (newLanguage: "ko" | "en") => { + localStorage.setItem(LOCAL_STORAGE_LANGUAGE_KEY, newLanguage); + setAppContext((ps) => ({ ...ps, language: newLanguage })); + }; + + return ( + + theme.palette.primary.nonFocus, w: "1.5rem", h: "1.5rem" }} /> + toggleLanguage("ko")} selected={language === "ko"}> + KO + + toggleLanguage("en")} selected={language === "en"}> + EN + + + ); +} + +const LanguageItem = styled(Button)<{ selected: boolean }>(({ selected, theme }) => ({ + color: selected ? theme.palette.primary.dark : theme.palette.primary.nonFocus, + minWidth: 0, + padding: "0.375rem 0.25rem", + transition: "color 0.2s ease", + fontSize: "0.75rem", +})); diff --git a/apps/pyconkr-2026/src/components/layout/PageLayout/index.tsx b/apps/pyconkr-2026/src/components/layout/PageLayout/index.tsx new file mode 100644 index 00000000..322bb1c9 --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/PageLayout/index.tsx @@ -0,0 +1,23 @@ +import { Stack, styled } from "@mui/material"; + +export const PageLayout = styled(Stack)(({ theme }) => ({ + height: "75%", + width: "100%", + maxWidth: "1200px", + + justifyContent: "flex-start", + alignItems: "center", + + paddingTop: theme.spacing(8), + paddingBottom: theme.spacing(8), + + paddingRight: theme.spacing(16), + paddingLeft: theme.spacing(16), + + [theme.breakpoints.down("lg")]: { + padding: theme.spacing(4), + }, + [theme.breakpoints.down("sm")]: { + padding: theme.spacing(2), + }, +})); diff --git a/apps/pyconkr-2026/src/components/layout/Sponsor/index.tsx b/apps/pyconkr-2026/src/components/layout/Sponsor/index.tsx new file mode 100644 index 00000000..b90f6eeb --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/Sponsor/index.tsx @@ -0,0 +1,181 @@ +import { isHexColor } from "@frontend/common/utils"; +import { Badge, CircularProgress, Divider, Stack, Tooltip, Typography, TypographyProps, alpha, darken, styled } from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import { Link } from "react-router-dom"; + +import { useAppContext } from "@apps/pyconkr-2026/contexts/app_context"; + +const LogoHeight: React.CSSProperties["height"] = "8rem"; +const LogoWidth: React.CSSProperties["width"] = "15rem"; +const LogoContainerHeight: React.CSSProperties["height"] = `calc(${LogoHeight} + 2rem)`; +const LogoContainerWidth: React.CSSProperties["width"] = `calc(${LogoWidth} + 4rem)`; + +const SponsorContainer = styled(Stack)({ + width: "100%", + alignItems: "center", + justifyContent: "center", +}); + +const SponsorSection = styled(Stack)({ + margin: "8rem 8rem 4rem 8rem", + width: "100%", + maxWidth: "1300px", +}); + +const SponsorStack = styled(Stack)({ + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "center", + alignItems: "center", + padding: "0 1rem", + gap: "4rem", +}); + +const LogoImageEqualWidthContainer = styled(Stack)(({ theme }) => ({ + position: "relative", + alignItems: "center", + justifyContent: "center", + alignContent: "stretch", + height: LogoContainerHeight, + maxHeight: LogoContainerHeight, + minWidth: LogoContainerWidth, + maxWidth: LogoContainerWidth, + border: `1px solid ${theme.palette.primary.light}`, + borderRadius: "0.5rem", + backgroundColor: alpha(theme.palette.primary.light, 0.66), + + transition: "all 0.3s ease-in-out", + + "&:hover": { + backgroundColor: `color-mix(in srgb, ${alpha(theme.palette.primary.light, 1)}, #fff)`, + borderColor: theme.palette.primary.dark, + boxShadow: theme.shadows[3], + }, +})); + +const LogoImageContainer = styled(Stack)({ + width: "auto", + height: "auto", + minHeight: LogoHeight, + maxWidth: LogoWidth, + maxHeight: LogoHeight, + objectFit: "contain", + alignItems: "center", + justifyContent: "center", + margin: "4rem 8rem", +}); + +const LogoImage = styled("img")({ + width: "auto", + height: "auto", + minHeight: LogoHeight, + maxWidth: LogoWidth, + maxHeight: LogoHeight, + objectFit: "contain", +}); + +const LogoBadgeContainer = styled(Stack)({ + position: "absolute", + width: "auto", + height: "auto", + top: "0.5rem", + right: "-0.5rem", + flexDirection: "column", + alignItems: "flex-end", + justifyContent: "center", + gap: "0.25rem", +}); + +const LogoBadge = styled(Badge, { shouldForwardProp: (prop) => prop !== "tagColor" })<{ tagColor?: string | null }>(({ theme, tagColor }) => { + const backgroundColor = isHexColor(tagColor) ? tagColor : theme.palette.primary.main; + const foldColor = isHexColor(tagColor) ? darken(tagColor, 0.3) : theme.palette.primary.dark; + + return { + alignItems: "flex-end", + + "& .MuiBadge-badge": { + position: "relative", + borderRadius: "0.25rem", + padding: "0 0.5rem", + backgroundColor, + color: theme.palette.getContrastText(backgroundColor), + borderEndEndRadius: "0", + transform: "none", + + "&:after": { + content: '""', + position: "absolute", + bottom: "-8px", + right: "-0.1px", + width: 0, + height: 0, + border: "solid 4px", + borderColor: `${foldColor} transparent transparent ${foldColor}`, + }, + }, + }; +}); + +export const Sponsor: React.FC = ErrorBoundary.with( + { + fallback: ( + + 후원사 정보를 불러오는 중 문제가 발생했습니다, +
+ 잠시 후 다시 시도해 주세요. +
+ ), + }, + Suspense.with({ fallback: }, () => { + const { sponsorTiers, language } = useAppContext(); + if (!sponsorTiers) return ; + + const textProps: TypographyProps = { + textAlign: "center", + fontWeight: "bold", + }; + + const titleStr = language === "ko" ? "후원사 목록" : "Sponsor List"; + + const visibleSponsorTiers = sponsorTiers.filter((t) => t.sponsors.length); + if (!visibleSponsorTiers.length) return null; + + return ( + + + + + {visibleSponsorTiers.map((sponsorTier, i, a) => ( + + + + {sponsorTier.sponsors.map((sponsor) => { + const sponsorName = sponsor.name.replace(/\\n/g, "\n"); + const sponsorNameContent = ; + return ( + + + + + {sponsor.tags.map((tag) => ( + + ))} + + + + + + + + ); + })} + + {i !== a.length - 1 && } + + ))} + + + + ); + }) +); diff --git a/apps/pyconkr-2026/src/components/layout/UserMenuButton/index.tsx b/apps/pyconkr-2026/src/components/layout/UserMenuButton/index.tsx new file mode 100644 index 00000000..81862665 --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/UserMenuButton/index.tsx @@ -0,0 +1,173 @@ +import { useCommonContext } from "@frontend/common/hooks/useCommonContext"; +import { UserSignInAccount, UserSignInMethod } from "@frontend/shop/components/common"; +import { useShopClient, useSignOutMutation, useUserStatus } from "@frontend/shop/hooks"; +import { UserSignedInStatus } from "@frontend/shop/schemas"; +import { AccountCircle, CalendarMonth, Login, Logout, ManageAccounts, Receipt } from "@mui/icons-material"; +import { Button, Divider, IconButton, ListItemIcon, ListItemText, Menu, MenuItem, styled, Typography } from "@mui/material"; +import { ErrorBoundary, Suspense } from "@suspensive/react"; +import { FC, MouseEvent, useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; + +import { useAppContext } from "@apps/pyconkr-2026/contexts/app_context"; + +const ColoredIconButton = styled(IconButton)(({ theme }) => ({ + color: theme.palette.primary.nonFocus, + "&:hover": { color: theme.palette.primary.dark }, + "&:active": { color: theme.palette.primary.main }, + transition: "color 0.4s ease, background-color 0.4s ease", +})); + +const ColoredTextButton = styled(Button)(({ theme }) => ({ + color: theme.palette.primary.nonFocus, + textTransform: "none", + fontWeight: 500, + maxWidth: "12rem", + "& .MuiButton-startIcon": { marginRight: theme.spacing(0.75) }, + "&:hover": { color: theme.palette.primary.dark, backgroundColor: "transparent" }, + "&:active": { color: theme.palette.primary.main }, + transition: "color 0.4s ease, background-color 0.4s ease", +})); + +const LabelText = styled("span")({ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +type UserMenuButtonProps = { onClose?: () => void; showLabel?: boolean }; + +type InnerUserMenuButtonPropType = UserMenuButtonProps & { + loading?: boolean; + user?: UserSignedInStatus["data"]["user"]; + onSignOut?: () => void; +}; + +const InnerUserMenuButton: FC = ({ loading, user, onSignOut, onClose, showLabel }) => { + const { language } = useAppContext(); + const { accountsDomain } = useCommonContext(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleOpen = (event: MouseEvent) => setAnchorEl(event.currentTarget); + const handleMenuClose = () => setAnchorEl(null); + + const signInLabel = language === "ko" ? "로그인" : "Sign In"; + const myTimetableLabel = language === "ko" ? "나의 세션 시간표 보기" : "View My Session Schedule"; + const orderHistoryLabel = language === "ko" ? "결제 내역" : "Order History"; + const manageAccountLabel = language === "ko" ? "계정 관리" : "Manage Account"; + const signOutLabel = language === "ko" ? "로그아웃" : "Sign Out"; + + const closeAll = () => { + handleMenuClose(); + onClose?.(); + }; + + const goToAccounts = () => { + handleMenuClose(); + onClose?.(); + window.open(accountsDomain, "_blank", "noopener,noreferrer"); + }; + + const handleSignOut = () => { + handleMenuClose(); + onClose?.(); + onSignOut?.(); + }; + + const triggerIcon = user ? : ; + const triggerLabel = user ? user.display || user.email : signInLabel; + const ariaProps = { + "aria-controls": open ? "user-menu" : undefined, + "aria-haspopup": "true" as const, + "aria-expanded": open ? ("true" as const) : undefined, + }; + + return ( + <> + {showLabel ? ( + + {triggerLabel} + + ) : ( + + {triggerIcon} + + )} + + {user ? ( + [ + + theme.palette.text.primary }} component="span"> + ( 소셜 로그인 - ) + + , + , + + + + + {myTimetableLabel} + , + + + + + {orderHistoryLabel} + , + + + + + {manageAccountLabel} + , + + + + + {signOutLabel} + , + ] + ) : ( + + + + + {signInLabel} + + )} + + + ); +}; + +const UserMenuButtonContent: FC = ({ onClose, showLabel }) => { + const shopAPIClient = useShopClient(); + const signOutMutation = useSignOutMutation(shopAPIClient); + const { data } = useUserStatus(shopAPIClient); + + return ; +}; + +export const UserMenuButton: FC = ({ onClose, showLabel }) => ( + }> + }> + + + +); + +const UserNameItem = styled(MenuItem)(({ theme }) => ({ + opacity: 1, + "&.Mui-disabled": { opacity: 1 }, + pointerEvents: "none", + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), +})); diff --git a/apps/pyconkr-2026/src/components/layout/index.tsx b/apps/pyconkr-2026/src/components/layout/index.tsx new file mode 100644 index 00000000..568bc80d --- /dev/null +++ b/apps/pyconkr-2026/src/components/layout/index.tsx @@ -0,0 +1,40 @@ +import styled from "@emotion/styled"; +import { Stack } from "@mui/material"; +import { Outlet } from "react-router-dom"; + +import { useAppContext } from "@apps/pyconkr-2026/contexts/app_context"; + +import Footer from "./Footer"; +import Header from "./Header"; +import { Sponsor } from "./Sponsor"; + +export default function MainLayout() { + const { shouldShowSponsorBanner } = useAppContext(); + + return ( + +
+ + + + {shouldShowSponsorBanner && } +