[
  {
    "path": ".browserslistrc",
    "content": "[production]\niOS >= 13.4, last 3 Safari versions, > 0.5% in KR, not dead\n\n[development]\nlast 1 chrome version\n"
  },
  {
    "path": ".editorconfig",
    "content": "# http://editorconfig.org\nroot = true\n\n[*.{js,ts,tsx}]\ncharset = utf-8\nend_of_line = lf\nindent_style = space\nindent_size = 2\ninsert_final_newline = true\nmax_line_length = 80\ntrim_trailing_whitespace = true\n\n[*.{json,yml,yaml}]\ncharset = utf-8\nend_of_line = lf\nindent_style = space\nindent_size = 2\ninsert_final_newline = true\ntrim_trailing_whitespace = true"
  },
  {
    "path": ".eslintrc.js",
    "content": "/** @type import('eslint').Linter.Config */\nmodule.exports = {\n  root: true,\n  extends: [\n    '@titicaca/eslint-config-triple',\n    '@titicaca/eslint-config-triple/frontend',\n    '@titicaca/eslint-config-triple/prettier',\n    'plugin:storybook/recommended',\n  ],\n  overrides: [\n    {\n      files: ['*.test.*', '*.spec.*'],\n      extends: [\n        'plugin:jest/style',\n        'plugin:jest/recommended',\n        'plugin:jest-dom/recommended',\n        'plugin:testing-library/react',\n      ],\n    },\n  ],\n}\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "* @titicacadev/frontend\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "<!-- 이 PR을 요약한 내용으로 위 제목 폼을 채워 주세요. -->\n\n## PR 설명\n\n<!-- PR의 목적, PR이 구현하는 기획이나 디자인(figma, slack or jira) 등 리뷰어가 참고할 내용을 적어주세요. -->\n\n## 변경 내역\n\n<!-- 실제 변경이 발생한 부분을 위주로 서술해주세요. -->\n<!-- 필요하다면 코드 레벨의 설명도 곁들일 수 있습니다. -->\n<!-- 리뷰어가 변경점에 대해 빠르게 이해를 할 수 있도록 서술해주세요. -->\n\n## 체크리스트\n\n<!-- 프로젝트별로 반드시 확인해야 하는 항목을 나열해주세요. -->\n<!-- 각 항목을 읽어 보시고, 해당하는 항목의 주석을 해제해주세요. -->\n<!-- 조금이라도 명확하지 않은 부분이 있다면 슬랙 #triple-web-dev 채널로 질문해주세요! -->\n<!-- - [x] 주요 동선의 통합 테스트를 진행하셨나요? -->\n<!-- - [x] 기획자/디자이너에게 확인을 받았나요? 혹은 확인이 필요없는 이슈인가요? -->\n\n## 스크린샷 & URL\n\n<!-- 이 변경과 관련있는 스크린샷을 첨부해 주세요. -->\n<!-- 반드시 필요한 게 아니라면 생략 가능합니다. -->\n<!-- 변경 사항을 확인할 수 있는 샘플 URL을 알려주세요. 바로 동작하는 링크일수록 좋습니다. -->\n"
  },
  {
    "path": ".github/workflows/add-label.yaml",
    "content": "name: Label a PR with its updated packages\n\non:\n  pull_request:\n    types: [opened, synchronize, ready_for_review]\n\njobs:\n  label-pr:\n    if: ${{ github.event.pull_request.draft == false && github.event.pull_request.user.login != 'triple-bot' && !contains(github.event.pull_request.labels.*.name, 'release') }}\n    runs-on: ${{ vars.NOL_RUNNER }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.pull_request.head.ref }}\n\n      - name: Get changed packages\n        run: |\n          RESPONSE=$(curl -H \"Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}\" \\\n          -H \"Accept: application/vnd.github.v3+json\" \\\n          \"https://api.github.com/repos/${{ github.repository }}/compare/${{ github.event.pull_request.base.ref }}...${{ github.event.pull_request.head.ref }}\")\n\n          if [[ $(echo \"$RESPONSE\" | jq '.total_commits') == null ]]; then\n            echo $RESPONSE | jq '.message'\n            exit 1\n          fi\n\n          CHANGED_PACKAGES=$(echo \"$RESPONSE\" | jq '[.files[].filename | capture(\"packages/(?<packageName>[^/]+)/\") .packageName] | unique | tostring')\n          echo \"CHANGED_PACKAGES=$CHANGED_PACKAGES\" >> $GITHUB_ENV\n\n      - name: Label PR\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          PACKAGE_LABELS=$(echo ${{ env.CHANGED_PACKAGES }} | jq .)\n          curl -X POST \\\n            -H \"Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}\" \\\n            -H \"Accept: application/vnd.github.v3+json\" \\\n            https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels \\\n            -d \"{\\\"labels\\\": $PACKAGE_LABELS }\"\n"
  },
  {
    "path": ".github/workflows/cd.yaml",
    "content": "name: CD\n\non:\n  push:\n    branches-ignore:\n      - '**'\n    tags:\n      - 'release-prod-*'\n      - 'release-pr-*'\n\nenv:\n  GITHUB_API_URL_BASE: https://api.github.com/repos/${{ github.repository }}\n  # Node.js\n  NODE_VERSION: 'lts/*'\n  PNPM_VERSION: '9'\n  NPM_REGISTRY_URL: 'https://registry.npmjs.org'\n  NODE_AUTH_TOKEN: ${{ secrets.READ_ONLY_NPM_TOKEN }}\n  HUSKY: 0\n  # Nx Cloud\n  NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}\n\njobs:\n  wait-for-ci:\n    runs-on: ${{ vars.NOL_RUNNER }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: pnpm/action-setup@v3\n        with:\n          version: ${{ env.PNPM_VERSION }}\n\n      - name: Use Node.js ${{ env.NODE_VERSION }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          registry-url: ${{ env.NPM_REGISTRY_URL }}\n          # cache: 'pnpm'\n\n      - name: Wait for CI\n        uses: fountainhead/action-wait-for-check@v1.1.0\n        id: wait-for-ci\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          checkName: build\n\n      - name: Shutdown workflow\n        if: steps.wait-for-ci.outputs.conclusion != 'success'\n        run: node -e 'process.exit(1)'\n\n  release:\n    needs: wait-for-ci\n    if: startsWith(github.event.ref, 'refs/tags/release-prod-')\n    runs-on: ${{ vars.NOL_RUNNER }}\n    permissions:\n      contents: read\n      id-token: write # npm provenance를 위한 OIDC 토큰 발급 권한\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: pnpm/action-setup@v3\n        with:\n          version: ${{ env.PNPM_VERSION }}\n\n      - name: Use Node.js ${{ env.NODE_VERSION }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          registry-url: ${{ env.NPM_REGISTRY_URL }}\n          # cache: 'pnpm'\n\n      - name: Get release version\n        run: echo \"DEPLOY_VERSION=v$(cat ./lerna.json | jq -r '.version')\" >> $GITHUB_ENV\n\n      - name: Install dependencies\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.READ_ONLY_NPM_TOKEN }}\n        run: pnpm install\n\n      - run: pnpm run build\n\n      - name: Release\n        run: pnpm -r publish --no-git-checks --provenance\n\n  tag:\n    needs: release\n    if: success()\n    runs-on: ${{ vars.NOL_RUNNER }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Get released version\n        run: echo \"TAG_NAME=v$(cat ./lerna.json | jq -r '.version')\" >> $GITHUB_ENV\n\n      - name: Create tag object\n        id: create-tag-object\n        # https://docs.github.com/en/free-pro-team@latest/rest/reference/git#create-a-tag-object\n        run: |\n          curl --url $GITHUB_API_URL_BASE/git/tags \\\n            -f \\\n            --request POST \\\n            -H 'Authorization: token ${{ secrets.TRIPLE_BOT_GITHUB_TOKEN }}' \\\n            -H 'Content-Type: application/json' \\\n            -d \"{\\\"tag\\\":\\\"$TAG_NAME\\\",\\\"message\\\":\\\"released at \\`${{ github.event.updated_at }}\\`\\\",\\\"object\\\":\\\"${GITHUB_SHA}\\\",\\\"type\\\":\\\"commit\\\"}\" \\\n          > tag.json\n          echo \"::set-output name=tag-sha::$(node -p -e 'require(`./tag.json`).sha')\"\n\n      - name: Check tag ref exist\n        id: check-tag-ref\n        # https://docs.github.com/en/free-pro-team@latest/rest/reference/git#get-a-reference\n        run: |\n          curl --url $GITHUB_API_URL_BASE/git/refs/tags/$TAG_NAME \\\n            -sI \\\n            -o /dev/null \\\n            -w \"%{http_code}\" \\\n            -H 'Authorization: token ${{ secrets.TRIPLE_BOT_GITHUB_TOKEN }}' \\\n          > status\n          echo \"::set-output name=status::$(cat status)\"\n\n      - name: Create new tag ref\n        if: ${{ steps.check-tag-ref.outputs.status != '200' }}\n        env:\n          TAG_SHA: ${{ steps.create-tag-object.outputs.tag-sha }}\n        # https://docs.github.com/en/free-pro-team@latest/rest/reference/git#create-a-reference\n        run: |\n          curl --url $GITHUB_API_URL_BASE/git/refs \\\n            -f \\\n            --request POST \\\n            -H 'Authorization: token ${{ secrets.TRIPLE_BOT_GITHUB_TOKEN }}' \\\n            -H 'Content-Type: application/json' \\\n            -d \"{\\\"ref\\\":\\\"refs/tags/$TAG_NAME\\\",\\\"sha\\\":\\\"$TAG_SHA\\\"}\"\n\n      - name: Update tag ref\n        if: ${{ steps.check-tag-ref.outputs.status == '200' }}\n        env:\n          TAG_SHA: ${{ steps.create-tag-object.outputs.tag-sha }}\n        # https://docs.github.com/en/free-pro-team@latest/rest/reference/git#update-a-reference\n        run: |\n          curl --url $GITHUB_API_URL_BASE/git/refs/tags/$TAG_NAME \\\n            -f \\\n            --request PATCH \\\n            -H 'Authorization: token ${{ secrets.TRIPLE_BOT_GITHUB_TOKEN }}' \\\n            -H 'Content-Type: application/json' \\\n            -d \"{\\\"force\\\":true,\\\"sha\\\":\\\"${TAG_SHA}\\\"}\"\n\n  canary-release:\n    needs: wait-for-ci\n    if: startsWith(github.event.ref, 'refs/tags/release-pr-')\n    runs-on: ${{ vars.NOL_RUNNER }}\n    permissions:\n      contents: read\n      id-token: write # npm provenance를 위한 OIDC 토큰 발급 권한\n      issues: write # PR 코멘트 작성 권한\n      pull-requests: write # PR 코멘트 작성 권한\n\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: pnpm/action-setup@v3\n        with:\n          version: ${{ env.PNPM_VERSION }}\n\n      - name: Use Node.js ${{ env.NODE_VERSION }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          registry-url: ${{ env.NPM_REGISTRY_URL }}\n          # cache: 'pnpm'\n\n      - name: Get release version\n        env:\n          TAG_NAME: ${{ github.event.ref }}\n        run: |\n          NEXT_VERSION=$(node -e \"const [major, minor, patch] = require('./lerna.json').version.split('.');console.log(['v'+major, minor, parseInt(patch, 10) + 1].join('.'))\")\n\n          PR_NUMBER=${TAG_NAME:21} # refs/tags/release-pr-<num>에서 <num>만 추출\n\n          REF_COUNT=$(node -p -e \"Math.max(0, parseInt((\\\"$(git describe --always --long --dirty --match \"v*.*.*\")\\\".match(/^(?:.*@)?.*-(\\d+)-.*?$/) || ['0', '0'])[1], 10) - 1)\")\n\n          echo \"PR_NUMBER=$PR_NUMBER\" >> $GITHUB_ENV\n          echo \"DEPLOY_VERSION=$NEXT_VERSION-pr-$PR_NUMBER.$REF_COUNT\" >> $GITHUB_ENV\n\n      - name: Install dependencies\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.READ_ONLY_NPM_TOKEN }}\n        run: pnpm install\n\n      - run: pnpm run build\n\n      - run: |\n          pnpm exec lerna version $DEPLOY_VERSION \\\n            --amend \\\n            --force-publish \\\n            --ignore-scripts \\\n            --no-git-tag-version \\\n            --preid \"pr-$PR_NUMBER\" \\\n            --yes\n\n      - name: Publish as canary\n        run: pnpm -r publish --tag canary --no-git-checks --provenance\n\n      - name: Notify released version on pull request\n        run: |\n          curl \\\n            --url $GITHUB_API_URL_BASE/issues/${{ env.PR_NUMBER }}/comments \\\n            -H \"Authorization: token ${{ secrets.GITHUB_TOKEN }}\" \\\n            -H \"Content-Type: application/json\" \\\n            -f --request POST \\\n            -d \"{\\\"body\\\":\\\"${{ env.DEPLOY_VERSION }} has been published!\\\"}\"\n"
  },
  {
    "path": ".github/workflows/ci.yaml",
    "content": "name: CI\non:\n  push:\n    branches:\n      - '**'\n    tags-ignore:\n      - '**'\n\nenv:\n  # Node.js\n  NODE_VERSION: 'lts/*'\n  PNPM_VERSION: '9'\n  NPM_REGISTRY_URL: 'https://registry.npmjs.org'\n  NODE_AUTH_TOKEN: ${{ secrets.READ_ONLY_NPM_TOKEN }}\n  HUSKY: 0\n  # Nx Cloud\n  NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}\n\njobs:\n  check-dependencies:\n    runs-on: ${{ vars.NOL_RUNNER }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: pnpm/action-setup@v3\n        with:\n          version: ${{ env.PNPM_VERSION }}\n\n      - name: Use Node.js ${{ env.NODE_VERSION }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          registry-url: ${{ env.NPM_REGISTRY_URL }}\n          cache: 'pnpm'\n\n      - run: pnpm install\n\n      - name: If working tree dirty, shutdown job\n        id: check-working-tree-clean\n        run: |\n          if [[ $(git diff --stat) != '' ]]; then\n            git diff --stat\n            exit 1\n          fi\n\n  lint:\n    runs-on: ${{ vars.NOL_RUNNER }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: pnpm/action-setup@v3\n        with:\n          version: ${{ env.PNPM_VERSION }}\n\n      - name: Use Node.js ${{ env.NODE_VERSION }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          registry-url: ${{ env.NPM_REGISTRY_URL }}\n          cache: 'pnpm'\n\n      - run: pnpm install\n\n      - name: Lint\n        id: lint\n        run: pnpm run lint\n\n  build:\n    runs-on: ${{ vars.NOL_RUNNER }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: pnpm/action-setup@v3\n        with:\n          version: ${{ env.PNPM_VERSION }}\n\n      - name: Use Node.js ${{ env.NODE_VERSION }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          registry-url: ${{ env.NPM_REGISTRY_URL }}\n          cache: 'pnpm'\n\n      - run: pnpm install\n\n      - name: Build\n        id: build\n        run: pnpm run build\n\n  test:\n    runs-on: ${{ vars.NOL_RUNNER }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: pnpm/action-setup@v3\n        with:\n          version: ${{ env.PNPM_VERSION }}\n\n      - name: Use Node.js ${{ env.NODE_VERSION }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          registry-url: ${{ env.NPM_REGISTRY_URL }}\n          cache: 'pnpm'\n\n      - run: pnpm install\n\n      - name: Generate coverage report\n        id: test\n        run: pnpm run test:coverage -- --ci --reporters github-actions --reporters summary --maxWorkers 2\n\n      - name: Upload coverage to Codecov\n        id: upload-coverage-to-codecov\n        uses: codecov/codecov-action@v3\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          directory: ./coverage\n\n  chromatic-deployment:\n    runs-on: ${{ vars.NOL_RUNNER }}\n\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: pnpm/action-setup@v3\n        with:\n          version: ${{ env.PNPM_VERSION }}\n\n      - name: Use Node.js ${{ env.NODE_VERSION }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          registry-url: ${{ env.NPM_REGISTRY_URL }}\n          cache: 'pnpm'\n\n      - run: pnpm install\n\n      - name: Publish to Chromatic\n        if: github.ref != 'refs/heads/main'\n        id: publish-to-chromatic\n        uses: chromaui/action@latest\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}\n          zip: true\n\n      - name: Publish to Chromatic and auto accept changes\n        if: github.ref == 'refs/heads/main'\n        uses: chromaui/action@latest\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}\n          autoAcceptChanges: true\n"
  },
  {
    "path": ".github/workflows/pr-closed.yaml",
    "content": "name: PR Closed\n\non:\n  pull_request:\n    types:\n      - closed\n\nenv:\n  GITHUB_API_URL_BASE: https://api.github.com/repos/${{ github.repository }}\n  TAG_NAME: release-pr-${{ github.event.pull_request.number }}\n\njobs:\n  delete-release-tag:\n    runs-on: ${{ vars.NOL_RUNNER }}\n\n    steps:\n      - name: Check tag ref exist\n        id: check-tag-ref\n        # https://docs.github.com/en/free-pro-team@latest/rest/reference/git#get-a-reference\n        run: |\n          curl --url $GITHUB_API_URL_BASE/git/refs/tags/$TAG_NAME \\\n            -sI \\\n            -o /dev/null \\\n            -w \"%{http_code}\" \\\n            -H 'Authorization: token ${{ secrets.TRIPLE_BOT_GITHUB_TOKEN }}' \\\n          > status\n          echo \"::set-output name=status::$(cat status)\"\n\n      - name: Delete release tag of this pull request\n        uses: dev-drprasad/delete-tag-and-release@v0.2.1\n        with:\n          delete_release: true\n          tag_name: ${{ env.TAG_NAME }}\n          repo: ${{ github.repository }}\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/tag-with-comment.yaml",
    "content": "name: tag-with-comment\n\non:\n  issue_comment:\n    types:\n      - 'created'\n\nenv:\n  GITHUB_API_URL_BASE: https://api.github.com/repos/${{ github.repository }}\n  # Node.js\n  NODE_VERSION: 'lts/*'\n  PNPM_VERSION: '9'\n  NPM_REGISTRY_URL: 'https://registry.npmjs.org'\n  SLACK_GITHUB_REPOSITORY: ${{ github.repository }}\n\njobs:\n  tag-with-comment:\n    if: github.event.issue.state == 'open' && github.event.issue.pull_request && endsWith(github.event.comment.body, 'release-canary')\n    runs-on: ${{ vars.NOL_RUNNER }}\n\n    steps:\n      - uses: pnpm/action-setup@v3\n        with:\n          version: ${{ env.PNPM_VERSION }}\n\n      - name: Use Node.js ${{ env.NODE_VERSION }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          registry-url: ${{ env.NPM_REGISTRY_URL }}\n\n      - name: Recognize head SHA of pull request\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          NODE_AUTH_TOKEN: ${{ secrets.READ_ONLY_NPM_TOKEN }}\n        run: |\n          pnpm dlx @titicaca/gha-tools fetch-github-pr ${{ github.event.issue.number }}\n          PR_SHA=$(cat ./pr.json | jq -r '.head.sha')\n          PR_REF=$(cat ./pr.json | jq -r '.head.ref')\n          echo \"PR_NUMBER=${{ github.event.issue.number }}\" >> $GITHUB_ENV\n          echo \"PR_SHA=$PR_SHA\" >> $GITHUB_ENV\n          echo \"PR_REF=$PR_REF\" >> $GITHUB_ENV\n          echo \"SLACK_GITHUB_REF=$PR_REF\" >> $GITHUB_ENV\n          echo \"PR_SHORT_SHA=${PR_SHA:0:7}\" >> $GITHUB_ENV\n          echo \"PR_TITLE=$(cat ./pr.json | jq -r '.title')\" >> $GITHUB_ENV\n          echo \"TAG_NAME=release-pr-${{ github.event.issue.number }}\" >> $GITHUB_ENV\n\n      - name: Leave reaction to comment\n        run: |\n          curl \\\n            --url https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \\\n            -H \"Authorization: token ${{ secrets.GITHUB_TOKEN }}\" \\\n            -H \"Accept: application/vnd.github.squirrel-girl-preview+json\" \\\n            -H \"Content-Type: application/json\" \\\n            -f --request POST \\\n            -d \"{\\\"content\\\":\\\"+1\\\"}\"\n\n      - name: Create tag object\n        id: create-tag-object\n        # https://docs.github.com/en/free-pro-team@latest/rest/reference/git#create-a-tag-object\n        run: |\n          curl --url $GITHUB_API_URL_BASE/git/tags \\\n            -f \\\n            --request POST \\\n            -H 'Authorization: token ${{ secrets.TRIPLE_BOT_GITHUB_TOKEN }}' \\\n            -H 'Content-Type: application/json' \\\n            -d \"{\\\"tag\\\":\\\"$TAG_NAME\\\",\\\"message\\\":\\\"released at \\`${{ github.event.updated_at }}\\`\\\",\\\"object\\\":\\\"${PR_SHA}\\\",\\\"type\\\":\\\"commit\\\"}\" \\\n          > tag.json\n          echo \"::set-output name=tag-sha::$(node -p -e 'require(`./tag.json`).sha')\"\n\n      - name: Check tag ref exist\n        id: check-tag-ref\n        # https://docs.github.com/en/free-pro-team@latest/rest/reference/git#get-a-reference\n        run: |\n          curl --url $GITHUB_API_URL_BASE/git/refs/tags/$TAG_NAME \\\n            -sI \\\n            -o /dev/null \\\n            -w \"%{http_code}\" \\\n            -H 'Authorization: token ${{ secrets.TRIPLE_BOT_GITHUB_TOKEN }}' \\\n          > status\n          echo \"::set-output name=status::$(cat status)\"\n\n      - name: Create new tag ref\n        if: ${{ steps.check-tag-ref.outputs.status != '200' }}\n        env:\n          TAG_SHA: ${{ steps.create-tag-object.outputs.tag-sha }}\n        # https://docs.github.com/en/free-pro-team@latest/rest/reference/git#create-a-reference\n        run: |\n          curl --url $GITHUB_API_URL_BASE/git/refs \\\n            -f \\\n            --request POST \\\n            -H 'Authorization: token ${{ secrets.TRIPLE_BOT_GITHUB_TOKEN }}' \\\n            -H 'Content-Type: application/json' \\\n            -d \"{\\\"ref\\\":\\\"refs/tags/$TAG_NAME\\\",\\\"sha\\\":\\\"$TAG_SHA\\\"}\"\n\n      - name: Update tag ref\n        if: ${{ steps.check-tag-ref.outputs.status == '200' }}\n        env:\n          TAG_SHA: ${{ steps.create-tag-object.outputs.tag-sha }}\n        # https://docs.github.com/en/free-pro-team@latest/rest/reference/git#update-a-reference\n        run: |\n          curl --url $GITHUB_API_URL_BASE/git/refs/tags/$TAG_NAME \\\n            -f \\\n            --request PATCH \\\n            -H 'Authorization: token ${{ secrets.TRIPLE_BOT_GITHUB_TOKEN }}' \\\n            -H 'Content-Type: application/json' \\\n            -d \"{\\\"force\\\":true,\\\"sha\\\":\\\"${TAG_SHA}\\\"}\"\n"
  },
  {
    "path": ".github/workflows/update-changelog.yaml",
    "content": "name: Update CHANGELOG\n\non:\n  pull_request:\n    types:\n      - labeled\n\nenv:\n  COMMIT_USER_EMAIL: triple-bot@interpark.com\n  COMMIT_USER_NAME: TRIPLE Bot\n  CURRENT_VERSION: ${{ github.event.pull_request.milestone.title }}\n  NODE_VERSION: 'lts/*'\n  NPM_REGISTRY_URL: 'https://registry.npmjs.org'\n  NODE_AUTH_TOKEN: ${{ secrets.READ_ONLY_NPM_TOKEN }}\n\njobs:\n  update-changelog:\n    runs-on: ${{ vars.NOL_RUNNER }}\n    if: ${{ github.event.label.name == 'release' }}\n    steps:\n      - name: Check if a milestone exists on PR\n        run: |\n          if [[ $(echo ${{ github.event.pull_request.milestone }}) == \"\" ]]; then\n            echo \"마일스톤을 등록해 주세요.\"\n            exit 1\n          fi\n\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          token: ${{ secrets.TRIPLE_BOT_GITHUB_TOKEN }}\n          ref: ${{ github.event.pull_request.head.ref }}\n\n      - name: Setup Node.js ${{ env.NODE_VERSION }}\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          registry-url: ${{ env.NPM_REGISTRY_URL }}\n\n      - name: Execute Changelog JavaScript\n        run: node scripts/changelog.js\n        env:\n          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Run prettier on CHANGELOG.md\n        run: npx prettier --no-config --write CHANGELOG.md\n\n      - name: Commit and push updated CHANGELOG\n        run: |\n          git config --local user.email \"${{ env.COMMIT_USER_EMAIL }}\"\n          git config --local user.name \"${{ env.COMMIT_USER_NAME }}\"\n          git add CHANGELOG.md\n          git commit -m \"Update ${{ env.CURRENT_VERSION }} CHANGELOG\"\n          git push\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# TypeScript v1 declaration files\ntypings/\n\n# storybook cache\nstorybook-static\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n.env.test\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n\n# next.js build output\n.next\n\n# nuxt.js build output\n.nuxt\n\n# vuepress build output\n.vuepress/dist\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# Build output\npackages/*/lib\n\n# Triple environment\npackages/*/package-lock.json\n.DS_Store\n\n# IntelliJ\n.idea\n\n# docs\ndocs/storybook-static/\n\n# swc\n.swc\n\n# vscode\n.vscode/*\n!.vscode/settings.json\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "pnpm exec lint-staged\n"
  },
  {
    "path": ".npmrc",
    "content": "engine-strict=true\n"
  },
  {
    "path": ".prettierignore",
    "content": "pnpm-lock.yaml\n"
  },
  {
    "path": ".prettierrc",
    "content": "\"@titicaca/prettier-config-triple\"\n"
  },
  {
    "path": ".storybook/decorators.tsx",
    "content": "import React from 'react'\nimport { GlobalStyle, defaultTheme } from '../packages/tds-theme/src'\nimport { TripleWeb } from '../packages/triple-web/src'\nimport { isMobile } from '../packages/triple-web-utils/src'\nimport { ThemeProvider } from 'styled-components'\nimport { UAParser } from 'ua-parser-js'\n\nexport function themeDecorator(Story) {\n  return (\n    <ThemeProvider theme={defaultTheme}>\n      <GlobalStyle />\n      <Story />\n    </ThemeProvider>\n  )\n}\n\nexport function tripleWebProviderDecorator(Story, context) {\n  const ua = new UAParser(navigator.userAgent).getResult()\n  return (\n    <TripleWeb\n      clientAppProvider={null}\n      envProvider={{\n        appUrlScheme: 'dev-soto',\n        basePath: '/',\n        webUrlBase: 'https://triple-dev.titicaca-corp.com',\n        facebookAppId: '',\n        defaultPageTitle: '',\n        defaultPageDescription: '',\n        googleMapsApiKey: 'AIzaSyDuSWU_yBwuQzeyRFcTqhyifqNX_8oaXI4',\n        afOnelinkId: '',\n        afOnelinkPid: '',\n        afOnelinkSubdomain: '',\n        webAssetsUrl: 'https://assets.triple-dev.titicaca-corp.com',\n      }}\n      i18nProvider={{\n        defaultLocale: 'ko',\n        locale: context.globals.locale,\n      }}\n      sessionProvider={{\n        user: null,\n      }}\n      userAgentProvider={{\n        ...ua,\n        isMobile: isMobile(ua),\n      }}\n    >\n      <Story />\n    </TripleWeb>\n  )\n}\n"
  },
  {
    "path": ".storybook/main.ts",
    "content": "import fs from 'fs'\n\nimport type { Options } from '@swc/core'\nimport type { StorybookConfig } from '@storybook/nextjs'\n\nconst TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin')\n\nconst EXCEPT_PACKAGES = ['middlewares']\n\nconst stories = fs\n  .readdirSync('packages')\n  .filter((pkg) => !EXCEPT_PACKAGES.includes(pkg))\n  .map((pkg) => `../packages/${pkg}/src/**/*.@(mdx|stories.@(js|jsx|ts|tsx))`)\n\nconst config: StorybookConfig = {\n  stories: ['../stories/**/*.mdx', ...stories],\n  addons: [\n    '@storybook/addon-webpack5-compiler-swc',\n    '@storybook/addon-links',\n    '@storybook/addon-essentials',\n    '@storybook/addon-onboarding',\n    '@chromatic-com/storybook',\n  ],\n  typescript: {\n    reactDocgenTypescriptOptions: {\n      tsconfigPath: 'tsconfig.test.json',\n      propFilter: (prop) => {\n        if (prop.name === 'css') {\n          return false\n        }\n\n        if (prop.declarations !== undefined && prop.declarations.length > 0) {\n          const hasPropAdditionalDescription = prop.declarations.find(\n            (declaration) => {\n              return !declaration.fileName.includes('node_modules')\n            },\n          )\n\n          return Boolean(hasPropAdditionalDescription)\n        }\n\n        return true\n      },\n    },\n\n    reactDocgen: 'react-docgen-typescript',\n  },\n  framework: {\n    name: '@storybook/nextjs',\n    options: {\n      strictMode: true,\n    },\n  },\n  swc: (config: Options): Options => {\n    return {\n      ...config,\n      jsc: {\n        ...config.jsc,\n        experimental: {\n          plugins: [['@swc/plugin-styled-components', {}]],\n        },\n      },\n    }\n  },\n  webpackFinal: async (config) => {\n    if (config.resolve) {\n      config.resolve.plugins = [\n        new TsconfigPathsPlugin({\n          configFile: 'tsconfig.test.json',\n        }),\n      ]\n      config.resolve.fallback = { fs: false }\n    }\n\n    return config\n  },\n  docs: {},\n  staticDirs: ['./public'],\n}\n\nexport default config\n"
  },
  {
    "path": ".storybook/preview.ts",
    "content": "import type { Preview } from '@storybook/react'\nimport { initialize, mswLoader } from 'msw-storybook-addon'\nimport { mockDateDecorator } from 'storybook-mock-date-decorator'\nimport { themeDecorator, tripleWebProviderDecorator } from './decorators'\n\n// Initialize MSW\ninitialize({\n  onUnhandledRequest: 'bypass',\n  serviceWorker: { url: '/mockServiceWorker.js' },\n})\n\nconst preview: Preview = {\n  loaders: [mswLoader],\n  decorators: [mockDateDecorator, themeDecorator, tripleWebProviderDecorator],\n\n  tags: ['autodocs'],\n\n  globalTypes: {\n    locale: {\n      name: 'Locale',\n      toolbar: {\n        icon: 'globe',\n        items: [\n          { value: 'ko', right: '🇰🇷', title: '한국어' },\n          { value: 'ja', right: '🇯🇵', title: '일본어' },\n          { value: 'zh-TW', right: '🇨🇳', title: '중국어(번체)' },\n        ],\n        dynamicTitle: true,\n      },\n    },\n  },\n  initialGlobals: {\n    locale: 'ko',\n  },\n}\n\nexport default preview\n"
  },
  {
    "path": ".storybook/public/mockServiceWorker.js",
    "content": "/* eslint-disable */\n/* tslint:disable */\n\n/**\n * Mock Service Worker (1.2.1).\n * @see https://github.com/mswjs/msw\n * - Please do NOT modify this file.\n * - Please do NOT serve this file on production.\n */\n\nconst INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'\nconst activeClientIds = new Set()\n\nself.addEventListener('install', function () {\n  self.skipWaiting()\n})\n\nself.addEventListener('activate', function (event) {\n  event.waitUntil(self.clients.claim())\n})\n\nself.addEventListener('message', async function (event) {\n  const clientId = event.source.id\n\n  if (!clientId || !self.clients) {\n    return\n  }\n\n  const client = await self.clients.get(clientId)\n\n  if (!client) {\n    return\n  }\n\n  const allClients = await self.clients.matchAll({\n    type: 'window',\n  })\n\n  switch (event.data) {\n    case 'KEEPALIVE_REQUEST': {\n      sendToClient(client, {\n        type: 'KEEPALIVE_RESPONSE',\n      })\n      break\n    }\n\n    case 'INTEGRITY_CHECK_REQUEST': {\n      sendToClient(client, {\n        type: 'INTEGRITY_CHECK_RESPONSE',\n        payload: INTEGRITY_CHECKSUM,\n      })\n      break\n    }\n\n    case 'MOCK_ACTIVATE': {\n      activeClientIds.add(clientId)\n\n      sendToClient(client, {\n        type: 'MOCKING_ENABLED',\n        payload: true,\n      })\n      break\n    }\n\n    case 'MOCK_DEACTIVATE': {\n      activeClientIds.delete(clientId)\n      break\n    }\n\n    case 'CLIENT_CLOSED': {\n      activeClientIds.delete(clientId)\n\n      const remainingClients = allClients.filter((client) => {\n        return client.id !== clientId\n      })\n\n      // Unregister itself when there are no more clients\n      if (remainingClients.length === 0) {\n        self.registration.unregister()\n      }\n\n      break\n    }\n  }\n})\n\nself.addEventListener('fetch', function (event) {\n  const { request } = event\n  const accept = request.headers.get('accept') || ''\n\n  // Bypass server-sent events.\n  if (accept.includes('text/event-stream')) {\n    return\n  }\n\n  // Bypass navigation requests.\n  if (request.mode === 'navigate') {\n    return\n  }\n\n  // Opening the DevTools triggers the \"only-if-cached\" request\n  // that cannot be handled by the worker. Bypass such requests.\n  if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {\n    return\n  }\n\n  // Bypass all requests when there are no active clients.\n  // Prevents the self-unregistered worked from handling requests\n  // after it's been deleted (still remains active until the next reload).\n  if (activeClientIds.size === 0) {\n    return\n  }\n\n  // https://github.com/mswjs/msw-storybook-addon/issues/36#issuecomment-1496150729\n  const url = new URL(event.request.url)\n\n  if (!url.pathname.startsWith('/api/')) {\n    // Do not propagate this event to other listeners (from MSW)\n    event.stopImmediatePropagation()\n  }\n\n  // Generate unique request ID.\n  const requestId = Math.random().toString(16).slice(2)\n\n  event.respondWith(\n    handleRequest(event, requestId).catch((error) => {\n      if (error.name === 'NetworkError') {\n        console.warn(\n          '[MSW] Successfully emulated a network error for the \"%s %s\" request.',\n          request.method,\n          request.url,\n        )\n        return\n      }\n\n      // At this point, any exception indicates an issue with the original request/response.\n      console.error(\n        `\\\n[MSW] Caught an exception from the \"%s %s\" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,\n        request.method,\n        request.url,\n        `${error.name}: ${error.message}`,\n      )\n    }),\n  )\n})\n\nasync function handleRequest(event, requestId) {\n  const client = await resolveMainClient(event)\n  const response = await getResponse(event, client, requestId)\n\n  // Send back the response clone for the \"response:*\" life-cycle events.\n  // Ensure MSW is active and ready to handle the message, otherwise\n  // this message will pend indefinitely.\n  if (client && activeClientIds.has(client.id)) {\n    ;(async function () {\n      const clonedResponse = response.clone()\n      sendToClient(client, {\n        type: 'RESPONSE',\n        payload: {\n          requestId,\n          type: clonedResponse.type,\n          ok: clonedResponse.ok,\n          status: clonedResponse.status,\n          statusText: clonedResponse.statusText,\n          body:\n            clonedResponse.body === null ? null : await clonedResponse.text(),\n          headers: Object.fromEntries(clonedResponse.headers.entries()),\n          redirected: clonedResponse.redirected,\n        },\n      })\n    })()\n  }\n\n  return response\n}\n\n// Resolve the main client for the given event.\n// Client that issues a request doesn't necessarily equal the client\n// that registered the worker. It's with the latter the worker should\n// communicate with during the response resolving phase.\nasync function resolveMainClient(event) {\n  const client = await self.clients.get(event.clientId)\n\n  if (client?.frameType === 'top-level') {\n    return client\n  }\n\n  const allClients = await self.clients.matchAll({\n    type: 'window',\n  })\n\n  return allClients\n    .filter((client) => {\n      // Get only those clients that are currently visible.\n      return client.visibilityState === 'visible'\n    })\n    .find((client) => {\n      // Find the client ID that's recorded in the\n      // set of clients that have registered the worker.\n      return activeClientIds.has(client.id)\n    })\n}\n\nasync function getResponse(event, client, requestId) {\n  const { request } = event\n  const clonedRequest = request.clone()\n\n  function passthrough() {\n    // Clone the request because it might've been already used\n    // (i.e. its body has been read and sent to the client).\n    const headers = Object.fromEntries(clonedRequest.headers.entries())\n\n    // Remove MSW-specific request headers so the bypassed requests\n    // comply with the server's CORS preflight check.\n    // Operate with the headers as an object because request \"Headers\"\n    // are immutable.\n    delete headers['x-msw-bypass']\n\n    return fetch(clonedRequest, { headers })\n  }\n\n  // Bypass mocking when the client is not active.\n  if (!client) {\n    return passthrough()\n  }\n\n  // Bypass initial page load requests (i.e. static assets).\n  // The absence of the immediate/parent client in the map of the active clients\n  // means that MSW hasn't dispatched the \"MOCK_ACTIVATE\" event yet\n  // and is not ready to handle requests.\n  if (!activeClientIds.has(client.id)) {\n    return passthrough()\n  }\n\n  // Bypass requests with the explicit bypass header.\n  // Such requests can be issued by \"ctx.fetch()\".\n  if (request.headers.get('x-msw-bypass') === 'true') {\n    return passthrough()\n  }\n\n  // Notify the client that a request has been intercepted.\n  const clientMessage = await sendToClient(client, {\n    type: 'REQUEST',\n    payload: {\n      id: requestId,\n      url: request.url,\n      method: request.method,\n      headers: Object.fromEntries(request.headers.entries()),\n      cache: request.cache,\n      mode: request.mode,\n      credentials: request.credentials,\n      destination: request.destination,\n      integrity: request.integrity,\n      redirect: request.redirect,\n      referrer: request.referrer,\n      referrerPolicy: request.referrerPolicy,\n      body: await request.text(),\n      bodyUsed: request.bodyUsed,\n      keepalive: request.keepalive,\n    },\n  })\n\n  switch (clientMessage.type) {\n    case 'MOCK_RESPONSE': {\n      return respondWithMock(clientMessage.data)\n    }\n\n    case 'MOCK_NOT_FOUND': {\n      return passthrough()\n    }\n\n    case 'NETWORK_ERROR': {\n      const { name, message } = clientMessage.data\n      const networkError = new Error(message)\n      networkError.name = name\n\n      // Rejecting a \"respondWith\" promise emulates a network error.\n      throw networkError\n    }\n  }\n\n  return passthrough()\n}\n\nfunction sendToClient(client, message) {\n  return new Promise((resolve, reject) => {\n    const channel = new MessageChannel()\n\n    channel.port1.onmessage = (event) => {\n      if (event.data && event.data.error) {\n        return reject(event.data.error)\n      }\n\n      resolve(event.data)\n    }\n\n    client.postMessage(message, [channel.port2])\n  })\n}\n\nfunction sleep(timeMs) {\n  return new Promise((resolve) => {\n    setTimeout(resolve, timeMs)\n  })\n}\n\nasync function respondWithMock(response) {\n  await sleep(response.delay)\n  return new Response(response.body, response)\n}\n"
  },
  {
    "path": ".stylelintrc.json",
    "content": "{\n  \"extends\": [\"@titicaca/stylelint-config-triple\"]\n}\n"
  },
  {
    "path": ".swcrc",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/swcrc\",\n  \"module\": {\n    \"type\": \"commonjs\"\n  },\n  \"jsc\": {\n    \"parser\": {\n      \"syntax\": \"typescript\",\n      \"tsx\": true\n    },\n    \"transform\": {\n      \"react\": {\n        \"runtime\": \"automatic\"\n      }\n    },\n    \"experimental\": {\n      \"plugins\": [[\"@swc/plugin-styled-components\", {}]]\n    }\n  }\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"eslint.workingDirectories\": [{ \"mode\": \"auto\" }]\n}\n"
  },
  {
    "path": "CHANGELOG-archive-201911.md",
    "content": "## 0.12.0 (2019-08-30)\n\n- `Checkbox` 에서도 error style 을 이용 할 수 있도록 변경\n\n  _Olaf_\n\n## 0.11.3 (2019-08-29)\n\n- `withField` 모든 폼에서 error, help 를 이용 할 수 있도록 변경\n\n  _Olaf_\n\n## 0.11.2 (2019-08-28)\n\n- `RecommendedContents` 두번째 컨텐츠의 우측 마진 제거\n\n  _Torres_\n\n## 0.11.1 (2019-08-20)\n\n- `RecommendedContents` title에 개행이 들어가있으면 띄워쓰기로 대체\n\n  _Torres_\n\n## 0.11.0 (2019-08-19)\n\n- `Pricing` suffix 제거, price text size 21px 로 변경\n\n  _Olaf_\n\n## 0.10.0 (2019-08-16)\n\n- `Pricing`에 custom label, description 추가\n\n  _Olaf_\n\n- `TripleDocument`에 `Table`요소 추가\n\n  _Torres_\n\n## 0.9.0 (2019-08-14)\n\n- `ActionSheet` React.ReactNode type 의 title 도 받을 수 있도록 변경\n\n  _Olaf_\n\n## 0.8.2 (2019-08-13)\n\n- `Image` 의 `SourceUrl` line-height를 1.2로 변경\n\n  _Torres_\n\n## 0.8.1 (2019-08-07)\n\n- `Radio` onChange 인자 값 수정\n\n  _Olaf_\n\n## 0.8.0 (2019-08-07)\n\n- `Radio` option 구조 변경\n\n  _Olaf_\n\n## 0.7.2 (2019-08-06)\n\n- `Radio` padding 값 수정\n\n  _Olaf_\n\n## 0.7.1 (2019-08-05)\n\n- `Radio Text` line Height 수정\n\n  _Olaf_\n\n## 0.7.0 (2019-08-05)\n\n- `Select, Radio` padding 값 수정\n\n  _Olaf_\n\n## 0.6.31 (2019-08-05)\n\n- `Numeric` padding 값 수정\n\n  _Olaf_\n\n## 0.6.30 (2019-08-02)\n\n- `Recommended Contents` 모바일 뷰에서 `Recommended Content`를 1 층에 2 개씩 표시\n\n  _Torres_\n\n## 0.6.29 (2019-08-01)\n\n- `Confirm Selector`의 스타일 적용 범위를 수정\n\n  _Olaf_\n\n## 0.6.28 (2019-07-30)\n\n- `Recommended Content`의 제목, `Text`의 `maxLines`를 써서 두 줄 표시 후 말 줄임\n\n  _Torres_\n\n## 0.6.27 (2019-07-30)\n\n- `Nummeric Spinner` 요소들이 고정 width 를 가지도록 변경\n\n  _Olaf_\n\n## 0.6.26 (2019-07-26)\n\n- `Nummeric Spinner` padding 값 수정\n- `Radio` border radius 수정\n\n  _Olaf_\n\n## 0.6.25 (2019-07-26)\n\n- `ListingFilter` 버튼 타입의 height 통일\n\n  _Luffy_\n\n## 0.6.24 (2019-07-25)\n\n- `ListingFilter` 버튼의 height 변경\n- `Label` 의 red color 를 Global Color 를 쓰지 않는 것으로 변경\n- `ExtendedResourceListElement` 를 외부에 export 하면서 컴포넌트 디렉토리 위치 변경\n\n  _Luffy_\n\n## 0.6.23 (2019-07-24)\n\n- `Radio` 의 line Height 를 수정한다.\n\n  _Olaf_\n\n## 0.6.22 (2019-07-24)\n\n- `RecommendedContent` 에 `background-position: center` 스타일 적용\n\n  _Torres_\n\n## 0.6.21 (2019-07-24)\n\n- `Global Style` body 에 style 추가\n\n  _Olaf_\n\n## 0.6.20 (2019-07-24)\n\n- `ReviewsList` 이미지에 `cursor: pointer` 추가\n\n  _Torres_\n\n## 0.6.19 (2019-07-23)\n\n- `Form` 요소들에게 border radius 2px 추가\n\n  _Olaf_\n\n## 0.6.18 (2019-07-23)\n\n- `Radio, Select` padding 값 수정, `Listing Filter` 버튼 사이즈 조절\n\n  _Olaf_\n\n## 0.6.17 (2019-07-19)\n\n- `Pricing` 컴포넌트의 버튼 font-weight:bold 처리\n\n  _Torres_\n\n## 0.6.16 (2019-07-19)\n\n- `RecommendedContents` 컴포넌트 추가\n\n  _Torres_\n\n## 0.6.15 (2019-07-19)\n\n- `Confirm Selector` 구조 변경\n\n  _Olaf_\n\n## 0.6.14 (2019-07-17)\n\n- `Clickable Components`의 style 에 `cursor: pointer` 추가\n\n  _Torres_\n\n## 0.6.13 (2019-07-17)\n\n- `TripleDocument`의 `list`타입에 기본 margin 추가 ({top: 10, left: 30, right: 30})\n\n  _Luffy_\n\n## 0.6.12 (2019-07-11)\n\n- `Select`에 placeholder condition 을 추가한다.\n\n  _Olaf_\n\n- `ExtendedResourceListElement` 에 `hideScrapButton` props 추가\n\n  _luffy_\n\n## 0.6.11 (2019-07-10)\n\n- `TripleDocument`에 `list`타입에 `rawHTML` 형태의 Text 인입 시 버그 수정.\n\n  _luffy_\n\n## 0.6.10 (2019-07-09)\n\n- `TripleDocument`에 `list`타입을 추가합니다.\n\n  _luffy_\n\n## 0.6.9 (2019-07-05)\n\n- `Accordion`에서 `{...props}`를 넘기지 않는 버그 수정\n\n  _jayg_\n\n## 0.6.8 (2019-07-04)\n\n- `ConfirmSelector` 에 Align option 을 추가합니다.\n\n  _Olaf_\n\n## 0.6.7 (2019-07-03)\n\n- `WrappedComponent`에서 발생하는 DOM attribute warning 제거\n\n  _jayg_\n\n- `ExtendedPoiListElement`에서 `small_square` 사이즈 이미지 사용하도록 변경\n\n  _Eeyore_\n\n## 0.6.6 (2019-07-02)\n\n- `poi` 에 base price 가 없을 경우 그어진 가격을 미노출하도록 변경\n\n  _Olaf_\n\n## 0.6.5 (2019-07-02)\n\n- `scrap-button`이 올바르게 `top`, `right` 속성 적용할 수 있도록 수정\n\n  _jayg_\n\n## 0.6.4 (2019-06-27)\n\n- `commons` index.js 에서 export 하도록 변경\n\n  _Olaf_\n\n## 0.6.3 (2019-06-27)\n\n- `text` declare 에러를 없애기 위해 title component 의 이름을 변경합니다.\n\n  _Olaf_\n\n## 0.6.2 (2019-06-27)\n\n- `commons` 를 export 하도록 변경\n\n  _Olaf_\n\n## 0.6.1 (2019-06-27)\n\n- `Footer` 회사 소개(triple-corp.com 링크) 추가\n\n  _Torres_\n\n- `utilities`, `resource-list-element`, `image`, `poi`, `product`, `region`, `review`, `section`, `tna`, `trieple-document` ts 로 변환\n\n  _jayg_\n\n## 0.6.0 (2019-06-26)\n\n- `tsconfig.json` 추가\n\n  _Olaf_\n\n## 0.5.0 (2019-06-24)\n\n- `Author`에 `bioOverride` 추가 및 새로운 모델 스펙에 맞춰서 props 변경\n- `Author` ts 로 변환\n\n  _Royd_\n\n- `action-sheet`, `image`, `input`, `label`, `pager`, `pricing`, `public-header`, `responsive`, `scrap-button`, `select`, `spinner`, `tabs` ts 로 변환\n\n  _jayg_\n\n## 0.4.13 (2019-06-24)\n\n- `accordion`, `app-banner`, `carousel`, `checkbox`, `content-elements`, `list`, `form-filed` ts 로 변환\n- javascript dot notation 을 typescript 에 맞춰 `React.PureComponent`의 static 멤버로 변경\n\n  _jayg_\n\n## 0.4.12 (2019-06-21)\n\n- `table`, `textarea`, `icon`, `tag`, `rating`, `drawer`, `footer`, `segment` ts 로 변환\n\n  _jayg_\n\n## 0.4.11 (2019-06-20)\n\n- `Radio` style 변경\n- `Numeric Spinner` 를 ts 로 변환\n\n  _Olaf_\n\n## 0.4.10 (2019-06-14)\n\n- `ReviewsList` onUnfoldButtonClick 을 받음\n- `AppBanner` onCTAClick props 추가\n\n  _Torres_\n\n- `radio`, `modal`을 ts 로 변환\n\n  _jayg_\n\n## 0.4.9 (2019-06-14)\n\n- `container` 오타 수정\n- `button`을 ts 로 변환\n\n  _jayg_\n\n## 0.4.8 (2019-06-14)\n\n- global color, size 를 별도로 관리\n- csstype 패키지 추가\n- `Container`를 ts 로 변환\n\n  _jayg_\n\n## 0.4.7 (2019-06-12)\n\n- `AppBanner` 에 `maxWidth` props 추가\n\n  _Torres_\n\n- `PublicHeader`에 `minWidth` props 추가\n\n  _Torres_\n\n- `navbar`, `text` 를 ts 로 변환\n\n  _jayg_\n\n## 0.4.6 (2019-06-12)\n\n- typescript declaration 생성 및 빌드 과정에 포함\n- `hr`를 ts 로 변환\n\n  _jayg_\n\n## 0.4.5 (2019-06-11)\n\n- typescript 지원을 위하여 webpack, babel 설정 변경\n\n  _jayg_\n\n## 0.4.4 (2019-06-04)\n\n- `form-field` export 방식 변경\n\n  _Olaf_\n\n## 0.4.3 (2019-06-04)\n\n- `form-field` hoc 를 외부로 내보내도록 변경\n\n  _Olaf_\n\n## 0.4.2 (2019-06-03)\n\n- `ExtendedResourceListElement` text name 한 줄 말줄임에서 두 줄 말줄임으로 변경\n\n  _Olaf_\n\n## 0.4.1 (2019-05-27)\n\n- `poi` Promotion tag condition 조건 변경\n\n  _Olaf_\n\n## 0.4.0 (2019-05-27)\n\n- `Pricing` floated container 를 % 로 나두도록 조절\n\n  _Olaf_\n\n- `Button` padding large size 추가\n\n  _Olaf_\n\n- `Textarea` reset style 추가\n\n  _Olaf_\n\n- `Pricing` IphoneX 대응 css 추가\n\n  _Olaf_\n\n- `Icon` 에 margin, padding option 을 추가합니다.\n\n  _Olaf_\n\n- `input` 의 reset css 를 추가합니다.\n\n  _Olaf_\n\n- `poi element` 의 tag 를 노출 할 수 있도록 변경\n\n  _Olaf_\n\n- `block` 타입의 `links`의 margin-top 을 45px 에서 30px 로 변경\n\n  _Royd_\n\n- `Date Picker` border-collapse 추가\n\n  _Olaf_\n\n- `Poi List` tags props 와 default tags 추가\n\n  _Olaf_\n\n- `TripleDocument`내의 links 요소의 스타일에서 `padding` 대신에 `margin` 사용\n\n  _Royd_\n\n- `Global-style` 의 b, strong 에 bold 추가\n\n  _Royd_\n\n- `Form` message 부분 2 줄시 style 깨지는 문제 해결\n\n  _Olaf_\n\n- `Confirm Box` 의 centered option 시 padding 제거\n\n  _Olaf_\n\n- `RangeDatePicker` isBlocked function 에 memoize 적용\n\n  _Olaf_\n\n- `RangeDatePicker` onChange Event 이름 변경 및 두 날짜 모두 선택된 후 날짜 선택시 date 가 reset 되도록 변경\n\n  _Olaf_\n\n- global-style 에 font-family 적용 및 각 element 에서 font-family 스타일 제거\n\n  _Royd_\n\n- `Text.Title` line-height 값을 1.2 로 설정\n\n  _Royd_\n\n- `ExtendedResourceListElement`의 `pricingNote` 값을 '세금 포함'으로 변경\n\n  _Eeyore_\n\n- `TripleDocument` 의 `Images` 에서 `onLinkClick` 지원\n\n  _Torres_\n\n- `Note` 의 `margin` 값 조정\n\n  _Torres_\n\n- `Label`에 `margin` prop 추가\n\n  _Royd_\n\n- `ListingFilter` line-height 디폴트 값을 1.2 로 설정\n\n  _Royd_\n\n- `Container` min Height , max Height option 추가\n\n  _Olaf_\n\n- `ListingFilter` iOS 모바일에서 터치 스크롤 이슈 해결 [#465](https://github.com/titicacadev/triple-design-system/pull/465)\n\n  _Royd_\n\n## 0.3.1 (2019-04-24)\n\n- `Modal` Alert Modal 추가\n\n  _Olaf_\n\n- `Text` line-height 디폴트 값을 1.2 로 설정\n\n  _Royd_\n\n- `Rating` 에 `onClick` 추가\n\n  _Torres_\n\n- `text` red color 추가\n\n  _Olaf_\n\n- global-style 에 `select` 태그를 위한 reset 스타일 추가\n\n  _Royd_\n\n- `ActionSheet` 최대 높이 수정\n\n  _Eeyore_\n\n- `Drawer` 요소 추가\n\n  _Eeyore_\n\n## 0.3.0 (2019-04-11)\n\n- `ExtendedResourceListElement`에 `pricingNote` 추가\n\n  _Eeyore_\n\n- `Form` option 추가 및 style 변경\n\n  ```\n  1. onChange event 중첩 해결\n  2. form field padding, margin option 제거\n  3. confirmbox filltype option 추가\n  ```\n\n  _Olaf_\n\n- `Pricing` 잘못된 base <--> sale 순서 바로잡음\n\n  _Eeyore_\n\n- `Form Field` margin, padding option 추가 및 error message absolute style 추가\n\n  _Olaf_\n\n- `Numeric Spinner` 추가\n\n  _Olaf_\n\n- `Form` 요소들 Change EvnetHandler 동작 방식 통일\n\n  ```js\n  onChange={(e) => onChange(e, e.target.value)}\n  ```\n\n  _Olaf_\n\n- `Button`에서 `ButtonIcon`에 `size` props 추가\n\n  _Torres_\n\n- `PoiListElement`에서 `prices`와 `starRating` 처리 (호텔 관련 필드)\n\n  _Royd_\n\n- `Forms` 컴포넌트 추가\n\n  ```\n  1. Input\n  2. Checkbox (ConfirmSelector)\n  3. Selectbox (GenderSelector)\n  4. Textarea\n  ```\n\n  _Olaf_\n\n- `DatePicker` Style 변경\n\n  ```\n  1. selcted class size 변경 40px -> 32px\n  2. 평일 주말 color 구분\n  3. week header 를 top 고정이 아닌 달마다 보이도록 변경\n  4. range picker 의 경우 nights (end - start) value 를 넘겨주도록 추가\n  5. table border spacing 추가\n  ```\n\n  _Olaf_\n\n- `Table` 컴포넌트 추가\n\n  ```\n  <Table type=\"horizontal\"}\n  <Table type=\"vertical\"}\n  ```\n\n  _Olaf_\n\n- `ActionSheet.Item` 에 `checked` 상태 추가\n\n  _Eeyore_\n\n## 0.2.8 (2019-04-03)\n\n- `ListingFilter` 컴포넌트 개편\n\n  ```\n  <ListingFilter.PrimaryFilterEntry>\n    5.17-5.20, 3명\n  </ListingFilter.PrimaryFilterEntry>\n  <ListingFilter.ExpandingFilterEntry>\n    침대타입\n  </ListingFilter.ExpandingFilterEntry>\n  <ListingFilter.FilterEntry>\n    무료취소\n  </ListingFilter.FilterEntry>\n  <ListingFilter.FilterEntry withIcon active>\n    음식점\n  </ListingFilter.FilterEntry>\n  ```\n\n  _Eeyore_\n\n- `Tabs` 컴포넌트 추가\n\n  _Eeyore_\n\n- `Section` 컴포넌트에 default prop 추가\n\n  ```\n    minWidth = 320,\n    maxWidth = 760,\n    padding = { left: 30, right: 30 },\n  ```\n\n  _Royd_\n\n- basic 타입의 `Button` 간소화\n\n  `borderRadius`, `fontSize` prop 삭제\n\n  `inverted`, `compact` prop 지원\n\n  ```\n  <button basic compact inverted color=\"blue\">버튼</Button>\n  ```\n\n  _Royd_\n\n- `TripleDocument` Image 목록 컴포넌트에서 캡션 유무에 따른 마진 분기\n\n  _Eeyore_\n\n- `Text` 컴포넌트에 number 타입의 `size` prop 지원\n\n  _Royd_\n\n- `Text` 컴포넌트의 `size` prop 에서 `\"larger\"` 삭제\n\n  _Royd_\n\n- `Spinner` 추가\n\n  _Olaf_\n\n- promo 타입의 `Label` 컴포넌트 추가\n\n  ```\n  <Label promo emphasized size=\"medium\" color=\"purple\">최대 24%</Label>\n  <Label promo size=\"small\" color=\"red\">지정일 사용</Label>\n  ```\n\n  _Royd_\n\n- 스토리보드에 jsx 애드온 추가\n\n  _Olaf_\n\n- `TripleDocument` 본문 컴포넌트의 alpha 값 `0.8`에서 `0.9`로 수정\n\n  _Torres_\n\n- `TripleDocument` Image 목록 컴포넌트에 `\"block\"` display 지원\n\n  _Torres_\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# CHANGELOG\n\n## v14.2.3\n\n### router\n\n- feat: Add peer dependency of triple-web-to-native-interfaces [#3783](https://github.com/titicacadev/triple-frontend/pull/3783)\n\n### standard-action-handler\n\n- feat: Add peer dependency of triple-web-to-native-interfaces [#3783](https://github.com/titicacadev/triple-frontend/pull/3783)\n\n### tds-widget\n\n- feat: Add peer dependency of triple-web-to-native-interfaces [#3783](https://github.com/titicacadev/triple-frontend/pull/3783)\n- feat: 놀 파트너 쿠폰 신규 추가 [#3788](https://github.com/titicacadev/triple-frontend/pull/3788)\n\n### triple-web\n\n- feat: Add peer dependency of triple-web-to-native-interfaces [#3783](https://github.com/titicacadev/triple-frontend/pull/3783)\n\n## v14.2.2\n\n```\n### tds-widget\n\n- [fix] legacy mileage 경로 수정 [#3773](https://github.com/titicacadev/triple-frontend/pull/3773)\n- fix: 쿠폰 버튼 컬러 오류 수정 [#3777](https://github.com/titicacadev/triple-frontend/pull/3777)\n- [DMTALK-606] room/member 초기화 로직을 재사용 가능한 함수로 추출 [#3778](https://github.com/titicacadev/triple-frontend/pull/3778)\n```\n\n## v14.2.1\n\n```\n### chat\n\n- feat(chat): ReservationInfo에 actions prop 추가 [#3771](https://github.com/titicacadev/triple-frontend/pull/3771)\n\n### tds-widget\n\n- feat(chat): ReservationInfo에 actions prop 추가 [#3771](https://github.com/titicacadev/triple-frontend/pull/3771)\n```\n\n## v14.2.0\n\n```\n### fetcher\n\n- feat: package version update [#3766](https://github.com/titicacadev/triple-frontend/pull/3766)\n\n### middlewares\n\n- feat: package version update [#3766](https://github.com/titicacadev/triple-frontend/pull/3766)\n\n### react-hooks\n\n- [NACS-686] NPM OIDC trusted publisher 수정 [#3767](https://github.com/titicacadev/triple-frontend/pull/3767)\n\n### tds-widget\n\n- [tds-widget] map-view가 default zoom level props를 받을 수 있도록 수정 [#3768](https://github.com/titicacadev/triple-frontend/pull/3768)\n\n### triple-web\n\n- feat: package version update [#3766](https://github.com/titicacadev/triple-frontend/pull/3766)\n\n### triple-web-nextjs-pages\n\n- feat: package version update [#3766](https://github.com/titicacadev/triple-frontend/pull/3766)\n```\n\n## v14.1.9\n\n### react-hooks\n\n- [chore] triple-web-to-native-interfaces 및 lottie-web 버전 업데이트 [#3761](https://github.com/titicacadev/triple-frontend/pull/3761)\n\n### router\n\n- [chore] triple-web-to-native-interfaces 및 lottie-web 버전 업데이트 [#3761](https://github.com/titicacadev/triple-frontend/pull/3761)\n- [fix] rotuer hook에 usecallback 추가 [#3763](https://github.com/titicacadev/triple-frontend/pull/3763)\n\n### standard-action-handler\n\n- [chore] triple-web-to-native-interfaces 및 lottie-web 버전 업데이트 [#3761](https://github.com/titicacadev/triple-frontend/pull/3761)\n\n### tds-theme\n\n- [fix] styled-component v6 Pseudoselectors breaking change 반영 [#3762](https://github.com/titicacadev/triple-frontend/pull/3762)\n\n### tds-ui\n\n- [fix] styled-component v6 Pseudoselectors breaking change 반영 [#3762](https://github.com/titicacadev/triple-frontend/pull/3762)\n\n### tds-widget\n\n- [chore] triple-web-to-native-interfaces 및 lottie-web 버전 업데이트 [#3761](https://github.com/titicacadev/triple-frontend/pull/3761)\n- [fix] styled-component v6 Pseudoselectors breaking change 반영 [#3762](https://github.com/titicacadev/triple-frontend/pull/3762)\n\n### triple-header\n\n- [chore] triple-web-to-native-interfaces 및 lottie-web 버전 업데이트 [#3761](https://github.com/titicacadev/triple-frontend/pull/3761)\n\n### triple-web\n\n- [chore] triple-web-to-native-interfaces 및 lottie-web 버전 업데이트 [#3761](https://github.com/titicacadev/triple-frontend/pull/3761)\n\n## v14.1.8\n\n```\n### triple-web\n\n- v13.48.1 변경사항을 적용합니다. [#3758](https://github.com/titicacadev/triple-frontend/pull/3758)\n```\n\n## v14.1.7\n\n```\n### tds-ui\n\n- [DMTALK-557] 탭 focused 오류 수정 [#3748](https://github.com/titicacadev/triple-frontend/pull/3748)\n- v13.46.1, v13.47.0, v13.48.1 변경사항을 반영합니다. [#3752](https://github.com/titicacadev/triple-frontend/pull/3752)\n\n### tds-widget\n\n- [DMTALK-526] 채팅방 나가기 [#3746](https://github.com/titicacadev/triple-frontend/pull/3746)\n- v13.46.1, v13.47.0, v13.48.1 변경사항을 반영합니다. [#3752](https://github.com/titicacadev/triple-frontend/pull/3752)\n```\n\n## v14.1.6\n\n### tds-widget\n\n- [DMTALK-555] dismissKeyboardOnSend 옵션 추가 [#3747](https://github.com/titicacadev/triple-frontend/pull/3747)\n\n## v14.1.5\n\n```\n### tds-widget\n\n- [TPB-3385] InvitationInterface 에  validInvitation 필드 추가 [#3741](https://github.com/titicacadev/triple-frontend/pull/3741)\n- [DMTALK-533] usePendingIntersections 추가 [#3742](https://github.com/titicacadev/triple-frontend/pull/3742)\n- [DMTALK-505] 타입 업데이트 [#3743](https://github.com/titicacadev/triple-frontend/pull/3743)\n```\n\n## v14.1.4\n\n```\n### tds-widget\n\n- [tds-widget] focus tracker에 disabled props를 추가합니다. [#3739](https://github.com/titicacadev/triple-frontend/pull/3739)\n```\n\n## v14.1.3\n\n### tds-ui\n\n- [tds-ui] 체크박스 사이즈 설정 prop을 추가합니다. [#3732](https://github.com/titicacadev/triple-frontend/pull/3732)\n- [tds-ui, tds-widget] 컴포넌트에 color props 추가 [#3735](https://github.com/titicacadev/triple-frontend/pull/3735)\n\n### tds-widget\n\n- [DMTALK-472] 채팅 디자인 개선사항 반영 [#3734](https://github.com/titicacadev/triple-frontend/pull/3734)\n- [tds-ui, tds-widget] 컴포넌트에 color props 추가 [#3735](https://github.com/titicacadev/triple-frontend/pull/3735)\n- [DMTALK-487] Chat/nol-input-area-ui 디자인 수정사항 반영 [#3736](https://github.com/titicacadev/triple-frontend/pull/3736)\n- [tds-widget] focus tracker에 autoZoomThreshold props 추가 [#3737](https://github.com/titicacadev/triple-frontend/pull/3737)\n\n## v14.1.2\n\n```\n### tds-widget\n\n- [DMTALK-217] welcomeMessage 전송시 UI 오류 수정 [#3688](https://github.com/titicacadev/triple-frontend/pull/3688)\n- [DMTALK-249] 재문의 가능여부 타입 정의 [#3727](https://github.com/titicacadev/triple-frontend/pull/3727)\n- [DMTALK-403] refactor: pusher data 타입 분리 [#3728](https://github.com/titicacadev/triple-frontend/pull/3728)\n- fix: rebase 누락 적용 [#3730](https://github.com/titicacadev/triple-frontend/pull/3730)\n```\n\n## 14.1.1\n\n```\n### middlewares\n\n- [middleware] next14용 미들웨어에 applySetCookie 적용 [#3717](https://github.com/titicacadev/triple-frontend/pull/3717)\n\n### tds-widget\n\n- [DMTALK-416] 채팅 > 디자인 개선 적용 [#3715](https://github.com/titicacadev/triple-frontend/pull/3715)\n- [DMTALK-425] input resize observer 옵션 변경 [#3722](https://github.com/titicacadev/triple-frontend/pull/3722)\n- [DMTALK-432] 쿠폰 메시지 디자인 변경 [#3723](https://github.com/titicacadev/triple-frontend/pull/3723)\n- [DMTALK-422] 이벤트 로깅을 위한 onClick 핸들러 전달 [#3724](https://github.com/titicacadev/triple-frontend/pull/3724)\n```\n\n## 14.1.0\n\n```\n### tds-theme\n\n- [tds-theme] vermilion 컬러를 찾지 못하는 오류 수정 [#3691](https://github.com/titicacadev/triple-frontend/pull/3691)\n\n### tds-ui\n\n- [tds-widget] react-day-picker의 props를 받을 수 있도록 추가 [#3689](https://github.com/titicacadev/triple-frontend/pull/3689)\n- [tds-ui] 체크박스 체크 위치 수정 [#3692](https://github.com/titicacadev/triple-frontend/pull/3692)\n- [tds-ui] popup에 id props를 추가합니다. [#3695](https://github.com/titicacadev/triple-frontend/pull/3695)\n- [tds-ui] ActionSheetItem에 plus icon 추가 [#3712](https://github.com/titicacadev/triple-frontend/pull/3712)\n- [tds-ui] carousel-item 추가 prop 받을 수 있도록 수정 [#3716](https://github.com/titicacadev/triple-frontend/pull/3716)\n\n### tds-widget\n\n- [tds-widget] react-day-picker의 props를 받을 수 있도록 추가 [#3689](https://github.com/titicacadev/triple-frontend/pull/3689)\n- [DMTALK-165] chat 컴포넌트 커스텀 필드 추가, 스타일 및 타입 수정 [#3690](https://github.com/titicacadev/triple-frontend/pull/3690)\n- [DMTALK-316] message payload 내 extra 노출 [#3698](https://github.com/titicacadev/triple-frontend/pull/3698)\n- [DMTALK-317] interactionStatusSlot 추가 [#3699](https://github.com/titicacadev/triple-frontend/pull/3699)\n- [DMTALK-234] scroll-bottom-buttons 리셋 로직 추가 [#3700](https://github.com/titicacadev/triple-frontend/pull/3700)\n- [DMTALK-310] ChatChannelEvents 타입 정의 [#3701](https://github.com/titicacadev/triple-frontend/pull/3701)\n- [DMTALK-304] 쿠폰 메시지 추가 [#3703](https://github.com/titicacadev/triple-frontend/pull/3703)\n- chat > expired 컴포넌트 배경색 변경 [#3704](https://github.com/titicacadev/triple-frontend/pull/3704)\n- [DMTALK-374] RICH 버블의 블록을 각각의 버블로 분리하는 옵션을 제공하고 버튼 버블을 추가합니다. [#3707](https://github.com/titicacadev/triple-frontend/pull/3707)\n- [tds-widget] RangePicker에서 publicHoliday를 props로 받아올 수 있도록 수정합니다. [#3710](https://github.com/titicacadev/triple-frontend/pull/3710)\n- [DMTALK-396] 프로필 이미지 object-fit 변경 [#3711](https://github.com/titicacadev/triple-frontend/pull/3711)\n- [DMTALK-374] parent message로 스크롤 되지 않는 오류 수정 [#3718](https://github.com/titicacadev/triple-frontend/pull/3718)\n- [tds-widget] map에서 fitBounds 비활성화 props 추가 [#3720](https://github.com/titicacadev/triple-frontend/pull/3720)\n\n### triple-web\n\n- [triple-web] multiple hash 지원 [#3706](https://github.com/titicacadev/triple-frontend/pull/3706)\n```\n\n## 14.0.13\n\n```\n### middlewares\n\n- (Chat) [DMTALK-60] 챗룸 컴포넌트 추가 [#3608](https://github.com/titicacadev/triple-frontend/pull/3608)\n\n### router\n\n- [DMTALK-183] 링크 두번 열리는 오류 수정 [#3681](https://github.com/titicacadev/triple-frontend/pull/3681)\n\n### tds-widget\n\n- (Chat) 채팅 버블 부가 정보 커스텀 스타일 프롭스 추가 [#3574](https://github.com/titicacadev/triple-frontend/pull/3574)\n- (Chat) 누락된 타입 export 및 props 추가 [#3585](https://github.com/titicacadev/triple-frontend/pull/3585)\n- tds-widget/chat에서 triple-web 디펜던시를 제거 [#3586](https://github.com/titicacadev/triple-frontend/pull/3586)\n- (chat) 커스텀이 가능한 채팅 리스트 공통 컴포넌트를 만든다. [#3588](https://github.com/titicacadev/triple-frontend/pull/3588)\n- (Chat) [DMTALK-31] 채팅 공통타입 정리 [#3589](https://github.com/titicacadev/triple-frontend/pull/3589)\n- (Chat) [DMTALK-32] 채팅 메시지 플로우 공통화  [#3593](https://github.com/titicacadev/triple-frontend/pull/3593)\n- (Chat) [DMTALK-51] 사용자 식별자 제외 API 응답 마이그레이션 [#3601](https://github.com/titicacadev/triple-frontend/pull/3601)\n- (Chat) 채팅 리스트 기본 기능 로직을 확장 가능하게 제공한다. [#3602](https://github.com/titicacadev/triple-frontend/pull/3602)\n- (Chat) [DMTALK-60] 챗룸 컴포넌트 추가 [#3608](https://github.com/titicacadev/triple-frontend/pull/3608)\n- [EPIC] tds-widget/chat을 확장하고 서비스간 중복 로직을 공통화 합니다  (v14) - 2/2 [#3614](https://github.com/titicacadev/triple-frontend/pull/3614)\n- [DMTALK-86] Chat 스타일 / 컴포넌트 확장 가능하도록 수정 [#3616](https://github.com/titicacadev/triple-frontend/pull/3616)\n- [DMTALK-88] 초대 및 채팅방 만료 정책 타입 추가 [#3620](https://github.com/titicacadev/triple-frontend/pull/3620)\n- [DMTALK-90] 새로운 메시지 수신 시 bottom scroll 여부 컨트롤 추가 [#3621](https://github.com/titicacadev/triple-frontend/pull/3621)\n- fix: onChatRestart 기본값 제거 [#3623](https://github.com/titicacadev/triple-frontend/pull/3623)\n- (Chat) nol-chat getMessages 응답값 migration [#3626](https://github.com/titicacadev/triple-frontend/pull/3626)\n- [DMTALK-67] ReservationLabel export 및 일부 Preview element css override 되도록 수정 [#3627](https://github.com/titicacadev/triple-frontend/pull/3627)\n- [DMTALK-112] 새로운 메시지 UI 추가 [#3628](https://github.com/titicacadev/triple-frontend/pull/3628)\n- [DMTALK] 조건문 내 오타 수정 [#3629](https://github.com/titicacadev/triple-frontend/pull/3629)\n- [DMTALK] `ProductMetaData, BookingMetaData` type 스웨거와 싱크 [#3633](https://github.com/titicacadev/triple-frontend/pull/3633)\n- [DMTALK] get messages 응답 인터페이스 변경 대응 [#3634](https://github.com/titicacadev/triple-frontend/pull/3634)\n- [DMTALK] refactor: export 네이밍 변경 [#3635](https://github.com/titicacadev/triple-frontend/pull/3635)\n- [DMTALK] get messages 응답 인터페이스 변경 대응 [#3639](https://github.com/titicacadev/triple-frontend/pull/3639)\n- [DMTALK] Preview 날짜 표기 방식 util 추가 [#3644](https://github.com/titicacadev/triple-frontend/pull/3644)\n- [DMTALK-126] 첫 메시지 전송 시 룸 생성 전 pending message 처리 [#3647](https://github.com/titicacadev/triple-frontend/pull/3647)\n- [DMTALK-135] 예약정보 UI 디자인 수정 반영 [#3650](https://github.com/titicacadev/triple-frontend/pull/3650)\n- [DMTALK-133] 상대방 메시지 실시간 받을 경우 스크롤이 올라가도록 수정 [#3653](https://github.com/titicacadev/triple-frontend/pull/3653)\n- [DMTALK] 중복 UI 정리 및 NolThemeProvider 생성 [#3657](https://github.com/titicacadev/triple-frontend/pull/3657)\n- [DMTALK-148] refactor: nol-theme-provider 사용 [#3661](https://github.com/titicacadev/triple-frontend/pull/3661)\n- [DMTALK] 잘못된 간격 수정 [#3662](https://github.com/titicacadev/triple-frontend/pull/3662)\n- [DMTALK-130] 안드로이드 스크롤 오류 관련 프롭스 추가 [#3663](https://github.com/titicacadev/triple-frontend/pull/3663)\n- [DMTALK] 네트워크 없을 경우 실패 아이콘 미노출되는 이슈 수정 [#3665](https://github.com/titicacadev/triple-frontend/pull/3665)\n- [DMTALK-156] 간격 수정 및 문구 변경으로 인한 max-width 수정 [#3666](https://github.com/titicacadev/triple-frontend/pull/3666)\n- [DMTALK-158] 누락된 디펜던시 및 message sanitize 추가 [#3667](https://github.com/titicacadev/triple-frontend/pull/3667)\n- [DMTALK] 채팅창 상세에서 상품/예약정보 접기펼치기 영역 확대 [#3668](https://github.com/titicacadev/triple-frontend/pull/3668)\n- [DMTALK] 취소 후 만료정책 추가 [#3669](https://github.com/titicacadev/triple-frontend/pull/3669)\n- [DMTALK] 상품/예약정보 접기펼치기 영역 - 클릭이벤트 조건 수정 [#3670](https://github.com/titicacadev/triple-frontend/pull/3670)\n- [DMTALK-176] 디자인 QA [#3675](https://github.com/titicacadev/triple-frontend/pull/3675)\n- [DMTALK-177] 전체보기 뷰 지오챗 스타일 오류 수정 [#3678](https://github.com/titicacadev/triple-frontend/pull/3678)\n- moment 라이브러리를 date-fns로 변경합니다.  [#3679](https://github.com/titicacadev/triple-frontend/pull/3679)\n- reservation info min-height 수정 [#3680](https://github.com/titicacadev/triple-frontend/pull/3680)\n- [DMTALK-183] 링크 두번 열리는 오류 수정 [#3681](https://github.com/titicacadev/triple-frontend/pull/3681)\n- [DMTALK-184] 아이콘 오류 수정 [#3683](https://github.com/titicacadev/triple-frontend/pull/3683)\n- [EPIC] tds-widget/chat을 확장하고 서비스간 중복 로직을 공통화 합니다 (v14) - 1 / 2 [#3684](https://github.com/titicacadev/triple-frontend/pull/3684)\n\n### triple-document\n\n- moment 라이브러리를 date-fns로 변경합니다.  [#3679](https://github.com/titicacadev/triple-frontend/pull/3679)\n\n### triple-web\n\n- (Chat) [DMTALK-60] 챗룸 컴포넌트 추가 [#3608](https://github.com/titicacadev/triple-frontend/pull/3608)\n\n### view-utilities\n\n- (Chat) [DMTALK-60] 챗룸 컴포넌트 추가 [#3608](https://github.com/titicacadev/triple-frontend/pull/3608)\n- moment 라이브러리를 date-fns로 변경합니다.  [#3679](https://github.com/titicacadev/triple-frontend/pull/3679)\n```\n\n## 14.0.12\n\n```\n### ab-experiments\n\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n- [KLZT-882] 클라이언트 세션 갱신 로직을 추가합니다.  [#3631](https://github.com/titicacadev/triple-frontend/pull/3631)\n\n### constants\n\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n\n### fetcher\n\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n- [KLZT-882] 클라이언트 세션 갱신 로직을 추가합니다.  [#3631](https://github.com/titicacadev/triple-frontend/pull/3631)\n- [middlware] 세션 갱신 로직을 수정합니다. [#3660](https://github.com/titicacadev/triple-frontend/pull/3660)\n\n### middlewares\n\n- v13.43.0 이후의 변경사항을 적용합니다. [#3603](https://github.com/titicacadev/triple-frontend/pull/3603)\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n- [middlware] 세션 갱신 로직을 수정합니다. [#3660](https://github.com/titicacadev/triple-frontend/pull/3660)\n\n### router\n\n- [router] openNativeLink에서 href가 아닌 path를 param으로 넘겨주도록 수정 [#3571](https://github.com/titicacadev/triple-frontend/pull/3571)\n\n### standard-action-handler\n\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n\n### tds-ui\n\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n\n### tds-widget\n\n- v13.41.0 ~ v13.42.1 변경사항을 반영합니다. [#3562](https://github.com/titicacadev/triple-frontend/pull/3562)\n- v13.43.0 이후의 변경사항을 적용합니다. [#3603](https://github.com/titicacadev/triple-frontend/pull/3603)\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n- [KLZT-882] 클라이언트 세션 갱신 로직을 추가합니다.  [#3631](https://github.com/titicacadev/triple-frontend/pull/3631)\n\n### triple-document\n\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n- [KLZT-882] 클라이언트 세션 갱신 로직을 추가합니다.  [#3631](https://github.com/titicacadev/triple-frontend/pull/3631)\n\n### triple-header\n\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n- [KLZT-882] 클라이언트 세션 갱신 로직을 추가합니다.  [#3631](https://github.com/titicacadev/triple-frontend/pull/3631)\n\n### triple-web\n\n- v13.43.0 이후의 변경사항을 적용합니다. [#3603](https://github.com/titicacadev/triple-frontend/pull/3603)\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n\n### triple-web-nextjs\n\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n\n### triple-web-nextjs-pages\n\n- [Epic] NOL 회원 통합 (v14) [#3604](https://github.com/titicacadev/triple-frontend/pull/3604)\n- [KLZT-882] 클라이언트 세션 갱신 로직을 추가합니다.  [#3631](https://github.com/titicacadev/triple-frontend/pull/3631)\n\n### view-utilities\n\n- v13.43.0 이후의 변경사항을 적용합니다. [#3603](https://github.com/titicacadev/triple-frontend/pull/3603)\n```\n\n## 14.0.11\n\n### tds-widget\n\n- v13.40.0 변경사항을 적용합니다. [#3545](https://github.com/titicacadev/triple-frontend/pull/3545)\n\n### triple-web\n\n- [triple-web] 앱설치유도 모달 및 로그인 모달의 showOptions를 수정합니다. [#3540](https://github.com/titicacadev/triple-frontend/pull/3540)\n\n### view-utilities\n\n- v13.40.1 변경사항을 적용합니다. [#3547](https://github.com/titicacadev/triple-frontend/pull/3547)\n\n## 14.0.10\n\n### triple-web\n\n- [triple-web] 웹뷰에서는 nativeTrackScreen만 실행하도록 수정합니다. [#3536](https://github.com/titicacadev/triple-frontend/pull/3536)\n\n## 14.0.9\n\n### tds-ui\n\n- App router에서 use client 문제 수정 [#3514](https://github.com/titicacadev/triple-frontend/pull/3514)\n- [tds-ui] onChange 함수가 중복 실행되지 않도록 수정합니다. [#3532](https://github.com/titicacadev/triple-frontend/pull/3532)\n\n### tds-widget\n\n- App router에서 use client 문제 수정 [#3514](https://github.com/titicacadev/triple-frontend/pull/3514)\n\n### triple-email-document\n\n- v13.39.0 수정사항을 반영합니다. [#3519](https://github.com/titicacadev/triple-frontend/pull/3519)\n\n### triple-web\n\n- App router에서 use client 문제 수정 [#3514](https://github.com/titicacadev/triple-frontend/pull/3514)\n- triple-web-nextjs-pages에 ssr-utils 추가 [#3518](https://github.com/titicacadev/triple-frontend/pull/3518)\n\n### triple-web-nextjs\n\n- App router에서 use client 문제 수정 [#3514](https://github.com/titicacadev/triple-frontend/pull/3514)\n- triple-web-nextjs-pages에 ssr-utils 추가 [#3518](https://github.com/titicacadev/triple-frontend/pull/3518)\n\n### triple-web-nextjs-pages\n\n- App router에서 use client 문제 수정 [#3514](https://github.com/titicacadev/triple-frontend/pull/3514)\n- triple-web-nextjs-pages에 ssr-utils 추가 [#3518](https://github.com/titicacadev/triple-frontend/pull/3518)\n\n## 14.0.8\n\n### tds-widget\n\nv13.38.1 변경사항을 적용합니다. [#3521](https://github.com/titicacadev/triple-frontend/pull/3521)\n\n## 14.0.7\n\n### meta-tags\n\n- [meta-tags] 앱라우터용 메타태그 및 QaPageScript, DiscussionForumPostingScript를 추가합니다. [#3500](https://github.com/titicacadev/triple-frontend/pull/3500)\n\n### middlewares\n\n- [middleware] 세션 쿠키 리프레시 로직을 수정합니다. [#3498](https://github.com/titicacadev/triple-frontend/pull/3498)\n\n### triple-web\n\n- [triple-web] web track event는 클라이언트 웹뷰가 아닐 때만 로깅하도록 수정합니다. [#3507](https://github.com/titicacadev/triple-frontend/pull/3507)\n\n### tds-widget\n\n- v13.37.0, v13.38.0 변경사항을 적용합니다. [#3512](https://github.com/titicacadev/triple-frontend/pull/3512)\n\n### type-definitions\n\n- v13.37.0, v13.38.0 변경사항을 적용합니다. [#3512](https://github.com/titicacadev/triple-frontend/pull/3512)\n\n## v14.0.6\n\n### tds-widget\n\n- [tds-widget] beforeScrapedChange시 파람에 eventParam을 넘기도록 수정합니다. [#3493](https://github.com/titicacadev/triple-frontend/pull/3493)\n\n### triple-web\n\n- [triple-web] firebaseAnalytics 인스턴스를 가져오지 못하는 이슈를 해결합니다. [#3492](https://github.com/titicacadev/triple-frontend/pull/3492)\n- [triple-web] 모달을 띄울 때 trackScreen이 호출되는 문제를 해결합니다. [#3496](https://github.com/titicacadev/triple-frontend/pull/3496)\n- [triple-web] 로그인 모달 버튼 함수 수정 [#3497](https://github.com/titicacadev/triple-frontend/pull/3497)\n\n## v14.0.5\n\n### router\n\n- [view-utilities, router] routelist에 라운지홈을 추가합니다. useNavigate에서 deeplink 이동 로직을 추가합니다 [#3486](https://github.com/titicacadev/triple-frontend/pull/3486)\n- 앱에서 navigate 동작 수정 [#3487](https://github.com/titicacadev/triple-frontend/pull/3487)\n\n### tds-widget\n\n- 앱 외부에서도 스크랩이 가능하도록 옵션 추가 [#3489](https://github.com/titicacadev/triple-frontend/pull/3489)\n\n### triple-web\n\n- 앱에서 navigate 동작 수정 [#3487](https://github.com/titicacadev/triple-frontend/pull/3487)\n- LoginCtaModal 기본 return url에서 hash 제거 [#3488](https://github.com/titicacadev/triple-frontend/pull/3488)\n\n### triple-web-nextjs\n\n- 앱에서 navigate 동작 수정 [#3487](https://github.com/titicacadev/triple-frontend/pull/3487)\n\n### triple-web-nextjs-pages\n\n- 앱에서 navigate 동작 수정 [#3487](https://github.com/titicacadev/triple-frontend/pull/3487)\n\n### triple-web-utils\n\n- 앱에서 navigate 동작 수정 [#3487](https://github.com/titicacadev/triple-frontend/pull/3487)\n\n### view-utilities\n\n- [view-utilities, router] routelist에 라운지홈을 추가합니다. useNavigate에서 deeplink 이동 로직을 추가합니다 [#3486](https://github.com/titicacadev/triple-frontend/pull/3486)\n\n## v14.0.4\n\n### i18n\n\n- triple-web 테스트 추가 [#3472](https://github.com/titicacadev/triple-frontend/pull/3472)\n\n### router\n\n- Link 컴포넌트에서 불필요한 inline style 제거 [#3482](https://github.com/titicacadev/triple-frontend/pull/3482)\n\n### standard-action-handler\n\n- 타입 에러 수정 [#3471](https://github.com/titicacadev/triple-frontend/pull/3471)\n\n### tds-ui\n\n- Card radius prop 이름 변경 [#3463](https://github.com/titicacadev/triple-frontend/pull/3463)\n- Carousel containerPadding, margin prop 복구 [#3470](https://github.com/titicacadev/triple-frontend/pull/3470)\n- 타입 에러 수정 [#3471](https://github.com/titicacadev/triple-frontend/pull/3471)\n\n### tds-widget\n\n- ScrapsProvider에 onScrapeFailed prop 넘길 수 있도록 추가 [#3469](https://github.com/titicacadev/triple-frontend/pull/3469)\n- 타입 에러 수정 [#3471](https://github.com/titicacadev/triple-frontend/pull/3471)\n- 13.35.0 - 13.36.0 변경사항을 14에 반영 [#3479](https://github.com/titicacadev/triple-frontend/pull/3479)\n\n### triple-document\n\n- #3437 변경사항을 적용합니다 [#3466](https://github.com/titicacadev/triple-frontend/pull/3466)\n- 타입 에러 수정 [#3471](https://github.com/titicacadev/triple-frontend/pull/3471)\n\n### triple-web\n\n- triple-web 테스트 추가 [#3472](https://github.com/titicacadev/triple-frontend/pull/3472)\n- 13.35.0 - 13.36.0 변경사항을 14에 반영 [#3479](https://github.com/titicacadev/triple-frontend/pull/3479)\n\n### triple-web-nextjs\n\n- [triple-web-nextjs] promise error 해결 [#3473](https://github.com/titicacadev/triple-frontend/pull/3473)\n- 13.35.0 - 13.36.0 변경사항을 14에 반영 [#3479](https://github.com/titicacadev/triple-frontend/pull/3479)\n\n### triple-web-nextjs-pages\n\n- 13.35.0 - 13.36.0 변경사항을 14에 반영 [#3479](https://github.com/titicacadev/triple-frontend/pull/3479)\n\n### triple-web-test-utils\n\n- 타입 에러 수정 [#3471](https://github.com/titicacadev/triple-frontend/pull/3471)\n\n### triple-web-utils\n\n- 13.35.0 - 13.36.0 변경사항을 14에 반영 [#3479](https://github.com/titicacadev/triple-frontend/pull/3479)\n\n## v14.0.3\n\n### standard-action-handler\n\n- 13.31.0 - 13.34.0 변경사항을 14에 반영 [#3460](https://github.com/titicacadev/triple-frontend/pull/3460)\n\n### tds-ui\n\n- 13.31.0 - 13.34.0 변경사항을 14에 반영 [#3460](https://github.com/titicacadev/triple-frontend/pull/3460)\n\n### tds-widget\n\n- user-verification service export 추가 [#3442](https://github.com/titicacadev/triple-frontend/pull/3442)\n- 13.31.0 - 13.34.0 변경사항을 14에 반영 [#3460](https://github.com/titicacadev/triple-frontend/pull/3460)\n\n### triple-web\n\n- useClientAppCallback의 fn, appInstallCtaModalOptions 파라미터 순서 변경 [#3440](https://github.com/titicacadev/triple-frontend/pull/3440)\n\n### view-utilities\n\n- 13.31.0 - 13.34.0 변경사항을 14에 반영 [#3460](https://github.com/titicacadev/triple-frontend/pull/3460)\n\n## v14.0.2\n\n### tds-widget\n\n- node querystring 대신 qs 사용 [#3423](https://github.com/titicacadev/triple-frontend/pull/3423)\n\n## v14.0.1\n\n### ab-experiments\n\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n\n### fetcher\n\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n\n### i18n\n\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n\n### intersection-observer\n\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n\n### meta-tags\n\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n\n### middlewares\n\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n\n### router\n\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n\n### standard-action-handler\n\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n\n### tds-theme\n\n- styled components에 transient prop 사용 [#3326](https://github.com/titicacadev/triple-frontend/pull/3326)\n\n### tds-ui\n\n- v13 변경사항 리베이스 [#3315](https://github.com/titicacadev/triple-frontend/pull/3315)\n- styled components 6 prop forward warning 수정 [#3323](https://github.com/titicacadev/triple-frontend/pull/3323)\n- styled components에 transient prop 사용 [#3326](https://github.com/titicacadev/triple-frontend/pull/3326)\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n- FlickingCarousel을 원상복구 [#3359](https://github.com/titicacadev/triple-frontend/pull/3359)\n- v14 버그 수정 [#3369](https://github.com/titicacadev/triple-frontend/pull/3369)\n\n### tds-widget\n\n- [v14] beforeScrapedChange prop 복구 [#3289](https://github.com/titicacadev/triple-frontend/pull/3289)\n- querystring 대신 qs 모듈 사용 [#3314](https://github.com/titicacadev/triple-frontend/pull/3314)\n- styled components 6 prop forward warning 수정 [#3323](https://github.com/titicacadev/triple-frontend/pull/3323)\n- styled components에 transient prop 사용 [#3326](https://github.com/titicacadev/triple-frontend/pull/3326)\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n- [v14] POI 대표 이미지 오류 수정 [#3356](https://github.com/titicacadev/triple-frontend/pull/3356)\n- FlickingCarousel을 원상복구 [#3359](https://github.com/titicacadev/triple-frontend/pull/3359)\n- [migration] v13.26.3 이후 변경사항 v14에 반영 [#3374](https://github.com/titicacadev/triple-frontend/pull/3374)\n- [migration] v13.29.0~v13.31.0 변경사항을 v14에 반영합니다. [#3381](https://github.com/titicacadev/triple-frontend/pull/3381)\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n- [v14] 캐러셀 관련 이슈 수정 [#3409](https://github.com/titicacadev/triple-frontend/pull/3409)\n\n### triple-document\n\n- styled components에 transient prop 사용 [#3326](https://github.com/titicacadev/triple-frontend/pull/3326)\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n\n### triple-email-document\n\n- styled components에 transient prop 사용 [#3326](https://github.com/titicacadev/triple-frontend/pull/3326)\n\n### triple-header\n\n- styled components에 transient prop 사용 [#3326](https://github.com/titicacadev/triple-frontend/pull/3326)\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n\n### triple-web\n\n- v13 변경사항 리베이스 [#3315](https://github.com/titicacadev/triple-frontend/pull/3315)\n- v14 버그 수정 [#3369](https://github.com/titicacadev/triple-frontend/pull/3369)\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n\n### triple-web-nextjs\n\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n- v14 버그 수정 [#3369](https://github.com/titicacadev/triple-frontend/pull/3369)\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n\n### triple-web-nextjs-pages\n\n- 스토리북 8 업그레이드 [#3341](https://github.com/titicacadev/triple-frontend/pull/3341)\n- v14 버그 수정 [#3369](https://github.com/titicacadev/triple-frontend/pull/3369)\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n\n### triple-web-test-utils\n\n- i18next 제거 & triple-web에 i18n context 추가 [#3387](https://github.com/titicacadev/triple-frontend/pull/3387)\n\n## v14\n\n### ab-experiments\n\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### action-sheet\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### ad-banners\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### app-banner\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### app-installation-cta\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### author\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### booking-completion\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### carousel\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### chat\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### color-palette\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### constants\n\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### content-sharing\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### core-elements\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### date-picker\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### directions-finder\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### drawer-button\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### fetcher\n\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### footer\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### form\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### hub-form\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### i18n\n\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### icons\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### image-carousel\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### image-viewer\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### intersection-observer\n\n- [v14] 빌드 에러 수정 1 [#3077](https://github.com/titicacadev/triple-frontend/pull/3077)\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- [v14] QA 버그 수정 [#3162](https://github.com/titicacadev/triple-frontend/pull/3162)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- styled-components 6 업그레이드 [#3268](https://github.com/titicacadev/triple-frontend/pull/3268)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### listing-filter\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### location-properties\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### map\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### meta-tags\n\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### middlewares\n\n- middleware 패키지 추가 및 세션 갱신 middlware 추가 [#3185](https://github.com/titicacadev/triple-frontend/pull/3185)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### modals\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### nearby-pois\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### poi-detail\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### poi-list-elements\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### popup\n\n- V14 에픽 브랜치 [#3189](https://github.com/titicacadev/triple-frontend/pull/3189)\n\n### react-hooks\n\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- [v14] 타입 개선과 버그 수정 [#3128](https://github.com/titicacadev/triple-frontend/pull/3128)\n- [v14] QA 버그 수정 [#3162](https://github.com/titicacadev/triple-frontend/pull/3162)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- styled-components 6 업그레이드 [#3268](https://github.com/titicacadev/triple-frontend/pull/3268)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### react-triple-client-interfaces\n\n- [v14] 스토리북 코드 수정 [#3063](https://github.com/titicacadev/triple-frontend/pull/3063)\n- [v14] react-triple-client-interfaces 패키지 제거 [#3068](https://github.com/titicacadev/triple-frontend/pull/3068)\n\n### router\n\n- [v14] useIsomorphicNavigation 복구 및 replies 마이그레이션 [#3062](https://github.com/titicacadev/triple-frontend/pull/3062)\n- [v14] react-triple-client-interfaces 패키지 제거 [#3068](https://github.com/titicacadev/triple-frontend/pull/3068)\n- [v14] 새로운 open link 훅 추가 [#3071](https://github.com/titicacadev/triple-frontend/pull/3071)\n- [v14] useClientAppActions import path 수정 [#3072](https://github.com/titicacadev/triple-frontend/pull/3072)\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- [v14] 테스트 코드를 수정합니다. [#3087](https://github.com/titicacadev/triple-frontend/pull/3087)\n- [v14] 컴포넌트형 링크 제거하고 useMake 훅 추가 [#3099](https://github.com/titicacadev/triple-frontend/pull/3099)\n- [v14] TripleWeb 사용 편의성 보완 [#3117](https://github.com/titicacadev/triple-frontend/pull/3117)\n- [triple-web] TransitionModal을 AppInstallCtaModal로 변경합니다. [#3249](https://github.com/titicacadev/triple-frontend/pull/3249)\n- 타입 문제 수정 [#3261](https://github.com/titicacadev/triple-frontend/pull/3261)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### scroll-spy\n\n- [v14] 빌드 에러 수정 1 [#3077](https://github.com/titicacadev/triple-frontend/pull/3077)\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- [V14] scroll-spy 패키지 제거 [#3089](https://github.com/titicacadev/triple-frontend/pull/3089)\n\n### scroll-to-element\n\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### standard-action-handler\n\n- [v14] standard-action-handler 마이그레이션 [#3061](https://github.com/titicacadev/triple-frontend/pull/3061)\n- [v14] useExternalRouter 제거 [#3067](https://github.com/titicacadev/triple-frontend/pull/3067)\n- [v14] react-triple-client-interfaces 패키지 제거 [#3068](https://github.com/titicacadev/triple-frontend/pull/3068)\n- [v14] 새로운 open link 훅 추가 [#3071](https://github.com/titicacadev/triple-frontend/pull/3071)\n- [v14] useClientAppActions import path 수정 [#3072](https://github.com/titicacadev/triple-frontend/pull/3072)\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- [v14] 테스트 코드를 수정합니다. [#3087](https://github.com/titicacadev/triple-frontend/pull/3087)\n- [triple-web] TransitionModal을 AppInstallCtaModal로 변경합니다. [#3249](https://github.com/titicacadev/triple-frontend/pull/3249)\n- 타입 문제 수정 [#3261](https://github.com/titicacadev/triple-frontend/pull/3261)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- styled-components 6 업그레이드 [#3268](https://github.com/titicacadev/triple-frontend/pull/3268)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n### tds-theme\n\n- [v14] ES Module 사용, 빌드 오류 일부 수정 [#3081](https://github.com/titicacadev/triple-frontend/pull/3081)\n- [v14] subpath exports 제거, intersection-observer 개선 [#3085](https://github.com/titicacadev/triple-frontend/pull/3085)\n- Vite 빌드 [#3264](https://github.com/titicacadev/triple-frontend/pull/3264)\n- styled-components 6 업그레이드 [#3268](https://github.com/titicacadev/triple-frontend/pull/3268)\n- ES Module 빌드 [#3275](https://github.com/titicacadev/triple-frontend/pull/3275)\n\n## v13.48.1\n\n```\n### react-contexts\n\n- [react-contexts] 웹뷰일 때 trackScreen이 호출되지 않는 이슈를 해결합니다.  [#3756](https://github.com/titicacadev/triple-frontend/pull/3756)\n```\n\n## v13.48.0\n\n### footer\n\n- [footer] 리뉴얼된 푸터 UI를 적용합니다. [#3751](https://github.com/titicacadev/triple-frontend/pull/3751)\n\n## v13.47.0\n\n### action-sheet\n\n- [STAY-1323] ActionSheetItem 이 overflow 속성을 받을 수 있도록 수정(optional) [#3714](https://github.com/titicacadev/triple-frontend/pull/3714)\n\n## 13.46.1\n\n### poi-detail\n\n- [INTTNA-2259] 상세 헤더 TNA 공급사 리뷰로 사용하기 위해 리뷰 개수만 확인하여 리뷰보기 노출 [3705](https://github.com/titicacadev/triple-frontend/pull/3705)\n\n## 13.46.0\n\n### ab-experiments\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n\n### ad-banners\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n\n### constants\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 세션 리프레쉬 로직을 수정합니다. [#3592](https://github.com/titicacadev/triple-frontend/pull/3592)\n\n### core-elements\n\n- [footer] 푸터에 버튼, 링크, 드롭다운을 원격으로 설정할 수 있도록 수정합니다. [#3615](https://github.com/titicacadev/triple-frontend/pull/3615)\n\n### date-picker\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n\n### fetcher\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n- [fetcher] authFetcherize에서 refresh의 apiUriBase를 fetcher와 통일합니다. [#3645](https://github.com/titicacadev/triple-frontend/pull/3645)\n- [KLZT-910] 서버의 401 에러의 AccessTokenExpiredException를 구분합니다. [#3646](https://github.com/titicacadev/triple-frontend/pull/3646)\n- [ui-flow] authGuard에서 firstTrial시 NEED_LOGIN 응답시 NEED_LOGIN_IDENTIFIER 리턴 [#3655](https://github.com/titicacadev/triple-frontend/pull/3655)\n\n### footer\n\n- [footer] 푸터에 버튼, 링크, 드롭다운을 원격으로 설정할 수 있도록 수정합니다. [#3615](https://github.com/titicacadev/triple-frontend/pull/3615)\n- [footer] footer의 disclaimer maxWidth 삭제 [#3648](https://github.com/titicacadev/triple-frontend/pull/3648)\n- [footer] 푸터 버튼에 key props를 추가합니다. [#3652](https://github.com/titicacadev/triple-frontend/pull/3652)\n\n### nearby-pois\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n\n### poi-detail\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n\n### public-header\n\n- [public-header, react-contexts] NOL 연동 회원의 경우 웹 사이드바 프로필에서 provider를 노출하지 않습니다. [#3580](https://github.com/titicacadev/triple-frontend/pull/3580)\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [public-header] provider 타입 추가에 따른 프로필 변경 [#3607](https://github.com/titicacadev/triple-frontend/pull/3607)\n- [public-header] NOL 멤버스 문구를 NOL 회원으로 변경합니다. [#3659](https://github.com/titicacadev/triple-frontend/pull/3659)\n\n### react-contexts\n\n- [public-header, react-contexts] NOL 연동 회원의 경우 웹 사이드바 프로필에서 provider를 노출하지 않습니다. [#3580](https://github.com/titicacadev/triple-frontend/pull/3580)\n- [react-contexts] NOL 통합 유저일 경우 로그아웃시 redirect합니다. [#3581](https://github.com/titicacadev/triple-frontend/pull/3581)\n- [react-contexts] 트리플 deviceId를 추가하는 미들웨어를 작성합니다. [#3584](https://github.com/titicacadev/triple-frontend/pull/3584)\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 세션 리프레쉬 로직을 수정합니다. [#3592](https://github.com/titicacadev/triple-frontend/pull/3592)\n- [react-contexts] setWebDeviceId 미들웨어에 applySetCookie를 적용합니다. [#3594](https://github.com/titicacadev/triple-frontend/pull/3594)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n- [react-contexts] 세션 체크 API로 변경 [#3605](https://github.com/titicacadev/triple-frontend/pull/3605)\n- [public-header] provider 타입 추가에 따른 프로필 변경 [#3607](https://github.com/titicacadev/triple-frontend/pull/3607)\n- [react-contexts] 미들웨어에 chain 및 기타 미들웨어의 export를 추가합니다. [#3609](https://github.com/titicacadev/triple-frontend/pull/3609)\n- [react-contexts] setWebDeviceId 미들웨어에 domain을 추가합니다. [#3610](https://github.com/titicacadev/triple-frontend/pull/3610)\n- [react-contexts] trackScreen에 nol_device_id를 기록하도록 수정합니다. [#3617](https://github.com/titicacadev/triple-frontend/pull/3617)\n- [react-contexts] 일반 로그아웃시 reload 추가 [#3641](https://github.com/titicacadev/triple-frontend/pull/3641)\n- [KLZT-910] 서버의 401 에러의 AccessTokenExpiredException를 구분합니다. [#3646](https://github.com/titicacadev/triple-frontend/pull/3646)\n\n### replies\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n\n### review\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n- [KLZT-910] 서버의 401 에러의 AccessTokenExpiredException를 구분합니다. [#3646](https://github.com/titicacadev/triple-frontend/pull/3646)\n\n### standard-action-handler\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [KLZT-910] 서버의 401 에러의 AccessTokenExpiredException를 구분합니다. [#3646](https://github.com/titicacadev/triple-frontend/pull/3646)\n\n### triple-document\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n\n### triple-header\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n\n### ui-flow\n\n- [public-header, react-contexts] NOL 연동 회원의 경우 웹 사이드바 프로필에서 provider를 노출하지 않습니다. [#3580](https://github.com/titicacadev/triple-frontend/pull/3580)\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n- [ui-flow] authGuard에서 firstTrial시 NEED_LOGIN 응답시 NEED_LOGIN_IDENTIFIER 리턴 [#3655](https://github.com/titicacadev/triple-frontend/pull/3655)\n\n### user-verification\n\n- [Epic] NOL 회원 통합 [#3590](https://github.com/titicacadev/triple-frontend/pull/3590)\n- [react-contexts] 클라이언트 세션 갱신 로직을 추가합니다. [#3595](https://github.com/titicacadev/triple-frontend/pull/3595)\n\n## 13.45.1\n\n### view-utilities\n\n- [view-utilities] routelist의 static-pages 정규식을 수정합니다. [#3598](https://github.com/titicacadev/triple-frontend/pull/3598)\n\n## 13.45.0\n\n### view-utilities\n\n- [KLZT-885] routelist에 static-pages, community, game을 추가합니다. [#3596](https://github.com/titicacadev/triple-frontend/pull/3596)\n\n## 13.44.0\n\n### footer\n\n- [footer, react-contexts] 푸터 정보를 footer.json으로 관리하도록 수정합니다 [#3577](https://github.com/titicacadev/triple-frontend/pull/3577)\n\n### react-contexts\n\n- [footer, react-contexts] 푸터 정보를 footer.json으로 관리하도록 수정합니다 [#3577](https://github.com/titicacadev/triple-frontend/pull/3577)\n\n## 13.43.0\n\n### react-contexts\n\n- session refresh middleware 추가 [#3564](https://github.com/titicacadev/triple-frontend/pull/3564)\n\n## 13.42.1\n\n### chat\n\n- (chat)fix: file input에 사진만 업로드 가능하도록 accept 추가 [#3552](https://github.com/titicacadev/triple-frontend/pull/3552)\n\n## 13.42.0\n\n### chat\n\n- (chat) 트리플 앱 디펜던시 제거 [#3554](https://github.com/titicacadev/triple-frontend/pull/3554)\n\n## v13.41.0\n\n### chat\n\n- [chat] 메세지에 IntersectionObserver와 ref를 추가합니다. [#3109](https://github.com/titicacadev/triple-frontend/pull/3109)\n- [chat] 부모 메세지 UI를 추가합니다 [#3111](https://github.com/titicacadev/triple-frontend/pull/3111)\n- [chat] 버블 스타일을 수정하고 날짜 및 시간 표기, 프로필 생략 기능을 추가합니다. [#3116](https://github.com/titicacadev/triple-frontend/pull/3116)\n- [chat] 메세지에 답장하기 아이콘을 추가합니다. [#3127](https://github.com/titicacadev/triple-frontend/pull/3127)\n- [chat] epic: geochat 기능을 chat 패키지에 추가합니다. [#3130](https://github.com/titicacadev/triple-frontend/pull/3130)\n- [chat] 답장하기 아이콘의 렌더링 조건을 수정합니다. [#3132](https://github.com/titicacadev/triple-frontend/pull/3132)\n- [chat] 긴 글 메세지의 전체보기 뷰를 추가합니다. [#3134](https://github.com/titicacadev/triple-frontend/pull/3134)\n- [chat] openMenu의 타입 오류를 수정합니다. [#3139](https://github.com/titicacadev/triple-frontend/pull/3139)\n- [chat] 지오챗 버블의 기능을 추가합니다. [#3146](https://github.com/titicacadev/triple-frontend/pull/3146)\n- [chat] intersection observer 교체 외 스타일 수정 [#3444](https://github.com/titicacadev/triple-frontend/pull/3444)\n- [chat] aTagNavigator가 외부 브라우저에서 열리도록 수정합니다. [#3551](https://github.com/titicacadev/triple-frontend/pull/3551)\n- [chat] 답장하기 버튼에 data-id 추가 [#3556](https://github.com/titicacadev/triple-frontend/pull/3556)\n\n## 13.40.1\n\n### view-utilities\n\n- [view-utilities] tna public_routerlist 정규식 수정 [#3542](https://github.com/titicacadev/triple-frontend/pull/3542)\n\n## 13.40.0\n\n### footer\n\n- [footer] 푸터 내 문의메일 정보 변경 [#3543](https://github.com/titicacadev/triple-frontend/pull/3543)\n\n## 13.39.0\n\n### triple-email-document\n\n- [triple-email-template] default 링크 스타일을 추가하고, 스크롤 포커싱 이슈를 해결합니다 [#3517](https://github.com/titicacadev/triple-frontend/pull/3517)\n\n## v13.38.1\n\n### footer\n\n- [footer] 푸터의 맞춤법을 수정합니다. [#3516](https://github.com/titicacadev/triple-frontend/pull/3516)\n\n## v13.38.0\n\n### footer\n\n- [footer] 푸터의 회사명, 대표명을 변경합니다. [#3505](https://github.com/titicacadev/triple-frontend/pull/3505)\n\n### review\n\n- [Reivew] 리뷰 플레이스홀더 마진 값을 수정합니다. [#3509](https://github.com/titicacadev/triple-frontend/pull/3509)\n\n## v13.37.0\n\n### triple-media\n\n- KLZT-766 영상기본음소거 [#3503](https://github.com/titicacadev/triple-frontend/pull/3503)\n\n### type-definitions\n\n- KLZT-766 영상기본음소거 [#3503](https://github.com/titicacadev/triple-frontend/pull/3503)\n\n## v13.36.0\n\n### react-contexts\n\n- [INTHOTEL-2407] defaultImage와 중복되는 이미지 필터링 [#3474](https://github.com/titicacadev/triple-frontend/pull/3474)\n- triple-web-to-native-interfaces 패키지 버전업 대응 [#3475](https://github.com/titicacadev/triple-frontend/pull/3475)\n\n### search\n\n- triple-web-to-native-interfaces 패키지 버전업 대응 [#3475](https://github.com/titicacadev/triple-frontend/pull/3475)\n\n### standard-action-handler\n\n- triple-web-to-native-interfaces 패키지 버전업 대응 [#3475](https://github.com/titicacadev/triple-frontend/pull/3475)\n\n## v13.35.0\n\n### react-triple-client-interfaces\n\n- [MAC용 트리플 앱 개발 지원] client meta data에 isMacApp 플래그 추가 [#3439](https://github.com/titicacadev/triple-frontend/pull/3439)\n\n### triple-document\n\n- [KLZT-668] 아티클 추천코스의 POI에 한줄 소개 영역을 추가합니다. [#3437](https://github.com/titicacadev/triple-frontend/pull/3437)\n\n## v13.34.0\n\n### modals\n\n- [KLZT-663] converse에 로그인 유도 모달을 추가합니다 [#3429](https://github.com/titicacadev/triple-frontend/pull/3429)\n\n### standard-action-handler\n\n- [KLZT-663] converse에 로그인 유도 모달을 추가합니다 [#3429](https://github.com/titicacadev/triple-frontend/pull/3429)\n\n### view-utilities\n\n- [KLZT-677] routelist에 tna, air hub 추가 [#3443](https://github.com/titicacadev/triple-frontend/pull/3443)\n\n## v13.33.0\n\n### footer\n\n- [KLZT-671] 푸터 내 통신판매업 신고번호 변경 [#3435](https://github.com/titicacadev/triple-frontend/pull/3435)\n\n## v13.32.0\n\n### review\n\n- [KLZT-655] 리뷰 정렬 옵션 변경 시 무관한 element로 포커싱이 되는 버그를 수정합니다. [#3425](https://github.com/titicacadev/triple-frontend/pull/3425)\n- [WATF-397] 리뷰 컴포넌트 내 배너를 prop으로 받도록 수정합니다. [#3433](https://github.com/titicacadev/triple-frontend/pull/3433)\n\n## v13.31.0\n\n### react-contexts\n\n- [react-contexts] ScrapProvider에서 스크랩 실패 시 콜백함수를 props로 받습니다. [#3418](https://github.com/titicacadev/triple-frontend/pull/3418)\n\n### review\n\n- [KLZT-103] 리뷰 컴포넌트 렌더링 버그를 수정합니다. [#3407](https://github.com/titicacadev/triple-frontend/pull/3407)\n\n## v13.30.0\n\n### footer\n\n- [KLZT-622] 푸터 주소 변경 [#3413](https://github.com/titicacadev/triple-frontend/pull/3413)\n\n## v13.29.0\n\n### footer\n\n- [footer] 푸터 KINT 진입점에 FA 로깅을 추가합니다. [#3379](https://github.com/titicacadev/triple-frontend/pull/3379)\n\n### react-contexts\n\n- [react-contexts] images-api v2 적용 [#3345](https://github.com/titicacadev/triple-frontend/pull/3345)\n\n## v13.28.0\n\n### footer\n\n- [footer] 푸터에 트리플 코리아 링크를 추가합니다. [#3370](https://github.com/titicacadev/triple-frontend/pull/3370)\n\n## v13.27.1\n\n### review\n\n- [review] travel-restritions-api 관련 코드를 제거하기 위해 코드젠 generated.tsx를 재생성합니다 [#3357](https://github.com/titicacadev/triple-frontend/pull/3357)\n\n## v13.27.0\n\n### public-header\n\n- [public-header] 헤더에 사이드바를 추가합니다. [#3328](https://github.com/titicacadev/triple-frontend/pull/3328)\n\n## v13.26.4\n\n### footer\n\n- [footer] 푸터 통신판매업 신고번호를 변경합니다. [#3329](https://github.com/titicacadev/triple-frontend/pull/3329)\n\n## v13.26.3\n\n### action-sheet\n\n- [action-sheet] pointer select revert [#3313](https://github.com/titicacadev/triple-frontend/pull/3313)\n\n### react-contexts\n\n- [react-contexts] user의 마일리지 뱃지 이미지 타입을 수정합니다. [#3310](https://github.com/titicacadev/triple-frontend/pull/3310)\n\n## v13.26.2\n\n### action-sheet\n\n- [action-sheet] 액션 시트에서 lockScroll을 해제할 수 있도록 옵션 제공 [#3295](https://github.com/titicacadev/triple-frontend/pull/3295)\n\n### react-contexts\n\n- [react-hooks] user 타입에 email 필드를 추가합니다. [#3304](https://github.com/titicacadev/triple-frontend/pull/3304)\n\n### react-hooks\n\n- [react-hooks] user 타입에 email 필드를 추가합니다. [#3304](https://github.com/titicacadev/triple-frontend/pull/3304)\n\n## v13.26.1\n\n### review\n\n- [review] 리뷰 상세 딥링크의 regionId 파라미터 null처리 추가 [#3266](https://github.com/titicacadev/triple-frontend/pull/3266)\n\n## v13.26.0\n\n### footer\n\n- [footer] 인터파크 사업장 주소 변경 [#3247](https://github.com/titicacadev/triple-frontend/pull/3247)\n\n## v13.25.3\n\n### triple-document\n\n- triple-document 내 쿠폰 모달 닫기 버튼 문구 수정 [#3243](https://github.com/titicacadev/triple-frontend/pull/3243)\n\n### core-elements\n\n- input error border-color 변경 [#3244](https://github.com/titicacadev/triple-frontend/pull/3244)\n\n## v13.25.2\n\n### triple-document\n\n- [triple-document] StickyTabs 이미지에 object-fit 적용 [#3238](https://github.com/titicacadev/triple-frontend/pull/3238)\n\n### view-utilities\n\n- [view-utilities] 맞춤일정 페이지를 routelist에 추가합니다. [#3239](https://github.com/titicacadev/triple-frontend/pull/3239)\n\n## v13.25.1\n\n### core-elements\n\n- form field error color 변경 [#3234](https://github.com/titicacadev/triple-frontend/pull/3234)\n\n### triple-document\n\n- sticky-tabs의 z-index 수정 [#3235](https://github.com/titicacadev/triple-frontend/pull/3235)\n\n## v13.25.0\n\n### triple-header, triple-document\n\n- triple-header가 로티 애니메이션 타입을 지원합니다.\n- triple-document에 Animation Element를 추가합니다. [#3210](https://github.com/titicacadev/triple-frontend/pull/3210)\n\n### triple-document\n\n- StickyTabs element 추가합니다. [#3220](https://github.com/titicacadev/triple-frontend/pull/3220)\n\n## v13.24.0\n\n### triple-document\n\n- 추천코스가 페스타 타입을 지원하도록 합니다. [#3202](https://github.com/titicacadev/triple-frontend/pull/3202)\n\n## v13.23.1\n\n### image-viewer\n\n- [image-viewer] 첫번째 이미지를 클릭했을 때 비정상적으로 작동하는 버그를 수정합니다. [#3187](https://github.com/titicacadev/triple-frontend/pull/3187)\n\n## v13.23.0\n\n### modals\n\n- [modals] 앱설치유도 모달에 POI 기본정보 type을 추가합니다. [#3182](https://github.com/titicacadev/triple-frontend/pull/3182)\n\n### public-header\n\n- [public-header] 헤더 로고에 onClick props를 추가합니다. [#3183](https://github.com/titicacadev/triple-frontend/pull/3183)\n\n## v13.22.0\n\n### image-viewer\n\n- [image-viewer] 확대뷰/격자뷰를 위한 이미지 뷰어 패키지를 생성합니다 [#3165](https://github.com/titicacadev/triple-frontend/pull/3165)\n- [image-viewer] 이미지 확대뷰 팝업을 생성합니다 [#3166](https://github.com/titicacadev/triple-frontend/pull/3166)\n- [image-viewer] 동영상 확대뷰를 작성합니다 [#3170](https://github.com/titicacadev/triple-frontend/pull/3170)\n- [image-viewer] 이미지 확대뷰에서 핀치 줌 기능을 적용합니다 [#3172](https://github.com/titicacadev/triple-frontend/pull/3172)\n- [review, image-viewer] 이미지 뷰어에서 ImagesContext를 제거하고 리뷰 컴포넌트에 이미지 뷰어를 적용합니다 [#3173](https://github.com/titicacadev/triple-frontend/pull/3173)\n- [image-viewer, poi-detail, review] 이미지 확대뷰 fa 이벤트를 추가합니다 [#3175](https://github.com/titicacadev/triple-frontend/pull/3175)\n- [image-viewer] 더블 클릭으로 인한 이미지 확대를 disable합니다 [#3176](https://github.com/titicacadev/triple-frontend/pull/3176)\n- [reviews] 이미지 확대뷰 QA [#3179](https://github.com/titicacadev/triple-frontend/pull/3179)\n\n### meta-tags\n\n- [meta-tags] 불필요 주석 제거 [#3163](https://github.com/titicacadev/triple-frontend/pull/3163)\n\n### poi-detail\n\n- [image-viewer] 확대뷰/격자뷰를 위한 이미지 뷰어 패키지를 생성합니다 [#3165](https://github.com/titicacadev/triple-frontend/pull/3165)\n- [poi-detail, review] 이미지 캐러셀의 cta 조건을 수정합니다 [#3171](https://github.com/titicacadev/triple-frontend/pull/3171)\n- [poi-detail] 이지역꿀정보의 fa를 추가합니다 [#3174](https://github.com/titicacadev/triple-frontend/pull/3174)\n- [image-viewer, poi-detail, review] 이미지 확대뷰 fa 이벤트를 추가합니다 [#3175](https://github.com/titicacadev/triple-frontend/pull/3175)\n- [poi-detail] 이지역꿀정보 영역 디자인을 수정합니다 [#3178](https://github.com/titicacadev/triple-frontend/pull/3178)\n\n### review\n\n- [image-viewer] 확대뷰/격자뷰를 위한 이미지 뷰어 패키지를 생성합니다 [#3165](https://github.com/titicacadev/triple-frontend/pull/3165)\n- [poi-detail, review] 이미지 캐러셀의 cta 조건을 수정합니다 [#3171](https://github.com/titicacadev/triple-frontend/pull/3171)\n- [review, image-viewer] 이미지 뷰어에서 ImagesContext를 제거하고 리뷰 컴포넌트에 이미지 뷰어를 적용합니다 [#3173](https://github.com/titicacadev/triple-frontend/pull/3173)\n- [image-viewer, poi-detail, review] 이미지 확대뷰 fa 이벤트를 추가합니다 [#3175](https://github.com/titicacadev/triple-frontend/pull/3175)\n- [reviews] 유저 photo가 없을 때 디폴트 프로필을 설정합니다 [#3180](https://github.com/titicacadev/triple-frontend/pull/3180)\n\n### triple-document\n\n- [triple-document] itinerary의 transportation 타입에 bike를 추가합니다 [#3177](https://github.com/titicacadev/triple-frontend/pull/3177)\n\n## v13.21.1\n\n### review\n\n- [reviews] 리뷰 더보기의 로그인 returnUrl을 리뷰 목록 페이지로 수정합니다. [#3167](https://github.com/titicacadev/triple-frontend/pull/3167)\n\n## v13.21.0\n\n### modals\n\n- [review] 웹에서도 리뷰 더보기 버튼을 통해 리뷰 상세 페이지에 접근할 수 있도록 수정합니다 [#3156](https://github.com/titicacadev/triple-frontend/pull/3156)\n- [modals] loginCtaModal의 로그인 클릭시에도 리퍼럴 이벤트를 기록하도록 수정합니다 [#3157](https://github.com/titicacadev/triple-frontend/pull/3157)\n\n### poi-detail\n\n- [poi-detail] 저장 유도 툴팁을 추가합니다 [#3152](https://github.com/titicacadev/triple-frontend/pull/3152)\n\n### review\n\n- [review] 웹에서도 리뷰 더보기 버튼을 통해 리뷰 상세 페이지에 접근할 수 있도록 수정합니다 [#3156](https://github.com/titicacadev/triple-frontend/pull/3156)\n- [review] 맞춤 일정 배너를 노출합니다 [#3158](https://github.com/titicacadev/triple-frontend/pull/3158)\n\n### triple-document\n\n- [triple-document] note가 markdownText를 지원하도록 수정합니다 [#3148](https://github.com/titicacadev/triple-frontend/pull/3148)\n\n### view-utilities\n\n- [review] 웹에서도 리뷰 더보기 버튼을 통해 리뷰 상세 페이지에 접근할 수 있도록 수정합니다 [#3156](https://github.com/titicacadev/triple-frontend/pull/3156)\n- [view-utilities] 항공 시세 페이지를 routelist에 추가합니다 [#3160](https://github.com/titicacadev/triple-frontend/pull/3160)\n\n## v13.20.0\n\n### view-utilities\n\n- [view-utilities] 말줄임 함수 수정 [#3154](https://github.com/titicacadev/triple-frontend/pull/3154)\n\n## v13.19.2\n\n### modals\n\n- TransitionModal에 타입을 추가합니다. [#3144](https://github.com/titicacadev/triple-frontend/pull/3144)\n\n## v13.19.1\n\n### nearby-pois\n\n- [nearby-pois] 더보기 버튼 클릭 시 api 호출이 중복되는 이슈 수정 [#3140](https://github.com/titicacadev/triple-frontend/pull/3140)\n- [nearby-pois] pois 중복을 제거하는 로직을 수정합니다. [#3141](https://github.com/titicacadev/triple-frontend/pull/3141)\n\n## v13.19.0\n\n### standard-action-handler\n\n- [standard-action-handler] requireTripleClient 함수 추가 [#3121](https://github.com/titicacadev/triple-frontend/pull/3121)\n\n### triple-document\n\n- [triple-document] tna slot의 타이틀을 두줄 노출하도록 수정 [#3133](https://github.com/titicacadev/triple-frontend/pull/3133)\n\n## v13.18.3\n\n### poi-detail\n\n- [poi-detail] DetailHeader의 아이콘을 정렬합니다. [#3126](https://github.com/titicacadev/triple-frontend/pull/3126)\n- [poi-detail] 리뷰 영상 관련 툴팁 노출 버그를 수정합니다 [#3129](https://github.com/titicacadev/triple-frontend/pull/3129)\n\n## v13.18.2\n\n### modals\n\n- [Review] notifyReviewDeleted 중복 호출 제거 및 리뷰 메뉴 선택 시 앱 설치 유도팝업 노출 [#3118](https://github.com/titicacadev/triple-frontend/pull/3118)\n\n### review\n\n- [Review] notifyReviewDeleted 중복 호출 제거 및 리뷰 메뉴 선택 시 앱 설치 유도팝업 노출 [#3118](https://github.com/titicacadev/triple-frontend/pull/3118)\n\n## v13.18.1\n\n### review\n\n- [review] 좋아요 카운트 버그 수정 [#3115](https://github.com/titicacadev/triple-frontend/pull/3115)\n\n## v13.18.0\n\n### chat\n\n- [Chat refactor] Bubble 리팩토링 [#2980](https://github.com/titicacadev/triple-frontend/pull/2980)\n- [Chat refactor] 채팅의 default 네브바와 input ui를 추가합니다. [#2982](https://github.com/titicacadev/triple-frontend/pull/2982)\n- [Chat refactor] 스크롤을 위한 context와 container를 작성합니다. [#2985](https://github.com/titicacadev/triple-frontend/pull/2985)\n- [Chat refactor] messagesReducer를 추가합니다. [#2986](https://github.com/titicacadev/triple-frontend/pull/2986)\n- [epic] Chat 리팩토링 [#2994](https://github.com/titicacadev/triple-frontend/pull/2994)\n- [Chat refactor] BubbleUI의 prop 변경 [#2998](https://github.com/titicacadev/triple-frontend/pull/2998)\n- [Chat refactor] 사용하지 않는 파일을 제거합니다. [#2999](https://github.com/titicacadev/triple-frontend/pull/2999)\n- [Chat refactor] 메시지 리스트를 렌더링하는 Messages 컴포넌트 생성 [#3003](https://github.com/titicacadev/triple-frontend/pull/3003)\n- [Chat refactor] Bubble의 default click 설정 [#3008](https://github.com/titicacadev/triple-frontend/pull/3008)\n- [chat refactor] constants의 export를 추가하고 다중 이미지 업로드 옵션을 추가합니다. [#3031](https://github.com/titicacadev/triple-frontend/pull/3031)\n- [Chat refactor] Bubble 스타일 설정 추가 및 스토리북 추가 [#3044](https://github.com/titicacadev/triple-frontend/pull/3044)\n- [chat refactor] scroll context에 스크롤 방지 옵션을 추가합니다. [#3051](https://github.com/titicacadev/triple-frontend/pull/3051)\n- [Chat] react-triple-client-interfaces의 Dependencies 수정 [#3088](https://github.com/titicacadev/triple-frontend/pull/3088)\n- [chat refactor] 메세지에 unreadCount를 계산하기 위한 prop을 추가합니다. [#3095](https://github.com/titicacadev/triple-frontend/pull/3095)\n- [Chat Refactor] MessagesReducer의 액션을 추가하고 타입을 수정합니다. [#3104](https://github.com/titicacadev/triple-frontend/pull/3104)\n\n## v13.17.0\n\n### replies\n\n- 댓글 작성, 삭제 handle function prop을 추가합니다 [#3098](https://github.com/titicacadev/triple-frontend/pull/3098)\n\n### reply\n\n- 댓글 작성, 삭제 handle function prop을 추가합니다 [#3098](https://github.com/titicacadev/triple-frontend/pull/3098)\n\n### triple-document\n\n- [triple-document] 추천 일정의 '내 일정으로 담기' 로직을 수정합니다. [#3096](https://github.com/titicacadev/triple-frontend/pull/3096)\n\n## v13.16.0\n\n### directions-finder\n\n- [directions-finder] grab 호출 버튼에 Intersecting Observer를 추가합니다. [#3082](https://github.com/titicacadev/triple-frontend/pull/3082)\n\n### i18n\n\n- [Review] 예약 상품 상세 정보 노출 [#3070](https://github.com/titicacadev/triple-frontend/pull/3070)\n\n### meta-tags\n\n- [meta-tags] reviewRating에 bestRating, worstRating을 추가합니다. [#3079](https://github.com/titicacadev/triple-frontend/pull/3079)\n\n### review\n\n- [Review] 예약 상품 상세 정보 노출 [#3070](https://github.com/titicacadev/triple-frontend/pull/3070)\n\n## v13.15.0\n\n### directions-finder\n\n- [directions-finder] grab 호출 버튼을 추가합니다. [#3032](https://github.com/titicacadev/triple-frontend/pull/3032)\n\n### i18n\n\n- [directions-finder] grab 호출 버튼을 추가합니다. [#3032](https://github.com/titicacadev/triple-frontend/pull/3032)\n\n### meta-tags\n\n- [meta-tags] 리뷰 스니펫에 inLanguage 항목을 추가합니다. [#3074](https://github.com/titicacadev/triple-frontend/pull/3074)\n\n## v13.14.2\n\n### react-contexts\n\n- [react-contexts] 틱톡 픽셀의 누락된 track 메소드를 추가합니다. [#3064](https://github.com/titicacadev/triple-frontend/pull/3064)\n\n## v13.14.1\n\n### resource-list-element\n\n- ExtendedResourceListElement badge 디자인 수정 [#3057](https://github.com/titicacadev/triple-frontend/pull/3057)\n\n### triple-header\n\n- [TFC-52] 트리플 헤더 블링크 이슈 [#2783](https://github.com/titicacadev/triple-frontend/pull/2783)\n\n## v13.14.0\n\n### chat\n\n- [Chat] 좋아요 기능 추가 [#3017](https://github.com/titicacadev/triple-frontend/pull/3017)\n\n### react-contexts\n\n- [react-contexts] 틱톡 pixel 이벤트를 추가합니다. [#3053](https://github.com/titicacadev/triple-frontend/pull/3053)\n\n### resource-list-element\n\n- ExtendedResourceListElement에 badge 영역 추가 [#3048](https://github.com/titicacadev/triple-frontend/pull/3048)\n\n## v13.13.0\n\n### poi-detail\n\n- [type-definitions] GuestModeType 타입 추가 및 적용 [#3028](https://github.com/titicacadev/triple-frontend/pull/3028)\n\n### poi-list-elements\n\n- [type-definitions] GuestModeType 타입 추가 및 적용 [#3028](https://github.com/titicacadev/triple-frontend/pull/3028)\n\n### triple-document\n\n- [triple-document] 가이드 일정영역 서울콘 대응 [#3025](https://github.com/titicacadev/triple-frontend/pull/3025)\n- [type-definitions] GuestModeType 타입 추가 및 적용 [#3028](https://github.com/titicacadev/triple-frontend/pull/3028)\n\n### type-definitions\n\n- [type-definitions] GuestModeType 타입 추가 및 적용 [#3028](https://github.com/titicacadev/triple-frontend/pull/3028)\n\n## v13.12.0\n\n### view-utilities\n\n- [view-utilities] 로그인 없이 이동할 수 있는 페이지 주소 목록에 웹일정판 path 추가 [#3015](https://github.com/titicacadev/triple-frontend/pull/3015)\n\n## v13.11.0\n\n### poi-detail\n\n- [Poi-detail] image carousel 서울콘 대응 [#2991](https://github.com/titicacadev/triple-frontend/pull/2991)\n\n### poi-list-elements\n\n- [Poi-list-elements] POI의 이름과 리전명 영역에 대표어값이 가장 우선적으로 표기되도록 함 [#2989](https://github.com/titicacadev/triple-frontend/pull/2989)\n- [Poi-list-elements] POI 리스트에서 스크랩 버튼을 숨길 수 있도록 함 [#2990](https://github.com/titicacadev/triple-frontend/pull/2990)\n\n### standard-action-handler\n\n- [standard-action-handler] API 응답 일반화 노출 기능을 추가합니다. [#2947](https://github.com/titicacadev/triple-frontend/pull/2947)\n\n### triple-document\n\n- [Poi-list-elements] POI 리스트에서 스크랩 버튼을 숨길 수 있도록 함 [#2990](https://github.com/titicacadev/triple-frontend/pull/2990)\n\n## v13.10.1\n\n### chat\n\n- [chat] beforeSendMessages를 리듀서에서 제거합니다. [#2988](https://github.com/titicacadev/triple-frontend/pull/2988)\n\n## v13.10.0\n\n### action-sheet\n\n- [action-sheet] action sheet item에 아이콘 추가 [#2979](https://github.com/titicacadev/triple-frontend/pull/2979)\n\n### app-installation-cta\n\n- [react-hooks] useLocalStorage, useSessionStorage 추가 [#2961](https://github.com/titicacadev/triple-frontend/pull/2961)\n\n### chat\n\n- [Chat] Message에 sender 정보를 포함하는 api 변경에 대응합니다. [#2973](https://github.com/titicacadev/triple-frontend/pull/2973)\n\n### poi-detail\n\n- [react-hooks] useLocalStorage, useSessionStorage 추가 [#2961](https://github.com/titicacadev/triple-frontend/pull/2961)\n\n### react-hooks\n\n- [react-hooks] useLocalStorage, useSessionStorage 추가 [#2961](https://github.com/titicacadev/triple-frontend/pull/2961)\n\n## v13.9.0\n\n### chat\n\n- [Chat] received, sent 구분 기준 변경 [#2968](https://github.com/titicacadev/triple-frontend/pull/2968)\n- [chat] beforeSentMessages 초기 props만 저장하도록 수정 [#2969](https://github.com/titicacadev/triple-frontend/pull/2969)\n- [chat] beforeSentMessages 기본값 불필요하므로 제거 [#2975](https://github.com/titicacadev/triple-frontend/pull/2975)\n\n### footer\n\n- [footer] Award Footer의 인증마크 변경 [#2974](https://github.com/titicacadev/triple-frontend/pull/2974)\n\n## v13.8.1\n\n### chat\n\n- [chat] 채팅 인입 후 메시지 전송시 상품/예약정보 중복노출 [#2960](https://github.com/titicacadev/triple-frontend/pull/2960)\n\n## v13.8.0\n\n### chat\n\n- [Chat] Product bubble 추가 [#2891](https://github.com/titicacadev/triple-frontend/pull/2891)\n\n### core-elements\n\n- [Chat] Product bubble 추가 [#2891](https://github.com/titicacadev/triple-frontend/pull/2891)\n\n## v13.7.0\n\n### chat\n\n- [Chat] message에 blinded 필드를 추가합니다. [#2886](https://github.com/titicacadev/triple-frontend/pull/2886)\n- [Chat] 전송 실패 메시지 처리 방식 변경 [#2938](https://github.com/titicacadev/triple-frontend/pull/2938)\n- [Chat] RoomMetadata의 타입을 변경합니다. [#2939](https://github.com/titicacadev/triple-frontend/pull/2939)\n- [Chat] 채팅 bubble의 link 스타일을 수정합니다. [#2944](https://github.com/titicacadev/triple-frontend/pull/2944)\n\n## v13.6.0\n\n### i18n\n\n- i18n 영어 설정을 추가합니다. [#2923](https://github.com/titicacadev/triple-frontend/pull/2923)\n\n### map\n\n- 구글맵 load script 언어 옵션 추가 [#2918](https://github.com/titicacadev/triple-frontend/pull/2918)\n\n## v13.5.0\n\n### i18n\n\n- [reviews] 리뷰에 사진/동영상 필터를 지원합니다. [#2896](https://github.com/titicacadev/triple-frontend/pull/2896)\n\n### review\n\n- [reviews] 리뷰에 사진/동영상 필터를 지원합니다. [#2896](https://github.com/titicacadev/triple-frontend/pull/2896)\n- [reviews] 코드를 리팩토링합니다. [#2904](https://github.com/titicacadev/triple-frontend/pull/2904)\n- [reviews] 정렬 옵션 및 필터가 싱크되도록 지원합니다. [#2907](https://github.com/titicacadev/triple-frontend/pull/2907)\n\n### web-storage\n\n- web storage error boundary 컴포넌트 타입 수정 [#2912](https://github.com/titicacadev/triple-frontend/pull/2912)\n\n## v13.4.0\n\n### chat\n\n- [Chat] RoomInterface에 roomType을 추가합니다. [#2900](https://github.com/titicacadev/triple-frontend/pull/2900)\n- [Chat] 채팅 리트라이 이벤트를 위한 props를 추가합니다. [#2905](https://github.com/titicacadev/triple-frontend/pull/2905)\n\n### common\n\n- tsconfig emit 관련 설정을 tsconfig.build.json 으로 이동 [#2898](https://github.com/titicacadev/triple-frontend/pull/2898)\n\n### core-elements\n\n- image-source 패키지 추가 [#2876](https://github.com/titicacadev/triple-frontend/pull/2876)\n\n### image-carousel\n\n- image-source 패키지 추가 [#2876](https://github.com/titicacadev/triple-frontend/pull/2876)\n\n### image-source\n\n- image-source 패키지 추가 [#2876](https://github.com/titicacadev/triple-frontend/pull/2876)\n\n### location-properties\n\n- [LocationProperties] 액션시트에서 누락된 주소를 복원합니다. [#2903](https://github.com/titicacadev/triple-frontend/pull/2903)\n\n### poi-detail\n\n- image-source 패키지 추가 [#2876](https://github.com/titicacadev/triple-frontend/pull/2876)\n\n### review\n\n- 리뷰 컴포넌트를 리펙토링합니다. [#2892](https://github.com/titicacadev/triple-frontend/pull/2892)\n\n### triple-document\n\n- image-source 패키지 추가 [#2876](https://github.com/titicacadev/triple-frontend/pull/2876)\n\n### triple-media\n\n- image-source 패키지 추가 [#2876](https://github.com/titicacadev/triple-frontend/pull/2876)\n\n## v13.3.0\n\n### chat\n\n- [Chat] iOS 모바일에서 init 시점에 infinite scroll 로직 실행되는 오류 수정 [#2868](https://github.com/titicacadev/triple-frontend/pull/2868)\n- Chat에 disableUnreadCount props를 추가합니다. [#2884](https://github.com/titicacadev/triple-frontend/pull/2884)\n\n### resource-list-element\n\n- [resource-list-element] 광고 표기 위치와 디자인을 수정합니다. [#2858](https://github.com/titicacadev/triple-frontend/pull/2858)\n\n## v13.2.4\n\n### slider\n\n- [slider] SliderBase disabled 상태 추가 [#2824](https://github.com/titicacadev/triple-frontend/pull/2824)\n\n## v13.2.3\n\n### chat\n\n- [chat] chat image url 생성 시 cloudinaryBucket을 우선적으로 사용하도록 변경 [#2838](https://github.com/titicacadev/triple-frontend/pull/2838)\n\n### modals\n\n- (modal) modal flexible 옵션을 추가합니다. [#2841](https://github.com/titicacadev/triple-frontend/pull/2841)\n\n## v13.2.2\n\n### directions-finder\n\n- 현지에서 길묻기 ellipsis 옵션 삭제 [#2834](https://github.com/titicacadev/triple-frontend/pull/2834)\n\n### view-utilities\n\n- feat: debounce에 leading, trailing 옵션 추가 [#2740](https://github.com/titicacadev/triple-frontend/pull/2740)\n\n## v13.2.1\n\n### react-contexts\n\n- react-contexts의 middleware export 경로를 복구합니다. [#2829](https://github.com/titicacadev/triple-frontend/pull/2829)\n\n## v13.2.0\n\n### color-palette\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### core-elements\n\n- react-test-renderer -> testing-library 변경 [#2814](https://github.com/titicacadev/triple-frontend/pull/2814)\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### date-picker\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n- mockdate 대신 storybook-mock-date-decorator 사용 [#2816](https://github.com/titicacadev/triple-frontend/pull/2816)\n\n### directions-finder\n\n- 현지에서 길묻기 팝업 글씨 크기를 조정합니다. [#2812](https://github.com/titicacadev/triple-frontend/pull/2812)\n\n### fetcher\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### image-carousel\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### map\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### meta-tags\n\n- app directory용 메타태그 유틸 함수를 작성합니다. [#2789](https://github.com/titicacadev/triple-frontend/pull/2789)\n- react-test-renderer -> testing-library 변경 [#2814](https://github.com/titicacadev/triple-frontend/pull/2814)\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n- app-directory 관련 코드를 TF에서 삭제합니다. [#2826](https://github.com/titicacadev/triple-frontend/pull/2826)\n\n### modals\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### public-header\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### react-contexts\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n- app-directory 관련 코드를 TF에서 삭제합니다. [#2826](https://github.com/titicacadev/triple-frontend/pull/2826)\n\n### react-triple-client-interfaces\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### replies\n\n- react-test-renderer -> testing-library 변경 [#2814](https://github.com/titicacadev/triple-frontend/pull/2814)\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### router\n\n- react-test-renderer -> testing-library 변경 [#2814](https://github.com/titicacadev/triple-frontend/pull/2814)\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### scrap-button\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### scroll-spy\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### scroll-to-element\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### triple-email-document\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### triple-fallback-action\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### ui-flow\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### user-verification\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n\n### view-utilities\n\n- 테스트 코드 린트 [#2815](https://github.com/titicacadev/triple-frontend/pull/2815)\n- app-directory 관련 코드를 TF에서 삭제합니다. [#2826](https://github.com/titicacadev/triple-frontend/pull/2826)\n\n## v13.2.0\n\n### core-elements\n\n- react-test-renderer -> testing-library 변경 [#2814](https://github.com/titicacadev/triple-frontend/pull/2814)\n\n### date-picker\n\n- mockdate 대신 storybook-mock-date-decorator 사용 [#2816](https://github.com/titicacadev/triple-frontend/pull/2816)\n\n### directions-finder\n\n- 현지에서 길묻기 팝업 글씨 크기를 조정합니다 [#2812](https://github.com/titicacadev/triple-frontend/pull/2812)\n\n### meta-tags\n\n- app directory용 메타태그 유틸 함수를 작성합니다. [#2789](https://github.com/titicacadev/triple-frontend/pull/2789)\n- react-test-renderer -> testing-library 변경 [#2814](https://github.com/titicacadev/triple-frontend/pull/2814)\n- app-directory 관련 코드를 TF에서 삭제합니다. [#2826](https://github.com/titicacadev/triple-frontend/pull/2826)\n\n### react-contexts\n\n- app-directory 관련 코드를 TF에서 삭제합니다. [#2826](https://github.com/titicacadev/triple-frontend/pull/2826)\n\n### replies\n\n- react-test-renderer -> testing-library 변경 [#2814](https://github.com/titicacadev/triple-frontend/pull/2814)\n\n### router\n\n- react-test-renderer -> testing-library 변경 [#2814](https://github.com/titicacadev/triple-frontend/pull/2814)\n\n### view-utilities\n\n- app-directory 관련 코드를 TF에서 삭제합니다. [#2826](https://github.com/titicacadev/triple-frontend/pull/2826)\n\n## v13.1.3\n\n### react-contexts\n\n- firebase 버전을 v9.15.0으로 다운그레이드합니다. [#2811](https://github.com/titicacadev/triple-frontend/pull/2811)\n\n## v13.1.2\n\n### react-contexts\n\n- [react-contexts] ios cookie fixation 기준 버전 변경 [#2800](https://github.com/titicacadev/triple-frontend/pull/2800)\n\n## v13.1.1\n\n### triple-document\n\n- 쿠폰 색상 HEX fixation 을 지원합니다. [#2791](https://github.com/titicacadev/triple-frontend/pull/2791)\n\n### view-utilities\n\n- view-utilities의 package.json main 필드를 복구합니다. [#2792](https://github.com/titicacadev/triple-frontend/pull/2792)\n\n## v13.1.0\n\n### action-sheet\n\n- focus trap 테스트가 랜덤하게 실패하는 현상 수정 [#2785](https://github.com/titicacadev/triple-frontend/pull/2785)\n\n### core-elements\n\n- workspace root의 의존성 제거 및 이동 [#2777](https://github.com/titicacadev/triple-frontend/pull/2777)\n\n### form\n\n- workspace root의 의존성 제거 및 이동 [#2777](https://github.com/titicacadev/triple-frontend/pull/2777)\n\n### map\n\n- workspace root의 의존성 제거 및 이동 [#2777](https://github.com/titicacadev/triple-frontend/pull/2777)\n\n### modals\n\n- TransitionModal에 community TransitionType 추가 [#2774](https://github.com/titicacadev/triple-frontend/pull/2774)\n- workspace root의 의존성 제거 및 이동 [#2777](https://github.com/titicacadev/triple-frontend/pull/2777)\n- focus trap 테스트가 랜덤하게 실패하는 현상 수정 [#2785](https://github.com/titicacadev/triple-frontend/pull/2785)\n\n### popup\n\n- focus trap 테스트가 랜덤하게 실패하는 현상 수정 [#2785](https://github.com/titicacadev/triple-frontend/pull/2785)\n\n### react-contexts\n\n- app 디렉토리용 eventTrackingProvider를 작성합니다. [#2768](https://github.com/titicacadev/triple-frontend/pull/2768)\n- /api/users/me의 타입을 최신화합니다. [#2770](https://github.com/titicacadev/triple-frontend/pull/2770)\n- workspace root의 의존성 제거 및 이동 [#2777](https://github.com/titicacadev/triple-frontend/pull/2777)\n\n### triple-document\n\n- triple-document 쿠폰 개선 [#2743](https://github.com/titicacadev/triple-frontend/pull/2743)\n\n### view-utilities\n\n- view-utilities에 common, client export 경로를 추가하고 query 관련 유틸함수를 작성합니다. [#2784](https://github.com/titicacadev/triple-frontend/pull/2784)\n\n## v13.0.2\n\n### review\n\n- TF13 review \bfix [#2766](https://github.com/titicacadev/triple-frontend/pull/2766)\n\n## v13.0.1\n\n### ad-banners\n\n- Revert egjs flicking 업데이트 [#2765](https://github.com/titicacadev/triple-frontend/pull/2765)\n\n### carousel\n\n- Revert egjs flicking 업데이트 [#2765](https://github.com/titicacadev/triple-frontend/pull/2765)\n\n### image-carousel\n\n- Revert egjs flicking 업데이트 [#2765](https://github.com/titicacadev/triple-frontend/pull/2765)\n\n### poi-detail\n\n- Revert egjs flicking 업데이트 [#2765](https://github.com/titicacadev/triple-frontend/pull/2765)\n\n## v13\n\n### action-sheet\n\n- Headless UI -> Floating UI 변경 [#2567](https://github.com/titicacadev/triple-frontend/pull/2567)\n\n### Breaking Change\n\n- [meta-tag] 구조화된 데이터 스크립트를 작성합니다. [#2674](https://github.com/titicacadev/triple-frontend/pull/2674)\n\n### core-elements\n\n- Headless UI -> Floating UI 변경 [#2567](https://github.com/titicacadev/triple-frontend/pull/2567)\n\n### i18n\n\n- i18n 번체 토큰 디렉토리 이름을 zh에서 zh-TW로 수정합니다. [#2446](https://github.com/titicacadev/triple-frontend/pull/2446)\n\n### meta-tags\n\n- [meta-tag] 구조화된 데이터 스크립트를 작성합니다. [#2674](https://github.com/titicacadev/triple-frontend/pull/2674)\n- 구조화된 데이터에 ReviewScript를 추가합니다. [#2726](https://github.com/titicacadev/triple-frontend/pull/2726)\n\n### modals\n\n- Headless UI -> Floating UI 변경 [#2567](https://github.com/titicacadev/triple-frontend/pull/2567)\n\n### popup\n\n- Headless UI -> Floating UI 변경 [#2567](https://github.com/titicacadev/triple-frontend/pull/2567)\n\n### review\n\n- [TFC-97] 호텔 리뷰 개선 - 더보기 UX 개선 (v13 용) [#2734](https://github.com/titicacadev/triple-frontend/pull/2734)\n- [review] mutation, 디자인 버그 수정 [#2736](https://github.com/titicacadev/triple-frontend/pull/2736)\n\n## v12.21.1\n\n### triple-document\n\n- [triple-document] POI 가격 0원 개선 - 0원일 때 일시품절 텍스트로 노출 [#2737](https://github.com/titicacadev/triple-frontend/pull/2737)\n\n## v12.21.0\n\n### review\n\n- [TFC-97] 호텔 리뷰 개선 - 더보기 UX 개선 [#2731](https://github.com/titicacadev/triple-frontend/pull/2731)\n\n## v12.20.0\n\n### chat\n\n- chat 구현에서 Polling을 제거하고 스크롤 동작 버그를 수정합니다. [#2687](https://github.com/titicacadev/triple-frontend/pull/2687)\n\n## v12.19.1\n\n### core-elements\n\n- [core-elements] radio-group, checkbox-group export [#2707](https://github.com/titicacadev/triple-frontend/pull/2707)\n\n## v12.19.0\n\n### footer\n\n- [footer] 푸터 사명 변경 [#2690](https://github.com/titicacadev/triple-frontend/pull/2690)\n\n### image-carousel\n\n- 비디오 자동 재생 시에 간헐적으로 발생하는 NotAllowedError를 핸들링합니다. [#2693](https://github.com/titicacadev/triple-frontend/pull/2693)\n\n### review\n\n- 비디오 자동 재생 시에 간헐적으로 발생하는 NotAllowedError를 핸들링합니다. [#2693](https://github.com/titicacadev/triple-frontend/pull/2693)\n\n## v12.18.3\n\n### constants\n\n- e-mail 유효성 검증이 특수문자, 이모지 등을 허용하는 현상을 수정합니다. [#2649](https://github.com/titicacadev/triple-frontend/pull/2649)\n\n## v12.18.2\n\n### modals\n\n- [modals] Modal height가 스크린 높이보다 커도 스크롤 가능하도록 수정 [#2661](https://github.com/titicacadev/triple-frontend/pull/2661)\n\n### triple-document\n\n- [triple-document] 표시가보다 판매가가 큰 경우 할인율과 표시가가 미노출되도록 수정합니다. [#2662](https://github.com/titicacadev/triple-frontend/pull/2662)\n\n## v12.18.1\n\n### footer\n\n- [Footer] 로그인 버튼 a -> button으로 수정 [#2646](https://github.com/titicacadev/triple-frontend/pull/2646)\n\n### meta-tags\n\n- [meta-tags] article script의 date 형식에 validation을 추가합니다. [#2653](https://github.com/titicacadev/triple-frontend/pull/2653)\n\n## v12.18.0\n\n### router\n\n- [ Router ] LocalLink 컴포넌트에 shallow property를 추가합니다. [#2639](https://github.com/titicacadev/triple-frontend/pull/2639)\n\n## v12.17.0\n\n### core-elements\n\n- [core-elements] CheckboxGroup, RadioGroup 접근성 수정 [#2581](https://github.com/titicacadev/triple-frontend/pull/2581)\n- [core-elements] Fieldset 추가 [#2582](https://github.com/titicacadev/triple-frontend/pull/2582)\n\n### review\n\n- PinnedMessage의 text 필드를 추가합니다. [#2606](https://github.com/titicacadev/triple-frontend/pull/2606)\n\n### triple-email-document\n\n- [triple-email-document] 이메일에서 도메인이 없는 링크 클릭 시, 정상적으로 랜딩되지 않는 이슈를 수정합니다. [#2559](https://github.com/titicacadev/triple-frontend/pull/2559)\n- [triple-email-document] 링크 Element에 존재하는 URL 변환하는 로직을 제거합니다. [#2610](https://github.com/titicacadev/triple-frontend/pull/2610)\n\n## v12.16.0\n\n### core-elements\n\n- react-aria 패키지 제거 [#2551](https://github.com/titicacadev/triple-frontend/pull/2551)\n\n### modals\n\n- 외부 클릭하면 닫는 테스트가 랜덤하게 실패하는 문제 수정 [#2560](https://github.com/titicacadev/triple-frontend/pull/2560)\n\n### action-sheet\n\n- 외부 클릭하면 닫는 테스트가 랜덤하게 실패하는 문제 수정 [#2560](https://github.com/titicacadev/triple-frontend/pull/2560)\n\n### app-installation-cta\n\n- 플로팅 버튼 디자인을 v1 버전으로 되돌립니다 [#2561](https://github.com/titicacadev/triple-frontend/pull/2561)\n\n## v12.15.0\n\n### common\n\n- CHANGELOG 자동화를 구현합니다. [#2518](https://github.com/titicacadev/triple-frontend/pull/2518)\n- chore: 불필요한 의존성 제거 [#2519](https://github.com/titicacadev/triple-frontend/pull/2519)\n- ci: renovate-pr-fix 삭제 [#2533](https://github.com/titicacadev/triple-frontend/pull/2533)\n\n### action-sheet\n\n- ActionSheet, Modal, Popup 접근성 테스트 추가 [#2480](https://github.com/titicacadev/triple-frontend/pull/2480)\n\n### review\n\n- 리뷰 목록에 pinned message를 노출합니다. [#2403](https://github.com/titicacadev/triple-frontend/pull/2403)\n\n### popup\n\n- [popup] 외부 클릭시 닫는 테스트 제거 [#2532](https://github.com/titicacadev/triple-frontend/pull/2532)\n- ActionSheet, Modal, Popup 접근성 테스트 추가 [#2480](https://github.com/titicacadev/triple-frontend/pull/2480)\n\n### modals\n\n- [ Modals ] Confirm body에 css prop을 추가합니다. [#2546](https://github.com/titicacadev/triple-frontend/pull/2546)\n- ActionSheet, Modal, Popup 접근성 테스트 추가 [#2480](https://github.com/titicacadev/triple-frontend/pull/2480)\n\n## 12.14.0\n\n- lint 캐시 가능하도록 설정 [#2471](https://github.com/titicacadev/triple-frontend/pull/2471)\n\n### action-sheet\n\n- action-sheet-item에 notice 아이콘 추가 [#2472](https://github.com/titicacadev/triple-frontend/pull/2472)\n\n## 12.13.0\n\n- turbo 제거, lerna + nx 사용 [#2462](https://github.com/titicacadev/triple-frontend/pull/2465)\n- 타입스크립트 빌드 컨픽 개선 [#2465](https://github.com/titicacadev/triple-frontend/pull/2462)\n\n### core-elements\n\n- List 에 marker prop 을 추가합니다. [#2463](https://github.com/titicacadev/triple-frontend/pull/2463)\n\n### modals\n\n- Panel에 webkit-mask-image 속성 제거 [#2468](https://github.com/titicacadev/triple-frontend/pull/2468)\n\n### react-triple-client-interfaces\n\n- TripleClientMetadataContext에 shouldUpdateUserAgentOnMount props를 추가합니다 [#2464](https://github.com/titicacadev/triple-frontend/pull/2464)\n\n## 12.12.2\n\n### replies\n\n- 댓글 따봉 아이콘이 짤리던 현상을 수정합니다. [#2458](https://github.com/titicacadev/triple-frontend/pull/2458)\n\n## 12.12.1\n\n### triple-media\n\n- 이미지 여백 제거를 위해 display: block 속성 추가 [#2456](https://github.com/titicacadev/triple-frontend/pull/2456)\n\n## 12.12.0\n\n- Headless UI으로 변경 [#2432](https://github.com/titicacadev/triple-frontend/pull/2432)\n  - React Aria에 버그가 많아서 Accessible overlay 컴포넌트 라이브러리를 [Headless UI](https://headlessui.com/)로 변경합니다.\n\n  - 변경된 컴포넌트:\n    - action-sheet\n    - core-elements/Drawer\n    - drawer-button\n    - modals\n    - popup\n\n  - react-transition-group -> Headless UI Transition으로 변경합니다.\n  - ActionSheet, Modals, Popup이 열려 있으면 스크롤이 막힙니다.\n  - Modal.Action에 cursor: pointer를 추가합니다.\n  - Drawer, DrawerButton에 `duration` prop을 추가합니다.\n\n### view-utilities\n\n- PUBLIC_ROUTELIST_REGEXES에 여행기 상세 주소 추가 [#2455](https://github.com/titicacadev/triple-frontend/pull/2455)\n\n### default-footer\n\n- Default Footer 에서 css 속성 사용 가능하도록 수정 [#2440](https://github.com/titicacadev/triple-frontend/pull/2440)\n\n## 12.11.0\n\n### common\n\n- Typescript 최신버전 적용 및 타입에러 수정 [#2435](https://github.com/titicacadev/triple-frontend/pull/2435)\n\n### ab-experiments\n\n- CSR 방식으로 A/B테스트 메타데이터를 가져올 때 세션 유무는 체크하지 않도록 합니다. [#2436](https://github.com/titicacadev/triple-frontend/pull/2436)\n\n### triple-header\n\n- 트리플헤더 날아오기 효과의 속도를 변경합니다. [#2414](https://github.com/titicacadev/triple-frontend/pull/2414)\n\n## 12.10.0\n\n### meta-tags\n\n- og-tag img URL을 변경합니다. [#2430](https://github.com/titicacadev/triple-frontend/pull/2430)\n\n## 12.9.0\n\n### common\n\n- npm 9 이상 버전 사용 [#2426](https://github.com/titicacadev/triple-frontend/pull/2426)\n\n### app-installation-cta\n\n- 플로팅 버튼 3차 UI 변경안 반영 [#2417](https://github.com/titicacadev/triple-frontend/pull/2417)\n\n### meta-tags\n\n- breadcrumb, article 스크립트를 추가합니다. [#2400](https://github.com/titicacadev/triple-frontend/pull/2400)\n- ThemeColorMeta의 기본 props color와 README를 일부 수정합니다. [#2428](https://github.com/titicacadev/triple-frontend/pull/2428)\n\n## 12.8.1\n\n### directions-finder\n\n- drawer action 조건 추가 [#2424](https://github.com/titicacadev/triple-frontend/pull/2424)\n\n## 12.8.0\n\n### core-elements\n\n- img, video global style height: auto 제거 [#2422](https://github.com/titicacadev/triple-frontend/pull/2422)\n\n### meta-tags\n\n- Theme color를 추가합니다. [#2420](https://github.com/titicacadev/triple-frontend/pull/2420)\n\n### ui-flow\n\n- authGuard의 refreshInAppSession 실행조건을 수정합니다. [#2419](https://github.com/titicacadev/triple-frontend/pull/2419)\n\n## 12.7.0\n\n### core-elements\n\n- Drawer의 layeringProps을 제거하고 z-index를 9999로 변경합니다. [#2404](https://github.com/titicacadev/triple-frontend/pull/2404)\n- Drawer를 Portal로 렌더합니다. [#2404](https://github.com/titicacadev/triple-frontend/pull/2404)\n- gender-selector 에 disabled 속성을 추가합니다. [#2410](https://github.com/titicacadev/triple-frontend/pull/2410)\n\n### drawer-button\n\n- DrawerButton의 layeringProps를 제거합니다. [#2404](https://github.com/titicacadev/triple-frontend/pull/2404)\n\n### modals\n\n- DrawerButton의 layeringProps를 제거합니다. [#2404](https://github.com/titicacadev/triple-frontend/pull/2404)\n- 설치유도팝업 문구를 수정합니다. [#2415](https://github.com/titicacadev/triple-frontend/pull/2415)\n\n### popup\n\n- Popup의 layeringProps을 제거하고 z-index를 9999로 변경합니다. [#2404](https://github.com/titicacadev/triple-frontend/pull/2404)\n- Popup을 Portal로 렌더합니다 [#2404](https://github.com/titicacadev/triple-frontend/pull/2404)\n\n## 12.6.0\n\n### intersection-observer\n\n- Lazy loaded IntersectionObserver를 deprecate 합니다. [#2407](https://github.com/titicacadev/triple-frontend/pull/2407)\n\n## 12.5.2\n\n### review\n\n- 최근여행 리뷰 개수 표시 오류를 수정합니다. [#2401](https://github.com/titicacadev/triple-frontend/pull/2401)\n\n## 12.5.1\n\n### public-header\n\n- PublicHeader의 일부 css 속성을 수정합니다 [#2397](https://github.com/titicacadev/triple-frontend/pull/2397)\n\n## 12.5.0\n\n### common\n\n- @titicaca/next-i18next 추가 [#2388](https://github.com/titicacadev/triple-frontend/pull/2388)\n- 의존성 버전을 wildcard로 변경 [#2390](https://github.com/titicacadev/triple-frontend/pull/2390)\n\n### chat, core-elements, react-contexts\n\n- 더 적절한 의존성 사용 [#2389](https://github.com/titicacadev/triple-frontend/pull/2389)\n\n### modal\n\n- 설치유도팝업 TransitionType에 loungeHome을 추가합니다. [#2392](https://github.com/titicacadev/triple-frontend/pull/2392)\n\n### triple-header\n\n- layout shift 를 개선합니다. [#2391](https://github.com/titicacadev/triple-frontend/pull/2391)\n\n### view-utilities\n\n- public route list에 /redirect를 제거하고 /benefit을 추가합니다. [#2396](https://github.com/titicacadev/triple-frontend/pull/2396)\n\n## 12.4.0\n\n### common\n\n- 리뷰, 푸터, 설치/로그인 유도 팝업에 적용된 이벤트 로깅 로직을 수정합니다. [#2354](https://github.com/titicacadev/triple-frontend/pull/2354)\n\n### footer\n\n- Award 푸터를 추가합니다. [#2375](https://github.com/titicacadev/triple-frontend/pull/2375)\n\n### listing-filter, review\n\n- ui-renewal 디자인 개선사항을 수정합니다. [#2378](https://github.com/titicacadev/triple-frontend/pull/2378)\n\n### public-header\n\n- 라운지 홈 공통헤더를 추가합니다. [#2342](https://github.com/titicacadev/triple-frontend/pull/2342)\n\n## 12.3.0\n\n### action-sheet\n\n- pointer-events css 추가 [#2380](https://github.com/titicacadev/triple-frontend/pull/2380)\n\n### action-sheet, modals\n\n- underlayProps, overlayProps, FocusScope 위치 변경 [#2370](https://github.com/titicacadev/triple-frontend/pull/2370)\n\n### poi-detail\n\n- POI 상세페이지에서 사용되는 Actions 컴포넌트 하단 HR1 너비를 수정합니다. [#2372](https://github.com/titicacadev/triple-frontend/pull/2372)\n\n### review\n\n- 댓글 개수를 표시할 때 pinned message도 포함하여 표시합니다. [#2373](https://github.com/titicacadev/triple-frontend/pull/2373)\n\n### triple-header\n\n- 트리플헤더를 추가합니다. [#2329](https://github.com/titicacadev/triple-frontend/pull/2329)\n\n### view-utilities\n\n- parsedQuery의 특정 key 값을 추출하는 함수를 생성합니다. [#2368](https://github.com/titicacadev/triple-frontend/pull/2368)\n\n## 12.2.1\n\n### action-sheet, modals\n\n- @react/aria에서 리랜더를 유발하는 usePreventScroll 제거 [#2363](https://github.com/titicacadev/triple-frontend/pull/2363)\n\n## 12.2.0\n\n### action-sheet\n\n- 액션시트 열고 닫히는 transition이 일부 브라우저에서 layout shift 되지 않도록 수정 [#2358](https://github.com/titicacadev/triple-frontend/pull/2358)\n\n### common\n\n- CI 속도 개선 [#2340](https://github.com/titicacadev/triple-frontend/pull/2340)\n- CD workflow 에러 수정 [#2351](https://github.com/titicacadev/triple-frontend/pull/2351)\n- css prop과 centered prop이 충돌하는 문제 수정 [#2352](https://github.com/titicacadev/triple-frontend/pull/2352)\n\n### core-elements\n\n- story title을 소문자로 변경 [#2345](https://github.com/titicacadev/triple-frontend/pull/2345)\n- ConfirmSelector 디자인 수정 [#2359](https://github.com/titicacadev/triple-frontend/pull/2359)\n- Rating 컴포넌트에 최대값,최소값 설정 추가 [#2364](https://github.com/titicacadev/triple-frontend/pull/2364)\n\n### modals\n\n- modal handler onClose 실행 분기문 이전 버전과 동일하게 변경 [#2347](https://github.com/titicacadev/triple-frontend/pull/2347)\n\n### poi-detail\n\n- Actions 스토리가 빌드마다 변경되는 현상 수정 [#2346](https://github.com/titicacadev/triple-frontend/pull/2346)\n\n### view-utilities\n\n- public route list에 `/redirect`를 추가합니다. [#2355](https://github.com/titicacadev/triple-frontend/pull/2355)\n\n## 12.1.1\n\n### action-sheet\n\n- 액션시트 아이템의 클릭 이벤트 조건을 수정합니다. [#2338](https://github.com/titicacadev/triple-frontend/pull/2338)\n\n## 12.1.0\n\n### common\n\n- i18n translation key에 한국어 fallback을 추가합니다 [#2332](https://github.com/titicacadev/triple-frontend/pull/2332)\n\n### chat\n\n- 초기 메시지가 prop으로 있을 경우 api 호출하지 않도록 수정 [#2331](https://github.com/titicacadev/triple-frontend/pull/2331)\n\n## 12.0.0 (ui-renewal)\n\n### common\n\n- Storybook을 root에서 빌드 [#2189](https://github.com/titicacadev/triple-frontend/pull/2189)\n- css prop 추가 [#2153](https://github.com/titicacadev/triple-frontend/pull/2153)\n- Global css prop 사용 [#2284](https://github.com/titicacadev/triple-frontend/pull/2284)\n- global-style 개선 [#2194](https://github.com/titicacadev/triple-frontend/pull/2194)\n- color-palette 대신 css variable 사용 [#2280](https://github.com/titicacadev/triple-frontend/pull/2280)\n- Compounded 컴포넌트(Subcomponent)를 고유의 컴포넌트로 분리 [#2303](https://github.com/titicacadev/triple-frontend/pull/2303)\n- build:ci 스크립트 제거 [#2276](https://github.com/titicacadev/triple-frontend/pull/2276)\n- peer dependencies 수정 [#2286](https://github.com/titicacadev/triple-frontend/pull/2286)\n\n### action-sheet\n\nActionSheet\n\n- ActionSheet 컴포넌트 접근성 개선 [#2229](https://github.com/titicacadev/triple-frontend/pull/2229)\n  - Portal에 렌더합니다.\n- ESC 버튼을 누르면 액션시트를 닫습니다.\n- 외부의 오버레이를 누르면 액션시트를 닫습니다.\n- 키보드를 사용한 포커스는 액션시트 내부에서만 이동합니다. 액션시트가 닫히면 이전의 포커스로 되돌아 갑니다.\n- (Breaking Change) 액션시트 내에서 발생한 이벤트가 버블링 되도록 변경되었습니다.\n- (Breaking Change) export default가 제거되었습니다. [#2281](https://github.com/titicacadev/triple-frontend/pull/2281)\n\n### chat\n\n- triple-chat-frontend를 기반으로 chat widget component를 생성합니다. [#2246](https://github.com/titicacadev/triple-frontend/pull/2246)\n\n### core-elements\n\nAccordion\n\n- 접근성을 개선합니다. [#2217](https://github.com/titicacadev/triple-frontend/pull/2217)\n- (Breaking Change) Context를 사용해서 active prop을 각자 서브 컴포넌트마다 전달하지 않고 상위 Accordion 컴포넌트에만 전달할 수 있도록 변경합니다.\n\nButton\n\n- 리팩토링 [#2264](https://github.com/titicacadev/triple-frontend/pull/2264)\n\nCheckbox\n\n- Checkbox, CheckboxGroup 컴포넌트가 새로 추가되었습니다. [#2239](https://github.com/titicacadev/triple-frontend/pull/2239)\n- 선택된 모양을 이미지 대신 svg로 그리도록 변경합니다.\n- `variant?: 'square' | 'rounded'` prop을 추가합니다. (기본값 'square')\n\nConfirmSelector\n\n- 리팩토링 [#2251](https://github.com/titicacadev/triple-frontend/pull/2251)\n  - 기존의 prop중에 안 쓰이거나 사실상 필요 없는 것들을 제거했습니다. `placeholder, textAlign, borderless, fillType, error, padding`\n\nFormField\n\n- withField hoc를 대체할 FormField 컴포넌트 추가 [#2257](https://github.com/titicacadev/triple-frontend/pull/2257)\n\nGenderSelector\n\n- 리팩토링 [#2253](https://github.com/titicacadev/triple-frontend/pull/2253)\n\nInput\n\n- 접근성을 개선합니다. [#2274](https://github.com/titicacadev/triple-frontend/pull/2274)\n- (Breaking Change) onChange 두번째 파라미터 value를 제거합니다.\n\nNumericSpinner\n\n- 접근성을 개선합니다. [#2250](https://github.com/titicacadev/triple-frontend/pull/2250)\n\nPortal\n\n- Portal 컴포넌트를 추가합니다. [#2228](https://github.com/titicacadev/triple-frontend/pull/2228)\n\nRadio\n\n- 접근성을 개선합니다. [#2235](https://github.com/titicacadev/triple-frontend/pull/2235)\n- RadioGroup 컴포넌트가 새로 추가되었습니다.\n- 선택된 모양을 이미지 대신 css로 그리도록 변경합니다.\n\nSelect\n\n- Select 컴포넌트 접근성 개선 [#2259](https://github.com/titicacadev/triple-frontend/pull/2259)\n\nTabs\n\n- Tabs 컴포넌트 접근성 개선 [#2270](https://github.com/titicacadev/triple-frontend/pull/2270)\n- (Breaking Change) 접근성 지원을 위해 Tabs 컴포넌트 사용 방법이 변경되었습니다.\n- `TabList`, `Tab`, `TabPanel` 서브 컴포넌트를 추가했습니다. 이 서브 컴포넌트들로 탭을 구성해야 합니다.\n- `options` prop을 제거하고 children을 사용하도록 합니다.\n- prop 이름을 변경합니다. `type` -> `variant`\n- onChange prop의 첫번쨰 파라미터 `event`를 제거합니다.\n- 키보드 포커스를 지원합니다.\n- table 대신 flex css를 사용하도록 변경합니다.\n\nTextArea\n\n- 접근성을 개선합니다. [#2268](https://github.com/titicacadev/triple-frontend/pull/2268)\n\nTooltip\n\n- [core-elements] Tooltip 컴포넌트에 role=\"tooltip\" 추가 [#2271](https://github.com/titicacadev/triple-frontend/pull/2271)\n\n### modals\n\n- Modal 컴포넌트 접근성 개선 [#2228](https://github.com/titicacadev/triple-frontend/pull/2228)\n  - Portal에 렌더합니다.\n- Body, Title, Description 서브 컴포넌트를 추가합니다.\n- ESC 버튼을 누르면 모달을 닫습니다.\n- 외부의 오버레이를 누르면 모달을 닫습니다.\n- 키보드를 사용한 포커스는 모달 내부에서만 이동합니다. 모달이 닫히면 이전의 포커스로 되돌아 갑니다.\n\n### slider\n\nSingleSlider, RangeSlider\n\n- 접근성 개선 [#2273](https://github.com/titicacadev/triple-frontend/pull/2273)\n\n## 11.1.0\n\n### react-contexts\n\n- 웹 로그아웃 시 401 응답도 정상적으로 처리하도록 합니다. [#2313](https://github.com/titicacadev/triple-frontend/pull/2313)\n\n## 11.0.0\n\n### common\n\n- TF에 국제화를 도입합니다. [#2232](https://github.com/titicacadev/triple-frontend/pull/2232)\n\n## 10.4.0\n\n### triple-document\n\n- regions element의 바로가기 버튼 위치를 조정합니다. [#2291](https://github.com/titicacadev/triple-frontend/pull/2291)\n\n### footer\n\n- 회사 소개 영역을 제거합니다. [#2285](https://github.com/titicacadev/triple-frontend/pull/2285)\n\n### web-storage, poi-detail\n\n- web-storage 오류 처리 함수 추가 [#2266](https://github.com/titicacadev/triple-frontend/pull/2266)\n\n## 10.3.0\n\n### app-installation-cta\n\n- 배너 CTA의 Dimmed 영역을 제거합니다. [#2265](https://github.com/titicacadev/triple-frontend/pull/2265)\n- 플로팅 버튼의 디자인을 리뉴얼합니다. [#2262](https://github.com/titicacadev/triple-frontend/pull/2262)\n\n## 10.2.1\n\n### core-elements\n\n- 블랙프라이데이 대응을 위한 블랙색상 레이블을 추가합니다. [#2260](https://github.com/titicacadev/triple-frontend/pull/2260)\n\n## 10.2.0\n\n### react-contexts\n\n- [react-contexts] checkIfReviewed 에러 로깅에 정보 추가 [#2245](https://github.com/titicacadev/triple-frontend/pull/2245)\n\n### core-elements, image-carousel, review\n\n- 비디오 자동재생 문제 해결 [#2244](https://github.com/titicacadev/triple-frontend/pull/2244)\n\n### poi-detail\n\n- 리뷰 쓰기 영역에 툴팁 추가 [#2240](https://github.com/titicacadev/triple-frontend/pull/2240)\n\n### common\n\n- [Fix] prettier 명령어를 js,ts,tsx도 검사하도록 수정합니다. [#2236](https://github.com/titicacadev/triple-frontend/pull/2236)\n\n## 10.1.0\n\n### common\n\n- Update dependency @swc/core to v1.3.10 [#2225](https://github.com/titicacadev/triple-frontend/pull/2225)\n- v9 to v10 마이그레이션 문서 추가 [#2221](https://github.com/titicacadev/triple-frontend/pull/2221)\n- Update dependency @sentry/nextjs to v7.16.0 [#2219](https://github.com/titicacadev/triple-frontend/pull/2219)\n\n### react-contexts\n\n- 세션 고정 방지 기능 추가 [#2223](https://github.com/titicacadev/triple-frontend/pull/2223)\n\n## 10.0.1\n\n### triple-document\n\n- 디폴트 이미지 컨테이너 설정 [#2224](https://github.com/titicacadev/triple-frontend/pull/2224)\n\n## 10.0.0\n\n### common\n\n- nothing-to-commit 에러를 무시합니다. [#2216](https://github.com/titicacadev/triple-frontend/pull/2216)\n\n### react-context\n\n- firebase v9 업그레이드 [#2202](https://github.com/titicacadev/triple-frontend/pull/2202)\n\n## 9.7.0\n\n### core-element\n\n- tabs에 rounded-tab 추가합니다. [#2200](https://github.com/titicacadev/triple-frontend/pull/2200)\n\n## 9.6.1\n\n### common\n\n- renovate-pr-fix GHA에서 TRIPLE_BOT_GITHUB_TOKEN를 사용하여 트리거합니다. [#2178](https://github.com/titicacadev/triple-frontend/pull/2178)\n\n### triple-document\n\n- 아티클 어드민에 사용되는 이미지 가로배열(default) margin props 누락 수정 [#2184](https://github.com/titicacadev/triple-frontend/pull/2184)\n\n## 9.6.0\n\n### triple-document\n\n- 어드민 페이지 이미지 분할 기능 [#2134](https://github.com/titicacadev/triple-frontend/pull/2134)\n\n### core-elements, reviews, image-carousel, poi-detail\n\n- 동영상 리뷰 지원 [#2142](https://github.com/titicacadev/triple-frontend/pull/2142)\n\n### constants\n\n- PASSPORT_NUMBER_REGEX를 15개로 고정 [2144](https://github.com/titicacadev/triple-frontend/pull/2144)\n\n## 9.5.0\n\n### modals\n\n- TransitionModal props에 action click optional props를 추가합니다. [#2121](https://github.com/titicacadev/triple-frontend/pull/2121)\n\n### common\n\n- update dependency @swc/core to v1.2.244 [#2119](https://github.com/titicacadev/triple-frontend/pull/2119)\n\n## 9.4.0\n\n### common\n\n- pin dependency typescript to v4.3.5 [#2114](https://github.com/titicacadev/triple-frontend/pull/2114)\n- update dependency @swc/core to v1.2.241 [#2111](https://github.com/titicacadev/triple-frontend/pull/2111)\n- update dependency @sentry/nextjs to v7.11.1 [#2109](https://github.com/titicacadev/triple-frontend/pull/2109)\n\n### triple-email-document\n\n- 비율에 따라 이미지를 렌더링합니다. [#2104](https://github.com/titicacadev/triple-frontend/pull/2104)\n\n## 9.3.0\n\n### footer\n\n- [footer] 대표자 변경: 김강세 --> 최휘영 [#2106](https://github.com/titicacadev/triple-frontend/pull/2106)\n\n### i18n\n\n- i18next triple-web-assets backend를 추가합니다. [#2102](https://github.com/titicacadev/triple-frontend/pull/2102)\n\n## 9.2.0\n\n### view-utilities\n\n- AppsFlyer 파라미터 중 is_retargeting 값을 true로 설정합니다. [#2099](https://github.com/titicacadev/triple-frontend/pull/2099)\n\n## 9.1.0\n\n### common\n\n- storybook 6.5로 업데이트 [#2094](https://github.com/titicacadev/triple-frontend/pull/2094)\n\n### footer\n\n- [footer] 사명 변경 [#2095](https://github.com/titicacadev/triple-frontend/pull/2095)\n\n### public-header\n\n- public-header props를 추가합니다. [#2097](https://github.com/titicacadev/triple-frontend/pull/2097)\n\n## 9.0.3\n\n### review\n\n- graphql mutation을 수정합니다. [#2092](https://github.com/titicacadev/triple-frontend/pull/2092)\n\n## 9.0.2\n\n### review\n\n- 리뷰 패키지 내 QueryProvider export를 제거합니다. [#2089](https://github.com/titicacadev/triple-frontend/pull/2089)\n- Review 스키마의 badges 쿼리 및 타입을 수정하고 추가 디자인 QA를 적용합니다. [#2090](https://github.com/titicacadev/triple-frontend/pull/2090)\n\n## 9.0.1\n\n### review\n\n- use-reviews 내 useQuery options을 수정합니다. [#2087](https://github.com/titicacadev/triple-frontend/pull/2087)\n\n## 9.0.0\n\n### common\n\n- KOREAN_REGEX에서 \"|\"를 제거. [#2077](https://github.com/titicacadev/triple-frontend/pull/2077)\n\n### map\n\n- Mapview zoom이 제대로 잡히지 않는 오류를 수정합니다. [#2078](https://github.com/titicacadev/triple-frontend/pull/2078)\n\n### core-elements\n\n- input, textarea에 font 초기화 [#2081](https://github.com/titicacadev/triple-frontend/pull/2081)\n\n### review\n\n- 아키텍쳐 및 graphql 적용합니다. [#2079](https://github.com/titicacadev/triple-frontend/pull/2079)\n- 최근방문한 리뷰를 구분하고 관련 내용을 추가합니다. [#2085](https://github.com/titicacadev/triple-frontend/pull/2085)\n\n## 8.1.2\n\n### ad-banners\n\n- 이벤트 수집이 안되는 오류를 해결합니다. [#2075](https://github.com/titicacadev/triple-frontend/pull/2075)\n\n## 8.1.1\n\n### map\n\n- 의도되지 않은 spinner를 제거합니다. [#2072](https://github.com/titicacadev/triple-frontend/pull/2072)\n\n### triple-document\n\n- StandardActionHandler 새 창 열기 기능에 필요한 props를 추가합니다. [#2071](https://github.com/titicacadev/triple-frontend/pull/2071)\n\n## 8.1.0\n\n### replies\n\n- FixedBottom 컨테이너에 safeAreaInsetMixin을 추가합니다. [#2063](https://github.com/titicacadev/triple-frontend/pull/2063)\n- 노출하는 댓글 갯수를 변경할 수 있도록 props를 추가합니다. [#2066](https://github.com/titicacadev/triple-frontend/pull/2066)\n\n### triple-document\n\n- images 요소의 이벤트 핸들러를 Override할 수 있도록 합니다. [#2065](https://github.com/titicacadev/triple-frontend/pull/2065)\n\n### core-elements\n\n- navbar title을 세로 중앙 정렬하기 위해 line-height를 활용합니다. [#2067](https://github.com/titicacadev/triple-frontend/pull/2067)\n\n## 8.0.0\n\n### common\n\n- update dependency @swc/core to v1.2.192 [#2057](https://github.com/titicacadev/triple-frontend/pull/2057)\n- update dependency @swc/core to v1.2.182 [#2051](https://github.com/titicacadev/triple-frontend/pull/2051)\n- update internal packages [#2049](https://github.com/titicacadev/triple-frontend/pull/2049)\n- 패키지 내 isomorphic-fetch를 제거하고 fetcher로 대체합니다. [#2045](https://github.com/titicacadev/triple-frontend/pull/2045)\n\n### social-reviews\n\n- ExternalLink의 imageUrl이 있을 때만 Image 노출 [#2060](https://github.com/titicacadev/triple-frontend/pull/2060)\n- ExternalLinks 컴포넌트 구현 [#2054](https://github.com/titicacadev/triple-frontend/pull/2054)\n\n### react-contexts\n\n- images-context 내 images를 가져오는 fetch부분을 수정합니다. [#2058](https://github.com/titicacadev/triple-frontend/pull/2058)\n\n### triple-email-document\n\n- element를 수정하고, component를 제거합니다. [#2056](https://github.com/titicacadev/triple-frontend/pull/2056)\n\n### standard-action-handler\n\n- 스크롤 액션을 추가합니다. [#2055](https://github.com/titicacadev/triple-frontend/pull/2055)\n\n### scroll-to-element\n\n- 스크롤 액션을 관리하는 패키지를 추가합니다. [#2053](https://github.com/titicacadev/triple-frontend/pull/2053)\n\n## 7.5.0\n\n### common\n\n- update dependency next to v12.1.6 [#2047](https://github.com/titicacadev/triple-frontend/pull/2047)\n- update dependency @swc/core to v1.2.174 [#2046](https://github.com/titicacadev/triple-frontend/pull/2046)\n\n### triple-email-document\n\n- elements를 확장합니다. [#2039](https://github.com/titicacadev/triple-frontend/pull/2039)\n\n### poi-detail\n\n- 운영시간, 휴무일을 조건에 따라 렌더링합니다. [#2031](https://github.com/titicacadev/triple-frontend/pull/2031)\n\n## 7.4.0\n\n### common\n\n- update dependency next to v12.1.5 [#2029](https://github.com/titicacadev/triple-frontend/pull/2029)\n- update dependency @sentry/nextjs to v6.19.7 [#2037](https://github.com/titicacadev/triple-frontend/pull/2037)\n- update internal packages to v4.17.0 [#2028](https://github.com/titicacadev/triple-frontend/pull/2028)\n- update dependency @swc/core to v1.2.173 [#2042](https://github.com/titicacadev/triple-frontend/pull/2042)\n- update dependency @swc/core to v1.2.172 [#2034](https://github.com/titicacadev/triple-frontend/pull/2034)\n- update dependency @swc/core to v1.2.168 [#2033](https://github.com/titicacadev/triple-frontend/pull/2033)\n\n### date-picker\n\n- day-picker의 fromMonth, toMonth가 동작되도록 수정 [#2036](https://github.com/titicacadev/triple-frontend/pull/2036)\n\n### react-context\n\n- notifyReviewDeleted 중복 호출 제거 [#2041](https://github.com/titicacadev/triple-frontend/pull/2041)\n\n### poi-detail\n\n- areas, vicinity deprecated 처리, areaName 추가 [#2035](https://github.com/titicacadev/triple-frontend/pull/2035)\n\n### react-triple-cliend-interfaces\n\n- web-to-native-interfaces 모듈을 peerDependencies로 설정 [#2040](https://github.com/titicacadev/triple-frontend/pull/2040)\n\n## 7.3.0\n\n### color-palette\n\n- red50 color 추가 [#2030](https://github.com/titicacadev/triple-frontend/pull/2030)\n\n### core-elements\n\n- global style 에 red50 color 추가 [#2030](https://github.com/titicacadev/triple-frontend/pull/2030)\n\n## 7.2.0\n\n### common\n\n- turborepo 추가 [#2011](https://github.com/titicacadev/triple-frontend/pull/2011)\n- codecov patch check를 끕니다. [#2026](https://github.com/titicacadev/triple-frontend/pull/2026)\n- Chromatic 변경 사항에서 오늘 날짜 제외 [#2017](https://github.com/titicacadev/triple-frontend/pull/2017)\n- Update Internal Packages to v4.16.0 [#2020](https://github.com/titicacadev/triple-frontend/pull/2020)\n- Update SWC Packages [#2013](https://github.com/titicacadev/triple-frontend/pull/2013)\n- update dependency @swc/core to v1.2.165 [#2023](https://github.com/titicacadev/triple-frontend/pull/2023)\n- update dependency next to v12.1.4 [#2016](https://github.com/titicacadev/triple-frontend/pull/2016)\n- update dependency @sentry/nextjs to v6.19.6 [#2000](https://github.com/titicacadev/triple-frontend/pull/2000)\n\n### triple-document\n\n- Tna Slot에 셀프패키지 노출 [#2024](https://github.com/titicacadev/triple-frontend/pull/2024)\n\n### replies\n\n- 입력창을 화면 최하단에 고정하는 props를 추가합니다. [#2014](https://github.com/titicacadev/triple-frontend/pull/2014)\n\n## 7.1.0\n\n### common\n\n- Update dependency @swc/core to v1.2.159 [#2012](https://github.com/titicacadev/triple-frontend/pull/2012)\n\n### user-verification\n\n- Docs의 오타를 수정합니다. [#2019](https://github.com/titicacadev/triple-frontend/pull/2019)\n- External promotion에 대응 가능하도록 인터페이스를 확장합니다. [#2018](https://github.com/titicacadev/triple-frontend/pull/2018)\n\n### triple-document\n\n- 타이머 기능 그룹 다운로드 버튼에 적용 [#2015](https://github.com/titicacadev/triple-frontend/pull/2015)\n\n## 7.0.0\n\n### common\n\n- Update dependency @sentry/nextjs to v6.18.1 [#1966](https://github.com/titicacadev/triple-frontend/pull/1966)\n- Update dependency @swc/core to v1.2.148 [#1975](https://github.com/titicacadev/triple-frontend/pull/1975)\n- Update titicacadev/triple-content packages to v4.13.0 [#1976](https://github.com/titicacadev/triple-frontend/pull/1976)\n- npm 버전 8.5이상으로 강제하고 node version을 17.7.0으로 변경합니다. [#2004](https://github.com/titicacadev/triple-frontend/pull/2004)\n\n### Package별 ESLint 활성화\n\n- eslint-config-triple v3의 점진적 적용을 위해 린트 검사를 비활성화했던 패키지 중 남은 25개 package에 대해 린팅을 적용하여 활성화를 완료합니다. 네이밍 컨벤션이나 no-any 규칙을 수정하면서 발생한 Breaking Change를 포함합니다. (changes on public-header, poi-detail, intersection-observer, scrap-button, style-box, type-definitions, react-hooks, web-storage, listing-filter, static-page-contents, poi-list-elements, form, ab-experiments, app-banner, slider, search, recommended-contents, pricing, app-installation-cta, image-carousel, hub-form, content-sharing, author, action-sheet, i18n) [#1755](https://github.com/titicacadev/triple-frontend/issues/1755)\n\n### react-triple-client-interfaces 적용\n\n- react-triple-client-interfaces 패키지로 웹-앱 동작을 분기합니다.\n- public-header [#1898](https://github.com/titicacadev/triple-frontend/pull/1898)\n- router [#2002](https://github.com/titicacadev/triple-frontend/pull/2002)\n- poi-detail [#2005](https://github.com/titicacadev/triple-frontend/pull/2005)\n- ui-flow [#2007](https://github.com/titicacadev/triple-frontend/pull/2007)\n- review, directions-finder, loction-properties [#2008](https://github.com/titicacadev/triple-frontend/pull/2008)\n\n### standard-action-handler\n\n- standard-action-handler Hook을 생성합니다. [#1967](https://github.com/titicacadev/triple-frontend/pull/1967)\n\n### replies\n\n- 입력창의 오류를 해결합니다. [#1972](https://github.com/titicacadev/triple-frontend/pull/1972)\n\n## 6.4.0\n\n### common\n\n- codecov 컨픽을 추가하고 threshold을 조금 느슨하게 설정 [#1958](https://github.com/titicacadev/triple-frontend/pull/1958)\n- TF 패키지 내 react 참조를 제거합니다. [#1959](https://github.com/titicacadev/triple-frontend/pull/1959)\n- 구버전 클라이언트 대응 분기(semver)를 제거합니다. [#1960](https://github.com/titicacadev/triple-frontend/pull/1960)\n\n### resource-list-element\n\n- ExtendedResourceListElement의 가변 높이를 보장하도록 수정합니다. [#1968](https://github.com/titicacadev/triple-frontend/pull/1968)\n\n### triple-document\n\n- 쿠폰 발급 시각 타이머 적용 [#1961](https://github.com/titicacadev/triple-frontend/pull/1961)\n\n### user-verification\n\n- 인증여부 확인 API 경로를 변경합니다. [#1963](https://github.com/titicacadev/triple-frontend/pull/1963)\n\n### triple-fallback-action\n\n- 서버에서 useLayoutEffect를 호출하지 않도록 변경 [#1955](https://github.com/titicacadev/triple-frontend/pull/1955)\n\n### replies\n\n- 답글이 달려있는 댓글 삭제 시 액션시트를 비활성화 합니다. [#1937](https://github.com/titicacadev/triple-frontend/pull/1937)\n- 이전 댓글 더보기 렌더링 조건을 수정합니다. [#1944](https://github.com/titicacadev/triple-frontend/pull/1944)\n- 입력창의 placeholder를 수정합니다. [#1948](https://github.com/titicacadev/triple-frontend/pull/1948)\n- 답글을 생성 시간 기준으로 오름차순 정렬합니다. [#1949](https://github.com/titicacadev/triple-frontend/pull/1949)\n- 불필요한 코드를 제거합니다. [#1953](https://github.com/titicacadev/triple-frontend/pull/1953)\n- 프로필 및 멘션을 클릭했을 때, 액션을 추가합니다. [#1970](https://github.com/titicacadev/triple-frontend/pull/1970)\n- 답글이 달려있는 댓글을 삭제할 때, 답글 순서가 바뀌는 오류를 수정합니다. [#1952](https://github.com/titicacadev/triple-frontend/pull/1952)\n\n### standard-action-handler\n\n- 새창열기 기능 추가 [#1939](https://github.com/titicacadev/triple-frontend/pull/1939)\n- 이미지 다운로드 기능 추가 [#1951](https://github.com/titicacadev/triple-frontend/pull/1951)\n\n## 6.3.0\n\n### view-utilities\n\n- makeDeepLinkGenerator가 지원하는 옵션을 확장합니다. [#1940](https://github.com/titicacadev/triple-frontend/pull/1940)\n\n### triple-fallback-action\n\n- Triple Fallback Action 패키지 추가 [#1935](https://github.com/titicacadev/triple-frontend/pull/1935)\n\n### meta-tags\n\n- CommonMeta에 manifest link 엘리먼트 추가 [#1933](https://github.com/titicacadev/triple-frontend/pull/1933)\n\n### react-triple-client-interfaces\n\n- App과 AppName을 외부로 노출 [#1931](https://github.com/titicacadev/triple-frontend/pull/1931)\n\n### triple-email-template\n\n- React 참조 코드를 제거합니다. [#1936](https://github.com/titicacadev/triple-frontend/pull/1936)\n- FullEmailTemplate를 추가합니다. [#1929](https://github.com/titicacadev/triple-frontend/pull/1929)\n\n### replies\n\n- 삭제 완료 toast를 렌더링합니다. [#1943](https://github.com/titicacadev/triple-frontend/pull/1943)\n- 액션시트 타이틀을 수정합니다. [#1942](https://github.com/titicacadev/triple-frontend/pull/1942)\n- 닉네임이 9자 이상일 때, 말줄임표로 표기합니다. [#1934](https://github.com/titicacadev/triple-frontend/pull/1934)\n- 비로그인 상태일 때, 로그인 유도 모달 노출 [#1927](https://github.com/titicacadev/triple-frontend/pull/1927)\n\n## 6.2.1\n\n### modal\n\n- 업데이트된 session-context를 반영하여 README를 수정합니다. [#1928](https://github.com/titicacadev/triple-frontend/pull/1928)\n\n### map\n\n- FocusTracker에 activeAutoZoom props을 추가합니다. [#1926](https://github.com/titicacadev/triple-frontend/pull/1926)\n\n## 6.2.0\n\n### core-elements\n\n- gap props추가 [#1916](https://github.com/titicacadev/triple-frontend/pull/1916)\n\n### map\n\n- PoiDotMarker의 props, type 및 CircleMarker의 type 등을 export 합니다. [#1917](https://github.com/titicacadev/triple-frontend/pull/1917)\n\n### router\n\n- LocalLink, ExternalLink 내용 보충 [#1911](https://github.com/titicacadev/triple-frontend/pull/1911)\n\n### triple-email-document\n\n- Migration에 필요한 Type을 내보내고, Color 표현을 수정합니다. [#1923](https://github.com/titicacadev/triple-frontend/pull/1923)\n- Text Element를 최신화합니다. [#1924](https://github.com/titicacadev/triple-frontend/pull/1924)\n- Footer, Preview를 추가합니다. [#1919](https://github.com/titicacadev/triple-frontend/pull/1919)\n- triple-email-document 관련 storybook 코드를 추가합니다. [#1903](https://github.com/titicacadev/triple-frontend/pull/1903)\n- 패키지를 추가합니다. [#1895](https://github.com/titicacadev/triple-frontend/pull/1895)\n\n### standard-action-handler\n\n- Standard Action Handler ReadMe 작성 [#1912](https://github.com/titicacadev/triple-frontend/pull/1912)\n\n## 6.1.1\n\n### map\n\n- FlexibleMarker의 content type을 수정합니다. [#1906](https://github.com/titicacadev/triple-frontend/pull/1906)\n\n## 6.1.0\n\n### common\n\n- 스크립트 위치를 scripts/ 디렉토리로 통일합니다. make-test-tsconfig에서 패키지 목록을 만드는 로직을 개선합니다 [#1891](https://github.com/titicacadev/triple-frontend/pull/1891)\n- v6 마이그레이션 가이드에 map 패키지 내용을 수정합니다 [#1896](https://github.com/titicacadev/triple-frontend/pull/1896)\n- v6 마이그레이션 가이드에 react-triple-client-interfaces에 대응하는 내용을 추가합니다 [#1890](https://github.com/titicacadev/triple-frontend/pull/1890)\n- v6 마이그레이션 가이드에 누락된 map 패키지 내용을 작성합니다 [#1892](https://github.com/titicacadev/triple-frontend/pull/1892)\n- v6 마이그레이션 가이드에 누락된 내용을 채웁니다 [#1889](https://github.com/titicacadev/triple-frontend/pull/1889)\n\n### poi-detail\n\n- PoiDetail 의 NoteContainer 수정으로 인해 생겼던 버그를 수정합니다 [#1900](https://github.com/titicacadev/triple-frontend/pull/1900)\n\n### replies\n\n- 새로고침 이슈를 해결합니다 [#1872](https://github.com/titicacadev/triple-frontend/pull/1872)\n\n### map\n\n- flexibleMarker에 누락된 props을 추가합니다 [#1904](https://github.com/titicacadev/triple-frontend/pull/1904)\n- 패키지의 README를 수정합니다 [#1896](https://github.com/titicacadev/triple-frontend/pull/1896)\n\n## 6.0.0\n\n### common\n\n- 실패 알림을 보내는 step이 job이 실패했을 때 실행되도록 처리 [#1882](https://github.com/titicacadev/triple-frontend/pull/1882)\n- package 별 누락된 dependencies 추가 [#1881](https://github.com/titicacadev/triple-frontend/pull/1881)\n- CI 워크플로의 job을 정리합니다 [#1880](https://github.com/titicacadev/triple-frontend/pull/1880)\n- npm script 정리 [#1878](https://github.com/titicacadev/triple-frontend/pull/1878)\n- 패키지별 package.json을 표준화합니다 [#1870](https://github.com/titicacadev/triple-frontend/pull/1870)\n- Sentry 패키지 의존성을 정리합니다 [#1856](https://github.com/titicacadev/triple-frontend/pull/1856)\n- Storybook 패키지 최신 버전 설치 [#1858](https://github.com/titicacadev/triple-frontend/pull/1858)\n- Create LICENSE [#1840](https://github.com/titicacadev/triple-frontend/pull/1840/files)\n- Storybook 주소를 갱신합니다 [#1839](https://github.com/titicacadev/triple-frontend/pull/1839/files)\n\n### poi-detail\n\n- image-carousel 내부의 노트 컴포넌트를 수정합니다 [#1874](https://github.com/titicacadev/triple-frontend/pull/1874)\n\n### router\n\n- LocalLink, ExternalLink가 앵커 태그를 직접 렌더링하도록 변경합니다 [#1873](https://github.com/titicacadev/triple-frontend/pull/1873)\n\n### user-verification\n\n- react-triple-client-interfaces를 이용합니다 [#1871](https://github.com/titicacadev/triple-frontend/pull/1871)\n\n### replies\n\n- 댓글&답글에 신고하기 기능을 추가합니다 [#1838](https://github.com/titicacadev/triple-frontend/pull/1838)\n- 댓글&답글 좋아요 반응 기능을 추가합니다 [#1845](https://github.com/titicacadev/triple-frontend/pull/1845)\n\n### react-triple-client-interfaces\n\n- router의 app-bridge를 이전합니다 [#1875](https://github.com/titicacadev/triple-frontend/pull/1875)\n- useTripleClientFeatureFlag 훅을 추가합니다 [#1866](https://github.com/titicacadev/triple-frontend/pull/1866)\n- react-triple-client-interfaces 패키지를 추가합니다 [#1832](https://github.com/titicacadev/triple-frontend/pull/1832)\n\n### ui-flow\n\n- ui-flow 디렉토리의 ESLint 검사를 활성화합니다 [#1857](https://github.com/titicacadev/triple-frontend/pull/1857)\n\n### core-elements\n\n- core-elements 디렉토리의 ESLint, Stylelint 검사를 활성화합니다 [#1850](https://github.com/titicacadev/triple-frontend/pull/1850)\n\n### app-installation-cta\n\n- 배너 CTA 오버레이 클릭시, 해당 배너가 닫히도록 합니다 [#1847](https://github.com/titicacadev/triple-frontend/pull/1847)\n\n### triple-document\n\n- triple-doucment의 불필요한 영역의snapshot을 ignore합니다 [#1844](https://github.com/titicacadev/triple-frontend/pull/1844)\n\n### ad-banners\n\n- ad-banners의 ListDirection enum의 멤버 네이밍 변경 [#1787](https://github.com/titicacadev/triple-frontend/pull/1787)\n\n### map\n\n- 오버레이 컴포넌트들을 정리합니다 [#1865](https://github.com/titicacadev/triple-frontend/pull/1865)\n- Map 구조 변경 및 기능을 추가합니다 [#1831](https://github.com/titicacadev/triple-frontend/pull/1831)\n- Map 디렉토리의 ESLint 검사를 활성화하고 오류를 수정합니다 [#1841](https://github.com/titicacadev/triple-frontend/pull/1841)\n\n### modals\n\n- login CTA 모달 관련 네이밍을 변경합니다 [#1791](https://github.com/titicacadev/triple-frontend/pull/1791)\n\n### fetcher\n\n- fetcher 패키지의 일부 인터페이스 이름 변경 [#1767](https://github.com/titicacadev/triple-frontend/pull/1767)\n\n### footer\n\n- CSFooter 컴포넌트 및 관련 코드 제거 [#1807](https://github.com/titicacadev/triple-frontend/pull/1807)\n\n### react-contexts\n\n- user-agent-context의 isPublic과 app 속성에 deprecation notice를 추가합니다[#1863](https://github.com/titicacadev/triple-frontend/pull/1863)\n- useHistoryContext 제거 [#1834](https://github.com/titicacadev/triple-frontend/pull/1834)\n- useURIHash -> useUriHash [#1798](https://github.com/titicacadev/triple-frontend/pull/1798)\n- HistoryProvider의 prop 이름 변경: loginCTAModalHash -> loginCtaModalHash [#1798](https://github.com/titicacadev/triple-frontend/pull/1798)\n- HashStrategy의 멤버 네이밍 변경 [#1798](https://github.com/titicacadev/triple-frontend/pull/1798)\n- GAParams -> GoogleAnalyticsParams [#1798](https://github.com/titicacadev/triple-frontend/pull/1798)\n- FAParams -> FirebaseAnalyticsParams [#1798](https://github.com/titicacadev/triple-frontend/pull/1798)\n- withUTMContext -> withUtmContext [#1798](https://github.com/titicacadev/triple-frontend/pull/1798)\n- WithUTMContextBaseProps -> WithUtmContextBaseProps [#1798](https://github.com/titicacadev/triple-frontend/pull/1798)\n- useUTMContext -> useUtmContext [#1798](https://github.com/titicacadev/triple-frontend/pull/1798)\n- UTMProvider -> UtmProvider [#1798](https://github.com/titicacadev/triple-frontend/pull/1798)\n- extractUTMContextFromQuery -> extractUtmContextFromQuery [#1798](https://github.com/titicacadev/triple-frontend/pull/1798)\n\n## 5.2.1\n\n### router\n\n- useNavigate 훅으로 특정 URL을 라우팅하지 못하는 문제를 수정합니다. [#1836](https://github.com/titicacadev/triple-frontend/pull/1836)\n\n### react-hooks\n\n- scrollToElement의 props을 수정합니다. [#1833](https://github.com/titicacadev/triple-frontend/pull/1833)\n\n### view-utilities\n\n- 앱스플라이어 UTM 파라미터 규칙을 수정합니다. [#1827](https://github.com/titicacadev/triple-frontend/pull/1827)\n\n## 5.2.0\n\n### ad-banners\n\n- router 패키지 의존성 추가 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n- history-context의 `navigate` 대신 router 패키지의 `useNavigate` 사용 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n\n### booking-completion\n\n- router 패키지 의존성 추가 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n- history-context의 `navigate` 대신 router 패키지의 `useNavigate` 사용 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n\n### footer\n\n- router 패키지 의존성 추가 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n- history-context의 `navigate` 대신 router 패키지의 `useNavigate` 사용 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n\n### nearby-pois\n\n- router 패키지 의존성 추가 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n- history-context의 `navigate` 대신 router 패키지의 `useNavigate` 사용 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n\n### replies\n\n- replies 디렉토리의 ESLint 검사를 활성화하고 오류를 수정합니다. [#1810](https://github.com/titicacadev/triple-frontend/pull/1810)\n- 댓글/답글 삭제하기 기능을 추가합니다. [#1761](https://github.com/titicacadev/triple-frontend/pull/1761)\n\n### review\n\n- router 패키지 의존성 추가 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n- history-context의 `navigate` 대신 router 패키지의 `useNavigate` 사용 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n\n### router\n\n- router 패키지의 link 모듈을 제거합니다. [#1828](https://github.com/titicacadev/triple-frontend/pull/1828)\n- 앱 전용 쿼리를 inlink일 때만 사용하도록 변경 [#1816](https://github.com/titicacadev/triple-frontend/pull/1816)\n- router 패키지에 라우터 훅 함수를 구현 [#1703](https://github.com/titicacadev/triple-frontend/pull/1703)\n\n### scroll-spy\n\n- scroll-spy 패키지를 추가합니다. [#1803](https://github.com/titicacadev/triple-frontend/pull/1803)\n\n### social-reviews\n\n- router 패키지 의존성 추가 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n- history-context의 `navigate` 대신 router 패키지의 `useNavigate` 사용 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n\n### standard-action-handler\n\n- 텍스트 복사 기능을 추가합니다. [#1813](https://github.com/titicacadev/triple-frontend/pull/1813)\n\n### triple-document\n\n- router 패키지 의존성 추가 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n- history-context의 `navigate` 대신 router 패키지의 `useNavigate` 사용 [#1800](https://github.com/titicacadev/triple-frontend/pull/1800)\n\n### user-verification\n\n- use-user-verification 테스트 과정에서 발생하는 워닝 제거 [#1818](https://github.com/titicacadev/triple-frontend/pull/1818)\n- triple-frontend에서 사용하는 openWindow를 router 함수로 대체합니다. [#1814](https://github.com/titicacadev/triple-frontend/pull/1814)\n\n### view-utilities\n\n- moment 의존성 수정 및 date format이 영어로 표기되는 문제를 fix합니다. [#1824](https://github.com/titicacadev/triple-frontend/pull/1824)\n- 항공 기획전, 투어티켓 기획전 라우터 추가 [#1815](https://github.com/titicacadev/triple-frontend/pull/1815)\n\n### integration-test\n\n- router 패키지와 modals 패키지의 상호작용을 테스트하는 코드를 추가합니다. 이를 위해 \"integration-test\" 패키지를 추가합니다. [#1825](https://github.com/titicacadev/triple-frontend/pull/1825)\n\n### Etc.\n\n- storybook 배포 제거 [#1817](https://github.com/titicacadev/triple-frontend/pull/1817)\n\n## 5.1.2\n\n### ESLint 검사\n\n- user-verification 디렉토리의 ESLint 검사를 활성화하고 오류를 수정합니다. [#1809](https://github.com/titicacadev/triple-frontend/pull/1809)\n\n### common\n\n- 마이그레이션 문서에 v1 to v2 문서 링크 추가 [#1808](https://github.com/titicacadev/triple-frontend/pull/1808)\n\n### router\n\n- default alert을 추가합니다. [#1806](https://github.com/titicacadev/triple-frontend/pull/1806)\n\n## 5.1.1\n\n### fetcher\n\n- request의 body 타입을 \"unknown\"으로 완화 [#1802](https://github.com/titicacadev/triple-frontend/pull/1802)\n\n### ESLint 검사\n\n- react-contexts [#1797](https://github.com/titicacadev/triple-frontend/pull/1797)\n- docs [#1801](https://github.com/titicacadev/triple-frontend/pull/1801)\n\n## 5.1.0\n\n### ESLint 검사\n\n- triple-document 디렉토리 ESLint 검사 활성화 및 린트 오류 수정 [#1795](https://github.com/titicacadev/triple-frontend/pull/1795)\n- standard-action-handler 디렉토리 ESLint 검사 활성화 및 린트 오류 수정 [#1794](https://github.com/titicacadev/triple-frontend/pull/1794)\n- review 디렉토리 ESLint 오류 수정 [#1793](https://github.com/titicacadev/triple-frontend/pull/1793)\n- modals 디렉토리의 ESLint 검사 활성화 [#1790](https://github.com/titicacadev/triple-frontend/pull/1790)\n- footer 디렉토리의 ESLint 검사를 활성화합니다. [#1788](https://github.com/titicacadev/triple-frontend/pull/1788)\n- ad-banners 패키지의 ESLint 검사를 활성화합니다. [#1786](https://github.com/titicacadev/triple-frontend/pull/1786)\n\n### fetcher\n\n- 응답 형식을 수정합니다 [#1785](https://github.com/titicacadev/triple-frontend/pull/1785)\n\n### router\n\n- router 패키지에 useNavigate 훅을 추가합니다. [#1784](https://github.com/titicacadev/triple-frontend/pull/1784)\n\n### date-picker\n\n- RangePicker V2 컴포넌트를 생성합니다. [#1749](https://github.com/titicacadev/triple-frontend/pull/1749)\n- RangePickerV2 작업 중, deprecated된 요소를 분리 합니다 [#1775](https://github.com/titicacadev/triple-frontend/pull/1775)\n\n### common\n\n- codecov를 연결합니다 [#1776](https://github.com/titicacadev/triple-frontend/pull/1776)\n- PR 템플릿 업데이트 [#1773](https://github.com/titicacadev/triple-frontend/pull/1773)\n\n## 5.0.1\n\n### core-elements\n\n- Select, Input의 css를 수정합니다. [#1762](https://github.com/titicacadev/triple-frontend/pull/1762)\n\n## 5.0.0\n\n### Breaking Changes\n\n#### common\n\n- deprecate된 환경 변수 prop을 제거합니다. [#1729](https://github.com/titicacadev/triple-frontend/pull/1729)\n\n#### core-elements\n\n- Text.Html/Text.WithRef 삭제 [#1747](https://github.com/titicacadev/triple-frontend/pull/1747)\n\n#### react-contexts\n\n- env context의 기본값 null로 설정 [#1731](https://github.com/titicacadev/triple-frontend/pull/1731)\n- ab-experiment-context를 triple-ab-experiment-context로 이름 변경 후, ab-experiment 패키지로 이전합니다. [#1730](https://github.com/titicacadev/triple-frontend/pull/1730)\n\n### New Features\n\n#### common\n\n- content-utilities 패키지 정리 [#1760](https://github.com/titicacadev/triple-frontend/pull/1760)\n- Resolve lint errors of view-utilities package [#1758](https://github.com/titicacadev/triple-frontend/pull/1758)\n- 수정할 린트 오류가 없는 패키지의 린트 검사를 활성화합니다. [#1757](https://github.com/titicacadev/triple-frontend/pull/1757)\n- CI에서 빌드와 테스트 순서 변경 [#1748](https://github.com/titicacadev/triple-frontend/pull/1748)\n- 타입 에러 해결 [#1746](https://github.com/titicacadev/triple-frontend/pull/1746)\n- storybook이 타입 체크를 하는 옵션 추가 및 스토리북 파일의 타입 오류 수정 [#1743](https://github.com/titicacadev/triple-frontend/pull/1743)\n- ts-jest를 설정하고, 실패하는 테스트를 수정합니다. [#1742](https://github.com/titicacadev/triple-frontend/pull/1742)\n- eslint 오류 수정 과정에서 작업한 리팩토링 [#1738](https://github.com/titicacadev/triple-frontend/pull/1738)\n- eslint-config-triple v3 적용 [#1737](https://github.com/titicacadev/triple-frontend/pull/1737)\n- Chromatic CI 추가 [#1732](https://github.com/titicacadev/triple-frontend/pull/1732)\n\n#### poi-list-elements\n\n- 저장버튼과 poi name이 겹치는 문제를 해결합니다 [#1754](https://github.com/titicacadev/triple-frontend/pull/1754)\n\n#### replies\n\n- 댓글/답글 서비스 로직을 개선합니다. [#1745](https://github.com/titicacadev/triple-frontend/pull/1745)\n- 댓글/답글 수정하기 기능을 추가합니다 [#1712](https://github.com/titicacadev/triple-frontend/pull/1712)\n\n## 4.1.4\n\n### triple-document\n\n- TextHtml을 TripleDocument의 마크다운을 위한 컴포넌트로 구현합니다 [#1739](https://github.com/titicacadev/triple-frontend/pull/1739)\n\n## 4.1.3\n\n### triple-document\n\n- Text의 중복 줄바꿈을 제거하는 함수 추가 [#1735](https://github.com/titicacadev/triple-frontend/pull/1735)\n\n## 4.1.2\n\n### app-installation-cta\n\n- floating-button-cta 디자인 오류를 해결합니다. [#1724](https://github.com/titicacadev/triple-frontend/pull/1724)\n\n### replies\n\n- 자식 컴포넌트의 이벤트를 막습니다. [#1723](https://github.com/titicacadev/triple-frontend/pull/1723)\n\n## 4.1.1\n\n### react-contexts\n\n- `SessionContextProvider.getInitialProps`를 클라이언트에서 실행할 때 인앱 환경을 제대로 감지하지 못하는 문제 수정 [#1720](https://github.com/titicacadev/triple-frontend/pull/1720)\n\n## 4.1.0\n\n## search\n\n- 렌더링 직후 input 엘리먼트의 focus 메서드를 호출 [#1684](https://github.com/titicacadev/triple-frontend/pull/1684)\n\n## core-elements\n\n- form-field에 required 디자인 추가 [#1709](https://github.com/titicacadev/triple-frontend/pull/1709)\n\n## replies\n\n- 댓글 컴포넌트 리팩토링 [#1711](https://github.com/titicacadev/triple-frontend/pull/1711)\n\n## ab-experiments\n\n- Peer 의존성으로 Next.js 12 허용 [#1713](https://github.com/titicacadev/triple-frontend/pull/1713)\n\n## fetcher\n\n- Peer 의존성으로 Next.js 12 허용 [#1713](https://github.com/titicacadev/triple-frontend/pull/1713)\n- 응답 객체에 status, url이 없는 문제 해결 [#1717](https://github.com/titicacadev/triple-frontend/pull/1717)\n\n## intersection-observer\n\n- Peer 의존성으로 Next.js 12 허용 [#1713](https://github.com/titicacadev/triple-frontend/pull/1713)\n\n## meta-tags\n\n- Peer 의존성으로 Next.js 12 허용 [#1713](https://github.com/titicacadev/triple-frontend/pull/1713)\n\n## react-contexts\n\n- Peer 의존성으로 Next.js 12 허용 [#1713](https://github.com/titicacadev/triple-frontend/pull/1713)\n\n## router\n\n- Peer 의존성으로 Next.js 12 허용 [#1713](https://github.com/titicacadev/triple-frontend/pull/1713)\n\n## ui-flow\n\n- Peer 의존성으로 Next.js 12 허용 [#1713](https://github.com/titicacadev/triple-frontend/pull/1713)\n\n## app-installation-cta\n\n- `BannerCTA`의 이미지 배너의 앱으로 가는 버튼과 배너를 닫는 버튼의 문구를 prop으로 받는 기능 추가 [#1714](https://github.com/titicacadev/triple-frontend/pull/1714)\n- `BannerCTA`의 텍스트 배너를 비활성화하는 prop 추가 [#1714](https://github.com/titicacadev/triple-frontend/pull/1714)\n\n## 4.0.0\n\n### Breaking Changes\n\n- floating-install-button 패키지 제거 [#1683](https://github.com/titicacadev/triple-frontend/pull/1683)\n- frontend-devtools 패키지 제거 [#1696](https://github.com/titicacadev/triple-frontend/pull/1696)\n\n#### fetcher\n\n- fetcher 응답 타입에서 `error`, `result`, body 관련 속성 제거 [#1624](https://github.com/titicacadev/triple-frontend/pull/1624)\n\n#### react-contexts\n\n- `SessionContextProvider`의 prop 변경 [#1705](https://github.com/titicacadev/triple-frontend/pull/1705)\n- `useSessionContext` 훅 제거 [#1705](https://github.com/titicacadev/triple-frontend/pull/1705)\n\n### New Features\n\n#### fetcher\n\n- fetcher 응답 타입에 `parsedBody` 추가 [#1624](https://github.com/titicacadev/triple-frontend/pull/1624)\n\n#### date-picker\n\n- `DayPicker`, `RangePicker` 컴포넌트에 오늘 날짜 표시를 숨길 수 있는 prop 추가 [#1688](https://github.com/titicacadev/triple-frontend/pull/1688)\n- `DayPicker` 컴포넌트에 달 페이지를 바꿀 수 있는 버튼을 표시하는 prop 추가 [#1688](https://github.com/titicacadev/triple-frontend/pull/1688)\n- `DayPicker` 컴포넌트의 날짜 영역 컴포넌트를 커스텀할 수 있는 기능 추가 [#1692](https://github.com/titicacadev/triple-frontend/pull/1692)\n\n#### react-contexts\n\n- humps 패키지 제거 [#1691](https://github.com/titicacadev/triple-frontend/pull/1691)\n- `SessionContextProvider`에 `getInitialProps` 메서드 추가 [#1705](https://github.com/titicacadev/triple-frontend/pull/1705)\n- `useSessionAvailability`, `useSessionControllers`, `useUser` 훅 추가 [#1705](https://github.com/titicacadev/triple-frontend/pull/1705)\n- `getSessionAvailablityFromRequest` 함수 추가 [#1705](https://github.com/titicacadev/triple-frontend/pull/1705)\n- `putInvalidSessionRemover` 함수 추가 [#1705](https://github.com/titicacadev/triple-frontend/pull/1705)\n\n#### core-elements\n\n- 폼 컴포넌트의 error 타입에 boolean 값도 들어갈 수 있도록 변경 [#1694](https://github.com/titicacadev/triple-frontend/pull/1694)\n- `SearchNavbar` 컴포넌트의 뒤로가기 아이콘 타입을 prop으로 받도록 처리 [#1706](https://github.com/titicacadev/triple-frontend/pull/1706)\n\n#### search\n\n- `FullScreenSearchView` 컴포넌트의 뒤로가기 아이콘 타입을 prop으로 받도록 처리 [#1706](https://github.com/titicacadev/triple-frontend/pull/1706)\n\n### Bug Fix\n\n#### router\n\n- `LocalLink`가 앱 전용 쿼리를 추가할 때 기존 쿼리를 보존하지 않는 문제 수정 [#1699](https://github.com/titicacadev/triple-frontend/pull/1699)\n\n### Etc.\n\n- `@swc/core` patch 버전 업그레이드 [#1687](https://github.com/titicacadev/triple-frontend/pull/1687)\n- README에서 브랜치 이름을 \"master\"에서 \"main\"으로 교정합니다. [#1695](https://github.com/titicacadev/triple-frontend/pull/1695)\n- tsc 설정을 개선합니다. [#1697](https://github.com/titicacadev/triple-frontend/pull/1697)\n- router 패키지의 코드를 정리합니다. [#1701](https://github.com/titicacadev/triple-frontend/pull/1701)\n\n## 3.4.1\n\n### footer\n\n- 배경 색상 범위를 수정합니다. (#1685)\n\n## 3.4.0\n\n### ui-flow\n\n- authGuard 테스트 재작성 (#1681)\n- ui-flow의 authGuard 코드를 정리합니다. (#1674)\n\n### footer\n\n- 320px 이하 화면을 대응합니다. (#1680)\n- maxWidth의 값을 변경합니다. (#1679)\n\n### replies\n\n- 이전 댓글&답글 더보기 로직을 수정합니다. (#1676)\n- 대댓글 작성 기능을 추가합니다. (#1668)\n- 댓글&답글 마크업 및 디자인을 수정합니다. (#1651)\n\n### fetcher\n\n- fetcher 코드를 정리합니다. (#1673)\n- addFetchersToGSSP 함수가 토큰을 갱신할 때 API 낭비를 줄입니다. (#1667)\n\n### core-elements\n\n- stack, responsive, section 리팩토링 (#1672)\n- text에 css prop 추가 및 코드 개선 (#1664)\n\n### location-properies\n\n- property-item 리팩토링 (#1671)\n\n## 3.3.1\n\n### common\n\n- 배포용 패키지는 babel로 빌드합니다. (#1669)\n\n## 3.3.0\n\n### react-contexts\n\n- API 요청이 성공했을 때에만 로그아웃 후 처리를 수행합니다. (#1665)\n\n### triple-document\n\n- 쿠폰 다운로드 Alert 타이틀 문구를 개선합니다. (#1663)\n- 쿠폰 다운로드 시도 시, API 호출 전 verification state 체크하는 부분을 제거합니다. (#1662)\n- 웹 브라우저에서 쿠폰을 다운로드할 수 있는 기능 추가 (#1658)\n- 쿠폰 그룹 다운로드할 때 인증에 실패하면 인증 프로세스 시작 (#1657)\n- 쿠폰 그룹 다운로드 컴포넌트 리팩토링 (#1656)\n- `InAppCouponDownloadButton` 리팩토링 (#1655)\n\n### poi-detail\n\n- maxWidth={0} 제거 (#1661)\n\n### react-hooks\n\n- Public 환경에서 visibilitychange 이벤트에 subscribe합니다. (#1660)\n\n### core-elements\n\n- container, flex-box, sticky-header에 css prop 추가 및 코드 개선 (#1654)\n- Container 유닛 테스트 추가 (#1650)\n- as, css prop을 지원하는 컴포넌트와 타입 추가 (#1643)\n\n### date-picker\n\n- range-picker test에서 query 함수로 getAllBy 사용 (#1652)\n\n### replies\n\n- 댓글을 작성할 수 있도록 수정합니다. (#1649)\n\n### docs\n\n- popup 스토리 수정 (#1648)\n- 댓글 컴포넌트의 스토리북에 control 기능을 추가합니다. (#1647)\n- storybook-addon-next-router 추가 (#1646)\n\n### router\n\n- Link 컴포넌트의 자식 노드에 덮어쓰이는 속성이 있을 때 경고를 추가합니다. (#1645)\n\n### form\n\n- action-sheet-selector default label 수정 (#1641)\n\n### etc\n\n- swc를 사용해 빌드합니다. (#1642)\n- tag object를 만들 때 SHA 참조 수정 (#1640)\n\n## 3.2.1\n\n### modal\n\n- 버튼에 box-sizing css 추가 (#1638)\n\n## 3.2.0\n\n### common\n\n- doc의 자체 eslint 규칙을 root로 통합합니다. (#1635)\n- 린트 관련 스크립트 정리 (#1634)\n- CD 워크플로에 카나리 릴리즈 job을 추가합니다. (#1628)\n- 태그 삭제 API를 Github Actions로 대체 (#1627)\n\n### view-utilities\n\n- 호텔의 라우터 경로 중 요금 상세페이지에 대한 ROUTERLIST_REGEXES를 추가합니다. (#1632)\n\n## 3.1.0\n\n### core-elements\n\n- GlobalColorSet (skyblue, lightpurple)을 추가합니다. (#1617)\n\n### view-utilities\n\n- 호텔의 라우터 경로중 요금 상세에 대한 ROUTERLIST_REGEXES 를 추가합니다 (#1616)\n\n### common\n\n- PR에 붙이는 태그에 PR 넘버 추가 (#1614)\n\n## 3.0.2\n\n### core-elements\n\n- sticky-header의 zTier, zIndex 기본 값 수정 (#1621)\n\n### view-utilities\n\n- 오타 수정 (#1622)\n\n## 3.0.1\n\n## common\n\n- CI/CD에서 의존성을 설치할 때 .npm을 캐싱 ([#1615](https://github.com/titicacadev/triple-frontend/pull/1615))\n- 버전 관리 방법을 lerna로 원상복구 ([#1613](https://github.com/titicacadev/triple-frontend/pull/1613))\n- lerna bootstrap 관련 스크립트 제거 ([#1613](https://github.com/titicacadev/triple-frontend/pull/1613))\n\n## core-elements\n\n- z-index의 기본 값이 중복으로 설정되는 것을 방지 ([#1618](https://github.com/titicacadev/triple-frontend/pull/1618))\n\n## fetcher\n\n- 응답이 ok일 때 `result`에 할당 ([#1619](https://github.com/titicacadev/triple-frontend/pull/1619))\n\n## 3.0.0\n\n### public-header\n\n- BREAKING CHANGE: 디자인 변경 사항 반영 (#1586)\n\n### react-contexts\n\n- BREAKING CHANGE: env-context에 필수 props 추가 afOnelinkSubdomain, afOnelinkId, afOnelinkPid (#1586)\n\n### core-elements\n\n- sticky-header 컴포넌트 추가 (#1586)\n\n## 2.41.0\n\n### replies\n\n- 댓글 컴포넌트를 추가합니다 (#1599)\n\n### standard-action-handler\n\n- Clipboard API에 fallback 함수로 execCommand 함수를 추가합니다. (#1601)\n\n### triple-document\n\n- 쿠폰 다운로드 버튼에서 사용하는 API 요청에 새로운 fetcher 인터페이스 사용 (#1602)\n\n### fetcher\n\n- fetcher 재시도 조건에서 body 존재유무를 제거합니다. (#1604)\n\n### view-utilities\n\n- param-injector에 injectIsSearchAd 추가 (#1605)\n\n### core-elements\n\n- input에 ref prop을 추가합니다. (#1606)\n\n### common\n\n- 패키지의 버전을 올릴 때 lerna 의존성을 제거합니다. 그리고 의존성 변경을 누락했는지 확인하는 job을 추가합니다. (#1607)\n- package-lock 업데이트 (#1608)\n\n## 2.40.0\n\n### poi-detail\n\n- 추천 아티클 '더 알아보기' 영역에 onClick을 추가합니다 (#1588)\n- 추천 아티클 size 및 button을 변경합니다 (#1589)\n\n### fetcher\n\n- HttpError를 좀 더 유용하게 기록합니다. (#1594)\n\n### view-utillities\n\n- appsflyer 어트리뷰션 오타를 수정합니다 (#1595)\n- 필요한 appsflyer 어트리뷰션를 추가합니다 (#1597)\n\n## 2.39.0\n\n### core-elements\n\n- numeric-spinner 오타 수정 (#1574)\n- cursor option을 추가합니다 (#1580)\n\n### fetcher\n\n- JSON 파싱 에러를 조용히 넘기도록 수정 (#1575)\n- 새로운 fetcher 개선사항 (#1579)\n\n### footer\n\n- DefaultFooter 앱 다운 버튼 숨김 옵션 추가 (#1581)\n\n### modals\n\n- 모달의 Default Action 을 방지하는 로직을 추가합니다 (#1577)\n\n### poi-detail\n\n- POI DetailHeader V2 버전 지도보기에 대한 액션이 존재할때만 노출합니다 (#1583)\n- RecommendedArticles가 zoneId를 받을 수 있도록 합니다 (#1585)\n\n### public-header\n\n- AutoHidingPublicHeader 추가 (#1578)\n\n### triple-document\n\n- PricePolicyCouponInfo의 쿠폰 노출 정책에 대한 강조색상을 확장합니다 (#1584)\n\n## 2.38.1\n\n### triple-document\n\n- 잘못된 아이콘 파일명을 수정합니다. (#1570)\n\n### booking-completion\n\n- 일정추가 버튼의 텍스트를 수정합니다. (#1567)\n\n## 2.38.0\n\n### docs\n\n- storieOf API 대신 CSF 포맷으로 스토리 변경 (#1562)\n\n### triple-document\n\n- @titicaca/scrap-button 의존성을 추가합니다. (#1564)\n\n### react-contexts\n\n- ScrapsProvider에 enableTrackEvent prop 추가 (#1565)\n\n### scrap-button\n\n- 스크랩 버튼에 이벤트 추가 (#1565)\n\n## 2.37.0\n\n### fetcher\n\n- README 재작성 (#1559)\n\n### react-contexts\n\n- 오타수정 (#1558)\n\n### static-map\n\n- 반응형 이미지 지원을 위해 source 태그를 사용합니다. (#1555)\n\n### ab-experiments\n\n- A/B 테스트 패키지를 추가합니다. (#1552)\n\n## 2.36.0\n\n### react-contexts\n\n- scraps context에 tna 타입 추가 (#1551)\n\n### triple-document\n\n- TNA 슬롯 컴포넌트에 저장 버튼 추가 (#1551)\n\n### pricing\n\n- Pricing 컴포넌트의 priceLabelOverride Props 의 타입을 확장합니다 (#1549)\n\n## 2.35.0\n\n### booking-completion\n\n- 내 일정으로 담기 버튼을 추가합니다. (#1545)\n\n### triple-document\n\n- 추천코스에 노출되는 내 일정으로 담기 버튼을 숨김 처리합니다. (#1543)\n\n### modals\n\n- modal의 width를 조절할수 있는 prop을 추가 (#1542)\n\n### fetcher\n\n- fetcher 모듈에 새로운 인증 방식을 추가; getServerSideProps에서 fetcher를 쉽게 사용할 수 있는 방법 마련 (#1533)\n\n## 2.34.0\n\n### map\n\n- CirclePin에 alwaysClickable props를 추가합니다. (#1528)\n\n### core-elements\n\n- BaseButton의 active border-style을 제거합니다. (#1531)\n\n### modals\n\n- 액션 문구의 줄바뀜 현상을 수정합니다. (#1535)\n\n### fetcher\n\n- Fetch함수의 파라미터를 생성하는 로직을 별도 함수로 분리합니다. (#1532)\n\n### react-contexts\n\n- SessionContext의 logout 함수에서 logout API를 호출합니다. (#1534)\n- 새로운 인증 방식을 적용했을 때, 페이지의 인증 여부를 SessionContext로 공급합니다. (#1536)\n\n## 2.33.0\n\n### triple-document\n\n- TripleDocument의 DefaultClickHandler에 trackWithMetadata를 추가합니다 (#1526)\n\n### static-map\n\n- 지도영역 크기의 기본척도를 개선합니다 (#1521)\n\n### react-contexts\n\n- Canonized URL을 Routability 체크에만 이용합니다. (#1517)\n- UTM Parameter를 trackScreen의 additionalMetadata로 전달합니다. (#1509)\n\n## 2.32.0\n\n### common\n\n- Next.js 11 사용을 준비합니다. (#1510)\n- ⬆️ node 16 사용 (#1511)\n- sync-deps: 의존성 변경을 필요한 곳에 반영하는 명령어 (#1516)\n  Release Docs 워크플로가 실패하는 문제를 수정합니다. (#1518)\n\n### triple-document\n\n- tna element를 AB테스트 결과에 맞게 수정합니다. (#1515)\n\n### react-context\n\n- viewItem을 EventTrackingProvider에서 기록합니다. (#1507)\n- trackScreen, trackEvent 호출 시 native client accessbility를 체크하지 않습니다. (#1513)\n- EventTrackingProvider가 호출하는 trackScreen은 warn message를 출력하지 않도록 합니다. (#1512)\n\n## 2.31.0\n\n### common\n\n- react 17을 준비합니다. (#1469)\n\n### react-contexts\n\n- 콜백 함수에서 발생한 에러를 React ErrorBoundary가 잡을 수 있도록 해주는 함수 `useErrorHandler` Hook 추가 (#1489)\n- EventTrackingProvider에서 trackScreen을 수행합니다. (#1504)\n- 공통 EventMetadata 를 추가하기 위한 Context API 를 추가합니다 (#1503)\n\n### fetcher\n\n- fetcher의 파라미터에 withApiUriBase, cookie를 추가합니다. (#1502)\n\n### meta-tags\n\n- 기본 OG 이미지 수정 (#1501)\n\n### triple-document\n\n- 아티클 내의 쿠폰선택에 관한 이벤트 로깅을 생성합니다. (#1499)\n\n### web-storage\n\n- @titicaca/web-storage: 효율적인 WebStorage API 에러 처리 (#1489)\n\n### app-installation-cta\n\n- WebStorage API 적용 (#1489)\n\n## 2.30.2\n\n### poi-list-element\n\n- 관광지 리스트 poi blank image를 변경합니다 (#1498)\n\n## 2.30.1\n\n### static-map\n\n- tna 아이콘 추가합니다 (#1494)\n\n## 2.30.0\n\n### footer\n\n- Sotrybook Footer 오류 해결을 위한 Decorator 추가 (#1490)\n\n### react-contexts\n\n- `event-tracking-context`에서 firebase 참조 경로를 수정합니다. (#1488)\n\n- `SessionContextProvider`에서 `/api/users/me` 요청을 제거합니다. (#1488)\n\n### router\n\n- inlink일때 AllowSource를 리턴하는 로직을 개선합니다. (#1486)\n\n### booking-completion\n\n- 리전을 가지고 도시메인으로 넘길때 useAppCallback으로 감싸줍니다. (#1481)\n\n### triple-document\n\n- 아티클 추천코스에 조건 별 Poi 행정구역명 표기 추가 (#1479)\n\n### map\n\n- TNA 마커 추가 (#1472)\n\n## 2.29.1\n\n### pricing\n\n- FixedPricingV2 컴포넌트의 `PurchaseButtonLoadingIndicator` 애니메이션 동작 방식을 수정합니다. (#1476)\n\n### core-elements\n\n- `global-style` 에 blue500 color set 을 추가합니다. (#1476)\n\n### color-palette\n\n- `colors` 에 blue500을 추가합니다. (#1476)\n\n## 2.29.0\n\n### poi-list-element\n\n- categories 타입 변경 및 PoiGQL categories를 사용합니다 (#1473)\n\n### pricing\n\n- FixedPricingV2 컴포넌트를 새로 생성합니다. (#1470)\n\n### ui-flow\n\n- 앱 환경에서 authGuard가 작동할 수 있도록 확장 (#1468)\n- useSessionCallback 에 useAuthWeb 옵션 추가 (#1463)\n\n### react-context\n\n- Event Tracking Context에 Firebase Web 연동을 추가합니다. (#1434)\n- trackSimpleEvent 퇴장을 준비합니다. (#1460)\n- ab-experiment-context에 experiment_impression 액션의 이벤트를 추가합니다. (#1461)\n\n### triple-document\n\n- PoiGQL를 렌더링 할 수 있도록 합니다. (#1467)\n- content-web에서 displayName 타입 에러를 해결합니다. (#1465)\n- Custom element 사용 시 warning을 제거합니다. (#1459)\n- 쿠폰 관련 로직을 정리합니다. (#1457)\n\n### github\n\n- 릴리즈된 버전을 PR 댓글로 추가합니다 (#1462)\n- CANARY_VERSION 환경변수로 노출되지 않는 문제 수정 (#1464)\n\n## 2.28.1\n\n### poi-detail\n\n- `DetailHeader` V2 리뷰 노출 로직을 수정합니다. (#1456)\n\n## 2.28.0\n\n### poi-detail\n\n- `DetailHeader` V2 타이틀고 영문 타이틀 사이의 간격을 수정합니다. (#1452)\n\n### pricing\n\n- `FixedPricing` 컴포넌트의 패딩을 외부에서 수정할 수 있게 인터페이스를 추가합니다. (#1452)\n\n### listing-filter\n\n- `listing-filter` container의 padding을 외부에서 수정할 수 있게 인터페이스를 추가합니다. (#1452)\n\n### Triple-document\n\n- Tna Element 교체 (#1418)\n\n### react-context\n\n- useABExperimentContext 훅 추가 (#1453)\n\n## 2.27.7\n\n### poi-detail\n\n- Detail-Header V2 리뷰 Rating과 숫자 사이의 간격을 조절합니다 (#1450)\n\n## 2.27.6\n\n### review\n\n- POI 상세 리뷰에서 닉네임이 콘텐츠 박스 밖으로 나오는 오류를 수정합니다. (#1448)\n\n## 2.27.5\n\n### poi-detail\n\n- DetailHeader V2 리뷰 섹션의 로직을 수정합니다 (#1445)\n\n## 2.27.4\n\n### router\n\n- router 패키지의 문서를 보강합니다. (#1440)\n\n### ui-flow\n\n- file기반 라우팅을 사용하지 않고 rewrites를 쓸 때 authGuard가 제대로 작동하지 않는 문제 수정 (#1441)\n\n### review\n\n- POI 상세 리뷰에서 텍스트 겹치는 오류를 수정합니다. (#1443)\n\n## 2.27.3\n\n### poi-list-elements\n\n- 행정구역 표기 추천검색 및 경로검색 결과 문구 반영합니다.(#1417)\n\n## 2.27.2\n\n### react-contexts\n\n- Google Analytics의 SPA Event Tracking 방식을 적용합니다. (#1431)\n\n## 2.27.1\n\n### core-elements\n\n- PointingTab non-active 상태의 컬러를 변경합니다. (#1428)\n\n### review\n\n- 리뷰 땡쓰 이벤트가 반대로 기록되고, 액션 이름에 있던 오타를 수정합니다. (#1429)\n\n## 2.27.0\n\n### ui-flow\n\n- Option을 추가합니다. `allowNonMembers`, `authType`을 지원합니다. (#1424, #1426)\n\n### footer\n\n- 다운로드 버튼 줄바뀜을 수정합니다. (#1425)\n\n## 2.26.0\n\n### Footer\n\n- Footer 링크를 개선합니다. (#1419)\n\n## 2.25.0\n\n### Pricing\n\n- `discountRate` 컴포넌트를 위한 스타일 확장을 진행합니다. (#1412)\n- `Text` Inline 속성으로 인한 `margin-bottom`이 작동하지 않는 버그를 수정합니다 (#1415)\n\n### react-contexts\n\n- `parseApp` 함수 외부로 노출 (#1414)\n\n### ui-flow\n\n- authGuard: getServerSideProps에서 로그인 여부를 검사하는 HOC (#1414)\n\n### footer\n\n- `DefaultFooter` 버튼 디자인 수정 (#1413)\n\n## 2.24.0\n\n### modals\n\n- `modals` navigate() 후 true를 반환합니다. (#1408)\n\n### react-contexts\n\n- `react-contexts` authBasePath를 deprecate합니다. (#1407)\n\n### Pricing\n\n- FixedPricing 컴포넌트의 툴팁 색상을 Props 인자값으로 받습니다. (#1406)\n\n### poi-detail\n\n- DetailHeader 스타일 확장 (#1390)\n\n## 2.23.2\n\n### react-contexts\n\n- Logout 구현 (#1404)\n\n## 2.23.1\n\n### poi-list-elements\n\n- type optional 적용 (#1402)\n\n## 2.23.0\n\n### user-verification\n\n- 일반 브라우저에서 user-verification 패키지를 사용 (#1399)\n\n### poi-list-elements\n\n- 아티클 상세 행정구역 표기 (#1392)\n\n### public-header\n\n- PublicHeader에 내 예약 목록으로 가는 링크를 추가합니다. (#1397)\n\n### footer\n\n- Default 푸터 디자인수정 (#1400)\n\n## 2.22.3\n\n### review\n\n- 댓글 아이콘에 trackEvent를 추가 합니다 (#1391)\n\n## 2.22.2\n\n### review\n\n- reviews storybook 에러를 해결합니다 (#1387)\n- 댓글 아이콘 클릭 시 리뷰 상세페이지 댓글 섹션으로 이동하는 핸들러 추가 (#1385)\n\n## 2.22.1\n\n### poi-detail\n\n- `ImageCarousel`에 `height` 인터페이스를 제공합니다. (#1383)\n\n### image-carousel\n\n- `height` 인터페이스를 제공합니다. (#1383)\n\n## 2.22.0\n\n### poi-detail\n\n- `ImageCarousel`에 `padding`, `borderRadius` 인터페이스를 제공합니다. (#1381)\n\n### review\n\n- 대댓글이 없어도 댓글 아이콘 표시 (#1374)\n\n### modals\n\n- `useLoginCTAModal`이 `setReturnUrl`인터페이스를 제공합니다. (#1373)\n\n### ui-flow\n\n- `useSessionCallback`에 `returnUrl`을 명시할 수 있도록 합니다. (#1373)\n\n### booking-completion\n\n- compact, myBookingButtonTitle props 추가 (#1380)\n\n## 2.21.1\n\n### triple-document\n\n- fetcher 의존성 추가 (#1377)\n\n## 2.21.0\n\n### poi-list-elements\n\n- PoiListElement notes의 타입을 완화 (#1369)\n\n### core-elements\n\n- navbar의 title을 사용 시 children을 flexible하게 관리 (#1366)\n- search-navbar의 높이를 일반 navbar와 동일하게 맞춰 수정합니다. (#1329)\n\n### view-utilities\n\n- generateUrl이 입력받은 query의 형식을 존중하여 병합합니다 (#1365)\n\n### review\n\n- messageCount 컴포넌트 추가 (#1360)\n\n## 2.20.1\n\n### react-contexts\n\n- history-context 에서 url에 Hash 삽입 시 arrayFormat을 repeat 형태로 유지합니다. (#1363)\n\n### fetcher\n\n- customHeader를 명시해도 sessionId 보존 (#1358)\n\n## 2.20.0\n\n### nearby-pois\n\n- regionId 의존성을 제거합니다 (#1355)\n\n### view-utilities\n\n- generateUrl 함수에 arrayFormat 옵션 인자를 추가합니다 (#1354)\n\n## 2.19.0\n\n### resource-list-element\n\n- 지역명을 받을수 있도록 prop을 추가합니다. (#1348)\n\n## 2.18.2\n\n### triple-document\n\n- 쿠폰 그룹 다운로드 모달, 로직 수정 (#1344)\n\n## 2.18.1\n\n### poi-detail\n\n- areas type 수정 (#1342)\n\n## 2.18.0\n\n### static-map\n\n- `static-map` zoom level과 frame을 제어 가능하도록 합니다. (#1310)\n\n### view-utilities\n\n- generateUrl 함수가 base URL의 query를 보존하지 못하는 문제 수정 (#1316)\n\n### config\n\n- eslint-config-triple@2.4.0 설치 (#1338)\n\n### triple-document\n\n- `triple-document`에서 prop으로 전달하던 값을 Context API로 공급합니다. (#1318)\n- 쿠폰 그룹 다운로드 지원 (#1322)\n\n### poi-detail, nearby-pois, poi-list-elements\n\n- 리전없는 행정구역 표기 추가 (#1332)\n\n### review\n\n- 리뷰 클릭 핸들러 reviewId 타입 fix (#1337)\n- 리뷰 팝업 모달 핸들러 정리, 이미지 누를 시 중복 모달 제거, 리뷰 더보기 누를시 팝업 모달 제거 (#1317)\n\n### core-elements\n\n- Stack 컴포넌트를 추가합니다. (#1248)\n- Navbar Item 버튼이미지를 추가합니다 (#1311)\n\n### intersection-observer\n\n- useIntersection Hook을 추가합니다 (#1326)\n\n### fetcher\n\n- responseError를 get하는 로직을 추가합니다. (#1324)\n\n## 2.17.0\n\n### package.json\n\n- 의존성을 추가하는 스크립트 작성 (#1309)\n\n### core-elements\n\n- Image.Overlay 컴포넌트에 기본 zTier 값을 추가합니다 (#1308)\n\n### poi-list-elements\n\n- POICardElement의 스크랩 버튼을 무조건 표시합니다 (#1307)\n\n### modals\n\n- transition-modal 팝업 오류 수정 (#1306)\n\n### fetcher\n\n- 응답의 content-type에 json이 포함되어있을 때만 json 파싱 (#1305)\n\n### view-utilities, react-contexts\n\n- normalize-query-keys 모듈을 추가합니다. (#1304)\n\n### husky\n\n- Husky v6 적용 (#1303)\n\n### modals\n\n- transition-modal 팝업 오류 수정 (#1306)\n\n## 2.16.1\n\n### core-elements\n\n- Dark 테마 일 시 배경색 white 지정 (#1300)\n\n## 2.16.0\n\n### fetcher\n\n- 특정 ErrorStatus에서 reFetch 시도합니다 (#1295)\n\n### modals\n\n- transition-modal 디자인 통일 (icon. description) (#1297)\n\n### core-elements\n\n- 사파리에서 발생하는 border-radius 버그 수정 (#1292)\n\n### triple-document, user-verification\n\n- 사용자 인증 플로우를 확장합니다. (#1280)\n\n### router\n\n- local-link 스크롤 이슈 수정 (#1289)\n\n## 2.15.1\n\n### common\n\n- @egjs/flicking, @egjs/react-flicking을 최신 버전으로 업그레이드합니다. (#1287)\n\n### core-elements\n\n- Image.Overlay 컴포넌트 z-index 제거 (#1286)\n\n### review\n\n- Image Dimmer의 z-index를 제거합니다. (#1286)\n\n## 2.15.0\n\n### router\n\n- Link 컴포넌트에 `트리플 전용 쿼리 파라미터`를 Optional Props 으로 추가 (#1256)\n\n### triple-document\n\n- `triple-document` 타임라인 영역 min-width 적용 (#1276)\n\n### color-palette\n\n- `white900` 추가 (#1277)\n\n### core-elements\n\n- global-style `--color-white900` 추가 (#1277)\n- root에 정의된 colorSet만 사용하도록 `getCssVariableColor` 함수를 추가합니다. (#1275)\n- Version up `content-utilities` (#1274)\n\n### reviews\n\n- Images 컴포넌트 추가 (#1277)\n- User 컴포넌트 수정 (#1281)\n\n### popup, action-sheet\n\n- CSSTransion 컴포넌트 props 로 추가합니다. (#1279)\n\n## 2.14.1\n\n### react-contexts\n\n- [Hitory Context] Canonization 시 유실되는 `/outlink` query param을 보존합니다. (#1270)\n\n### poi-list-elements\n\n- POI 목록 요소에 area 정보를 표시합니다. (#1271)\n\n## 2.14.0\n\n### common\n\n- `Storybook` 사용법 링크를 추가합니다 (#1268)\n- `docs`를 빌드하는 Dockerfile을 개선합니다. (#1265)\n\n### review\n\n- `shortend` 리뷰 개수 4개로 수정 (#1249)\n- 유저 탈퇴 시에도 별점 노출 (#1249)\n- 유저 프로필 여행자 클럽 정보 대신 리뷰 개수 노출 (#1249)\n\n### core-elements\n\n- Videon 컴포넌트 브라우저 지원을 위한 리스너 수정 (#1267)\n\n## 2.13.1\n\n### view-utilities\n\n- strictQuery가 boolean 값을 결정하는 기준을 완화합니다. (#1264)\n\n### triple-document\n\n- local 빌드시 type에러를 수정합니다 (#1259)\n\n### core-elements\n\n- Video 컴포넌트 Pending 로직 변경을 위한 리스너 추가 (#1263)\n\n## 2.13.0\n\n### common\n\n- 개발 환경에서 테스트 파일에 동일한 타입스크립트 설정을 적용하도록 변경 (#1234)\n- 테스트 파일이 빌드되지 않도록 babel 설정 수정 (#1234)\n- `build-watch`가 지켜보는 파일 범위 축소 및 로그 간소화 (#1243)\n- docs.standalone 빌드 실패 문제 해결 (#1252)\n- 사용하지 않는 Dockerfile 제거 (#1255)\n- 사용 빈도가 낮은 GCP, GHA Manifest 삭제 (#1254)\n- 패키지 의존성 그래프 개선 (#1244)\n\n### color-palette\n\n- 색상 변수 및 `getColor` 함수 deprecate 처리 (#1230)\n\n### core-elements\n\n- `global-style`에 기본 색상을 CSS 상수로 추가 (#1230)\n- `VideoFrame`, `VideoElement` 컴포넌트 추가 (#1206)\n- `Video` 컴포넌트 구조 정리 (#1206)\n\n### react-contexts\n\n- [Device Context] `autoplay`와 `networkType` 값 추가 및 request header에서 이들을 파싱하는 함수 추가 (#1221)\n- [Scraps Context] 스크랩 여부를 컴포넌트 트리 전체가 한 객체에 저장하도록 처리 (#1231)\n\n### fetcher\n\n- Body의 Stringfy하지 않는 옵션 추가 (#1241)\n\n### popup\n\n- 팝업 컨테이너 하단 패딩 제거 (#1236)\n\n### triple-document\n\n- links 컴포넌트 중 \"list\" 타입의 links를 기본 links 컴포넌트로 fallback 처리 (#1247)\n\n### docs\n\n- Storybook 6 사용 (#1245)\n- `triple-document` 스토리 정비 (#1246)\n\n## 2.12.1\n\n### poi-detail\n\n- RecommendedArticles 하단 링크 TEXT 수정 (#1237)\n\n## 2.12.0\n\n### type-definitions\n\n- [type-definitions] 인벤토리 API 응답 타이핑 (#1223)\n\n### poi-detail\n\n- RecommendedArticles, TransitionModal Text 수정 (#1232)\n\n### core-elements\n\n- optimizedImg를 사용함에 필요한 optimized Prop 추가 (#1185)\n\n### triple-document\n\n- `Ship` 아이콘 추가 (#1226)\n\n### common\n\n- 버전을 올리고 `package-lock.json`, `docs/package-lock.json` 업데이트 하는 스크립트 추가 (#1222)\n\n## 2.11.0\n\n### app-installation-cta\n\n- article-card-cta 컴포넌트 추가 및 story doc 추가 (#1197)\n\n### date-picker\n\n- DayPicker-wrapper를 768로 제한 (#1216)\n\n### core-elements, action-sheet, app-installation-cta, floating-install-button, popup\n\n- 화면 레이아웃을 벗어나는 UI의 최대 너비 제한 (#1212)\n\n### common\n\n- npm@7 사용 이후 의존성 설치에 실패하는 문제 수정 (#1213)\n- `react-contexts` 패키지를 사용할 때 peer Dependency로 의존하도록 수정 (#1215)\n- 빌드, 개발 과정에 [TypeScript의 Project Reference 설정](https://www.typescriptlang.org/docs/handbook/project-references.html) 적용 (#1215)\n\n### react-contexts\n\n- [images-context] reFetch Action 추가 (#1210)\n\n### triple-document\n\n- [tag-label] 태그라벨 안 이미지 변경, square 모서리 부분 수정 (#1195)\n- [poi] poi 추천코스에 하나만 넣으면 zoom level이 너무 커져 default zoom 값 수정 (#1195)\n\n### carousel\n\n- carousel 패키지 추가 (#1120)\n\n### core-elements\n\n- [carousel] Carousel 컴포넌트 deprecated 주석 명시 (#1120)\n\n### view-utilities\n\n- query 값의 타입을 확정해서 사용할 수 있게 해주는 `strictQuery` 추가 (#1201)\n\n## 2.10.1\n\n### common\n\n- [common] peerDependencies에 이미 있는 모듈을 devDependencies에서 제거합니다 (#1186)\n\n### triple-document\n\n- 지도, 추천코스 아이템이 없는 케이스에 대한 예외처리 (#1194)\n\n## 2.10.0\n\n### social-reviews\n\n- Support placeholder image for social reviews (#1193)\n\n### triple-document\n\n- 임의의 anchor 컴포넌트 추가 (#1190)\n- 지도,추천코스 > 일정담기 이벤트 핸들처리 로직 추가 (#1191)\n\n## 2.9.2\n\n### triple-document\n\n- 지도, 추천코스 Poi 메모 영역 line-height 값 조정 (#1184)\n\n## 2.9.1\n\n### triple-document\n\n- 지도, 추천코스 Poi 카드 타이틀, 메모 말줄임 처리 (#1180)\n\n## 2.9.0\n\n### date-picker\n\n- enableSameDay가 false일 때 같은날짜를 무시하는 로직 개선 (#1174)\n\n### triple-document\n\n- optimized prop를 추가 (#1165)\n- Triple Document에 지도, 추천컴포넌트 추가 (#1167)\n\n### footer\n\n- Default Footer 안 wording을 변경합니다 (#1168)\n- Footer 패키지에서 사용하는 react-contexts를 peerDependencies에 추가합니다 (#1177)\n\n### triple-media\n\n- OptimizedImg 컴포넌트를 추가합니다 (#1163)\n\n### image-carousel\n\n- flicking isPaying 시 clickEvent를 발생 시키지 않음\n  useRef를 사용하여, react-flicking 내부 인터페이스 사용 (#1162)\n\n## 2.8.0\n\n### common\n\n- tsconfig.tsbuildinfo 파일의 change event를 무시합니다. (#1153)\n- npm workspaces 사용 (#1152)\n- package-lock 업데이트 (#1154, #1155)\n- 컴포넌트의 underline 설정이 우선하도록 && hack 추가 (#1157)\n\n### core-elements\n\n- lazy loading 및 cloudinary optimization처리되는 `OptimizedImg`를 추가합니다. (#1145)\n\n## 2.7.3\n\n### react-contexts\n\n- `ab-experiment-context`의 `ABExperimentProvider`에서 실험 정보 API를\n  세션이 있을 때만 호출하도록 수정 (#1150)\n\n## 2.7.2\n\n### react-contexts\n\n- `ab-experiment-context`의 `useABExperimentVariant`에 실험 시작 이벤트에 attribute를 추가할 수 있는 파라미터 추가 (#1148)\n\n## 2.7.1\n\n### react-contexts\n\n- `experiment-context`의 `useABExperimentConversionTracker`의 반환 함수 타입 수정 (#1146)\n\n## 2.7.0\n\n### react-contexts\n\n- `experiment-context` 모듈 추가 (#1123)\n\n### map\n\n- `@types/googlemaps` 버전에 ~ 사용 (#1143)\n\n### router\n\n- link 모듈 구현 (#1121)\n- 키 누르고 링크를 눌렀을 때 기본 앵커 태그처럼 작동하도록 처리 (#1142)\n\n## 2.6.0\n\n### react-contexts\n\n- tna 상세프로그램 더보기 router를 추가합니다. (#1135)\n\n### Modals\n\n- tna type의 description을 수정합니다. (#1130)\n\n### MapView\n\n- `bounds`, `padding` ,`onLoad` prop 을 추가합니다. (#1131)\n- 구글맵 `Libraries` 옵션 설정 관련 performance warning 이슈 해결 (#1127)\n\n## 2.5.0\n\n### map\n\n- `MapView` 컴포넌트를 추가합니다.\n\n### react-contexts\n\n- in-app 내비게이션에도 URL canonization을 적용합니다. (#1090)\n- `navigate()` 에서 tna 상세로의 링크를 허용합니다. (#1113)\n\n### recommended-contents\n\n- ul 바로 아래에 li 태그가 들어가도록 수정 (#1117)\n\n### meta-tag\n\n- Facebook OpenGraph의 `title`, `description` 태그 기본값을 `env-context`에서 가져옵니다. (#1112)\n\n### poi-detail\n\n- Image carousel의 가로폭을 768 기반으로 변경합니다. (#1114)\n\n### public-header\n\n- 로고 이미지 비율을 바로잡습니다. (#1115)\n\n### resource-list-element\n\n- 가격정보를 `children` 으로 받을 수 있도록합니다. (#1109, #1110)\n\n### navbar\n\n- `maxWidth` 가 기본값을 100%를 가지도록 처리합니다. (#1108)\n\n## 2.4.1\n\n### ad-banners\n\n- `ContentDetailsBanner` 컴포넌트 Click Event contentType 수집\n\n## 2.4.0\n\n### common\n\n- `HistoryProvider`, `SessionContextProvider` 를 Storybook Decorator로 제공합니다. (#1080)\n- 760 대신 768을 기준으로 큰 화면 레이아웃을 구성합니다. (#1091)\n\n### ad-banners\n\n- `ListTopBanners` 컴포넌트 `margin` 추가 (#1092)\n- `ListTopBanners` 컴포넌트 `contentId` props를 optional props로 수정 (#1092)\n- `ContentDetailsBanner` 컴포넌트 추가 (#1099)\n\n### date-picker\n\n- 공휴일을 추가합니다 (#1097)\n\n### react-contexts\n\n- 환경 변수를 공급하는 context 추가. `EnvProvider`, `useEnv` (#1059)\n- `HistoryProvider`에 env context 적용 및 `appUrlScheme`, `webUrlBase` prop deprecate 처리 (#1059)\n- `SessionContextProvider`에 env context 적용 및 `authBasePath` prop deprecate 처리 (#1059)\n- `withEventTrackingProvider` 에 `options` 파라미터추가 (#1087)\n- `pageLabel` State를 삭제합니다. (#1094)\n\n### resource-list-element\n\n- `resource`를 optional하게 받을수 있도록 수정합니다. (#1093)\n\n### reviews\n\n- env context 적용 및 `ReviewContainer`의 `appUrlScheme` prop deprecate 처리 (#1059)\n\n### search\n\n- `Search` 컴포넌트에서 `backOrClose` 제거 (#1096)\n\n### footer\n\n- `CSFooter`에 env context 적용 및 `appUrlScheme` prop deprecate 처리 (#1059)\n\n### meta-tags\n\n- `AppleSmartBannerMeta`와 `FacebookAppLinkMeta`에 env context 적용 및 `appUrlScheme` prop deprecate 처리 (#1059)\n- `EssentialContentMeta`의 `canonicalUrl` prop의 기본 값 제거 및 값이 없을 때 태그 표시 안 함 (#1095)\n- `FacebookOpenGraphMeta`의 `canonicalUrl`을 필수 prop으로 변경 (#1095)\n\n## 2.3.1\n\n### ui-flow\n\n- `GuardedScrapsProvider`가 export되지 않는 문제를 수정합니다. ([#1084](#https://github.com/titicacadev/triple-frontend/pull/1084))\n\n## 2.3.0\n\n### core-elements\n\n- `H1Props`의 `children`을 `PropsWithChildren`으로 표현 ([#1074](https://github.com/titicacadev/triple-frontend/pull/1074))\n\n### ui-flow\n\n- 패키지 추가 ([#1063](https://github.com/titicacadev/triple-frontend/pull/1063))\n- `GuardedScrapsProvider` 추가 ([#1065](https://github.com/titicacadev/triple-frontend/pull/1065))\n\n### action-sheet\n\n- 기본 `zTier`를 3으로 설정합니다. ([#1070](https://github.com/titicacadev/triple-frontend/pull/1070))\n\n### poi-detail\n\n- 헤더의 거점지역 형식을 수정합니다. ([#1058](https://github.com/titicacadev/triple-frontend/pull/1058))\n- 영업시간 prop의 형식을 수정합니다. ([#1062](https://github.com/titicacadev/triple-frontend/pull/1062))\n\n### social-reviews\n\n- 링크의 설명 영역을 한 줄로 제한합니다. ([#1082](https://github.com/titicacadev/triple-frontend/pull/1082))\n\n### directions-finder\n\n- 현지 이름, 주소가 있을 때만 \"현지에서 길묻기\" 버튼을 노출합니다. ([#1079](https://github.com/titicacadev/triple-frontend/pull/1079))\n\n### ad-banners\n\n- 최신 API를 반영합니다. ([#1073](https://github.com/titicacadev/triple-frontend/pull/1073))\n- `ContentType`에 `air`를 추가합니다. ([#1073](https://github.com/titicacadev/triple-frontend/pull/1073))\n- default export하는 컴포넌트를 deprecate 처리하고, `ListTopBanners`를 추가합니다. ([#1073](https://github.com/titicacadev/triple-frontend/pull/1073))\n\n### review\n\n- 앱 관련 액션을 하나의 훅으로 모읍니다. ([#1077](https://github.com/titicacadev/triple-frontend/pull/1077))\n- 액션에 유저 인증을 필수로 합니다. ([#1064](https://github.com/titicacadev/triple-frontend/pull/1064))\n\n### meta-tags\n\n- 중복될 수 있는 `meta` 태그에 `key`를 추가합니다. ([#1069](https://github.com/titicacadev/triple-frontend/pull/1069))\n\n## 2.2.1\n\n### review\n\n- 액션시트의 zTier를 3으로 명시 (#1060)\n\n## 2.2.0\n\n### Search\n\n- 검색 컴포넌트에 인풋 클릭 핸들러 prop을 추가합니다. (#1052)\n\n### common\n\n- eslint-config-triple 최신 버전을 설치합니다. (#1054)\n\n### react-context\n\n- label를 추가합니다. (#1056)\n\n## 2.1.0\n\n### common\n\n- github action setEnv 방식 변경 (#1044)\n- Environment variable을 직접 참조합니다. (#1048)\n\n### footer\n\n- `CSFooter` 컴포넌트에 ButtonClickEvent의 callback을 오버라이드 할수 있는 prop을 추가합니다. (#1040)\n\n### react-contexts\n\n- navigate() 에서 URL을 더 상세하게 해석합니다. (#1036)\n- 스크랩 count를 사용하는 부분 ScrapsProvider 없는 경우 대응 (#1042)\n- session-context의 getSessionId가 클라이언트 쪽에서도 쿠키를 가져오도록 수정 (#1047)\n\n### modals\n\n- 낮은 버전 Android 기기에는 /login path가 없습니다. Alert을 대신 렌더링합니다. (#1049)\n\n### core-elements\n\n- SencondaryNavbar의 position 기본값 수정 (#1045)\n\n### type-definitions\n\n- Image frame 관련 공통 타입을 type-definitions 패키지로 옮깁니다. (#1043)\n\n## 2.0.0\n\n### common\n\n- 등장, 퇴장 애니메이션이 있는 컴포넌트에\n  `CSSTransition`의 `mountOnEnter`, `unmountOnExit` 설정하는 prop 추가\n- PR 정보를 가져오는 gh-tools 커맨드 `fetch-github-pr`로 변경 (#977)\n- 타입이 어긋나있던 story를 수정 (#985)\n- 전역 테스트 환경을 설정합니다. (#1023)\n\n### icons\n\n- Icons 컴포넌트를 추가합니다. (#806)\n\n### fetcher\n\n- fetcher 유틸을 추가합니다. (#962)\n\n### constants\n\n- 공통 상수 및 정규 표현식을 담는 모듈 추가합니다. (#1010)\n\n### core-elements\n\n- `Image` 컴포넌트를 하위 컴포넌트를 조합하는 방식으로 개선 (#956, #1006)\n- `Navbar`에 `position` prop 추가 및 `SecondaryNavbar` prop 개선 (#980)\n- triple-document에 있던 typography 컴포넌트 추가 (#978)\n- `Video`에 `showNativeControls` prop 추가 (#996)\n- `SearchNavbar`에 `borderless` prop 추가 (#1009)\n- `PointingTab`의 세로 패딩을 조정할 수 있는 prop 추가 (#995)\n\n### action-sheet\n\n- 오버레이를 클릭했을 때 propagation을 막습니다. (#1024)\n\n### date-picker\n\n- `beforeBlock`, `afterBlock` prop의 형식을 Date에서 string으로 바꿉니다.\n\n### react-contexts\n\n- `withEventTrackingProvider` HOC 추가, event-tracking-context 구조 개선 및 문서 보강 (#974)\n- Event Tracking Context에 Facebook Pixel 연동 (#979)\n- history context에서 해시 추가할 때 root 페이지에서 asPath가 /인 상황 대응 (#1003, #1011)\n- `SessionContextProvider` 를 추가합니다. (#1022)\n- `ScrapsProvider`가 없으면 `useScrapsContext`에서 에러를 던집니다. (#1027)\n- 자식에서 `ScrapsProvider`가 없다는 에러를 잡고, UI를 없애주는 `ScrapsContextGuard` 컴포넌트를 추가합니다. (#1027)\n\n### react-hooks\n\n- `useLottie` 추가 (#1012)\n\n### poi-detail\n\n- `DetailHeader`에 거점 지역 정보 추가 (#976)\n\n### poi-list-elements\n\n- `POICardElement`에서 계산된 스크랩 카운트 사용 (#1015)\n\n### scrap-button\n\n- react-context를 연동하여 Uncontrolled 컴포넌트로 변경 (#900, #984)\n- Regular, Compact deprecated 처리 및 Overlay, Outline으로 이름 변경 (#1027)\n- 스크랩 버튼 size 조절 prop 추가 (#1027)\n- 스크랩 버튼을 가릴 수 있는 `ScrapsButtonMask` 컴포넌트 구현 (#1027)\n\n### drawer-button\n\n- layering props 추가 (#990, #1007)\n- 배경색 추가 (#1019)\n\n### search\n\n- `borderless` prop 추가 (#1009)\n- `zIndex`, `zTier` 를 prop 으로 받을 수 있도록 합니다. (#1031)\n\n### triple-document\n\n- elements 디렉토리 추가, Regions, T&A 관련 export를 제거 및 typography 컴포넌트 core-elements로 이동 (#978)\n- Inline link click handler 추가 (#994)\n- `hideVideoControls` prop 추가 (#996)\n- Video control 제어를 위해 앱 버전 사용 (#1017)\n- `table` element 데이터 스키마 변경 (#1020)\n\n### triple-media\n\n- `showNativeControls` prop 추가 (#996)\n- `margin`, `frame` 을 추가합니다. (#1033)\n\n### modal\n\n- LoginCTAModalProvider 컴포넌트와 `useLoginModal` hook 함수를 추가합니다. (#1034)\n\n## 1.34.0 (2020-09-02)\n\n### meta-tags\n\n- 메타 태그 컴포넌트 추가 (#929)\n\n### action-sheet\n\n- 구조정리 및 최신 패턴 적용 (#937)\n\n### search & hook\n\n- onEnter, onAutoCompletion 기능 개선 및 취소기능 재구현 (#939)\n\n### common\n\n- style-box 패키지의 cstype을 3으로 업그레이드합니다. (#945)\n\n## 1.33.0 (2020-08-26)\n\n### common\n\n- UI 요소의 등장, 퇴장 애니메이션을 정비하고; 퇴장 상태에서 UI를 가립니다. (#932)\n\n### publich-header\n\n- `mobileViewHeight`, `borderless` 속성 추가합니다. (#931)\n\n### react-contexts\n\n- history-context를 둘로 나눕니다. (#930)\n\n### poi-list-elements\n\n- poi-card-element에 priceLabelOverride를 추가합니다. (#925)\n\n## 1.32.0 (2020-08-20)\n\n### triple-document\n\n- gapless-block의 경우 컨테이너의 상하단 마진을 삭제합니다. (#918)\n\n### review\n\n- 유저 포인트가 있을 때에만 UI를 노출합니다. (#919)\n\n### standard-action-handler\n\n- 타이핑을 개선합니다. (#922)\n\n### content-sharing\n\n- Asset 이미지를 HTTPS 프로토콜로 fetch합니다. (#920)\n\n## 1.31.0 (2020-08-13)\n\n### core-elements\n\n- navbar를 확장에 유연하도록 변경합니다 (#911)\n\n### standard-action-handler\n\n- 신규 패키지를 추가합니다 (#910)\n\n## 1.30.2 (2020-08-11)\n\n### common\n\n- csstype 3으로 업그레이드 (#913, #914)\n\n### app-installation-cta\n\n- 앱설치 배너 제목을 입력할 수 있는 기능 추가 (#915)\n\n## 1.30.1 (2020-08-03)\n\n### core-elements\n\n- label 색상에 orange 를 추가합니다. (#906)\n\n## 1.30.0 (2020-07-30)\n\n### common\n\n- release-docs 를 GHA workflow 로 옮깁니다. (#888)\n- release-docs workflow 에서 Dockerfile 오타 수정 (#889)\n- release-docs Standalone build/release 를 가능하게 합니다. (#894)\n- release-docs 이미지 빌드시 NPM_TOKEN 변수를 대문자로 넘깁니다. (#890)\n- release-docs Github Package Registry 에도 이미지를 push 해둡니다. (#903)\n- i18n/lib/provider를 resolve 하지 못하는 문제 수정 (#893)\n\n### triple-document\n\n- TripleElementData interface를 export합니다 (#892)\n- display=\"block\" 형식의 links element가 level을 가지도록 합니다. (#896)\n\n### core-elements\n\n- 롤링 스피너 무한히 반복될 수 있도록 수정 (#895)\n\n### modals\n\n- useTransitionModal에 memo 적용 (#897)\n\n### pricing\n\n- fixed pricing 에 max-width prop 을 추가합니다 (#902)\n\n## 1.29.0 (2020-07-22)\n\n### core-elements\n\n- pointing Tabs에 스크롤 prop을 추가합니다. (#873)\n\n### hub-form\n\n- CTA 버튼의 배경색을 변경합니다. (#855)\n\n### react-contexts\n\n- asyncBack과 historyContext의 back 타입을 조정합니다 (#879)\n\n### common\n\n- eslint rule을 타입스크립트로 변경합니다. (#878)\n\n## 1.28.1 (2020-07-16)\n\n### app-installation-cta\n\n- Chatbot CTA 가 inventory item 이 없을때 콘텐츠가 비어있는 상태로 뜨는 문제를 수정합니다. (#877)\n\n## 1.28.0 (2020-07-15)\n\n### app-installation-cta\n\n- 각 CTA 에서 모두 지표 트래킹을 가능하게합니다. (#872)\n\n### core-elements\n\n- Radio컴포넌트의 텍스트 `text-align` 을 left로 고정합니다. (#871)\n\n## 1.27.1 (2020-07-09)\n\n### hub-form\n\n- HubForm의 box-shadow 스타일을 커스터마이징할 수 있도록 합니다. (#866)\n\n### core-elements\n\n- feature: skeleton ui 를 추가합니다. (#835)\n- Radio 컴포넌트에 multiline, textAlign, outline props를 추가합니다. (#863)\n- rolling-spinner 에 FALLBACK_ACTION_CLASS_NAME class 를 추가합니다. (#864)\n- skeleton ui 를 추가합니다. (#865)\n\n### i18n\n\n- Locale asset의 로딩을 방지합니다. (#858)\n\n### poi-detail\n\n- hr 구분선을 제거 할 수 있는 prop 을 추가합니다. (#859)\n\n### app-installation-cta\n\n- 챗봇 스타일 CTA 를 추가합니다. (#857)\n\n### ETC\n\n- useScrollToAnchorHook 설명 문서를 추가합니다. (#861)\n- docs 파일이 prettier로 포매팅할 수 있도록 수정 및 md, yaml도 prettier로 검사 (#867)\n- test 패키지 및 관련 코드 제거 (#848)\n\n## 1.26.0 (2020-07-01)\n\n### color-palette\n\n- `blue60`을 추가합니다. (#838)\n\n### listing-filter\n\n- underline filter entry를 추가합니다. (#845)\n- line-height를 px단위로 고정합니다. (#841)\n\n### core-elements\n\n- rolling-spinner 컴포넌트를 추가합니다. (#823)\n- Spinner에 fallback class를 추가합니다. (#837)\n- Navbar의 TitleContainer 영역을 확장합니다. (#839)\n\n### date-picker\n\n- RangePicker에서 오늘 날짜가 속한 달을 가장 처음 표시하도록 수정합니다. (#842)\n\n### hub-form\n\n- 패키지를 추가합니다. (#840)\n\n### slider\n\n- min, max값을 step의 배수로 보정하는 기능을 추가합니다. (#844)\n\n### poi-list-elements\n\n- 가격 노출 여부를 결정하는 prop을 추가합니다. (#828)\n\n## 1.25.0 (2020-06-24)\n\n### common\n\n- clean 태스크에서 .tsbulidinfo 파일을 삭제합니다. (#812)\n\n### app-installation-cta\n\n- 이미지 배너의 앱설치 버튼 레이블 변경 (#825, #826)\n\n### core-elements\n\n- Carousel 의 Item 에 IntersectionObserver 를 내장시킵니다. (#822)\n- Container 에서 width, height 의 unit, bg color 를 지원하는 prop 을 추가합니다. (#824)\n\n### date-picker\n\n- react-date-picker 의 style override 코드 리펙토링 (#625, #804)\n\n### user-verification\n\n- 휴대전화번호 점유인증 테스트 과정에서 발견한 수정 사항들을 반영합니다. (#829)\n  - 워딩과 일부 디자인 요소의 svg를 수정합니다.\n  - Verification context의 기본값을 지정합니다.\n  - VerificationRequest에서 forceVerification을 false로 변경할 수 있도록 합니다.\n\n## 1.24.0 (2020-06-18)\n\n### common\n\n- root, docs, tests의 패키지 업데이트 (#797)\n- CHANGELOG 1.22.0 배포 날짜를 수정합니다 (#789)\n- Declarations 빌드 시 incremental flag 사용 (#726)\n\n### core-elements\n\n- Navbar.Item 의 icon 에 따라 기본 className 을 추가 (#805)\n- FlexBox 를 추가합니다. (#790)\n\n### user-verification\n\n- 패키지를 추가했습니다. (#786)\n- react-contexts를 devDeps, peerDeps 로 이동했습니다. (#815)\n\n### reviews\n\n- regionId를 옵셔녈하게 변경 (#813)\n\n### form\n\n- checkbox, radio 에 label 을 추가합니다 (#803)\n\n### type-definitions\n\n- ListingHotel.source.priceInfo optional 처리 (#796)\n\n### docs\n\n- eslint-mdx 플러그인을 추가합니다. (#814)\n- storybook 파일 형식을 최신 방식으로 변경합니다. (#798)\n\n## 1.23.0 (2020-06-04)\n\n- `react-contexts` device context에 state 추가 #787device context에 state 추가 (#787)\n- `common` Config: lint 설정을 개선합니다. (#783)\n\n## 1.22.0 (2020-06-02)\n\n- `react-contexts` ImageContext에서 hotel 타입의 경우에는 poi 타입으로 변경하지 않습니다. (#775)\n- `search` enter 시 input blur 처리하도록 액션을 추가합니다. (#776)\n- `core-elements`, `action-sheet` Navbar 컴포넌트와 ActionSheet.Item 컴포넌트에 message, support 아이콘을 추가합니다. (#771)\n- `poi-detail` Actions 컴포넌트에 margin, padding타입을 추가합니다. (#773)\n- `common`\n  - Navbar, Footer, AppBanner, PublicHeader의 tag를 시멘틱한 tag로 수정합니다. (#778)\n  - typescript(3.9,x), styled-component(5.x)를 최신버전으로 올립니다. (#780)\n\n## 1.21.0 (2020-05-21)\n\n- `core-elements` Text 요소에서 textAlign props 를 제공합니다. (#346) (#765)\n- `resource-poi-element` partnerName을 추가합니다. (#766)\n- `triple-document` 에서 embedded 컴포넌트를 별도의 파일로 분리합니다. (#759)\n- `common`\n  - cd worlflow 에서 tagging 시 v prefix 가 누락되는 버그를 수정합니다. (#768)\n  - canary publish 시 전달이 누락된 GITHUB_TOKEN 을 추가합니다. (#764)\n  - cd workflow 내 notifier 전달 파라미터 오타 수정 (#761)\n\n## 1.20.0 (2020-05-14)\n\n- `triple-document`\n  - `imageSourceComponent`를 optional 처리합니다. (#754)\n  - 임베딩에서 텍스트, 이미지 순서로 표시하는 경우를 대응합니다. (#753)\n- `common`\n  - npx @titicaca/gha-tools 로 notify 와 pr 정보를 처리합니다. (#757)\n  - Canary Version 을 추론하여 notifier 에 알려줍니다. (#752)\n- `docs` jsx 애드온을 제거합니다. (#755)\n- `review` 이미지가 있을 때와 없을 때 리뷰 최대 라인을 분기 처리합니다. (#758)\n- `type-definitions` ListingHotel 의 TRIPLECLUB뱃지 타입을 string 으로 변경합니다. (#750)\n- `core-elements` Select에서 disabled 를 받습니다. (#748)\n\n## 1.19.1 (2020-05-11)\n\n- `view-utilies` type-definition 모듈 의존성 devDep -> dev 으로 변경합니다.\n\n## 1.19.0 (2020-05-07)\n\n- `color-palette` (#717)\n  - 개별 color 를 export 하고, ColorSet export 방식을 변경합니다.\n  - getColor 에서 ColorSet 이 아닌 color 입력시 폴백을 제공합니다.\n- `core-elements` Text 의 strokeThrough color 또는 alpha 가 잘못표시되는 문제를 수정합니다.(#717)\n- `view-utilities` PointGeo 간 직선거리를 구하는 함수를 공통함수로 분리합니다. (#742)\n- `poi-detail` RecommendedArticles 컴포넌트를 추가합니다. (#722)\n- `poi-list-element` notes prop 을 추가하여 note 값을 제어합니다. (#741)\n- `color`\n  - emerald 색상을 변경합니다. (#740)\n  - getColor 의 alpha 소수점을 2 자리까지 허용합니다. (#721)\n  - 개별 color 를 export 합니다. (#717)\n- `triple-document` T&A slot element의 구현을 content-web과 맞춥니다. (#736)\n- `react-hook`\n  - 자주 사용되는 hook 을 TF 로 이동합니다. (#718)\n  - use-scroll-lock 을 추가합니다. (#699)\n- `direction-finder` 국내 전화번호이면 국제전화 요금 안내를 보여주지 않을 수 있도록 합니다. (#735)\n- `nearby-pois` 더 많은 장소 보기 버튼 바로 위에 보이는 구분선을 제거해야 합니다. (#734)\n- `poi-card-lement` POICardElement를 추가합니다. (#725)\n- `poi-carousel-element` PoiCarouselElement의 타입 제약 조건 완화. (#709)\n- `type-definitions` originalPrice 에 대한 타입이 누락되어있어 추가합니다. (#731)\n- `common`\n  - Storybook webpack에 watchOptions를 추가합니다. (#727)\n  - File watch handler에 debounce를 적용합니다. (#724)\n  - qs.parse의 리턴 타입 달라진 버전 대응 (#728)\n- `review` 리뷰 하단 UI가 깨지는 케이스를 수정합니다. (#719)\n\n## 1.18.0 (2020-04-27)\n\n- `color-palette` ColorSet 의 값이 `rgba()` 로 감싸지게 만들고, getColor 에서 `rgba()` 를 제거하여 반환하도록 합니다. (#685)\n- `colre-elements` Card 컴포넌트를 추가합니다. (#681)\n- `review` 디자인을 개선합니다. (#694)\n- `poi-detail` 패키지를 추가합니다. (#691)\n- `social-reviews` 목록이 비어있을 때 섹션을 감춥니다. (#697)\n- `core-elements` Image 컴포넌트를 분리합니다. (#695)\n- `core-elements` / `poi-list-elements` / `resource-list-element` 광고 라벨을 추가합니다. (#696)\n- `type-definitions` POI의 image 속성을 optional로 처리합니다. (#702)\n- `docs` Form 패키지 디펜던시를 추가합니다. (#703)\n- `nearby-pois` 맛집 hasMore 정보가 제대로 반영되지 않던 문제를 수정합니다. (#705)\n- `location-properties` isPublic일 때 long click을 막습니다. (#704)\n- `review` writeReview시 photoFirst 인자를 추가합니다. (#707)\n- `review` sorting option 레이블을 수정합니다. (#706)\n\n## 1.17.2 (2020-04-22)\n\n- `core-elements` tabs 와 select에 적용한 color 값 오류를 수정합니다. (#686)\n\n## 1.17.1 (2020-04-20)\n\n- `resource-list-element` (#677)\n  - maxCommentLines props 을 추가하여 POI 설명 노출 라인 수 제어\n  - basePriceUnit 을 추가합니다\n- `scrap-button` 2배 아이콘을 3 배 아이콘으로 변경합니다 (#677)\n\n## 1.17.0 (2020-04-20)\n\n- `core-elements` List 하위 li에 적용되는 스타일은 direct children에만 영향이 있도록 합니다 (#673)\n- StaticIntersectionObserver를 사용합니다 (#672)\n- `social-reviews` Divider를 바로잡습니다 (#670)\n- `resource-list-element` POI, 상품 리스트 엘리먼트를 li 외의 태그로 렌더링 가능하게 합니다 (#669)\n- `color-palette` 체크리스트 v2에서 사용되는 컬러셋을 추가합니다 (#668)\n\n## 1.16.1 (2020-04-17)\n\n- `core-elements` List divider의 default style을 수정합니다 (#662)\n- `common` PR canary release 시 올바른 커밋을 fetch합니다 (#663)\n- `pricing` 잘못된 색상을 수정합니다 (#664)\n- `modal`, `popup`, `action-sheet` user-select를 none으로 변경합니다 (#666)\n\n## 1.16.0 (2020-04-16)\n\n- `common` context HOC 타이핑 개선 (#649)\n- `core-elements` 2배 이미지들을 3배 이미지로 변경합니다 (#657)\n- `core-elements` small size label 의 r 값을 2로 변경합니다 (#654)\n- `action-sheet` action sheet dimmed의 z-index 를 10으로 유지합니다 (#655)\n- `search-navbar` input 의 스타일을 수정하여 꿈틀거림을 방지합니다 (#647)\n- `directions-finder` 전화하기 클릭시 navigate 대신 window href 를 이용하도록 변경 (#645)\n- `react-context` withUserAgent 함수 타이핑 개선 (#643)\n- `react-context` history context 타이핑 개선 (#658)\n- `search-web` input ref 를 내부에 추가합니다. 삭제시 focus 될 수 있도록 합니다 (#652)\n- `pricing, poi, resource-list-element` isSoldOut Props 을 추가하여 판매완료 케이스를 대응합니다 (#650)\n\n## 1.15.0 (2020-04-10)\n\n- `location-properties` 패키지에 `onCopy` 핸들러 추가 (#641)\n\n## 1.14.0 (2020-04-09)\n\n- `StaticIntersectionObserver` 추가 (#627)\n- `direction-finder` 전화번호가 없는 경우 전화하기 버튼 숨김 (#636)\n- `search-navbar` 입력된 값에 따라 검색 버튼의 동작 변경 (#639)\n- `color-palette` 패키지 추가 (#638)\n- `search` 검색 버튼 추가 (#637)\n- `context` ImagesContext에 type definitions 이용 (#633)\n\n## 1.13.1 (2020-04-03)\n\n- `dev` bootstrap할 때 ci 해제 (#628)\n- `dev` Checkout시 branch name을 사용합니다. (#629)\n- `date-picker` 토, 일요일이 선택 날짜 범위에 있을 때 정렬이 어긋나는 문제 수정 (#626)\n\n## 1.13.0 (2020-04-02)\n\n- `resource-list-element`, `poi-list-elements` 호텔 목록에서 distance 표시할 수 있도록 인터페이스 추가 및 수정 (#621)\n- `type-definitions` 공용 타입 정의 패키지 추가 (#616)\n- `common` Canary 릴리즈시 모든 패키지를 내보냅니다. (#619)\n- `date-picker` RangePicker에 DateLabel 을 추가합니다 (#614)\n- `triple-document` Optional props를 명시합니다. (#618)\n- `i18n` Provider가 없을 때 반드시 fallback을 사용하도록 합니다. (#612)\n- `core-elements` Radio 텍스트가 길 경우 말줄임표 추가 (#617)\n- `dev` Canary release의 preid로 PR 번호 사용 (#622)\n\n## 1.12.0 (2020-03-26)\n\n- `core-elements` Pager 삭제 (#532)\n- `common` triple-web-to-native-interfaces 버전 1.0.0로 업데이트 (#583)\n- `i18n` 패키지 추가 (#580)\n- `react-contexts` HistoryContext에 showTransitionModal 인터페이스 추가 (#586)\n- `directions-finder` 패키지 추가 (#590)\n- `static-map` 패키지 추가 (#592)\n- `core-elements` Text에 ref를 지정할 수 있도록 수정 (#592)\n- `core-elements` longClickable HoC를 추가 (#597)\n- `resource-list-element` salePrice 가 0 원이더라도 priceLabelOverride 가 있다면 가격영역 노출 (#602)\n- `social-reviews` 소셜 리뷰 패키지 추가 (#604)\n- `location-properties` 패키지 추가 (#601)\n- `react-contexts` native 인터페이스 사용할 수 없을 때 openWindow가 window.open 사용 (#600)\n- `poi-list-elements` POI 타입에 regionId 추가 (#608)\n- `date-picker` RangePicker에 sameFromTo 옵션 추가 (#609)\n- `frontend-devtools` react-use-reducer-logger 추가 (#587)\n- `nearby-pois` 근처의 추천 장소 컴포넌트 패키지 추가, `poi-list-elements`에 pointGeolocation props 추가 (#610)\n- `core-elements` Section의 type annotation 확장, 중복 제거 (#607)\n\n## 1.11.0 (2020-03-19)\n\n- `poi-list-element` 하위호환을 위해 prices 를 추가 (#577)\n- `react-contexts` useIsomorphicNavigation 을 제공 (#576)\n- `image` story 추가 (#573)\n- `core-element` ImageSource 타입 정의 개선 (#572)\n- `poi-list-element` priceLabelOverride 추가 (#571)\n- `footer`1:1 문의 버튼 표시 여부 제어 가능하도록 props 추가 (#570)\n- ScrapButtom prop 타입 간단하게 수정하고, list element의 prop에 제네릭 사용 (#569)\n  - `triple-document`, `scrap-button`, `poi-list-element`, `product-list-element`\n- `pricing` 사용자 메세지 추가 (#565)\n- `footer` 신규 Footer를 추가합니다. (#562)\n\n## 1.10.1 (2020-03-13)\n\n- `navbar` Navbar icon에 햄버거 추가 (#563)\n- `poi-list-elements` label 위치 변경에 따른 스크랩 버튼 포지션 수정 (#561)\n- `core-elements` gapless-block형태일 때 이미지 컨테이너 공백을 제거합니다 (#560)\n- `common` Optional chaining과 Nullish coalescing을 사용할 수 있게 설정 (#559)\n\n## 1.10.0 (2020-03-12)\n\n- `style-box` 타입을 수정합니다 (#557)\n- `review` writeReview interface를 변경합니다 (#555)\n- `core-elements` ButtonGroup 컴포넌트에 buttonCount prop을 추가합니다 (#554)\n- `triple-document` Image, Video에 display 타입으로 margin이 없는 block 형태를 추가합니다 (#553)\n- `resource-list-element` label 위치를 변경합니다 (#551)\n- `common` lint-staged를 업데이트 합니다 (#549)\n- `core-elements` Image, Video 컴포넌트의 프레임 타입에 original 추가 및 분기 처리 (#547)\n\n## 1.9.1 (2020-03-09)\n\n- `format-number` revert code (#545)\n\n## 1.9.0 (2020-03-05)\n\n- `style-box` 스타일 박스 요소를 추가합니다 (#529, #538)\n- `common` 9:5 frame size 를 추가합니다. (#522)\n- `core-elements` 새로운 라디오 디자인 구현 (#527)\n- `core-elements` Video 컴포넌트에 poster prop을 추가하고, playsInline 속성을 지정합니다. (#515)\n- `core-elements` List 컴포넌트에 dividerWeight prop을 추가합니다. (#514 #518)\n  - verticalGap 과 divider 의 내부 구현방식을 개선합니다.\n- `booking-completion` ✨ optional 이벤트 추가 (#537)\n- `booking-completion` 🏷 메인으로 가기 label 변경. (#528)\n- `poi-carousel-element` PoiCarouselElement 의 imageFrame 을 조절할 수 있도록 추가합니다. (#516)\n- `react-contexts` myReviews optional 처리 (#540)\n- `review` 수정/삭제 시트 열 때 생기는 오류를 수정합니다. (#521)\n\n- `dev` lint 에 어긋나는 부분을 수정합니다 (#542)\n- `dev` 💪eslint-config-triple의 버전을 1.0.0 으로 올립니다. (#533)\n- `dev` CI, CD 결과 Slack 노티파이를 개선합니다 (#523, #524, #534)\n- `dev` dev 환경 URL을 참조하는 asset path를 수정합니다. (#517)\n- `dev` 스토리북을 타입스크립트 환경으로 변경합니다. (#519)\n\n## 1.8.0 (2020-02-25)\n\n- `common` lint-staged 출력을 제거합니다. (#500)\n- `core-elements` 미디어 비율을 추가하고, 비율 값을 key로 가지는 frame 옵션을 추가합니다. (#512)\n- `core-elements` CarouselSizes를 추가하고, 그 값에 따른 크기를 정의합니다. (#508)\n- `core-elements` Video 컨트롤을 추가합니다. (#503)\n- `core-elements` Container에 display, scroll 관련 prop을 추가합니다. (#501)\n- `booking-completion` 도시 메인으로 가는 버튼을 추가합니다. (#506)\n- `footer` 항공 문의 메시지를 추가합니다 (#499)\n- `poi-list-element` PoiCarouselElement가 부가 정보를 노출 할 수 있도록 prop을 추가합니다. (#509)\n- `poi-list-element` PoiCarouselElement가 커스텀 텍스트를 사용할 수 있도록 prop을 추가합니다. (#498)\n- `poi-list-element` 스크랩 관련 prop이 없으면 스크랩 버튼을 비활성화하는 조건을 추가합니다. (#495)\n- `review` 리뷰 컴포넌트 더보기의 노출 조건을 변경합니다. (#510)\n- `triple-document` Video를 지원합니다. (#361)\n- `triple-frontend-docs` 스토리를 패키지별로 분류합니다. (#505)\n\n## 1.7.1 (2020-02-20)\n\n- `core-elements` SearchNavBar 컴포넌트에서 누락된 `onKeyUp` props를 추가합니다. #492\n- `search` onDelete 핸들러에 방금 삭제한 키워드를 전달합니다. #494 #490\n- `search` controlledKeyword 의 update 조건 개선 (빈문자열 허용) #496\n\n## 1.7.0 (2020-02-17)\n\n- `core-elements` Navbar 의 background-color 지정시의 css 오류를 수정합니다. #481\n- `core-elements`, `Search` 에서 SearchNavbar Input 에 inputRef prop 을 추가합니다. #485 #482\n- `pricing` pricingUnit prop 을 추가합니다. #486\n- bootstrap 후에는 package-lock.json 이 생성되지 않도록 합니다. #480\n\n## 1.6.2 (2020-02-12)\n\n- `modals` children type 변경 (string -> ReactNode) #478\n\n## 1.6.1 (2020-02-12)\n\n- `search` onBackClick props 추가 #476\n- `search` keyword prop 입력 후 텍스 변경 불가능 오류 수정 #475\n- `booking-completion` 스타일 수정 #474\n\n## 1.6.0 (2020-02-10)\n\n- `slider` 패키지 추가 #469 #464\n- `booking-completion` 제목을 옵셔널하게 받을 수 있도록 수정 #466\n- `booking-completion` docs에 패키지 추가 #463\n- `date-picker` 오늘 + 주말 + 비활성화된 날짜를 회색으로 표시 #467\n\n## 1.5.0 (2020-02-06)\n\n- `search` 안드로이드 환경에서 팝업에 있을 경우 상단 navbar가 고정되지 않는 문제 수정 #454\n- `core-elements` 잘못된 color type 수정 #455\n- `pricing` FixedPricing 컴포넌트 내부 버튼 비활성화 기능 추가 #457\n- `common` tsconfig 공통옵션 추출 #458\n- `booking-completion` 예약완료 패키지 추가 #439\n- `core-elements` drawer-button 패키지 추가 #445\n- `triple-document` triple-document에 tsc strict 옵션 설정 #442\n- `popup` popup에 -webkit-scrollbar 속성 추가 #446\n\n## 1.4.2 (2020-02-03)\n\n- `poi-list-elements` areas 타입을 수정합니다\n\n## 1.4.1 (2020-02-03)\n\n- `poi-list-elements` categories 타입을 수정합니다\n\n## 1.4.0 (2020-01-30)\n\n- `list` hr1 의 구분선 색상을 변경할 수 있도록 prop을 추가합니다\n\n## 1.3.8 (2020-01-29)\n\n- color palette 추가 (#429)\n- `accordon`의 `Content`와 `Floded` 리턴 방식 변경 (#432)\n- `input` `textarea` props 수정 (#433)\n- `styleLint` 추가 (#434)\n\n## 1.3.7 (2020-01-22)\n\n- `intersection-observer` safe prop 추가\n- `react-contexts` 패키지에 tsc strict 옵션 설정\n- `*-list-element` 패키지들에 ts strict 옵션 설정\n- `resource-list-element` 이미지와 가격 간격을 좁힙니다\n- `review` 잘못 할당한 ga event 를 수정합니다.\n- `popup` open일 경우 scroll를 reset시켜줍니다.\n- `core-elements` feat: 규격화 폰트사이즈 적용\n- `core-elements` tab 글자를 bold 처리 합니다\n- `theme` 테마를 제공합니다\n- `navbar` backgroundColor prop를 추가합니다.\n- `pricing` fixed label 의 기본 색상을 변경합니다\n\n## 1.3.6 (2020-01-16)\n\n- `input` 잘못된 type 을 수정합니다. (#415)\n- `pricing` rich 의 custom label 스타일을 수정합니다. (#414)\n- `margin, padding mixins` 0 의 경우도 받아들일 수 있도록 변경합니다` (#413)\n\n## 1.3.5 (2020-01-15)\n\n## 1.3.4 (2020-01-15)\n\n- dev 스크립트 실행시 변경된 파일이 속한 패키지만 다시 빌드하는 스크립트 작성 (#390)\n- modals 패키지에 ts strict 옵션을 추가합니다 (#393)\n- react-hooks 패키지에 ts strict 옵션을 추가합니다 (#398)\n- image-carousel 패키지에 ts strict 옵션을 추가합니다 (#404)\n- search 패키지에 ts strict 옵션을 추가합니다 (#403)\n- public-header 패키지에 strict 옵션을 추가합니다 (#405)\n- search 컴포넌트를 controlled input처럼 사용할 수 있는 옵션을 추가합니다 (#383)\n- pricing 의 label, description 의 타입을 확장합니다 (#407)\n- core-ements/numeric-spinner 에 className props 이 확장되도록 설정합니다. (#394)\n- action-sheet 에 className props 이 확장되도록 설정합니다. (#396)\n- ExtendedResourceListElement 에서 pricing 의 description 을 활용 할 수 있도록 추가합니다 (#410)\n\n## 1.3.3 (2020-01-08)\n\n- git hook에서 lint-staged 가 제대로 수행되지 않는 버그를 수정\n- git hook에서 prettier 삭제\n- `core-elements`\n  - scrap-button 패키지 제거\n  - Button 컴포넌트에 as prop 추가\n  - margin, padding 스타일을 지정하는 mixin 추가 (#381)\n  - core-elements 패키지에 ts strict 설정을 추가합니다 (#377)\n- `pricing` pricing 패키지에 ts strict 설정을 추가합니다 (#386)\n\n## 1.3.2 (2020-01-02)\n\n- `Popup`의 navbar를 생략할 수 있는 `noNavbar` props 제공\n- `ad-banners` 배너 목록 조회, 노출/클릭 이벤트 핸들러를 prop으로 넣을 수 있는 기능 추가 (#353)\n\n## 1.3.1 (2019-12-27)\n\n- `Alert` confirmText type 변경, 불필요한 prop 제거\n\n## 1.3.0 (2019-12-23)\n\n- `form` type 재정의\n- `styled-components` V4 로 version up\n\n## 1.2.9 (2019-12-18)\n\n- `listing-filter` FilterEntryBase에 disabled prop 추가\n- `core-elements` SearchNavbar에서 InputMask 사용하지 않도록 처리, prop 형식 변경\n\n## 1.2.8 (2019-12-12)\n\n- `Image Context` fetch 시 넘겨주는 callback 에 대한 예외처리 추가\n\n## 1.2.7 (2019-12-11)\n\n- `popup` 네비바 버튼의 아이콘을 선택하는 icon prop 추가\n\n## 1.2.6 (2019-12-10)\n\n- `Tooltip` 컬러 추가\n\n## 1.2.5 (2019-12-10)\n\n- `Tooltip` onClick event 추가\n\n## 1.2.4 (2019-12-10)\n\n- `ExtendedResourceListElement` baseprice 에 관계없이 pricingNote 을 노출 할 수 있도록 변경\n\n## 1.2.3 (2019-12-10)\n\n- `pricing` baseprice, pricingNote 에 따라 스타일 수정\n- `ExtendedResourceListElement` baseprice 에 따른 pricingNote 노출 조건 추가\n\n## 1.2.2 (2019-12-10)\n\n- `review` 컴포넌트에 ga/fa 지표 관련 코드 추가\n\n## 1.2.1 (2019-12-06)\n\n- `image-carousel`과 `ad-banners` 에서 의존하고 있는 `@egjs/flicking`, `@egjs/react-flicking` 의 버전을 고정합니다.\n  - `@egjs/flicking@3.4.0`\n  - `@egjs/reac-flicking@3.1.0`\n\n## 1.2.0 (2019-12-05)\n\n- `Pricing` 호텔의 할인률이 0 보다 아래인 경우 할인률을 노출하지 않습니다.\n- GitHub Actions로 CI 작업을 전환합니다.\n- app-installation-cta: 인벤토리 조회 기능을 포함한 BannerCTA 컴포넌트 구현합니다.\n- `Navbar` 목록 아이콘을 추가합니다.\n- tooltip 스타일을 세부 조정할 수 있는 prop을 추가합니다.\n- storybook 버젼을 v5.2 로 올립니다.\n- history-context push, router, back 등의 hash routing 함수가 Promise를 반환합니다.\n- review placeholderText prop을 추가합니다.\n- dev Canary release 테스팅: 누락된 tsconfig.json 파일을 추가합니다.\n- `core-elements` 의 carousel/CarouselBase 에 `overflow-y: hidden` 속성을 추가합니다.\n- author-intro 의 line-height 지정 버그를 수정합니다.\n\n## 1.1.0 (2019-11-28)\n\n- `cloudbuild.release.yaml`에 timeout을 추가합니다.\n- Pricing component 의 스타일을 수정합니다.\n- 리뷰의 앱링크를 업데이트 합니다.\n- `initialHashStrategy` 에 따라 초기 uriHash 를 사용 방법을 결정합니다.\n- floating-install-button의 노출 여부와 형태와 관련한 조건을 변경합니다.\n- transition-modal의 view type string 메시지를 변경합니다.\n- `UserAgentContext`에서 mobile 여부를 제공합니다.\n\n## 1.0.0 (2019-11-21)\n\n- `triple-document` 패키지에서 텍스트 요소(`Paragraph`, `H1`, `H2`, ...)를\n  인터페이스로 노출합니다.\n- `MyReviewsProvider`의 props 중 `type`을 `resourceType`으로 변경합니다.\n- `ReviewLikesContext`가 노출하는 인터페이스를 다음과 같이 변경합니다:\n\n  ```ts\n  interface ReviewLikesContextProps {\n    deriveCurrentStateAndCount: (currentState: {\n      reviewId: any;\n      liked: boolean;\n      likesCount: number;\n    }) => { liked: boolean; likesCount: number };\n    updateLikedStatus: (newLikes: { [reviewId: string]: boolean }) => void;\n  }\n  ```\n\n- `ReviewLikesContext`의 위치를 `@titicaca/review` 패키지로 옮깁니다.\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, caste, color, religion, or sexual\nidentity and orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n- Demonstrating empathy and kindness toward other people\n- Being respectful of differing opinions, viewpoints, and experiences\n- Giving and gracefully accepting constructive feedback\n- Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n- Focusing on what is best not just for us as individuals, but for the overall\n  community\n\nExamples of unacceptable behavior include:\n\n- The use of sexualized language or imagery, and sexual attention or advances of\n  any kind\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or email address,\n  without their explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official email address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\ntour-fe@interparktriple.com.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series of\nactions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or permanent\nban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within the\ncommunity.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.1, available at\n[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].\n\nCommunity Impact Guidelines were inspired by\n[Mozilla's code of conduct enforcement ladder][Mozilla CoC].\n\nFor answers to common questions about this code of conduct, see the FAQ at\n[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at\n[https://www.contributor-covenant.org/translations][translations].\n\n[homepage]: https://www.contributor-covenant.org\n[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html\n[Mozilla CoC]: https://github.com/mozilla/diversity\n[FAQ]: https://www.contributor-covenant.org/faq\n[translations]: https://www.contributor-covenant.org/translations\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# 기여하기\n\n프로젝트 기여자들이 작업하는데 필요한 준비 및 작업 과정을 설명합니다.\n\n## 사전 준비\n\n- Node.js LTS\n- pnpm\n\n## 설치\n\n프로젝트를 클론합니다:\n\n```sh\ngit clone git@github.com:titicacadev/triple-frontend.git && cd triple-frontend\n```\n\n디펜던시를 설치합니다:\n\n```sh\npnpm install\n```\n\n## 작업 과정\n\n### 기능 추가\n\n1. 작업자가 코드 기여\n2. 의존성 변경이 있으면, `pnpm run sync-deps` 명령어 실행\n3. 커밋 & 푸시\n4. PR 생성 & 리뷰\n5. 버전 생성 (Optional): `pnpm run version` (경우에 따라 PR과 함께 혹은 별도로 생성)\n6. main 머지\n7. `/release` 커맨드로 배포\n8. 배포 (Optional): CD에서 패키지 publish, npm 페이지 통해서 확인\n\n### 패키지 추가\n\n1. `lerna create [패키지명]` 커맨드로 패키지 추가\n2. 적절한 `package.json` 및 `tsconfig.json` 수정 및 생성\n3. `pnpm run sync-deps` 명령어 실행\n4. `src`에 코드 작성\n5. 버전 생성 (Optional): 기존 패키지에서 분리가 일어나서 API 인터페이스에\n   변경이 있었다면 MAJOR, 기존 패키지와 관련 없는 패키지 추가라면 MINOR 버전\n   올림\n   - `pnpm run version -- major`\n   - `pnpm run version -- minor`\n6. 커밋 & 푸시\n7. PR 생성 & 리뷰\n8. main 머지\n9. `/release` 커맨드로 배포\n10. 배포 (Optional): CD에서 패키지 publish, npm 페이지 통해서 확인\n\n### 패키지에 의존성 추가\n\n```bash\npnpm install --workspace=<의존성을 추가할 대상 패키지> <추가할 패키지>\n```\n\ndevDependency이면 --dev를, peer dependency이면 --peer를 추가합니다.\n\n## 테스트\n\n각 패키지별 유닛 테스트를 추가할 예정입니다.\n\n## 리뷰\n\n- 모든 PR 리뷰는 GitHub의 `@frontend` 팀에게 자동으로 할당됩니다.\n- 팀 멤버 2인 이상이 승인해야 머지할 수 있습니다.\n- 머지 전 머지 체크리스트를 모두 확인해야 합니다.\n\n## 배포\n\n새로운 버전에 반영할 내용은 마일스톤을 통해 관리합니다. 가장 먼저 할 일은\n이번 릴리즈에 해당하는 마일스톤에서 완료되지 않은 작업을 챙기는 일입니다.\n각 이슈의 작업자에게 찾아가 이번 릴리즈에 포함할 예정인지 물어봅시다. 이번 릴리즈에 포함해야 하는데 리뷰가 덜 된 Pull Request는 리뷰를 독려합니다.\n\n모든 패키지를 동시에 같은 버전으로 릴리즈합니다. 버저닝 방식은 하위 패키지 전체를\n아우르는 [Semantic Versioning](https://semver.org)을 사용합니다: `MAJOR.MINOR.PATCH`로\n버저닝하며, 아래 설명을 참고하여 해당하는 버전을 올립니다.\n\n> 1.  `MAJOR` version when you make incompatible API changes,\n> 2.  `MINOR` version when you add functionality in a backwards compatible manner, and\n> 3.  `PATCH` version when you make backwards compatible bug fixes.\n\n- 특정 패키지에 기능 추가: 인터페이스 변경 없이 특정 패키지의 기능이\n  추가되었다면 `MINOR` 버전을 올립니다.\n- 패키지 추가: 다른 패키지에 영향이 없는 범위에서 패키지가 추가되었다면 기능\n  추가로 볼 수 있고, `MINOR` 버전을 올립니다.\n- 패키지 분리: 분리 대상인 패키지의 인터페이스에 변경이 일어납니다. 해당\n  패키지를 이용하는 프로젝트들에 코드 변경이 필요하기 때문에 `MAJOR` 버전\n  올림에 해당합니다.\n- 인터페이스 개선: 인터페이스 변경이 하위 호환을 보장하는지 여부에 따라서\n  `MAJOR` 혹은 `MINOR` 버전 올림에 해당합니다.\n- 버그 수정: 인터페이스 변경이 없는 버그 수정은 `PATCH` 버전 올림입니다.\n\n마일스톤의 모든 이슈가 완료되었다면 이제 새 버전을 릴리즈할 차례입니다. 다음 순서로 진행합니다.\n\n1. 최신 기본 브랜치에서 릴리즈용 브랜치를 만듭니다.\n\n```bash\ngit switch -c release/v2.9.0\n```\n\n2. 새로운 버전을 올립니다. 원하는 버전을 선택하면 lerna가 알아서 모든 패키지의 버전을 바꿉니다.\n\n```bash\npnpm run version\n\n> version\n> lerna version --no-push --force-publish\n\nlerna notice cli v3.22.1\nlerna info current version 2.8.0\nlerna WARN force-publish all packages\nlerna info Assuming all packages changed\n? Select a new version (currently 2.8.0) (Use arrow keys)\n  Patch (2.8.1)\n❯ Minor (2.9.0)\n  Major (3.0.0)\n  Prepatch (2.8.1-alpha.0)\n  Preminor (2.9.0-alpha.0)\n  Premajor (3.0.0-alpha.0)\n  Custom Prerelease\n  Custom Version\n```\n\n3. Pull Request를 생성하여 변경 내역을 기본 브랜치로 머지합니다. 이때, Pull Request에 `release` 라벨과 마일스톤이 등록되어 있어야 CHANGELOG가 자동으로 작성됩니다. 릴리즈 PR을 생성할 때 반드시 `release` 라벨과 마일스톤을 등록해주세요!\n\n4. `#triple-web-dev-notifications` 채널에서 `/release triple-frontend main`을 입력하여 CD를 실행합니다.\n\n5. 릴리즈가 완료되면 마일스톤을 닫고, 다음 minor 버전의 마일스톤을 생성합니다.\n\n## 주의사항\n\n- Docs를 비롯한 패키지 내에서 다른 패키지를 import하는 경우, 대상 패키지를\n  빌드한 이후에만 의도한 동작을 수행할 수 있습니다.\n- 뷰 및 기능에 변경이 있는 기여인 경우, docs 페이지도 그에 준하게 업데이트해야\n  합니다.\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 InterparkTriple Corp.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "MIGRATION.md",
    "content": "# 마이그레이션 가이드\n\n## v13 to v14\n\n- [v14 업그레이드 가이드](https://inpk.atlassian.net/wiki/x/OIOzGw)\n\n## v13.41.0 to v13.42.0\n\n- [13.42.0 업그레이드 가이드](https://yanoljagroup.atlassian.net/wiki/spaces/dev/pages/1762068097/triple-frontend+13+13.42.0)\n\n## v12 to v13\n\n- [v13 업그레이드 가이드](https://titicaca.atlassian.net/l/cp/GhNV1PUo)\n\n## v12 to v12.12.0\n\n- ActionSheet, Modal, Popup 컴포넌트를 중첩할 때의 사용방법이 변경되었습니다.\n- [12.12.0 업그레이드 가이드](https://titicaca.atlassian.net/l/cp/KKEJqXpa)\n\n## v11 to v12\n\n### ui renewal\n\n- 컴포넌트의 접근성, 스타일 관련 코드가 개편되었습니다.\n- [TF renewal-ui(v12.0.0) 업그레이드 가이드](https://titicaca.atlassian.net/wiki/spaces/dev/pages/3232759922/TF+renewal-ui+v12.0.0)\n\n## v10 to v11\n\n### i18n\n\n- TF에 국제화를 도입합니다.\n- [국제화(i18n) 적용 가이드](https://titicaca.atlassian.net/wiki/spaces/dev/pages/3241280391/i18n)\n\n## v9 to v10\n\n### react-contexts\n\n- firebase를 v9로 업그레이드하여 dependency 및 일부 로직의 수정이 필요합니다.\n- [firebase v9 업그레이드 문서](https://titicaca.atlassian.net/wiki/spaces/dev/pages/3202777132/TF+firebase+v9)를 참고해주시기 바랍니다.\n\n## v8 to v9\n\n### review 패키지 react-query 적용\n\n- react-query의 적용으로 `QueryProvider` 마운트가 필요하게 됐습니다.\n- review 패키지가 필요할 경우 해당 프로젝트에 'react-query'를 install 해주고 아래와 같이 추가해주세요.\n\n```\n// app.tsx\n\nimport { QueryProvider } from 'react-query'\n\n// react-query 옵션 링크\n// https://react-query-v2.tanstack.com/reference/useQuery#_top\n// 아래 defaultOptions을 제외하고는 필요에 따라 option이 달라져 해당 옵션만 추가합니다.\n\nconst queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      keepPreviousData: true,\n    },\n  },\n})\n\n<QueryProvider client={queryClient}>\n  <Component {...pageProps} />\n</QueryProvider>\n\n```\n\n## v7 to v8\n\n### triple-email-document\n\n- `EmailFooter`가 제거되었습니다.\n\n  뉴스레터 / 이메일에서 사용하는 푸터 디자인이 개별로 정의되었기에. 패키지에서 관리하지 않습니다.\n\n## v6 to v7\n\n### `@titicaca/react-triple-client-interfaces` 사용\n\n- TF 패키지 내부의 웹-앱 동작 분기가 `react-triple-client-interfaces`에 의존하게 되었습니다.\n  - `TripleClientMetadataProvider` 마운트가 필요합니다.\n\n### 네이밍이 변경된 패키지\n\n#### app-installation-cta\n\n- AppInstallationCTA -> AppInstallationCta\n- ArticleCardCTA -> ArticleCardCta\n- BannerCTA -> BannerCta\n- ChatbotCTA -> ChatbotCta\n- FloatingButtonCTA -> FloatingButtonCta\n\n#### poi-list-elements\n\n- POICardElement -> PoiCardElement\n- POIListElementBaseProps -> PoiListElementBaseProps\n\n#### type-definitions\n\n- PointGeoJSON -> PointGeoJson\n- ListingPOI -> ListingPoi\n\n#### poi-detail\n\n- onCTAClick -> onCtaClick\n- setArticleCardCTA -> setArticleCardCta\n\n## v5 to v6\n\n### map 구조 변경 및 기존 컴포넌트 오타 수정\n\n중간에 `cirlce` 오타를 `circle`로 올바르게 수정\n`HotelCirlceMarker` => `HotelCirlceMarker`\n`AttractionCirlceMarker` => `AttractionCircleMarker`\n`RestaurantCirlceMarker` => `RestaurantCircleMarker`\n\n아래의 자세한 내용들은 map 패키지 리드미를 참고해주세요. (https://github.com/titicacadev/triple-frontend/blob/main/packages/map/README.md)\n\n마커\n\n- 말풍선 모양의 마커 => `PoiDotMarker` 사용\n- CircleMarker 중 active 및 default를 구분하는 마커 => `FlexibleMarker` 사용\n\n옵션\n\n- `MapView` 컴포넌트에 `coordinates` props이 추가되어 maptoptions을 적용하는 방법이 2가지로 변경되었습니다.\n\n1. Map에 대한 options을 직접주입\n2. `coordinates`를 통한 options 주입\n\n- 이미지 사용 시 inline svg 이미지를 사용합니다.\n\n- 지도 상의 focus를 움직이기 위한 `FocusTracker`가 추가되었습니다.\n\n### fetcher 패키지의 일부 인터페이스 이름 변경\n\n`addFetchersToGSSP`는 `addFetchersToGssp`가 되었습니다.\n`HTTPMethods`는 `HttpMethods`가 되었고, 멤버 이름도 PascalCase가 되었습니다. 예를 들어 `HttpMethods.Get` 형태로 사용합니다.\n\n### react-contexts 인터페이스의 네이밍 변경\n\n다음과 같은 네이밍 변경이 있었습니다.\n\n- useURIHash -> useUriHash\n- HistoryProvider의 prop 이름 변경: loginCTAModalHash -> loginCtaModalHash\n- HashStrategy의 멤버 네이밍 변경\n- GAParams -> GoogleAnalyticsParams\n- FAParams -> FirebaseAnalyticsParams\n- withUTMContext -> withUtmContext\n- WithUTMContextBaseProps -> WithUtmContextBaseProps\n- useUTMContext -> useUtmContext\n- UTMProvider -> UtmProvider\n- extractUTMContextFromQuery -> extractUtmContextFromQuery\n\n### `useHistoryContext`\n\n제거했습니다. `uriHash`는 `useURIHash`로 참조할 수 있고, 나머지 함수는 `useHistoryFunctions`로 참조할 수 있습니다.\n자세한 내용은 [#928](https://github.com/titicacadev/triple-frontend/pull/928)을 확인하세요.\n\n### user-verification 패키지의 의존성 추가\n\n- `TripleClientMetadataProvider`가 Mount된 페이지에서만 사용할 수 있습니다.\n\n### `LocalLink`, `ExternalLink`가 a 태그를 직접 렌더링\n\n이제 `LocalLink`와 `ExternalLink`의 자식 엘리먼트로 a 태그를 넣어줄 필요가 없습니다.\na 태그를 직접 렌더링합니다.\n\na 태그에 스타일링이 필요하다면 styled-components로 확장하세요.\n\n```ts\nconst StyledLocalLink = styled(LocalLink)`\n  /* 확장할 스타일을 넣으세요 */\n`\n```\n\n`LocalLink`와 `ExternalLink`가 a 태그나 button 태그를 렌더링하면서 둘 사이의 스타일을 일정하게 유지할 필요가 있었습니다.\n그래서 a 태그에 `display: inline-block` 속성을 추가했습니다.\n스타일이 어긋나지 않는지 확인해주세요.\n\n### ad-banners의 `ListDirection`의 멤버 네이밍 변경\n\n`ListDirection.VERTICAL`을 `ListDirection.Vertical`로 변경했습니다.\n`ListDirection.HORIZONTAL`을 `ListDirection.Horizontal`로 변경했습니다.\n\n### modals 패키지 인터페이스의 네이밍 변경\n\n다음과 같은 네이밍 변경이 있었습니다.\n\n- useLoginCTAModal -> useLoginCtaModal\n- withLoginCTAModal -> withLoginCtaModal\n- LoginCTAModalProvider -> LoginCtaModalProvider\n\n### `@titicaca/react-triple-client-interfaces` 사용\n\n트리플 네이티브 클라이언트를 이용하거나 클라이언트 종류에 따른 동작 분기가 필요한 경우\n`@titicaca/react-triple-client-interfaces` 패키지가 제공하는 기능을 사용해야 합니다.\n이 패키지 동작에 필요한 `TripleClientMetadataProvider`를 마운트해주세요.\n\n자세한 설명은 [패키지 README](https://github.com/titicacadev/triple-frontend/tree/main/packages/react-triple-client-interfaces)를\n참고 바랍니다.\n\n## v4 to v5\n\n### deprecated props 제거 및 사용 방법\n\n아래에 정의되어있는 컴포넌트들의 `appUrlScheme`, `webUrlBase`, `fbAppId` props를 지원하지 않습니다.\n\nprops를 사용하고 있다면 제거하세요.\n\n- HistoryProvider (appUrlScheme, webUrlBase)\n- CSFooter (appUrlScheme)\n- Review (appUrlScheme)\n- FacebookOpenGraphMeta (appUrlScheme, fbAppId)\n- FacebookAppLinkMeta (appUrlScheme)\n- AppleSmartBannerMeta (appUrlScheme)\n\n해당 props가 필요하면 `EnvProvider`를 사용하세요.\n\n```tsx\n<EnvProvider\n  appUrlScheme={process.env.APP_URL_SCHEME}\n  webUrlBase={process.env.WEB_URL_BASE}\n  fbAppId={process.env.FB_APP_ID}\n>\n  <HistoryProvider {...props}>\n    {children}\n  </HisotryProvider>\n</EnvProvider>\n```\n\n### Text.Html/Text.WithRef 삭제\n\ncore-elements 패키지에서 `Text.Html/Text.WithRef` 컴포넌트를 더이상 지원하지 않습니다.\n\n`<Text.Html />` 컴포넌트를 사용하던 곳에서는 직접 styled(Text)를 만들어서 필요한 css를 선언하세요.\n\n```tsx\n// v4\nconst TextHtml = styled(Text.Html)`\n  font-size: 14px;\n  ...\n`\n\n// v5\nconst TextHtml = styled(Text)`\n  font-size : 14px;\n  ...\n`\n```\n\n`<Text.WithRef />` 컴포넌트는 `<Text />` 컴포넌트를 사용하세요.\n\n```tsx\n// v4\n<Text.WithRef {...props}>\n  {children}\n</Text.WithRef>\n\n// v5\n<Text {...props}>\n  {children}\n</Text>\n```\n\n## v3 to v4\n\n### floating-install-button 패키지 제거\n\n대신 app-installation-cta 패키지의 `FloatingButtonCTA` 컴포넌트를 사용하세요.\n\n### fetcher 응답 타입 변경\n\n`error`, `result`, body 관련 속성이 없어졌습니다. `result` 대신 `parsedBody`를 사용하세요.\n`parsedBody`는 ok 여부에 따라 타입이 분기되므로 ok를 확인해야 합니다.\n\n```ts\nconst response = await fetcher<SuccessResponse, FailureResponse>('/api/foo')\n\nif (response.ok === true) {\n  // response.parsedBody는 SuccessResponse\n} else {\n  // response.parsedBody는 FailureResponse\n}\n```\n\n오류를 내야할 때는 `response`의 `status` 속성을 이용해 에러 메시지를 만드세요.\n\n```ts\nif (response.ok === false) {\n  const { status } = response\n\n  throw new Error(`Fail to fetch foo: ${status}`)\n}\n```\n\n### session-context 재작성\n\n`SessionContextProvider`의 prop이 변경되었습니다. 일일히 넣어줄 필요 없이, `SessionContextProvider.getInitialProps`의 반환 값을 사용하면 됩니다.\n\n```tsx\n// in _app, getInitialProps.\nconst sessionContextProviderProps = await SessionContextProvider.getInitialProps(ctx)\n\nreturn {\n  sessionContextProviderProps\n}\n\n// in JSX area of _app\n\n<SessionContextProvider {...sessionContextProviderProps}>\n```\n\n그리고 `useSessionContext`가 없어졌습니다. 로그인 여부가 필요하면 `useSessionAvailablity`를 사용하세요. 로그인, 로그아웃 함수가 필요하면 `useSessionControllers`를 사용하세요. 사용자 정보가 필요하면 `useUser`를 사용하세요.\n\n## v2 to v3\n\n### react-contexts: EnvProvider에 필수 props 필요\n\n새 props가 추가되었습니다.\n\n- afOnelinkId (required)\n- afOnelinkPid (required)\n- afOnelineSubdomain (required)\n\n### public-header: props 변경 필요함\n\ndeprecated된 props을 제거합니다.\n\n- href\n- playStoreUrl\n- appStoreUrl\n- fixed\n- minWidth\n- mobileViewHeight\n- borderless\n- children\n\n새 props가 추가되었습니다.\n\n- category (optional)\n- deeplinkPath (optional)\n- disableAutoHide (optional)\n\n### public-header: position: fixed 제거됨\n\n`position: fixed` 스타일이 제거되어 기본적으로 페이지 상단에 고정되지 않습니다.\n페이지 상단에 고정시키기 위해서는 `core-elements/StickyHeader`와 같이 사용할 수 있습니다.\n\n```tsx\n<StickyHeader>\n  <PublicHeader>\n</StickyHeader>\n```\n\n## v1 to v2\n\nhttps://github.com/titicacadev/triple-frontend/issues/1008\n"
  },
  {
    "path": "README.md",
    "content": "# Triple Frontend\n\n[![codecov](https://codecov.io/gh/titicacadev/triple-frontend/branch/main/graph/badge.svg?token=B1ME2OJD68)](https://codecov.io/gh/titicacadev/triple-frontend)\n\n트리플 프론트엔드 공용 컴포넌트 및 라이브러리입니다.\n\n## 문서\n\n[Triple Frontend Storybook](https://storybook.triple-corp.com)에서 컴포넌트 문서와 예시를 볼 수 있습니다.\n\n## 기여하기\n\n[CONTRIBUTING.md](./CONTRIBUTING.md)를 참고해주세요.\n\n## 관련 서비스\n\n- [트리플](https://triple.guide)\n- [TRIPLE Korea](https://triple.global)\n\n## 라이선스\n\n[MIT License](./LICENSE)\n"
  },
  {
    "path": "chromatic.config.json",
    "content": "{\n  \"projectId\": \"Project:617b7c4431c922004a42c7cc\",\n  \"onlyChanged\": true,\n  \"zip\": true\n}\n"
  },
  {
    "path": "codecov.yml",
    "content": "coverage:\n  status:\n    project:\n      default:\n        target: auto\n        threshold: 10%\n    patch: off\n"
  },
  {
    "path": "examples/nextjs-app/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n.yarn/install-state.gz\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env*.local\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n"
  },
  {
    "path": "examples/nextjs-app/README.md",
    "content": "# Next.js App Example\n\n`triple-frontend` 모노레포 패키지를 로컬 개발 환경에서 테스트하고 통합하기 위한 실험용 프로젝트입니다. `triple-frontend` 패키지가 Next.js App Router 환경에서 어떻게 작동하는지 탐구할 수 있도록 간단한 설정을 제공합니다.\n"
  },
  {
    "path": "examples/nextjs-app/app/layout.tsx",
    "content": "import type { Metadata } from 'next'\n\nimport StyledComponentsRegistry from '../lib/registry'\nimport ThemeProvider from '../lib/theme'\n\nexport const metadata: Metadata = {\n  title: 'Create Next App',\n  description: 'Generated by create next app',\n}\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode\n}>) {\n  return (\n    <html lang=\"en\">\n      <StyledComponentsRegistry>\n        <ThemeProvider>\n          <body>{children}</body>\n        </ThemeProvider>\n      </StyledComponentsRegistry>\n    </html>\n  )\n}\n"
  },
  {
    "path": "examples/nextjs-app/app/page.tsx",
    "content": "export default function Home() {\n  return null\n}\n"
  },
  {
    "path": "examples/nextjs-app/app/template.tsx",
    "content": "import { buildTripleWebProps, TripleWeb } from '@titicaca/triple-web-nextjs'\nimport { PropsWithChildren } from 'react'\n\nexport default async function Template({ children }: PropsWithChildren) {\n  const { clientAppProvider, sessionProvider, userAgentProvider } =\n    await buildTripleWebProps()\n\n  return (\n    <TripleWeb\n      clientAppProvider={clientAppProvider}\n      envProvider={{\n        afOnelinkId: '',\n        afOnelinkPid: '',\n        afOnelinkSubdomain: '',\n        appUrlScheme: '',\n        basePath: '/',\n        defaultPageDescription: '',\n        defaultPageTitle: '',\n        facebookAppId: '',\n        webUrlBase: '',\n      }}\n      i18nProvider={{ defaultLocale: 'ko' }}\n      sessionProvider={sessionProvider}\n      userAgentProvider={userAgentProvider}\n    >\n      {children}\n    </TripleWeb>\n  )\n}\n"
  },
  {
    "path": "examples/nextjs-app/lib/registry.tsx",
    "content": "'use client'\n\nimport { useState, ReactNode } from 'react'\nimport { useServerInsertedHTML } from 'next/navigation'\nimport {\n  ServerStyleSheet,\n  StyleSheetManager,\n  WebTarget,\n} from 'styled-components'\nimport isPropValid from '@emotion/is-prop-valid'\n\nexport default function StyledComponentsRegistry({\n  children,\n}: {\n  children: ReactNode\n}) {\n  // Only create stylesheet once with lazy initial state\n  // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state\n  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())\n\n  useServerInsertedHTML(() => {\n    const styles = styledComponentsStyleSheet.getStyleElement()\n    styledComponentsStyleSheet.instance.clearTag()\n    return <>{styles}</>\n  })\n\n  if (typeof window !== 'undefined') {\n    return <>{children}</>\n  }\n\n  return (\n    <StyleSheetManager\n      sheet={styledComponentsStyleSheet.instance}\n      shouldForwardProp={shouldForwardProp}\n    >\n      {children}\n    </StyleSheetManager>\n  )\n}\n\n// This implements the default behavior from styled-components v5\nfunction shouldForwardProp(propName: string, target: WebTarget) {\n  if (typeof target === 'string') {\n    // For HTML elements, forward the prop if it is a valid HTML attribute\n    return isPropValid(propName)\n  }\n  // For other elements, forward all props\n  return true\n}\n"
  },
  {
    "path": "examples/nextjs-app/lib/theme.tsx",
    "content": "'use client'\n\nimport { ThemeProvider as StyledThemeProvider } from 'styled-components'\nimport { defaultTheme, GlobalStyle } from '@titicaca/tds-theme'\nimport { PropsWithChildren } from 'react'\n\nexport default function ThemeProvider({ children }: PropsWithChildren) {\n  return (\n    <StyledThemeProvider theme={defaultTheme}>\n      <GlobalStyle />\n      {children}\n    </StyledThemeProvider>\n  )\n}\n"
  },
  {
    "path": "examples/nextjs-app/next.config.mjs",
    "content": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n  compiler: {\n    styledComponents: true,\n  },\n}\n\nexport default nextConfig\n"
  },
  {
    "path": "examples/nextjs-app/package.json",
    "content": "{\n  \"name\": \"nextjs-app\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"lint\": \"next lint\"\n  },\n  \"dependencies\": {\n    \"@emotion/is-prop-valid\": \"^1.3.1\",\n    \"@titicaca/tds-theme\": \"workspace:*\",\n    \"@titicaca/tds-ui\": \"workspace:*\",\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"@titicaca/triple-web-nextjs\": \"workspace:*\",\n    \"next\": \"14.2.20\",\n    \"react\": \"^18.3.1\",\n    \"react-dom\": \"^18.3.1\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^20.17.10\",\n    \"@types/react\": \"^18.3.18\",\n    \"@types/react-dom\": \"^18.3.5\",\n    \"eslint\": \"^8.57.1\",\n    \"prettier\": \"^3.4.2\",\n    \"typescript\": \"5.7.2\"\n  }\n}\n"
  },
  {
    "path": "examples/nextjs-app/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ]\n  },\n  \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \".next/types/**/*.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "examples/nextjs-pages/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n.yarn/install-state.gz\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env*.local\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n"
  },
  {
    "path": "examples/nextjs-pages/README.md",
    "content": "# Next.js Pages Example\n\n`triple-frontend` 모노레포 패키지를 로컬 개발 환경에서 테스트하고 통합하기 위한 실험용 프로젝트입니다. `triple-frontend` 패키지가 Next.js Pages Router 환경에서 어떻게 작동하는지 탐구할 수 있도록 간단한 설정을 제공합니다.\n"
  },
  {
    "path": "examples/nextjs-pages/next.config.mjs",
    "content": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n  reactStrictMode: true,\n  compiler: {\n    styledComponents: true,\n  },\n  transpilePackages: [\n    '@titicaca/tds-theme',\n    '@titicaca/tds-ui',\n    '@titicaca/triple-web',\n    '@titicaca/triple-web-nextjs-pages',\n  ],\n}\n\nexport default nextConfig\n"
  },
  {
    "path": "examples/nextjs-pages/package.json",
    "content": "{\n  \"name\": \"nextjs-pages\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"lint\": \"next lint\"\n  },\n  \"dependencies\": {\n    \"@emotion/is-prop-valid\": \"^1.3.1\",\n    \"@titicaca/tds-theme\": \"workspace:*\",\n    \"@titicaca/tds-ui\": \"workspace:*\",\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"@titicaca/triple-web-nextjs-pages\": \"workspace:*\",\n    \"next\": \"14.2.20\",\n    \"react\": \"^18.3.1\",\n    \"react-dom\": \"^18.3.1\",\n    \"styled-components\": \"^6.1.13\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^20.17.10\",\n    \"@types/react\": \"^18.3.18\",\n    \"@types/react-dom\": \"^18.3.5\",\n    \"eslint\": \"^8.57.1\",\n    \"prettier\": \"^3.4.2\",\n    \"typescript\": \"5.7.2\"\n  }\n}\n"
  },
  {
    "path": "examples/nextjs-pages/pages/_app.tsx",
    "content": "import App from 'next/app'\nimport type { AppContext, AppInitialProps, AppProps } from 'next/app'\nimport { StyleSheetManager, ThemeProvider, WebTarget } from 'styled-components'\nimport {\n  buildTripleWebProps,\n  BuildTripleWebPropsResult,\n  TripleWeb,\n} from '@titicaca/triple-web-nextjs-pages'\nimport { defaultTheme, GlobalStyle } from '@titicaca/tds-theme'\nimport isPropValid from '@emotion/is-prop-valid'\n\ntype MyAppProps = BuildTripleWebPropsResult\n\nexport default function MyApp({\n  Component,\n  pageProps,\n  clientAppProvider,\n  sessionProvider,\n  userAgentProvider,\n}: AppProps & MyAppProps) {\n  return (\n    <StyleSheetManager shouldForwardProp={shouldForwardProp}>\n      <ThemeProvider theme={defaultTheme}>\n        <GlobalStyle />\n        <TripleWeb\n          clientAppProvider={clientAppProvider}\n          envProvider={{\n            afOnelinkId: '',\n            afOnelinkPid: '',\n            afOnelinkSubdomain: '',\n            appUrlScheme: '',\n            basePath: '/',\n            defaultPageDescription: '',\n            defaultPageTitle: '',\n            facebookAppId: '',\n            webUrlBase: '',\n          }}\n          i18nProvider={{ defaultLocale: 'ko' }}\n          sessionProvider={sessionProvider}\n          userAgentProvider={userAgentProvider}\n        >\n          <Component {...pageProps} />\n        </TripleWeb>\n      </ThemeProvider>\n    </StyleSheetManager>\n  )\n}\n\nMyApp.getInitialProps = async (\n  context: AppContext,\n): Promise<MyAppProps & AppInitialProps> => {\n  const ctx = await App.getInitialProps(context)\n\n  return {\n    ...ctx,\n    ...(await buildTripleWebProps(context.ctx)),\n  }\n}\n\n// This implements the default behavior from styled-components v5\nfunction shouldForwardProp(propName: string, target: WebTarget) {\n  if (typeof target === 'string') {\n    // For HTML elements, forward the prop if it is a valid HTML attribute\n    return isPropValid(propName)\n  }\n  // For other elements, forward all props\n  return true\n}\n"
  },
  {
    "path": "examples/nextjs-pages/pages/_document.tsx",
    "content": "import { Html, Head, Main, NextScript } from 'next/document'\n\nexport default function Document() {\n  return (\n    <Html lang=\"en\">\n      <Head />\n      <body>\n        <Main />\n        <NextScript />\n      </body>\n    </Html>\n  )\n}\n"
  },
  {
    "path": "examples/nextjs-pages/pages/index.tsx",
    "content": "export default function Home() {\n  return null\n}\n"
  },
  {
    "path": "examples/nextjs-pages/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true\n  },\n  \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "global.d.ts",
    "content": "import '@testing-library/jest-dom'\nimport 'jest-styled-components'\n"
  },
  {
    "path": "jest-setup.js",
    "content": "import '@testing-library/jest-dom'\nimport 'jest-styled-components'\n\nimport { TextDecoder, TextEncoder } from 'util'\n\nglobal.TextDecoder = TextDecoder\nglobal.TextEncoder = TextEncoder\n\nclass ResizeObserver {\n  observe() {}\n\n  unobserve() {}\n\n  disconnect() {}\n}\n\nglobal.ResizeObserver = ResizeObserver\n"
  },
  {
    "path": "jest.config.js",
    "content": "const { pathsToModuleNameMapper } = require('ts-jest')\n\nconst { compilerOptions } = require('./tsconfig.test.json')\n\n/** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */\nmodule.exports = {\n  transform: {\n    '^.+\\\\.(t|j)sx?$': ['@swc/jest'],\n  },\n  testEnvironment: 'jsdom',\n  testPathIgnorePatterns: ['lib', 'node_modules'],\n  setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],\n  modulePaths: [compilerOptions.baseUrl],\n  moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {\n    prefix: '<rootDir>/',\n  }),\n  transformIgnorePatterns: [\n    '<rootDir>/node_modules/.pnpm/(?!(firebase|@firebase\\\\+util)@)',\n  ],\n  collectCoverageFrom: [\n    '<rootDir>/packages/**/*.{js,jsx,ts,tsx}',\n    '!**/*.stories.*',\n  ],\n  coveragePathIgnorePatterns: ['/node_modules/', '/lib'],\n}\n"
  },
  {
    "path": "lerna.json",
    "content": "{\n  \"$schema\": \"node_modules/lerna/schemas/lerna-schema.json\",\n  \"npmClient\": \"pnpm\",\n  \"version\": \"14.2.3\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"root\",\n  \"private\": true,\n  \"engines\": {\n    \"node\": \">= 20\",\n    \"pnpm\": \"9.x\"\n  },\n  \"scripts\": {\n    \"dev\": \"pnpm run storybook\",\n    \"build\": \"lerna run build\",\n    \"test\": \"jest\",\n    \"test:watch\": \"jest --watch\",\n    \"test:coverage\": \"jest --coverage\",\n    \"clean:deps\": \"rimraf node_modules packages/**/node_modules\",\n    \"clean:build\": \"rimraf packages/**/lib packages/**/*.tsbuildinfo node_modules/.cache/nx\",\n    \"clean\": \"pnpm run clean:build && pnpm run clean:deps\",\n    \"prepare\": \"husky\",\n    \"preinstall\": \"npx only-allow pnpm\",\n    \"version\": \"lerna version --force-publish --no-push\",\n    \"lint:es\": \"lerna run lint:es\",\n    \"lint:es:fix\": \"lerna run lint:es:fix\",\n    \"lint:style\": \"lerna run lint:style\",\n    \"lint:style:fix\": \"lerna run lint:style:fix\",\n    \"lint:etc\": \"lerna run lint:etc\",\n    \"lint:etc:fix\": \"lerna run lint:etc:fix\",\n    \"lint\": \"lerna run lint:es,lint:style,lint:etc\",\n    \"lint:fix\": \"lerna run lint:es:fix,lint:style:fix,lint:etc:fix\",\n    \"storybook\": \"storybook dev -p 6006\",\n    \"build-storybook\": \"storybook build\"\n  },\n  \"devDependencies\": {\n    \"@chromatic-com/storybook\": \"^1.9.0\",\n    \"@storybook/addon-essentials\": \"^8.6.15\",\n    \"@storybook/addon-links\": \"^8.6.15\",\n    \"@storybook/addon-onboarding\": \"^8.6.15\",\n    \"@storybook/addon-webpack5-compiler-swc\": \"^1.0.6\",\n    \"@storybook/blocks\": \"^8.6.15\",\n    \"@storybook/nextjs\": \"^8.6.15\",\n    \"@storybook/preview-api\": \"^8.6.15\",\n    \"@storybook/react\": \"^8.6.15\",\n    \"@swc/cli\": \"0.6.0\",\n    \"@swc/core\": \"1.10.15\",\n    \"@swc/jest\": \"0.2.37\",\n    \"@swc/plugin-styled-components\": \"6.7.0\",\n    \"@testing-library/dom\": \"^10.4.0\",\n    \"@testing-library/jest-dom\": \"^6.6.3\",\n    \"@testing-library/react\": \"^16.2.0\",\n    \"@testing-library/user-event\": \"^14.6.1\",\n    \"@titicaca/eslint-config-triple\": \"5.3.1\",\n    \"@titicaca/prettier-config-triple\": \"1.2.1\",\n    \"@titicaca/stylelint-config-triple\": \"1.4.0\",\n    \"@types/jest\": \"^29.5.14\",\n    \"@types/node\": \"^18.19.75\",\n    \"@types/react\": \"^18.3.18\",\n    \"@types/react-dom\": \"^18.3.5\",\n    \"@types/testing-library__jest-dom\": \"^5.14.9\",\n    \"@vitejs/plugin-react-swc\": \"^3.8.0\",\n    \"browser-assert\": \"^1.2.1\",\n    \"chromatic\": \"^11.25.2\",\n    \"csstype\": \"^3.1.3\",\n    \"eslint\": \"^8.57.1\",\n    \"eslint-plugin-jest\": \"^27.9.0\",\n    \"eslint-plugin-jest-dom\": \"^5.5.0\",\n    \"eslint-plugin-mdx\": \"^2.3.4\",\n    \"eslint-plugin-storybook\": \"^0.11.2\",\n    \"eslint-plugin-testing-library\": \"^6.5.0\",\n    \"husky\": \"^9.1.7\",\n    \"jest\": \"^29.7.0\",\n    \"jest-environment-jsdom\": \"^29.7.0\",\n    \"jest-styled-components\": \"^7.2.0\",\n    \"lerna\": \"^9.0.0\",\n    \"lint-staged\": \"^13.3.0\",\n    \"msw\": \"^2.7.0\",\n    \"msw-storybook-addon\": \"^2.0.4\",\n    \"next\": \"^14.2.24\",\n    \"nx-cloud\": \"16.4.0\",\n    \"prettier\": \"^3.4.2\",\n    \"react\": \"^18.3.1\",\n    \"react-dom\": \"^18.3.1\",\n    \"rimraf\": \"^5.0.10\",\n    \"rollup-plugin-node-externals\": \"^7.1.3\",\n    \"rollup-plugin-preserve-directives\": \"^0.4.0\",\n    \"storybook\": \"^8.6.15\",\n    \"storybook-addon-swc\": \"^1.2.0\",\n    \"storybook-mock-date-decorator\": \"^2.0.6\",\n    \"styled-components\": \"^6.1.15\",\n    \"stylelint\": \"^15.11.0\",\n    \"ts-jest\": \"^29.2.5\",\n    \"tsconfig-paths-webpack-plugin\": \"^4.2.0\",\n    \"typescript\": \"5.7.3\",\n    \"ua-parser-js\": \"^1.0.40\",\n    \"vite\": \"^5.4.14\",\n    \"vite-plugin-checker\": \"^0.8.0\",\n    \"vite-plugin-dts\": \"^3.9.1\"\n  },\n  \"lint-staged\": {\n    \"**/*.{js,ts,tsx}\": [\n      \"eslint\",\n      \"prettier --check\"\n    ],\n    \"**/*.{json,yaml,md}\": [\n      \"prettier --check\"\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/ab-experiments/README.md",
    "content": "# ab-experiments\n\nA/B 테스트를 지원하는 패키지입니다.\n\n## 인터페이스 종류\n\n### 1. triple-ab-experiment-context\n\n**트리플의 사용자 ID(내부 API를 이용)**를 이용하여 A/B 테스트를 도와주는 context입니다.\n\n### 2. google-optimize-context\n\n**구글 옵티마이즈**를 이용하여 세션을 통해 A/B 테스트를 도와주는 context입니다.\n"
  },
  {
    "path": "packages/ab-experiments/package.json",
    "content": "{\n  \"name\": \"@titicaca/ab-experiments\",\n  \"version\": \"14.2.3\",\n  \"description\": \"a/b experiments package\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/ab-experiments\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/fetcher\": \"workspace:*\"\n  },\n  \"devDependencies\": {\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"next\": \"^14.2.24\",\n    \"react\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/triple-web\": \"*\",\n    \"next\": \"^13.4 || ^14.0\",\n    \"react\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/ab-experiments/src/google-optimize-context/README.md",
    "content": "# google-optimize-context\n\n**Google Optimize**를 이용하여 세션을 통해 A/B 테스트를 도와주는 context입니다.\n\n## 인터페이스\n\n### `GoogleOptimizeExperimentProvider`\n\n자식 컴포넌트에 실험ID와 실험 컨테이너ID를 공급하여 A/B테스트 환경을 제공합니다.\n\n#### props\n\n| 이름         | 타입     | 설명                                                            |\n| :----------- | :------- | :-------------------------------------------------------------- |\n| experimentId | `string` | Google Optimize에서 생성한 실험 ID                              |\n| containerId  | `string` | Google Optimize 컨테이너 ID <br />(ex. GTM-XXXXXX / OPT-XXXXXX) |\n\n### `useExperimentVariant`\n\nA/B 테스트의 후보군을 파라미터로 받으며, 반환 값은 후보군 중 선택된 단일값입니다.\n\n#### props\n\n| 이름     | 타입  | 설명                                 |\n| :------- | :---- | :----------------------------------- |\n| variants | `T[]` | A/B테스트에 사용될 2개 이상의 후보군 |\n\n## 사용 예시\n\n테스트 환경에 따른 `GOOGLE_OPTIMIZE_CONTAINER_ID` & `EXPERIMENT_NAME_ID`를 설정해줍니다.\n\n```shell\n// envs.dev, staging, prod\n\nNEXT_PUBLIC_GOOGLE_OPTIMIZE_CONTAINER_ID = \"Google Optimize Container ID\" (ex. GTM-XXXXXX / OPT-XXXXXX)\nNEXT_PUBLIC_EXPERIMENT_NAME_ID = \"Google Optimize Experiment Name ID\"\n```\n\nA/B 테스트가 필요한 페이지를 `GoogleOptimizeExperimentProvider`로 감쌉니다.\n\n```tsx\nexport default function TestPage(){\n\n  return (\n    <GoogleOptimizeExperimentProvider\n      experimentId={process.env.NEXT_PUBLIC_EXPERIMENT_NAME_ID}\n      containerId={process.env.NEXT_PUBLIC_GOOGLE_OPTIMIZE_CONTAINER_ID}>\n      <SomeComponent>\n    </GoogleOptimizeExperimentProvider>\n  )\n}\n```\n\n`useExperimentVariant`를 이용하여 A/B 테스트의 후보군(variants)을 설정합니다.\n\n```tsx\nexport default function TestComponent(){\n  const experimentText = useExperimentVariant({\n    variants: [\n      ... // 후보군 입력\n      ]\n  })\n\n  return (\n    <div>{experimentText}</div>\n  )\n}\n```\n\n[Google Optimize](https://optimize.google.com/optimize/home/#/accounts)로 이동하여 A/B 테스트를 생성합니다.\n\n- A/B 테스트 생성 방법은 [google-optimize A/B 테스트 만들기](https://support.google.com/optimize/answer/6211930?hl=ko)를 참고해주세요.\n"
  },
  {
    "path": "packages/ab-experiments/src/google-optimize-context/context.tsx",
    "content": "import {\n  useState,\n  useEffect,\n  PropsWithChildren,\n  createContext,\n  useContext,\n} from 'react'\nimport Head from 'next/head'\n\ndeclare global {\n  interface Window {\n    dataLayer: unknown[]\n  }\n}\n\nconst GOOGLE_OPTIMIZE_SCRIPT_ID = 'google-optimize-script'\n\nconst ExperimentVariantContext = createContext<number | null>(null)\n\nexport function GoogleOptimizeExperimentProvider({\n  experimentId,\n  containerId,\n  children,\n}: PropsWithChildren<{\n  experimentId: string | undefined\n  containerId: string | undefined\n}>) {\n  const [variant, setVariant] = useState<number>(-1)\n\n  useEffect(() => {\n    function gtag(...args: unknown[]) {\n      if (window.dataLayer) {\n        window.dataLayer.push(args)\n      } else {\n        window.dataLayer = [args]\n      }\n    }\n\n    if (!experimentId) {\n      return\n    }\n\n    gtag('event', 'optimize.callback', {\n      name: experimentId,\n      callback: (value: string) => {\n        setVariant(parseInt(value))\n      },\n    })\n  }, [experimentId])\n\n  if (!experimentId || !containerId) {\n    return <>{children}</>\n  }\n\n  return (\n    <>\n      <Head>\n        <script\n          key={GOOGLE_OPTIMIZE_SCRIPT_ID}\n          id={GOOGLE_OPTIMIZE_SCRIPT_ID}\n          src={`https://www.googleoptimize.com/optimize.js?id=${containerId}`}\n        />\n      </Head>\n\n      <ExperimentVariantContext.Provider value={variant}>\n        {children}\n      </ExperimentVariantContext.Provider>\n    </>\n  )\n}\n\nexport function useExperimentVariant<T>({ variants }: { variants: T[] }) {\n  const variant = useContext(ExperimentVariantContext)\n\n  if (variant === -1) {\n    return null\n  }\n\n  if (variant === null) {\n    throw new Error('최적화 variant provider가 없습니다.')\n  }\n  return variants[variant]\n}\n"
  },
  {
    "path": "packages/ab-experiments/src/google-optimize-context/index.ts",
    "content": "export * from './context'\n"
  },
  {
    "path": "packages/ab-experiments/src/index.ts",
    "content": "export * from './google-optimize-context'\nexport * from './triple-ab-experiment-context'\n"
  },
  {
    "path": "packages/ab-experiments/src/triple-ab-experiment-context/README.md",
    "content": "# triple-ab-experiment-context\n\n**트리플의 사용자 ID(내부 API를 이용)**를 이용하여 A/B 테스트를 도와주는 context입니다.\n\n## 인터페이스\n\n### `getTripleABExperiment`\n\nslug에 대응하는 `testId`와 사용자가 속해있는 그룹을 반환합니다.\n첫 번째 파라미터는 slug, 두 번째 파라미터는 fetcher의 options 객체입니다.\n\n### `TripleABExperimentProvider`\n\n자식 컴포넌트에 `TripleABExperimentMeta` 값을 공급합니다.\nmeta 값은 prop으로 넣어주며 prop이 없을 경우 자체적으로 API 요청을 시도합니다.\n\n#### props\n\n| 이름    | 설명                                                                                  |\n| ------- | ------------------------------------------------------------------------------------- |\n| slug    | A/B 테스트 slug 값                                                                    |\n| meta    | SSR에서 조회한 `ExperimentMeta` 값. 넣어주지 않으면 Provider가 자체적으로 가져옵니다. |\n| onError | API에서 에러가 발생했을 때 처리 함수.                                                 |\n\n### `useTripleABExperimentVariant`\n\n사용자의 그룹에 맞는 variant를 선택해서 반환합니다.\nAB 테스트의 slug, 각 그룹의 후보군, fallback 값을 파라미터로 받습니다.\n주어진 slug에 맞는 meta 값을 찾을 수 없으면 fallback 값을 반환합니다.\n이 훅이 마운트되면 세션 시작을 알리는 이벤트가 기록됩니다.\n\n```ts\nconst Component = useTripleABExperimentVariant(\n  'component-ab-test',\n  { A: OriginalComponent, B: NewComponent },\n  OriginalComponent,\n)\n```\n\n### `useTripleABExperimentConversionTracker`\n\nAB 테스트의 전환을 기록합니다.\n\n## 사용 예시\n\n`getServerSideProps` 함수에서 `getABExperiment`를 사용하여 실험 정보를 가져옵니다.\n\n> 💡만약 클라이언트에서 실험 정보를 가져오면\n> 사용자는 fallback 값을 봤다가 실험 값을 보게 됩니다.\n> 이러한 \"깜박거림\"을 방지하려면 서버사이드에서 실험 정보를 가져와야 합니다.\n\n```ts\nFooPage.getServerSideProps = async ({ req }) => {\n  const [{ result: messageMeta }, { result: componentMeta }] =\n    await Promise.all([\n      getTripleABExperiment(MESSAGE_AB_TEST_ID, { req }),\n      getTripleABExperiment(COMPONENT_AB_TEST_ID, { req }),\n    ])\n\n  return {\n    props: {\n      messageMeta,\n      componentMeta,\n    },\n  }\n}\n```\n\nA/B 테스트를 진행하려는 지점을 `TripleABExperimentProvider`로 감쌉니다.\n\n```tsx\nexport function Foo({ messageMeta, componentMeta }) {\n  return (\n    <TripleABExperimentProvider\n      slug={MESSAGE_AB_TEST_ID}\n      meta={messageMeta}\n      onError={(error) => {\n        Sentry.captureException(error)\n      }}\n    >\n      <TripleABExperimentProvider\n        slug={COMPONENT_AB_TEST_ID}\n        meta={componentMeta}\n        onError={(error) => {\n          Sentry.captureException(error)\n        }}\n      >\n        <SomeComponent />\n        {/* ... */}\n      </TripleABExperimentProvider>\n    </TripleABExperimentProvider>\n  )\n}\n```\n\nA/B 테스트 대상을 렌더링하는 컴포넌트에서 `useTripleABExperimentVariant` 훅을 사용하여\n유형에 맞는 값을 고르도록 해줍니다.\n유형은 컴포넌트, 문자열, 숫자, 함수 등 모든 타입이 가능합니다.\n\n```ts\nconst ExperimentTargetComponent = useTripleABExperimentVariant(\n  COMPONENT_AB_TEST_ID,\n  {\n    a: OriginalComponent,\n    b: NewComponent,\n  },\n  OriginalComponent,\n)\n```\n\n```ts\nconst experimentTargetMessage = useTripleABExperimentVariant(\n  MESSAGE_AB_TEST_ID,\n  {\n    a: '이 호텔을 예약하세요!',\n    b: '다른 호텔보다 평균 3만원 저렴한 호텔을 예약해보세요!',\n  },\n  '호텔을 예약하세요.',\n)\n```\n\n`useTripleABExperimentConversionTracker` 훅의 함수를 이용해 실험에서 측정하려는 목표 행동을 기록합니다.\n\n```tsx\nconst trackComponentTestConversion =\n  useTripleABExperimentConversionTracker(COMPONENT_AB_TEST_ID)\nconst trackMessageTestConversion =\n  useTripleABExperimentConversionTracker(MESSAGE_AB_TEST_ID)\n\nconst handleButtonClick = () => {\n  trackComponentTestConversion()\n  trackMessageTestConversion()\n}\n\nreturn <Button onClick={handleButtonClick}>{experimentTargetMessage}</Button>\n```\n\n`useTripleABExperimentImpressionTracker` 훅의 함수를 이용해 실험에서 측정하려는 목표 노출을 기록합니다.\n\n```tsx\nconst trackComponentTestImpression =\n  useTripleABExperimentImpressionTracker(COMPONENT_AB_TEST_ID)\nconst trackMessageTestImpression =\n  useTripleABExperimentImpressionTracker(MESSAGE_AB_TEST_ID)\n\nconst handleButtonImpression = () => {\n  trackComponentTestImpression()\n  trackMessageTestImpression()\n}\n\nreturn (\n  <StaticIntersectionObserver\n    threshold={0.5}\n    onChange={({ isIntersecting }: { isIntersecting: boolean }) => {\n      if (isIntersecting) {\n        handleButtonImpression\n      }\n    }}\n  >\n    <Button onClick={handleButtonClick}>{experimentTargetMessage}</Button>\n  </StaticIntersectionObserver>\n)\n```\n"
  },
  {
    "path": "packages/ab-experiments/src/triple-ab-experiment-context/context.tsx",
    "content": "import {\n  createContext,\n  PropsWithChildren,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from 'react'\nimport { useTrackEvent, useSessionAvailability } from '@titicaca/triple-web'\nimport { NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\n\nimport { TripleABExperimentMeta, getTripleABExperiment } from './service'\n\ninterface TripleABExperimentMetas {\n  [key: string]: TripleABExperimentMeta | undefined\n}\n\nconst TripleABExperimentContext = createContext<TripleABExperimentMetas>({})\n\nexport function TripleABExperimentProvider({\n  slug,\n  meta: metaFromSSR,\n  onError: onErrorFromProps,\n  children,\n}: PropsWithChildren<{\n  slug: string\n  /**\n   * SSR 단계에서 조회한 값을 넣어 줄 수 있는 prop\n   */\n  meta?: TripleABExperimentMeta\n  onError?: (error: unknown) => void\n}>) {\n  const onErrorRef = useRef(onErrorFromProps)\n  const experimentMetas = useContext(TripleABExperimentContext)\n  const [meta, setMeta] = useState(metaFromSSR)\n\n  useEffect(() => {\n    const onError = onErrorRef.current\n\n    async function fetchAndSetMeta() {\n      const response = await getTripleABExperiment(slug)\n\n      if (response === NEED_LOGIN_IDENTIFIER || response.ok === false) {\n        if (response === NEED_LOGIN_IDENTIFIER) {\n          onError?.(new Error(NEED_LOGIN_IDENTIFIER))\n        } else {\n          const { status, url } = response\n          onError?.(new Error(`${status} - ${url}`))\n        }\n\n        return\n      }\n\n      const { parsedBody } = response\n\n      setMeta(parsedBody)\n    }\n\n    if (!metaFromSSR) {\n      fetchAndSetMeta()\n    }\n  }, [metaFromSSR, slug])\n\n  const value = useMemo(\n    () => ({ ...experimentMetas, [slug]: meta }),\n    [experimentMetas, slug, meta],\n  )\n\n  return (\n    <TripleABExperimentContext.Provider value={value}>\n      {children}\n    </TripleABExperimentContext.Provider>\n  )\n}\n\nfunction useTripleABExperimentMeta(\n  slug: string,\n  onError?: (error: Error) => void,\n) {\n  const sessionAvailable = useSessionAvailability()\n\n  const metas = useContext(TripleABExperimentContext)\n  const meta = useMemo(() => metas[slug], [metas, slug])\n\n  try {\n    if (!meta) {\n      throw new Error(`Cannot find \"${slug}\" in AB experiments.`)\n    }\n    return meta\n  } catch (error) {\n    if (sessionAvailable === true && onError) {\n      // session이 없을 때 발생한 에러는 리포팅 할 필요 없습니다.\n      onError(error as Error)\n    }\n    return null\n  }\n}\n\ninterface OptionalAttributes {\n  content_type?: string\n  item_id?: string\n  item_name?: string\n  region_id?: string\n  zone_id?: string\n}\ntype ReservedAttributes =\n  | 'action'\n  | 'experiment_name'\n  | 'experiment_id'\n  | 'variant_id'\ntype EventAttributes<T = OptionalAttributes> = keyof T &\n  ReservedAttributes extends never\n  ? T\n  : Omit<T, ReservedAttributes>\n\n/**\n * 주어진 slug의 AB 테스트 전환 이벤트를 기록합니다.\n * 콜백 함수가 받는 파라미터는 이벤트에 따라 선택적으로 넣어줄 수 있습니다.\n * @param slug 실험 slug\n * @param onError\n */\n\nexport function useTripleABExperimentConversionTracker(\n  slug: string,\n  onError?: (error: Error) => void,\n): <T = OptionalAttributes>(params?: EventAttributes<T>) => void {\n  const trackEvent = useTrackEvent()\n  const meta = useTripleABExperimentMeta(slug, onError)\n\n  return useCallback(\n    (eventParams) => {\n      if (meta) {\n        const { testId, group } = meta\n\n        trackEvent({\n          fa: {\n            action: 'experiment_key_conversion',\n            experiment_name: slug,\n            experiment_id: testId,\n            variant_id: group,\n            ...eventParams,\n          },\n        })\n      }\n    },\n    [meta, slug, trackEvent],\n  )\n}\n\n/**\n * 주어진 slug의 AB 테스트 노출 이벤트를 기록합니다.\n * 콜백 함수가 받는 파라미터는 이벤트에 따라 선택적으로 넣어줄 수 있습니다.\n * @param slug 실험 slug\n * @param onError\n */\n\nexport function useTripleABExperimentImpressionTracker(\n  slug: string,\n  onError?: (error: Error) => void,\n): <T = OptionalAttributes>(params?: EventAttributes<T>) => void {\n  const trackEvent = useTrackEvent()\n  const meta = useTripleABExperimentMeta(slug, onError)\n\n  return useCallback(\n    (eventParams) => {\n      if (meta) {\n        const { testId, group } = meta\n\n        trackEvent({\n          fa: {\n            action: 'experiment_impression',\n            experiment_name: slug,\n            experiment_id: testId,\n            variant_id: group,\n            ...eventParams,\n          },\n        })\n      }\n    },\n    [meta, slug, trackEvent],\n  )\n}\n\n/**\n * 주어진 slug의 실험 variant를 선택합니다.\n * @param slug 실험 slug\n * @param variants 실험 선택지. A, B, C, D...를 key 값으로 하는 객체\n * @param fallback 실험을 찾을 수 없거나 variants에 현재 실험군이 설정되어있지 않으면 반환하는 값\n * @param onError\n */\nexport function useTripleABExperimentVariant<T, U = OptionalAttributes>(\n  slug: string,\n  variants: {\n    [group: string]: T\n  },\n  fallback: T,\n  onError?: (error: Error) => void,\n  eventAttributesFromProps?: EventAttributes<U>,\n): T {\n  const trackEvent = useTrackEvent()\n  const meta = useTripleABExperimentMeta(slug, onError)\n  const eventAttributesRef = useRef(eventAttributesFromProps)\n\n  const { testId, group } = meta || {}\n\n  useEffect(() => {\n    if (testId && group) {\n      trackEvent({\n        fa: {\n          action: 'enter_experiment',\n          experiment_name: slug,\n          experiment_id: testId,\n          variant_id: group,\n          ...eventAttributesRef.current,\n        },\n      })\n    }\n  }, [group, slug, testId, trackEvent])\n\n  return group && group in variants ? variants[group] : fallback\n}\n\nexport function useTripleABExperimentContext() {\n  const context = useContext(TripleABExperimentContext)\n\n  if (context === undefined) {\n    throw new Error('TripleABExperimentProvider is required')\n  }\n\n  return context\n}\n"
  },
  {
    "path": "packages/ab-experiments/src/triple-ab-experiment-context/index.ts",
    "content": "export * from './context'\nexport * from './service'\n"
  },
  {
    "path": "packages/ab-experiments/src/triple-ab-experiment-context/service.ts",
    "content": "import { authGuardedFetchers, RequestOptions } from '@titicaca/fetcher'\n\nexport interface TripleABExperimentMeta {\n  testId: number\n  group: string\n}\n\nexport async function getTripleABExperiment(\n  slug: string,\n  options?: RequestOptions,\n) {\n  return authGuardedFetchers.get<TripleABExperimentMeta>(\n    `/api/abtest/${slug}`,\n    options,\n  )\n}\n"
  },
  {
    "path": "packages/ab-experiments/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/ab-experiments/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/ab-experiments/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/constants/README.md",
    "content": "# `@titicaca/constants`\n\n트리플 프론트엔드의 공통 상수를 모아놓는 패키지입니다.\n\n- 공통 상수\n- 공통 정규 표현식\n"
  },
  {
    "path": "packages/constants/package.json",
    "content": "{\n  \"name\": \"@titicaca/constants\",\n  \"version\": \"14.2.3\",\n  \"description\": \"triple frontend constants definitions\",\n  \"keywords\": [\n    \"triple\",\n    \"frontend\",\n    \"typescript\"\n  ],\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/constants\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/constants/src/index.ts",
    "content": "export * from './regex'\n\n/** API 호출 시 필요한 사용자 세션 HTTP 헤더 키  */\nexport const SESSION_KEY = 'x-soto-session'\nexport const TP_TK = 'TP_TK'\nexport const TP_SE = 'TP_SE'\nexport const X_TRIPLE_WEB_DEVICE_ID = 'x-triple-web-device-id'\n"
  },
  {
    "path": "packages/constants/src/regex.test.ts",
    "content": "import { EMAIL_REGEX } from './regex'\n\nconst testIsInvalidEmail = (email: string) => !EMAIL_REGEX.test(email)\nconst testIsValidEmail = (email: string) => EMAIL_REGEX.test(email)\n\ntest('영문, 숫자, 특수문자 +-_. 이외의 문자가 포함되어 있으면(@는 1회만 허용) 유효하지 않은 이메일 주소입니다', () => {\n  const INVALID_USER_ID_CHAR = [\n    '^test@triple-corp.com',\n    'test‼️@triple-corp.com',\n    'test@$triple-corp.com',\n    'test@triple#-corp.com',\n    'test@triple-~corp.com',\n    'test@triple-corp.com*',\n    'test@triple@corp.com',\n    'test@triple-corp.com@',\n  ]\n  expect(INVALID_USER_ID_CHAR.every(testIsInvalidEmail)).toBe(true)\n})\n\ntest('사용자 ID가 영문, 숫자, 특수문자 +-_로 시작하지 않으면 유효하지 않은 이메일 주소입니다.', () => {\n  const INVALID_INIT_CHAR = ['.test@triple-corp.com']\n  expect(INVALID_INIT_CHAR.every(testIsInvalidEmail)).toBe(true)\n  const VALID_INIT_CHAR = [\n    '+test@triple-corp.com',\n    '-test@triple-corp.com',\n    '_test@triple-corp.com',\n  ]\n  expect(VALID_INIT_CHAR.every(testIsValidEmail)).toBe(true)\n})\n\ntest('사용자 ID에 .이 포함되어 있을 때 바로 다음 문자가 영문, 숫자, 특수문자 +-_로 시작하지 않으면 유효하지 않은 이메일 주소입니다.', () => {\n  const NOT_SINGLE_DOT = ['t..est@triple-corp.com', 'test..@triple-corp.com']\n  expect(NOT_SINGLE_DOT.every(testIsInvalidEmail)).toBe(true)\n})\n\ntest('-(하이픈)이 도메인 주소 시작 또는 끝에 존재하면 유효하지 않은 이메일 주소입니다.', () => {\n  const INVALID_DOMAIN_NAME = [\n    'test@triple-corp-.com',\n    'test@-triple-corp.com',\n    'test@-triple-corp-.com',\n  ]\n  expect(INVALID_DOMAIN_NAME.every(testIsInvalidEmail)).toBe(true)\n})\n\ntest('2글자 이상의 최상위 도메인 주소가 존재하지 않으면 유효하지 않은 이메일 주소입니다.', () => {\n  const INVALID_DOMAIN_NAME = ['test@triple-corp.c', 'test@triple.corp.c']\n  expect(INVALID_DOMAIN_NAME.every(testIsInvalidEmail)).toBe(true)\n  const VALID_DOMAIN_NAME = [\n    'test@triple.corp.co',\n    'test@triple.corp.com',\n    'test@triple-corp.com',\n  ]\n  expect(VALID_DOMAIN_NAME.every(testIsValidEmail)).toBe(true)\n})\n"
  },
  {
    "path": "packages/constants/src/regex.ts",
    "content": "/**\n * 삼성 스마트폰 천지인 middle dot 입력 이슈\n * https://github.com/titicacadev/triple-hotels-web/issues/1923\n */\nconst KOREAN_MIDDLE_DOT_UNICODES = '\\u318D\\u119E\\u11A2\\u2022\\u2025\\u00B7\\uFE55'\nconst KOREAN_VOWEL_UNICODES = '\\u314F-\\u3163'\n\n/**\n * TODO:\n * > 미묘하게 중복되는 정규식을 한 번에 관리할 만한 방법이 있을까요?\n * > 예를 들어 한글 문자열이라던지..\n * - https://github.com/titicacadev/triple-frontend/pull/1010#pullrequestreview-517340132\n */\nexport const ENNAME_REGEX = /^[A-Z\\s]+$/\nexport const KONAME_REGEX = /^[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]+$/\nexport const NAME_REGEX = /^([가-힣]{1,100}|[a-zA-Z]{2,32})$/\nexport const FULL_NAME_REGEX =\n  /^([가-힣]{2,100}|[a-zA-Z]{2,32}\\s?[a-zA-Z]{2,32})$/\n\nexport const EMAIL_REGEX =\n  /^[a-zA-Z0-9_\\-+]+(\\.[a-zA-Z0-9_\\-+]+)*@[a-zA-Z0-9]+[a-zA-Z0-9-]*[a-zA-Z0-9]+(\\.[a-zA-Z0-9-]+)*(\\.[a-zA-Z]{2,})$/\nexport const PHONE_REGEX =\n  /^(010[-. ]?([0-9]{4})|(011|016|017|018|019)[-. ]?([0-9]{3,4}))[-. ]?([0-9]{4})$/\nexport const DATE_REGEX =\n  /^(19|20)\\d{2}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[0-1])$/\nexport const CARD_NUMBER_REGEX = /^[1-9][0-9]{11,15}$/\nexport const CARD_PASSWORD_NUMBER_REGEX = /^[0-9]{2}$/\nexport const ONLY_MONTH_DAY_DATE_REGEX =\n  /^(0[1-9]|1[0-2])\\/?([0-9]{4}|[0-9]{2})$/\nexport const ZIP_CODE_REGEX = /^[0-9]{5}$/\nexport const ADDRESS_REGEX = /^([a-zA-Z]|[0-9]|[ ]){1,35}$/\n\nexport const SLASH_HYPHEN_REGEX = /(\\/|-)/g\nexport const KOREAN_REGEX = /[ㄱ-ㅎㅏ-ㅣ가-힣]+/g\nexport const PASSPORT_NUMBER_REGEX = /[^A-Z0-9]{15}/g\nexport const ALPHABET_REGEX = /([^a-zA-Z])+/g\nexport const PASSPORT_NAME_REGEX = new RegExp(\n  `([^ㄱ-ㅎ가-힣${KOREAN_VOWEL_UNICODES}${KOREAN_MIDDLE_DOT_UNICODES}|a-zA-Z])+`,\n  'g',\n)\n"
  },
  {
    "path": "packages/constants/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/constants/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/constants/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/fetcher/README.md",
    "content": "# fetcher\n\n[fetch API](https://developer.mozilla.org/ko/docs/Web/API/Fetch_API)를 사용할 때\n공통으로 필요한 기능을 제공하는 패키지입니다.\n\nfetch API의 기본적인 기능을 모두 사용할 수 있고, 추가로 다음 기능을 제공합니다.\n\n- [요청 헤더에 \"X-Soto-Session\" 추가](#x-soto-session-header)\n- [요청 헤더에 \"Content-Type\" 추가](#content-type-header)\n- [요청의 body를 stringification](#body-stringification)\n- [요청 \"credentials\"를 `same-origin`으로 설정](#credentials)\n- [HTTP 응답 에러 처리](#handle-http-errors)\n- [특정 응답 코드일 때 요청 재시도](#retry)\n- [응답 값 json 파싱](#parse-as-json)\n- [(TypeScript) 응답 타입 지정](#response-type-casting)\n- [HTTP 메서드별 함수](#methods)\n- [액세스 토큰 갱신 기능](#token-refresh)\n- [`getServerSideProps`에 fetchers를 제공하는 팩토리 함수](#ssr-fetchers)\n\n## 요청 관련 기능\n\n### <a name=\"x-soto-session-header\">헤더에 \"X-Soto-Session\" 추가</a>\n\n쿠키에 \"x-soto-session\" 값이 있으면 해당 값을 \"X-Soto-Session\" 헤더로 넣습니다.\n\n> 액세스 토큰을 이용한 인증 방식을 사용하면서 \"x-soto-session\"을 쿠키로 대체했습니다.\n> v3에서 없어질 기능입니다.\n\n### <a name=\"content-type-header\">헤더에 \"Content-Type\" 추가</a>\n\n`body`가 존재하고 `useBodyAsRaw` 옵션이 꺼져있으면 \"Content-Type\"을 `application/json`으로 지정합니다.\n\n### <a name=\"body-stringification\">body stringification</a>\n\n`JSON.stringify`를 통해 body 속성을 문자열로 변경합니다.\n따라서 body 속성에는 JSON 객체를 직접 집어넣을 수 있습니다.\n\n### <a name=\"credentials\">\"credentials\"를 `same-origin`으로 설정</a>\n\n## 응답 관련 기능\n\n### <a name=\"handle-http-errors\">HTTP 에러 처리</a>\n\n응답 상태 코드가 300 이상일 때 에러를 발생시킵니다. `result` 속성을 비우고,\n에러 객체를 `error`에 담아서 반환합니다.\n\n응답의 문자열 형태 값을 에러 메시지에 담습니다.\n\n### <a name=\"retry\">재시도</a>\n\n`retryable` 옵션이 켜져있을 때, GET 메서드의 응답이 502, 503, 504 중 하나라면\n3번까지 다시 시도합니다.\n\n### <a name=\"parse-as-json\">응답 값 json 파싱</a>\n\n응답 상태 코드가 200이고 응답 헤더의 \"Content-Type\"에 `json`이 명시되어있을 때\n응답을 json으로 간주하고 파싱합니다. 파싱 과정에서 오류가 발생하면 undefined를 반환합니다.\n\n### <a name=\"response-type-casting\">(TypeScript) 응답 타입 지정</a>\n\n함수 제너릭의 첫 번째 인자로 응답 타입을 지정할 수 있습니다.\n\n```ts\nconst { result } = await fetcher<{ name: string; email: string }>('/api/...')\n// result의 타입은 { name: string, email: string } | undefined\n```\n\n## 인터페이스 관련\n\n### <a name=\"methods\">HTTP 메서드별 함수</a>\n\nGET, POST, PUT, DELETE 메서드별 함수를 따로 제공합니다. `get`, `post`, `put`, `del`\n\n### <a name=\"token-refresh\">액세스 토큰 갱신 기능</a>\n\n`authGuardedFetchers` 객체로 묶여있는 함수를 사용하면 액세스 토큰 갱신 방법을 신경 쓸 필요가 없습니다.\n\n응답 상태 코드가 401일 때 액세스 토큰을 갱신하고, 갱신에 실패했다면 로그인 핸들러를 호출하는 기능이 추가되어있습니다.\n\n```ts\nconst response = await autGuardedFetchers.get<{ name: string; email: string }>(\n  '/api/...',\n)\n// response는 HttpResponse<{ name: string, email: string }> | 'NEED_LOGIN'입니다.\n\nif (response === 'NEED_LOGIN') {\n  // 로그인 페이지로 이동\n}\n// response는 이제 기존 응답처럼 사용할 수 있습니다.\nconst { ok, result, error } = response\n```\n\n> 하위 호환을 위해 새로운 인터페이스로 추가했습니다. v3부터 기본 제공할 예정입니다.\n\n### <a name=\"ssr-fetchers\">`getServerSideProps`에 fetchers를 제공하는 팩토리 함수</a>\n\nNext.js의 `getServerSideProps` 함수 안에 fetcher를 공급하는 팩토리 함수를 제공합니다.\nAPI의 URL을 절대 경로로 만들어주는 기능과 액세스 토큰 갱신 기능을 내장합니다.\n\n```ts\nconst getServerSideProps = addFetchersToGSSP(\n  async function ({\n    customContext: {\n      fetchers: { get },\n    },\n  }): Promise<GetServerSidePropsResult<Props>> {\n    const response = await get('/api/xxxx')\n    // ...\n  },\n  { apiUriBase: process.env.API_URI_BASE },\n)\n```\n"
  },
  {
    "path": "packages/fetcher/package.json",
    "content": "{\n  \"name\": \"@titicaca/fetcher\",\n  \"version\": \"14.2.3\",\n  \"description\": \"Utilities for Triple view libraries and applications\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/fetcher\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"ts-custom-error\": \"^3.3.1\",\n    \"universal-cookie\": \"^8.0.1\"\n  },\n  \"devDependencies\": {\n    \"@sentry/nextjs\": \"7.120.3\",\n    \"@titicaca/view-utilities\": \"workspace:*\",\n    \"@types/node-fetch\": \"^2.6.12\",\n    \"isomorphic-fetch\": \"^2.2.1\",\n    \"next\": \"^14.2.24\",\n    \"node-fetch\": \"^2.7.0\"\n  },\n  \"peerDependencies\": {\n    \"@sentry/nextjs\": \"*\",\n    \"@titicaca/view-utilities\": \"*\",\n    \"next\": \"^13.4 || ^14.0\"\n  }\n}\n"
  },
  {
    "path": "packages/fetcher/src/add-fetchers-to-gssp.test.ts",
    "content": "import 'isomorphic-fetch'\nimport { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'\n\nimport { addFetchersToGssp } from './add-fetchers-to-gssp'\nimport { get, post } from './methods'\nimport { HttpResponse } from './types'\n\njest.mock('./methods')\nconst mockedGet = get as jest.MockedFunction<typeof get>\nconst mockedPost = post as jest.MockedFunction<typeof post>\n\nconst baseContext = {\n  req: { headers: { cookie: '' } },\n  res: { setHeader: () => {} },\n} as unknown as GetServerSidePropsContext\nconst customApiUriBase = 'https://my-base-path.co.kr'\n\nbeforeEach(() => {\n  mockedGet.mockClear()\n  mockedPost.mockClear()\n})\n\ntest('apiUriBase 파라미터를 요청의 base href로 사용합니다.', async () => {\n  mockedGet.mockImplementation(() => {\n    const { headers, ok, status, url } = new Response('', { status: 200 })\n\n    return Promise.resolve({ headers, ok, status, url, parsedBody: '' })\n  })\n\n  const gssp = addFetchersToGssp(\n    async ({\n      customContext: {\n        fetchers: { get },\n      },\n    }) => {\n      const response = await get('/api/a')\n      return { props: { response } }\n    },\n    { apiUriBase: customApiUriBase },\n  )\n\n  await gssp(baseContext)\n  expect(mockedGet).toHaveBeenCalledWith(\n    expect.stringContaining(customApiUriBase),\n    expect.any(Object),\n  )\n})\n\ntest('토큰을 갱신했을 때 context.res의 setHeader를 이용해 쿠키 갱신 헤더를 추가합니다.', async () => {\n  const validCookie = 'VALID_COOKIE'\n  mockedGet.mockImplementation(() => {\n    const { headers, ok, status, url } = new Response('', { status: 401 })\n\n    return Promise.resolve({ headers, ok, status, url, parsedBody: '' })\n  })\n  mockedPost.mockImplementation(() => {\n    return Promise.resolve({\n      ok: true,\n      headers: {\n        get(key: string) {\n          if (key === 'set-cookie') {\n            return validCookie\n          }\n          return ''\n        },\n      },\n    } as unknown as HttpResponse<unknown>)\n  })\n\n  const setHeader = jest.fn()\n\n  const gssp = addFetchersToGssp(\n    async ({\n      customContext: {\n        fetchers: { get },\n      },\n    }) => {\n      const response = await get('/api/a')\n      return { props: { response } }\n    },\n    { apiUriBase: customApiUriBase },\n  )\n\n  await gssp({\n    ...baseContext,\n    res: { setHeader },\n  } as unknown as GetServerSidePropsContext)\n\n  expect(setHeader).toHaveBeenCalledWith('set-cookie', validCookie)\n})\n\ntest('API 요청을 여러 번 해도 refresh는 한 번만 호출합니다.', async () => {\n  const validCookie = 'VALID_COOKIE'\n  mockedGet.mockImplementation(async (_, { cookie } = {}) => {\n    const { headers, ok, status, url } =\n      cookie === validCookie\n        ? new Response('', { status: 200 })\n        : new Response('', { status: 401 })\n\n    return { headers, ok, status, url, parsedBody: '' }\n  })\n\n  mockedPost.mockImplementation(() => {\n    return Promise.resolve({\n      ok: true,\n      headers: {\n        get() {\n          return validCookie\n        },\n      },\n    } as unknown as HttpResponse<unknown>)\n  })\n\n  const setHeader = jest.fn()\n\n  const gssp = addFetchersToGssp(\n    async ({\n      customContext: {\n        fetchers: { get },\n      },\n    }): Promise<GetServerSidePropsResult<Record<string, never>>> => {\n      await Promise.all([get('/api/a'), get('/api/b'), get('/api/c')])\n      await get('/api/d')\n      return { props: {} }\n    },\n    { apiUriBase: 'https://triple-dev.titicaca-corp.com' },\n  )\n\n  await gssp({\n    ...baseContext,\n    res: { setHeader },\n  } as unknown as GetServerSidePropsContext)\n\n  expect(mockedPost).toHaveBeenCalledTimes(1)\n})\n\ntest('API를 여러 번 호출하더라도 유효한 쿠키 하나만 사용합니다.', async () => {\n  const validCookie = 'VALID_COOKIE'\n  mockedGet.mockImplementation(async (_, { cookie } = {}) => {\n    const { headers, ok, status, url } =\n      cookie === `${validCookie}-1`\n        ? new Response('', { status: 200 })\n        : new Response('', { status: 401 })\n\n    return { headers, ok, status, url, parsedBody: '' }\n  })\n\n  let refreshCount = 0\n  mockedPost.mockImplementation(() => {\n    refreshCount += 1\n    const cookie = `${validCookie}-${refreshCount}`\n    return Promise.resolve({\n      ok: true,\n      headers: {\n        get() {\n          return cookie\n        },\n      },\n    } as unknown as HttpResponse<unknown>)\n  })\n\n  const setHeader = jest.fn()\n\n  const gssp = addFetchersToGssp(\n    async ({\n      customContext: {\n        fetchers: { get },\n      },\n    }): Promise<\n      GetServerSidePropsResult<{\n        responses: [unknown, unknown, unknown, unknown]\n      }>\n    > => {\n      const [a, b, c] = await Promise.all([\n        get('/api/a'),\n        get('/api/b'),\n        get('/api/c'),\n      ])\n      const d = await get('/api/d')\n      return { props: { responses: [a, b, c, d] } }\n    },\n    { apiUriBase: 'https://triple-dev.titicaca-corp.com' },\n  )\n\n  const gsspResponse = await gssp({\n    ...baseContext,\n    res: { setHeader },\n  } as unknown as GetServerSidePropsContext)\n\n  expect(gsspResponse).toEqual(\n    expect.objectContaining({\n      props: expect.objectContaining({\n        responses: [\n          expect.objectContaining({ ok: true }),\n          expect.objectContaining({ ok: true }),\n          expect.objectContaining({ ok: true }),\n          expect.objectContaining({ ok: true }),\n        ],\n      }),\n    }),\n  )\n  expect(setHeader).toHaveBeenCalledWith('set-cookie', `${validCookie}-1`)\n})\n\ntest('토큰을 갱신하면 갱신한 쿠키 값으로 다음 API를 요청합니다.', async () => {\n  const validCookie = 'VALID_COOKIE'\n  const dapiRecorder = jest.fn()\n\n  mockedGet.mockImplementation(async (href, { cookie } = {}) => {\n    if (href.includes('/api/d')) {\n      dapiRecorder(cookie)\n    }\n\n    const { headers, ok, status, url } =\n      cookie === validCookie\n        ? new Response('', { status: 200 })\n        : new Response('', { status: 401 })\n\n    return {\n      headers,\n      ok,\n      status,\n      url,\n      parsedBody: '',\n    }\n  })\n  mockedPost.mockImplementation(() => {\n    return Promise.resolve({\n      ok: true,\n      headers: {\n        get() {\n          return validCookie\n        },\n      },\n    } as unknown as HttpResponse<unknown>)\n  })\n\n  const gssp = addFetchersToGssp(\n    async ({\n      customContext: {\n        fetchers: { get },\n      },\n    }): Promise<\n      GetServerSidePropsResult<{\n        responses: [unknown, unknown, unknown, unknown]\n      }>\n    > => {\n      const [a, b, c] = await Promise.all([\n        get('/api/a'),\n        get('/api/b'),\n        get('/api/c'),\n      ])\n      const d = await get('/api/d')\n      return { props: { responses: [a, b, c, d] } }\n    },\n    { apiUriBase: 'https://triple-dev.titicaca-corp.com' },\n  )\n  await gssp(baseContext)\n\n  expect(dapiRecorder).toHaveBeenCalledTimes(1)\n  expect(dapiRecorder).toHaveBeenCalledWith(validCookie)\n})\n"
  },
  {
    "path": "packages/fetcher/src/add-fetchers-to-gssp.ts",
    "content": "import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'\n\nimport {\n  authFetcherize,\n  BaseFetcher,\n  ExtendFetcher,\n  NEED_LOGIN_IDENTIFIER,\n  ssrFetcherize,\n  i18nFetcherize,\n} from './factories'\nimport { del, get, post, put } from './methods'\nimport { RequestOptions, HttpResponse } from './types'\n\n/**\n * 주어진 getServerSideProps 함수의 context에 fetcher를 추가하는 팩토리 함수\n * @param gssp\n * @param options 추가 옵션\n * @returns getServerSideProps로 전달할 수 있는 함수\n */\nexport function addFetchersToGssp<Props, CustomContext = Record<string, never>>(\n  gssp: (\n    ctx: GetServerSidePropsContext & {\n      customContext: {\n        fetchers: {\n          get: ExtendFetcher<typeof get, typeof NEED_LOGIN_IDENTIFIER>\n          post: ExtendFetcher<typeof post, typeof NEED_LOGIN_IDENTIFIER>\n          put: ExtendFetcher<typeof put, typeof NEED_LOGIN_IDENTIFIER>\n          del: ExtendFetcher<typeof del, typeof NEED_LOGIN_IDENTIFIER>\n        }\n      }\n    },\n  ) => Promise<GetServerSidePropsResult<Props>>,\n  { apiUriBase: apiUriBaseFromOptions }: { apiUriBase?: string } = {},\n): (\n  ctx: GetServerSidePropsContext & { customContext?: CustomContext },\n) => Promise<GetServerSidePropsResult<Props>> {\n  const apiUriBase = apiUriBaseFromOptions || process.env.API_URI_BASE\n\n  if (!apiUriBase) {\n    throw new Error(\n      'API 요청 URL을 알 수 없습니다. apiUriBase 옵션을 추가하거나 API_URI_BASE 환경 변수를 추가하세요.',\n    )\n  }\n\n  return function fetchersAddedGssp(ctx) {\n    const ssrFetcherOptions = {\n      apiUriBase,\n      cookie: ctx.req.headers.cookie,\n    }\n\n    const { setCookie, cookieOverrider } = createCookieOverrider()\n\n    const authGuardOptions: Parameters<typeof authFetcherize>[1] = {\n      refresh: createRefresh({ ssrFetcherOptions }),\n      onCookieRenew: (cookie: string) => {\n        ctx.res.setHeader('set-cookie', cookie)\n        setCookie(cookie)\n      },\n    }\n\n    const combinedMiddlewares = <Fetcher extends BaseFetcher>(\n      fetcher: Fetcher,\n    ) =>\n      cookieOverrider(\n        authFetcherize(\n          ssrFetcherize(i18nFetcherize(fetcher, ctx), ssrFetcherOptions),\n          authGuardOptions,\n        ),\n      )\n\n    return gssp({\n      ...ctx,\n      customContext: {\n        ...ctx.customContext,\n        fetchers: {\n          get: combinedMiddlewares(get),\n          put: combinedMiddlewares(put),\n          post: combinedMiddlewares(post),\n          del: combinedMiddlewares(del),\n        },\n      },\n    })\n  }\n}\n\nfunction createRefresh({\n  ssrFetcherOptions,\n}: {\n  ssrFetcherOptions: Parameters<typeof ssrFetcherize>[1]\n}) {\n  let promise: Promise<HttpResponse<Record<string, never>>> | undefined\n  return function refresh(options?: RequestOptions) {\n    const ssrPost = ssrFetcherize(post, ssrFetcherOptions)\n    promise ||= ssrPost('/api/users/web-session/token', options)\n    return promise\n  }\n}\n\nfunction createCookieOverrider() {\n  let cookie: string | undefined\n\n  function cookieOverrider<Fetcher extends BaseFetcher>(fetcher: Fetcher) {\n    return function cookieOverridingFetcher(href, options) {\n      if (cookie === undefined) {\n        return fetcher(href, options)\n      }\n\n      return fetcher(href, { ...options, cookie })\n    } as Fetcher\n  }\n\n  return {\n    cookieOverrider,\n    setCookie: (newCookie: string) => {\n      cookie = newCookie\n    },\n  }\n}\n"
  },
  {
    "path": "packages/fetcher/src/auth-guarded-methods.ts",
    "content": "import { authFetcherize } from './factories'\nimport { fetcher } from './fetcher'\nimport { del, get, post, put } from './methods'\n\nconst authFetcherizeOptions: Parameters<typeof authFetcherize>[1] = {\n  refresh: (options) => post('/api/users/web-session/token', options),\n}\n\nexport const authGuardedFetchers = {\n  fetcher: authFetcherize(\n    (href, options) => fetcher(href, options || {}),\n    authFetcherizeOptions,\n  ),\n  get: authFetcherize(get, authFetcherizeOptions),\n  post: authFetcherize(post, authFetcherizeOptions),\n  put: authFetcherize(put, authFetcherizeOptions),\n  del: authFetcherize(del, authFetcherizeOptions),\n}\n"
  },
  {
    "path": "packages/fetcher/src/factories.test.ts",
    "content": "import { IncomingMessage } from 'http'\n\nimport { ssrFetcherize } from './factories'\n\nfunction makeTestSuite(testingFunction: typeof ssrFetcherize) {\n  test('주어진 cookie를 호출에 이용합니다.', () => {\n    const fetcher = jest.fn()\n\n    const ssrFetcher = testingFunction(fetcher, {\n      apiUriBase: 'https://triple-dev.titicaca-corp.com',\n      cookie: 'THIS_IS_MOCK_COOKIE',\n    })\n\n    ssrFetcher('/api/mock-url')\n\n    expect(fetcher).toHaveBeenCalledWith(\n      expect.any(String),\n      expect.objectContaining({ cookie: 'THIS_IS_MOCK_COOKIE' }),\n    )\n  })\n\n  test('href에 주어진 apiUriBase를 붙입니다.', () => {\n    const fetcher = jest.fn()\n\n    const ssrFetcher = testingFunction(fetcher, {\n      apiUriBase: 'https://triple-dev.titicaca-corp.com',\n      cookie: 'THIS_IS_MOCK_COOKIE',\n    })\n\n    ssrFetcher('/api/mock-url')\n\n    expect(fetcher).toHaveBeenCalledWith(\n      'https://triple-dev.titicaca-corp.com/api/mock-url',\n      expect.anything(),\n    )\n  })\n\n  test('헤더에 \"x-triple-from-ssr\"을 추가합니다.', () => {\n    const fetcher = jest.fn()\n\n    const ssrFetcher = testingFunction(fetcher, {\n      apiUriBase: 'https://triple-dev.titicaca-corp.com',\n      cookie: 'THIS_IS_MOCK_COOKIE',\n    })\n\n    ssrFetcher('/api/mock-url')\n\n    expect(fetcher).toHaveBeenCalledWith(\n      expect.any(String),\n      expect.objectContaining({\n        headers: expect.objectContaining({\n          'x-triple-from-ssr': 'true',\n        }),\n      }),\n    )\n  })\n\n  test('fetcher에 req를 전달하지 않습니다.', () => {\n    const fetcher = jest.fn()\n\n    const ssrFetcher = testingFunction(fetcher, {\n      apiUriBase: 'https://triple-dev.titicaca-corp.com',\n      cookie: 'THIS_IS_MOCK_COOKIE',\n    })\n\n    ssrFetcher('/api/mock-url', {\n      req: { headers: { cookie: 'my-own-cookie' } } as IncomingMessage,\n    })\n    expect(fetcher).toHaveBeenCalledWith(\n      expect.any(String),\n      expect.not.objectContaining({ req: expect.anything() }),\n    )\n  })\n\n  test('withApiUribase를 무조건 비활성화합니다.', () => {\n    const fetcher = jest.fn()\n\n    const ssrFetcher = testingFunction(fetcher, {\n      apiUriBase: 'https://triple-dev.titicaca-corp.com',\n      cookie: 'THIS_IS_MOCK_COOKIE',\n    })\n\n    ssrFetcher('/api/mock-url')\n    expect(fetcher).toHaveBeenCalledWith(\n      expect.any(String),\n      expect.objectContaining({ withApiUriBase: false }),\n    )\n    ssrFetcher('/api/mock-url', { withApiUriBase: true })\n    expect(fetcher).toHaveBeenCalledWith(\n      expect.any(String),\n      expect.objectContaining({ withApiUriBase: false }),\n    )\n  })\n\n  test('생성한 fetcher의 파라미터로 cookie 값을 덮어쓸 수 있습니다.', () => {\n    const fetcher = jest.fn()\n\n    const ssrFetcher = testingFunction(fetcher, {\n      apiUriBase: 'https://triple-dev.titicaca-corp.com',\n      cookie: 'THIS_IS_MOCK_COOKIE',\n    })\n\n    ssrFetcher('/api/mock-url', { cookie: 'I_WANT_OVERRIDE_MY_COOKIE' })\n\n    expect(fetcher).toHaveBeenCalledWith(\n      expect.any(String),\n      expect.objectContaining({ cookie: 'I_WANT_OVERRIDE_MY_COOKIE' }),\n    )\n  })\n}\n\ndescribe('ssrFetcherize', () => {\n  makeTestSuite(ssrFetcherize)\n})\n\ndescribe('중복해서 적용해도 같은 결과를 반환합니다.', () => {\n  const testingFunction: typeof ssrFetcherize = (fetcher, options) =>\n    ssrFetcherize(ssrFetcherize(fetcher, options), options)\n\n  makeTestSuite(testingFunction)\n})\n"
  },
  {
    "path": "packages/fetcher/src/factories.ts",
    "content": "import { GetServerSidePropsContext } from 'next'\nimport { generateUrl, parseUrl } from '@titicaca/view-utilities'\n\nimport { HttpResponse, RequestOptions } from './types'\nimport {\n  captureHttpError,\n  handle401Error,\n  NEED_REFRESH_IDENTIFIER,\n} from './response-handler'\n\nexport type BaseFetcher<Extending = unknown> = <\n  SuccessBody,\n  FailureBody = unknown,\n>(\n  href: string,\n  options?: RequestOptions,\n) => Promise<HttpResponse<SuccessBody, FailureBody> | Extending>\n\nexport type ExtendFetcher<Fetcher extends BaseFetcher, Extending> = <\n  SuccessBody,\n  FailureBody = unknown,\n>(\n  href: string,\n  options?: RequestOptions,\n) => Promise<\n  | HttpResponse<SuccessBody, FailureBody>\n  | Exclude<\n      ReturnType<Fetcher> extends Promise<infer Resolved> ? Resolved : never,\n      HttpResponse<unknown, unknown>\n    >\n  | Extending\n>\n\n/**\n * 주어진 fetcher 함수를 SSR 단계에서 작동하도록 변환하는 함수입니다.\n * 다음 기능을 담고 있습니다.\n *\n * * API 요청 경로에 scheme과 host 추가\n * * SSR 요청에 사용하던 options (req, withApiUriBase) 비활성화\n * * 요청에 \"x-triple-from-ssr\" 헤더 추가\n * * 요청에 cookie 추가\n */\nexport function ssrFetcherize<Fetcher extends BaseFetcher>(\n  fetcher: Fetcher,\n  {\n    apiUriBase,\n    cookie,\n  }: {\n    /**\n     * API 요청 경로에 추가하는 scheme과 host 값\n     */\n    apiUriBase: string\n    /**\n     * 요청에 첨부하는 쿠키 값\n     */\n    cookie?: string\n  },\n): Fetcher {\n  const { scheme, host } = parseUrl(apiUriBase)\n\n  return ((href, optionsParams) => {\n    const {\n      req,\n      withApiUriBase,\n      headers,\n      cookie: overridingCookie,\n      ...options\n    } = optionsParams || {}\n    const finalCookie = overridingCookie ?? cookie\n\n    return fetcher(generateUrl({ scheme, host }, href), {\n      ...options,\n      ...(finalCookie && { cookie: finalCookie }),\n      withApiUriBase: false,\n      headers: {\n        ...headers,\n        'x-triple-from-ssr': 'true',\n      },\n    })\n  }) as Fetcher\n}\n\nexport const NEED_LOGIN_IDENTIFIER = 'NEED_LOGIN'\n\n/**\n * 주어진 주소가 트리플 도메인인지 확인하는 함수입니다.\n * 호스트가 없거나 환경 변수 값 NEXT_PUBLIC_WEB_URL_BASE의 호스트와 동일하면 트리플 도메인입니다.\n *\n * @param href\n * @returns\n */\nfunction isTripleHref(href: string): boolean {\n  const { host } = parseUrl(href)\n  const { host: tripleHost } = parseUrl(process.env.NEXT_PUBLIC_WEB_URL_BASE)\n  // 클라이언트, 서버 모두에서 유효한 환경 변수가 필요하기 때문에\n  // API_URI_BASE 대신 NEXT_PUBLIC_WEB_URL_BASE를 사용합니다.\n  return !host || host === tripleHost\n}\n\n/**\n * 주어진 fetcher를 세션이 존재할 때만 작동하도록 변환하는 함수\n * 로그인이 필요하면 response 대신 NEED_LOGIN_IDENTIFIER를 반환합니다.\n */\nexport function authFetcherize<Fetcher extends BaseFetcher>(\n  fetcher: Fetcher,\n  {\n    refresh,\n    onCookieRenew,\n  }: {\n    /**\n     * 액세스 토큰이 만료되었을 때 작동하는 함수입니다.\n     * 리프레시 토큰으로 액세스 토큰을 갱신하는 API 요청 함수를 넣어주세요.\n     */\n    refresh: (\n      options?: Pick<RequestInit, 'signal'>,\n    ) => Promise<HttpResponse<Record<string, never>>>\n    /**\n     * 액세스 토큰을 갱신했을 때 새로운 쿠키를 파라미터로 호출하는 함수입니다.\n     * 새로운 쿠키를 다루는 작업이 필요하면 넣어주세요.\n     */\n    onCookieRenew?: (cookie: string) => void\n  },\n): ExtendFetcher<Fetcher, typeof NEED_LOGIN_IDENTIFIER> {\n  return (async <SuccessBody, FailureBody = unknown>(\n    href: string,\n    options?: RequestOptions,\n  ) => {\n    const firstTrialResponse = await fetcher<SuccessBody, FailureBody>(\n      href,\n      options,\n    )\n\n    if (isTripleHref(href) === false) {\n      return firstTrialResponse\n    }\n\n    if (\n      typeof firstTrialResponse !== 'object' ||\n      firstTrialResponse === null ||\n      'status' in firstTrialResponse === false\n    ) {\n      // fetcher가 확장된 응답을 반환했을 때\n      // TODO: 좀 더 분명한 구분 방법으로 대체하기\n      return firstTrialResponse\n    }\n\n    const checkFirstTrialResponse = await handle401Error<\n      SuccessBody,\n      FailureBody\n    >(firstTrialResponse as HttpResponse<SuccessBody, FailureBody>)\n    if (checkFirstTrialResponse !== NEED_REFRESH_IDENTIFIER) {\n      return checkFirstTrialResponse === NEED_LOGIN_IDENTIFIER\n        ? NEED_LOGIN_IDENTIFIER\n        : firstTrialResponse\n    }\n\n    const refreshResponse = await refresh({\n      signal: options?.signal,\n      ...(options?.withApiUriBase && {\n        withApiUriBase: options.withApiUriBase,\n      }),\n    })\n\n    if (refreshResponse.ok === false) {\n      if (refreshResponse.status === 400 || refreshResponse.status === 401) {\n        captureHttpError(refreshResponse)\n        return NEED_LOGIN_IDENTIFIER\n      }\n\n      throw new Error(`${refreshResponse.status} - ${refreshResponse.url}`)\n    }\n\n    const newCookie = refreshResponse.headers.get('set-cookie')\n\n    const secondTrialResponse = await fetcher<SuccessBody, FailureBody>(href, {\n      ...options,\n      cookie: newCookie ?? undefined,\n    })\n\n    if (newCookie && onCookieRenew !== undefined) {\n      onCookieRenew(newCookie)\n    }\n\n    return secondTrialResponse\n  }) as unknown as ExtendFetcher<Fetcher, typeof NEED_LOGIN_IDENTIFIER>\n}\n\nexport function i18nFetcherize<Fetcher extends BaseFetcher>(\n  fetcher: Fetcher,\n  ctx: GetServerSidePropsContext,\n): Fetcher {\n  const {\n    req: { headers },\n  } = ctx\n  const langHeader = (headers['x-triple-user-lang'] ?? 'ko') as string\n  const countryHeader = (headers['x-triple-user-country'] ?? 'kr') as string\n\n  return ((href, optionsParams) => {\n    const { headers, ...options } = optionsParams ?? {}\n\n    return fetcher(href, {\n      ...options,\n      headers: {\n        ...headers,\n        'x-triple-user-lang': langHeader,\n        'x-triple-user-country': countryHeader,\n      },\n    })\n  }) as Fetcher\n}\n"
  },
  {
    "path": "packages/fetcher/src/fetcher.ts",
    "content": "import { makeRequestParams } from './make-request-params'\nimport safeParseJson from './safe-parse-json'\nimport { HttpMethods, HttpResponse, RequestOptions } from './types'\n\nexport async function fetcher<SuccessBody, FailureBody = unknown>(\n  url: string,\n  options: RequestOptions,\n): Promise<HttpResponse<SuccessBody, FailureBody>> {\n  const { retryable, method = HttpMethods.Get } = options\n\n  const fetchFunction =\n    method === HttpMethods.Get && retryable\n      ? makeFetchRetryable({\n          fetch,\n          retryCount: 3,\n        })\n      : fetch\n\n  const response = await fetchFunction(...makeRequestParams(url, options))\n  const body = await readResponseBody(response)\n\n  const { headers, status, url: responseUrl } = response\n\n  if (response.ok === true) {\n    return {\n      headers,\n      status,\n      url: responseUrl,\n      ok: true,\n      parsedBody: body as SuccessBody,\n    }\n  }\n\n  return {\n    headers,\n    status,\n    url: responseUrl,\n    ok: false,\n    parsedBody: body as FailureBody,\n  }\n}\n\nconst refetchStatuses = [502, 503, 504]\n\nfunction makeFetchRetryable({\n  fetch,\n  retryCount,\n}: {\n  fetch: (href: string, requestInit?: RequestInit) => Promise<Response>\n  retryCount: number\n}) {\n  function isRetryable({\n    response,\n    remainRetry,\n  }: {\n    response: Response\n    remainRetry: number\n  }): boolean {\n    return remainRetry > 0 && refetchStatuses.includes(response.status)\n  }\n\n  return function retryableFetch(\n    href: string,\n    requestInit?: RequestInit,\n  ): Promise<Response> {\n    async function retryer(remainRetry: number): Promise<Response> {\n      const response = await fetch(href, requestInit)\n\n      if (isRetryable({ response, remainRetry }) === false) {\n        return response\n      }\n\n      const jitterDelay = Math.random() + 1\n\n      await new Promise((resolve) =>\n        setTimeout(resolve, 100 * (Math.pow(2, 3 - remainRetry) + jitterDelay)),\n      )\n\n      return retryer(remainRetry - 1)\n    }\n\n    return retryer(retryCount)\n  }\n}\n\nexport function readResponseBody(response: Response) {\n  const contentType = response.headers.get('content-type')\n  const jsonParseAvailable = contentType && /json/.test(contentType)\n\n  if (jsonParseAvailable) {\n    return safeParseJson(response)\n  }\n\n  return response.text()\n}\n"
  },
  {
    "path": "packages/fetcher/src/index.ts",
    "content": "export * from './types'\nexport { authFetcherize, ssrFetcherize } from './factories'\nexport { NEED_LOGIN_IDENTIFIER } from './factories'\nexport { addFetchersToGssp } from './add-fetchers-to-gssp'\nexport { fetcher, readResponseBody } from './fetcher'\nexport { get, put, post, del } from './methods'\nexport { authGuardedFetchers } from './auth-guarded-methods'\nexport {\n  captureHttpError,\n  handle401Error,\n  NEED_REFRESH_IDENTIFIER,\n  ACCESS_TOKEN_EXPIRED_EXCEPTION,\n} from './response-handler'\nexport {\n  sessionRefresh,\n  sessionRefreshOnSSR,\n  type SetCookie,\n} from './session-refresh'\nexport {\n  serverFetchers,\n  serverFetcherize,\n  removeInvalidCookies,\n} from './server-fetch'\n"
  },
  {
    "path": "packages/fetcher/src/make-request-params.test.ts",
    "content": "import { IncomingMessage } from 'http'\n\nimport { makeRequestParams } from './make-request-params'\n\ndescribe('makeRequestParams', () => {\n  const OLD_ENV = process.env\n\n  beforeEach(() => {\n    jest.resetModules()\n    process.env = { ...OLD_ENV }\n  })\n\n  afterAll(() => {\n    process.env = OLD_ENV\n  })\n\n  test('req 대신 cookie와 withApiUriBase를 사용합니다.', () => {\n    process.env.API_URI_BASE = 'https://triple-dev.titicaca-corp.com'\n\n    const deprecateOptions = makeRequestParams('/api/mock-url', {\n      req: { headers: { cookie: 'mock-cookie-value' } } as IncomingMessage,\n    })\n    const newOptions = makeRequestParams('/api/mock-url', {\n      withApiUriBase: true,\n      cookie: 'mock-cookie-value',\n    })\n\n    expect(newOptions).toEqual(deprecateOptions)\n  })\n\n  test('req가 존재하면 API_URI_BASE가 환경 변수에 존재하는지 검사합니다.', () => {\n    expect(() =>\n      makeRequestParams('/api/mock-url', {\n        req: { headers: { cookie: 'mock-cookie-value' } } as IncomingMessage,\n      }),\n    ).toThrow()\n\n    process.env.API_URI_BASE = 'https://triple-dev.titicaca-corp.com'\n\n    expect(() =>\n      makeRequestParams('/api/mock-url', {\n        req: { headers: { cookie: 'mock-cookie-value' } } as IncomingMessage,\n      }),\n    ).not.toThrow()\n  })\n\n  test('withApiUriBase가 true면 API_URI_BASE가 환경 변수에 존재하는지 검사합니다.', () => {\n    expect(() =>\n      makeRequestParams('/api/mock-url', {\n        withApiUriBase: true,\n      }),\n    ).toThrow()\n\n    process.env.API_URI_BASE = 'https://triple-dev.titicaca-corp.com'\n\n    expect(() =>\n      makeRequestParams('/api/mock-url', {\n        withApiUriBase: true,\n      }),\n    ).not.toThrow()\n  })\n\n  test('req가 존재하면 요청 URL에 API_URI_BASE를 추가합니다.', () => {\n    process.env.API_URI_BASE = 'https://triple-dev.titicaca-corp.com'\n\n    expect(\n      makeRequestParams('/api/mock-url', {\n        req: { headers: {} } as IncomingMessage,\n      })[0],\n    ).toBe('https://triple-dev.titicaca-corp.com/api/mock-url')\n  })\n\n  test('withApiUriBase가 true이면 요청 URL에 API_URI_BASE를 추가합니다.', () => {\n    process.env.API_URI_BASE = 'https://triple-dev.titicaca-corp.com'\n\n    expect(\n      makeRequestParams('/api/mock-url', { withApiUriBase: true })[0],\n    ).toBe('https://triple-dev.titicaca-corp.com/api/mock-url')\n  })\n})\n"
  },
  {
    "path": "packages/fetcher/src/make-request-params.ts",
    "content": "import Cookies from 'universal-cookie'\n\nimport { RequestOptions } from './types'\n\nexport function makeRequestParams(\n  href: string,\n  {\n    req,\n    cookie = req?.headers.cookie,\n    withApiUriBase = !!req,\n    useBodyAsRaw,\n    body,\n    headers: customHeaders,\n    retryable: _,\n    ...rest\n  }: RequestOptions,\n): [string, RequestInit | undefined] {\n  if (withApiUriBase && !process.env.API_URI_BASE) {\n    throw new Error(\n      'Insufficient environment variables in `.env.*` files\\n- API_URI_BASE',\n    )\n  }\n\n  const baseUrl: string = withApiUriBase\n    ? (process.env.API_URI_BASE as string)\n    : ''\n\n  const reqUrl: string = baseUrl + href\n\n  const sessionId = cookie\n    ? new Cookies(cookie).get('x-soto-session')\n    : undefined\n\n  const headers = {\n    ...customHeaders,\n    ...(!!body && !useBodyAsRaw && { 'content-type': 'application/json' }),\n    ...(sessionId && { 'x-soto-session': sessionId }),\n    ...(cookie && { cookie }),\n  }\n\n  return [\n    reqUrl,\n    {\n      credentials: 'same-origin',\n      headers,\n      body: body\n        ? useBodyAsRaw\n          ? (body as BodyInit)\n          : JSON.stringify(body)\n        : undefined,\n      ...rest,\n    },\n  ]\n}\n"
  },
  {
    "path": "packages/fetcher/src/methods.ts",
    "content": "import { fetcher } from './fetcher'\nimport { HttpMethods, HttpResponse, RequestOptions } from './types'\n\nexport const get = addMethod(fetcher, HttpMethods.Get)\nexport const put = addMethod(fetcher, HttpMethods.Put)\nexport const post = addMethod(fetcher, HttpMethods.Post)\nexport const del = addMethod(fetcher, HttpMethods.Delete)\n\nfunction addMethod(\n  fetcher: <SuccessBody, FailureBody = unknown>(\n    href: string,\n    options: RequestOptions,\n  ) => Promise<HttpResponse<SuccessBody, FailureBody>>,\n  method: HttpMethods,\n) {\n  return <SuccessBody, FailureBody = unknown>(\n    href: string,\n    options?: RequestOptions,\n  ) => fetcher<SuccessBody, FailureBody>(href, { ...options, method })\n}\n"
  },
  {
    "path": "packages/fetcher/src/response-handler.ts",
    "content": "import { withScope, captureException } from '@sentry/nextjs'\n\nimport { HttpResponse } from './types'\nimport { NEED_LOGIN_IDENTIFIER } from './factories'\nimport { readResponseBody } from './fetcher'\n\nexport function captureHttpError<\n  Response extends HttpResponse<unknown, unknown>,\n>(response: Response): void {\n  if (response.ok === false) {\n    withScope((scope) => {\n      scope.setTag('errorType', 'HTTPError')\n      scope.setExtra('body', response.parsedBody)\n      captureException(new Error(`${response.status} - ${response.url}`))\n    })\n  }\n}\n\nexport const ACCESS_TOKEN_EXPIRED_EXCEPTION = 'AccessTokenExpiredException'\nexport const NEED_REFRESH_IDENTIFIER = 'NEED_REFRESH'\n\ninterface ErrorResponseBody {\n  exception: string\n  message: string\n  status: string\n}\n\ntype ResponseWithError = Pick<Response, 'headers' | 'ok' | 'status' | 'url'> & {\n  ok: false\n  parsedBody: ErrorResponseBody\n}\n\nexport async function handle401Error<SuccessBody, FailureBody>(\n  response: HttpResponse<SuccessBody, FailureBody> | Response,\n) {\n  if (response.ok || response.status !== 401) {\n    return response\n  }\n\n  let exception = ''\n\n  if (response instanceof Response) {\n    const parsedBody = (await readResponseBody(response)) as ErrorResponseBody\n    exception = parsedBody.exception\n  } else {\n    const errorResponse = response as ResponseWithError\n    if (errorResponse.status === 401) {\n      exception = errorResponse.parsedBody.exception\n    }\n  }\n\n  if (exception === ACCESS_TOKEN_EXPIRED_EXCEPTION) {\n    return NEED_REFRESH_IDENTIFIER\n  }\n  return NEED_LOGIN_IDENTIFIER\n}\n"
  },
  {
    "path": "packages/fetcher/src/safe-parse-json.test.ts",
    "content": "import { Response } from 'node-fetch'\n\nimport safeParseJson from './safe-parse-json'\n\nit('JSON 파싱 에러를 조용히 넘깁니다.', async () => {\n  const response = new Response('', {\n    headers: {\n      'content-type': 'application/json',\n    },\n  })\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const json = await safeParseJson(response as any)\n  expect(json).toBeUndefined()\n})\n"
  },
  {
    "path": "packages/fetcher/src/safe-parse-json.ts",
    "content": "export default async function safeParseJson(\n  response: Response,\n): Promise<unknown> {\n  try {\n    const json = await response.json()\n    return json\n  } catch {\n    return undefined\n  }\n}\n"
  },
  {
    "path": "packages/fetcher/src/server-fetch.ts",
    "content": "import {\n  parseCookie,\n  stringifyCookie,\n} from 'next/dist/compiled/@edge-runtime/cookies'\nimport Cookies from 'universal-cookie'\n\nimport { fetcher } from './fetcher'\nimport { del, get, post, put } from './methods'\nimport { BaseFetcher } from './factories'\nimport { RequestOptions } from './types'\n\nexport const serverFetchers = {\n  fetcher: serverFetcherize((href, options) => fetcher(href, options || {})),\n  get: serverFetcherize(get),\n  post: serverFetcherize(post),\n  put: serverFetcherize(put),\n  del: serverFetcherize(del),\n}\n\nexport function serverFetcherize<Fetcher extends BaseFetcher>(\n  fetcher: Fetcher,\n): Fetcher {\n  return (async (href, options: RequestOptions) => {\n    const isServer = typeof window === 'undefined'\n    let serverCookie: string | undefined\n\n    if (isServer) {\n      const { cookies } = await import('next/headers')\n\n      serverCookie = cookies().toString()\n    }\n\n    const finalCookie = options?.cookie ?? serverCookie\n    const validCookies = finalCookie\n      ? removeInvalidCookies(finalCookie)\n      : undefined\n\n    return fetcher(href, {\n      ...options,\n      ...(validCookies && { cookie: validCookies }),\n      withApiUriBase: true,\n      headers: {\n        ...options?.headers,\n        'x-triple-from-ssr': 'true',\n      },\n    })\n  }) as Fetcher\n}\n\nexport function removeInvalidCookies(_cookies: string) {\n  const validCookies = new Cookies()\n  const cookieMap = parseCookie(_cookies)\n  cookieMap.forEach((value, name) => {\n    if (isValidCookieValue(value)) {\n      validCookies.set(name, value)\n    }\n  })\n  return stringifyCookie(validCookies.getAll<{ name: string; value: string }>())\n}\n\nfunction isValidCookieValue(value?: string | null) {\n  return (\n    value !== undefined &&\n    value !== null &&\n    value !== 'null' &&\n    value !== 'undefined' &&\n    value.length > 0\n  )\n}\n"
  },
  {
    "path": "packages/fetcher/src/session-refresh.ts",
    "content": "import { ssrFetcherize } from './factories'\nimport { post } from './methods'\nimport { RequestOptions } from './types'\n\nexport type SetCookie = Record<string, string>\n/**\n *\n * @param fetcher post 함수\n * @param options fetcher에 전달할 옵션\n * @returns session 갱신이 성공한 경우에는 새로운 쿠키를 string[] 값의 형태로 반환하고, 실패한 경우에는 undefined를 반환합니다.\n * ex)  ['TP_SE=some session token; Path=/; HttpOnly; Secure']\n */\nexport async function sessionRefresh({\n  fetcher = post,\n  options,\n}: {\n  fetcher?: typeof post\n  options?: RequestOptions\n}): Promise<string[] | undefined> {\n  const response = await fetcher<Record<never, never> | { message: string }>(\n    '/api/users/web-session/token',\n    options,\n  )\n  if (response.ok) {\n    const setCookie = response.headers.getSetCookie()\n    return setCookie\n  }\n  return undefined\n}\n\nexport async function sessionRefreshOnSSR({\n  apiUriBase,\n  cookie,\n  options: initialOptions,\n}: {\n  apiUriBase: string\n  cookie: string\n  options?: Omit<RequestOptions, 'cookie'>\n}) {\n  const fetch = ssrFetcherize(post, { apiUriBase, cookie })\n  return async () => {\n    await sessionRefresh({ fetcher: fetch, options: initialOptions })\n  }\n}\n"
  },
  {
    "path": "packages/fetcher/src/types.ts",
    "content": "import { IncomingMessage } from 'http'\n\nexport type RequestOptions = Omit<RequestInit, 'body'> & {\n  /**\n   * @deprecated req보다는 cookie, withApiUriBase 사용을 권장합니다!\n   *\n   * 참조: https://github.com/titicacadev/triple-frontend/issues/1334\n   */\n  req?: IncomingMessage\n  /** don't stringfy body */\n  useBodyAsRaw?: boolean\n  retryable?: boolean\n  body?: unknown\n  /**\n   * cookie를 인자로 받을 시 해당 cookie를 헤더에 삽입\n   * 브라우저의 fetch는 쿠키를 보내거나 받지 않기 때문에 SSR시에만 유효합니다.\n   */\n  cookie?: string\n  /**\n   * withApiUriBase true일 때\n   * URL에 API base URL을 붙여 요청을 절대 경로로 보낼 수 있게 만듦\n   */\n  withApiUriBase?: boolean\n}\n\nexport enum HttpMethods {\n  Get = 'GET',\n  Post = 'POST',\n  Delete = 'DELETE',\n  Put = 'PUT',\n  Patch = 'PATCH',\n}\n\nexport interface HttpErrorResponse extends Error {\n  status?: number\n  exception?: string\n  code?: string\n  title?: string\n  message: string\n}\n\nexport type SuccessOrFailureBody<SuccessBody, FailureBody> =\n  | { ok: true; parsedBody: SuccessBody }\n  | { ok: false; parsedBody: FailureBody }\n\nexport type HttpResponse<SuccessBody, FailureBody = unknown> = Pick<\n  Response,\n  'headers' | 'ok' | 'status' | 'url'\n> &\n  SuccessOrFailureBody<SuccessBody, FailureBody>\n"
  },
  {
    "path": "packages/fetcher/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/fetcher/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/fetcher/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/i18n/package.json",
    "content": "{\n  \"name\": \"@titicaca/i18n\",\n  \"version\": \"14.2.3\",\n  \"description\": \"Triple-frontend Internalization\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/i18n\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/i18n/src/index.ts",
    "content": "export { locales } from './locales'\nexport { interpolate } from './interpolate'\nexport * from './types'\n"
  },
  {
    "path": "packages/i18n/src/interpolate.ts",
    "content": "export const interpolate = (\n  text: string,\n  values: Record<string, string> = {},\n) => {\n  return text.replace(/{{(\\w+)}}/g, (match, key) => {\n    return values[key] !== undefined ? values[key] : match\n  })\n}\n"
  },
  {
    "path": "packages/i18n/src/locales/en.ts",
    "content": "export default {\n  '앱에서 보기': 'View in app',\n  닫기: 'Close',\n  트리플: 'Triple',\n  '트리플 앱 설치하기': 'Install Tripl app',\n  '가이드북, 일정짜기, 길찾기, 맛집':\n    'Guidebook, itinerary, directions, restaurants',\n  '편하게 앱에서 보기': 'Conveniently view in the app',\n  '아깝지만 나중에 받을게요': \"It's a shame, but I'll get it later.\",\n  전화하기: 'Call',\n  '국제 전화 요금이 부과될 수 있습니다.': 'International call rates may apply.',\n  '현지에서 길묻기': 'Ask for directions locally',\n  'grab-hocul': 'Grab Taxi',\n  길찾기: 'Get Directions',\n  주소: 'Address',\n  전화: 'Phone call',\n  홈페이지: 'Home page',\n  복사하기: 'Copy',\n  복사: 'Copy',\n  '로그인이 필요합니다.': 'Login is required.',\n  '로그인하고 트리플을 더 편하게 이용하세요':\n    'Log in and use Triple\\nmore comfortably🙂',\n  취소: 'cancellation',\n  확인: 'Check',\n  '일정 짜기부터 호텔, 투어・티켓 예약까지! 트리플로 한 번에 여행 준비하세요.':\n    'From planning to hotel, tour, and ticket reservations!\\nPrepare for a trip at once in triple.',\n  '트리플 가기': 'Go to Triple',\n  저장취소: 'Save Cancel',\n  저장하기: 'Save',\n  리뷰수정: 'Edit review',\n  리뷰쓰기: 'Write a review',\n  일정추가: 'Add schedule',\n  공유하기: 'share',\n  '근처의 추천 장소': 'Recommended places nearby',\n  '장소가 없습니다.': 'There is no place.',\n  '더 많은 장소 보기': 'See more places',\n  관광명소: 'Attractions',\n  음식점: 'restaurant',\n  호텔: 'hotel',\n  '{{starRating}}성급': '{{starRating}} stars',\n  'Triple 항공 홈': 'Triple Aviation Home',\n  'Triple 숙소 홈': 'Triple Accommodation Home',\n  'Triple 투어 티켓 홈': 'Triple Tour Ticket Home',\n  'Triple 홈': 'Triple Home',\n  항공: 'Airline',\n  숙소: 'lodging',\n  '투어 티켓': 'tour ticket',\n  '내 예약': 'my reservation',\n  '댓글 수정 중': 'Editing comment',\n  '{{mentioningUserName}}님께 답글 작성 중':\n    'Replying to {{mentioningUserName}}',\n  '{{mentioningUserName}}님에게 작성한 답글 수정 중':\n    'Editing reply to {{mentioningUserName}}',\n  '답글을 입력하세요.': 'Please enter your reply.',\n  '댓글을 입력하세요.': 'Please enter your comment.',\n  등록: 'registration',\n  광고: 'advertisement',\n  '{{title}} 썸네일': '{{title}} thumbnail',\n  '소셜 리뷰': 'social review',\n  '컨텐츠를 불러올 수 없습니다.': 'Content could not be loaded.',\n  '컨텐츠를 로딩중입니다.': 'Content is loading.',\n  '클립보드에 복사되었습니다.': 'Copied to clipboard.',\n  '링크를 복사했습니다.': 'I copied the link.',\n  '인증이 필요해요!': 'I need verification!',\n  '예약을 위해서는 휴대폰 인증이 필요합니다. (최초 1회)':\n    'Mobile phone verification is required\\nto make a reservation. (First time)',\n  뒤로가기: 'Go back',\n  인증하기: 'Verify',\n  '문제가 발생했습니다.': 'A problem has occurred.',\n  '브라우저에서 사용해주세요.': 'Please use it in your browser.',\n  '브라우저를 완전히 종료하고 다시 접속해 주세요.':\n    'Please completely close your browser and reconnect.',\n  '브라우저 캐시를 모두 비워주세요.': 'Please clear all browser cache.',\n  '브라우저의 쿠키 차단 설정을 해제해주세요.':\n    'Please disable cookie blocking settings in your browser.',\n  '알 수 없는 에러가 발생했습니다. 잠시 후 다시 시도해 주세요.':\n    'An unknown error occurred. ',\n  남자: 'Male',\n  여자: 'Female',\n  선택완료: 'Selection complete',\n  선택하기: 'Select',\n  휴무일: 'Closed days',\n  '영업준비중 {{todayBusinessHours}}':\n    'Preparing for business {{todayBusinessHours}}',\n  리뷰보기: 'See reviews',\n  지도보기: 'View map',\n  '놓치기 아까운 이 지역 꿀 정보':\n    'Great information about this area\\nthat you don’t want to miss',\n  '놓치기 아까운 이 지역 꿀 정보 2':\n    'Great information about this area that you don’t want to miss',\n  '여행 정보 더보기': 'More travel information',\n  '트리플 앱에서 더보기': 'See more in Triple App',\n  '영업중 {{todayBusinessHours}}': 'Open {{todayBusinessHours}}',\n  '더이상 운영하지 않습니다.': 'It is no longer in operation.',\n  '이곳의 첫 번째 사진을 올려주세요.': 'Please post the first photo here.',\n  '답글을 삭제하시겠습니까?': 'Are you sure you want to delete your reply?',\n  '댓글을 삭제하시겠습니까?': 'Are you sure you want to delete your comment?',\n  '삭제되었습니다.': 'It has been deleted.',\n  '수정을 취소하시겠습니까? 수정한 내용은 저장되지 않습니다.':\n    'Do you want to cancel your modifications?\\nYour modifications will not be saved.',\n  '이전 댓글 더보기': 'View previous comments',\n  '아직 댓글이 없어요. 가장 먼저 댓글을 작성해보세요!':\n    'There are no comments yet.\\nBe the first to write a comment!',\n  수정하기: 'Edit',\n  삭제하기: 'Delete',\n  '삭제하겠습니까? 삭제하면 적립된 리뷰 포인트도 함께 사라집니다.':\n    'Are you sure you want to delete it? ',\n  신고하기: 'Report',\n  최근여행: 'Recent travel',\n  '최근 6개월 내에 방문한 여행의 리뷰만 모아 볼 수 있습니다.':\n    'You can only collect reviews of trips taken within the last 6 months.',\n  '이곳의 첫 번째 리뷰를 올려주세요.': 'Be the first to review here.',\n  '선택한 조건의 리뷰가 없습니다.':\n    'There are no reviews for the selected criteria.',\n  '다녀온 여행지의 리뷰를 남겨보세요.':\n    'Leave a review\\nofthe places you visited.',\n  '전체 리뷰 보기': 'See full review',\n  '탈퇴한 사용자입니다.': 'This user has withdrawn.',\n  추천순: 'Recommended',\n  최신순: 'Latest',\n  '접근할 수 없는 링크입니다.': 'This link is inaccessible.',\n  '웹에서 보기': 'View on web',\n  '트리플에서 보기': 'View in triple',\n  '내 일정으로 담기': 'Add to My Schedule',\n  보기: 'look',\n  바로가기: 'Shortcuts',\n  더보기: 'see more',\n  '신고가 접수되어 블라인드 처리되었습니다.':\n    'A report has been received and has been blinded.',\n  '{{visitYear}}년 {{visitMonth}}월 여행':\n    '{{visitYear}}year {{visitMonth}}month travel',\n  '{{reviewsCount}}개의 리뷰': '{{reviewsCount}}reviews',\n  '쿠폰 받기': 'Get Coupon',\n  '이미 받은 쿠폰입니다.': 'This coupon has already been received.',\n  '쿠폰 받기 완료': 'Completed receiving coupon',\n  '쿠폰을 받았습니다! 쿠폰함에서 확인할 수 있어요~!':\n    'You received a coupon!\\nYou can check it in the coupon box!',\n  '쿠폰을 모두 받았습니다. 쿠폰함에서 확인할 수 있어요~!':\n    'I have received all coupons.\\nYou can check it in the coupon box!',\n  '쿠폰함에서 쿠폰을 확인하세요.': 'Check the coupon in the coupon box.',\n  '쿠폰을 모두 받았습니다. (이미 받은 쿠폰 제외) 쿠폰함에서 확인할 수 있어요~!':\n    'I have received all coupons.\\n(Excluding coupons already received)\\nYou can check it in the coupon box!',\n  '쿠폰함 가기': 'Go to coupon box',\n  '쿠폰 확인': 'Check coupon',\n  '쿠폰 다운로드 안내': 'Coupon download information',\n  쿠폰할인: 'Coupon discount',\n  가능: 'possible',\n  쿠폰할인가: 'Coupon discount price',\n  쿠폰적용시: 'When applying coupon',\n  무료: 'free',\n  '내 예약에서 확인': 'Check in My Reservations',\n  '예약이 접수되었습니다.': 'Your reservation\\nhas been accepted',\n  '자세한 사항은 내 예약에서 확인해주세요.':\n    'Please check My Reservation for more details.',\n  '트리플 홈으로 가기': 'Go to Triple Home',\n  '{{regionName}} 여행 준비하러 가기': '{{regionName}} Go prepare for travel',\n  '내 일정에 추가하기': 'Add to my schedule',\n  '{{formattedSalePrice}}원': '{{formattedSalePrice}}one',\n  일시품절: 'Temporarily out of stock',\n  '저장 {{formattedScrapsCount}}': 'save {{formattedScrapsCount}}',\n  원: 'krw',\n  '최대 금액 초과': 'Exceeded maximum amount',\n  '{{flooredPrice}}천원': '{{flooredPrice}}One thousand krw',\n  '{{formattedPrice}}원': '{{formattedPrice}} krw',\n  '{{flooredPrice}}만{{convertedPrice}}': '{{flooredPrice}}{{convertedPrice}}',\n  '출처 {{httpsSchemeRemovedUrl}}': 'source {{httpsSchemeRemovedUrl}}',\n  '{{distance}} 이내': '{{distance}} Within',\n  '{{formattedNightlyPrice}}원': '{{formattedNightlyPrice}}krw',\n  '작성자가 삭제한 댓글입니다.': 'This comment has been deleted by the author.',\n  '다른 사용자의 신고로 블라인드 되었습니다.':\n    \"You have been blinded due to another user's report.\",\n  '내 답글': 'my reply',\n  '내 댓글': 'my comment',\n  답글: 'reply',\n  댓글: 'comment',\n  '좋아요 {{likeReactionCount}}': 'great {{likeReactionCount}}',\n  답글달기: 'Reply',\n  '이전 답글 더보기': 'View previous replies',\n  '...더보기': '… view more',\n  '포인트별 혜택 보기': 'View benefits by point',\n  리뷰: 'review',\n  '개의 리뷰': 'reviews',\n  '{{numOfRestReviews}}개 리뷰 더보기':\n    '{{numOfRestReviews}}View more dog reviews',\n  '리뷰 쓰면 여행자 클럽 최대 3포인트!':\n    'Write a review and receive up to 3 Traveler Club points!',\n  '{{formattedApplicableAmountAfterUsingCoupon}}원':\n    '{{formattedApplicableAmountAfterUsingCoupon}}krw',\n  '{{title}}의 썸네일': '{{title}}thumbnail of',\n  '{{formattedBasePrice}}원': '{{formattedBasePrice}}krw',\n  '셀프패키지 추가할인 가능': 'Additional discount available for self-package',\n  관광: 'tourism',\n  맛집: 'restaurant',\n  '솔직한 리뷰 110만개와 최신 여행 소식까지 앱에서 편하게 확인하세요!':\n    'Check out 1.1 million honest reviews and the latest travel news conveniently in the app!',\n  '여기는 트리플 앱이 필요해요': 'You need a triple app here',\n  '사진/동영상': 'Photos/Videos',\n  '별점 높은순': 'Highest rating',\n  '별점 낮은순': 'Lowest rating',\n  '구매 인증 리뷰': 'Certified Purchase Review',\n} as const\n"
  },
  {
    "path": "packages/i18n/src/locales/index.ts",
    "content": "import en from './en'\nimport ja from './ja'\nimport ko from './ko'\nimport zhTw from './zh-TW'\n\nexport const locales = { en, ja, ko, zhTw }\n"
  },
  {
    "path": "packages/i18n/src/locales/ja.ts",
    "content": "export default {\n  '앱에서 보기': 'アプリで見る',\n  닫기: '閉じる',\n  트리플: 'TRIPLE',\n  '트리플 앱 설치하기': 'TRIPLEアプリをインストールする',\n  '가이드북, 일정짜기, 길찾기, 맛집':\n    'ガイドブック・日程を作成・ルートを探す・グルメ',\n  '편하게 앱에서 보기': 'アプリで見る',\n  '아깝지만 나중에 받을게요': '後で受け取ります',\n  전화하기: '電話する',\n  '국제 전화 요금이 부과될 수 있습니다.':\n    '国際電話料金が請求される場合があります。',\n  '현지에서 길묻기': '現地で道を尋ねる',\n  'grab-hocul': 'Grabでタクシーを呼ぶ',\n  길찾기: 'ルートを探す',\n  주소: '住所',\n  전화: '電話',\n  홈페이지: 'ホームページ',\n  복사하기: 'コピーする',\n  복사: 'コピー',\n  '로그인이 필요합니다.': 'ログインしてください。',\n  '로그인하고 트리플을 더 편하게 이용하세요':\n    'ログインしてもっと便利にTRIPLEをご利用ください🙂',\n  취소: 'キャンセル',\n  확인: '確認',\n  '일정 짜기부터 호텔, 투어・티켓 예약까지! 트리플로 한 번에 여행 준비하세요.':\n    '日程の管理からホテル・ツアー。チケットの予約まで！\\nTRIPLEならワンストップで準備完了',\n  '트리플 가기': 'TRIPLEに移動',\n  저장취소: '保存を取り消す',\n  저장하기: '保存する',\n  리뷰수정: 'レビューを編集',\n  리뷰쓰기: 'レビューを書く',\n  일정추가: '日程を作成',\n  공유하기: '共有する',\n  '근처의 추천 장소': '近所のおすすめ',\n  '장소가 없습니다.': '場所がありません。',\n  '더 많은 장소 보기': '場所をもっと見る',\n  관광명소: '観光名所',\n  음식점: '飲食店',\n  호텔: 'ホテル',\n  '{{starRating}}성급': '{{starRating}}星',\n  'Triple 항공 홈': 'TRIPLE 航空ホーム',\n  'Triple 숙소 홈': 'TRIPLE 宿泊ホーム',\n  'Triple 투어 티켓 홈': 'TRIPLE ツアー・チケットホーム',\n  'Triple 홈': 'TRIPLE ホーム',\n  항공: '航空',\n  숙소: '宿泊',\n  '투어 티켓': 'ツアーチケット',\n  '내 예약': '私の予約',\n  '댓글 수정 중': 'コメントを編集中',\n  '{{mentioningUserName}}님께 답글 작성 중':\n    '{{mentioningUserName}}さんにコメントを作成中',\n  '{{mentioningUserName}}님에게 작성한 답글 수정 중':\n    '{{mentioningUserName}}さんに書いたコメントを編集中',\n  '답글을 입력하세요.': 'コメントする',\n  '댓글을 입력하세요.': '返信する',\n  등록: '登録',\n  광고: '広告',\n  '{{title}} 썸네일': '{{title}} サムネイル',\n  '소셜 리뷰': 'SNSレビュー',\n  '컨텐츠를 불러올 수 없습니다.': 'コンテンツを読み込むことができません。',\n  '컨텐츠를 로딩중입니다.': 'コンテンツを読込中です。',\n  '클립보드에 복사되었습니다.': 'クリップボードにコピーしました。',\n  '링크를 복사했습니다.': 'リンクをコピーしました。',\n  '인증이 필요해요!': 'ご本人確認が必要です。',\n  '예약을 위해서는 휴대폰 인증이 필요합니다. (최초 1회)':\n    '予約のためには\\n携帯端末のご本人確認が必要です（最初1回）',\n  뒤로가기: 'もどる',\n  인증하기: '本人確認する',\n  '문제가 발생했습니다.': '問題が発生しました。',\n  '브라우저에서 사용해주세요.': 'ブラウザーでご利用ください。',\n  '브라우저를 완전히 종료하고 다시 접속해 주세요.':\n    'ブラウザーを完全に終了し、再起動してください。',\n  '브라우저 캐시를 모두 비워주세요.':\n    'ブラウザーのキャッシュをクリアしてください。',\n  '브라우저의 쿠키 차단 설정을 해제해주세요.':\n    'ブラウザーのクッキーを許可してください。',\n  '알 수 없는 에러가 발생했습니다. 잠시 후 다시 시도해 주세요.':\n    '原因不明のエラーが起きました。しばらくしてからもう一度お試しください。',\n  남자: '男性',\n  여자: '女性',\n  선택완료: '選択完了',\n  선택하기: '選択する',\n  휴무일: '定休日',\n  '영업준비중 {{todayBusinessHours}}': '営業準備中 {{todayBusinessHours}}',\n  리뷰보기: 'レビューを見る',\n  지도보기: '地図を見る',\n  '놓치기 아까운 이 지역 꿀 정보': '見逃すと損！\\nこの地域の情報',\n  '놓치기 아까운 이 지역 꿀 정보 2': '見逃すと損！この地域の情報',\n  '여행 정보 더보기': '旅行の情報を見る',\n  '트리플 앱에서 더보기': 'TRIPLEアプリで見る',\n  '영업중 {{todayBusinessHours}}': '営業中 {{todayBusinessHours}}',\n  '더이상 운영하지 않습니다.': '閉店しました',\n  '이곳의 첫 번째 사진을 올려주세요.': 'ここの最初の写真を登録してください。',\n  '답글을 삭제하시겠습니까?': 'コメントを削除しますか？',\n  '댓글을 삭제하시겠습니까?': 'コメントを削除しますか？',\n  '삭제되었습니다.': '削除されました。',\n  '수정을 취소하시겠습니까? 수정한 내용은 저장되지 않습니다.':\n    '編集をキャンセルしますか？\\n編集した内容は保存できません。',\n  '이전 댓글 더보기': '以前のコメントを見る',\n  '아직 댓글이 없어요. 가장 먼저 댓글을 작성해보세요!':\n    'まだコメントがありません。\\nいち早くコメントを書いてみましょう。',\n  수정하기: '編集する',\n  삭제하기: '削除する',\n  '삭제하겠습니까? 삭제하면 적립된 리뷰 포인트도 함께 사라집니다.':\n    '削除しますか？削除すると貯まったレビューポイントも無効になります。',\n  신고하기: '通報する',\n  최근여행: '最近の旅行',\n  '최근 6개월 내에 방문한 여행의 리뷰만 모아 볼 수 있습니다.':\n    '最近６ヶ月以内のレビューだけをまとめて見ることができます。',\n  '이곳의 첫 번째 리뷰를 올려주세요.':\n    'ここの初めてのレビューを書いてください。',\n  '선택한 조건의 리뷰가 없습니다.': '選択した条件のレビューがありません。',\n  '다녀온 여행지의 리뷰를 남겨보세요.':\n    '訪問した旅行地の\\nレビューを書いてみてください。',\n  '전체 리뷰 보기': '全てのレビューを見る',\n  '탈퇴한 사용자입니다.': '脱会したユーザーです。',\n  추천순: 'おすすめ順',\n  최신순: '最新順',\n  '접근할 수 없는 링크입니다.': 'アクセスできないリンクです。',\n  '웹에서 보기': 'ウェブで見る',\n  '트리플에서 보기': 'TRIPLEで見る',\n  '내 일정으로 담기': '私の日程に追加',\n  보기: '見る',\n  바로가기: '移動する',\n  더보기: 'もっと見る',\n  '신고가 접수되어 블라인드 처리되었습니다.': '通報により、表示できません。',\n  '{{visitYear}}년 {{visitMonth}}월 여행':\n    '{{visitYear}}年 {{visitMonth}}月の旅行',\n  '{{reviewsCount}}개의 리뷰': '{{reviewsCount}}個のレビュー',\n  '쿠폰 받기': 'クーポンを獲得する',\n  '이미 받은 쿠폰입니다.': '獲得済みのクーポンです。',\n  '쿠폰 받기 완료': 'クーポンを獲得しました。',\n  '쿠폰을 받았습니다! 쿠폰함에서 확인할 수 있어요~!':\n    'クーポンを獲得しました。\\nマイクーポンで確認できます。',\n  '쿠폰을 모두 받았습니다. 쿠폰함에서 확인할 수 있어요~!':\n    '全てのクーポンを獲得しました。\\nマイクーポンで確認することができます。',\n  '쿠폰함에서 쿠폰을 확인하세요.': 'マイクーポンでクーポンを確認してください。',\n  '쿠폰을 모두 받았습니다. (이미 받은 쿠폰 제외) 쿠폰함에서 확인할 수 있어요~!':\n    '全てのクーポンを獲得しました。\\n(獲得済みのクーポンを除く)\\nマイクーポンで確認することができます。',\n  '쿠폰함 가기': 'マイクーポンに移動',\n  '쿠폰 확인': 'クーポンを確認',\n  '쿠폰 다운로드 안내': 'クーポンのダウンロード案内',\n  쿠폰할인: 'クーポン割引',\n  가능: '可能',\n  쿠폰할인가: 'クーポン割引価格',\n  쿠폰적용시: 'クーポン適用価格',\n  무료: '無料',\n  '내 예약에서 확인': '「私の予約」で確認',\n  '예약이 접수되었습니다.': '予約を\\n受け付けました。',\n  '자세한 사항은 내 예약에서 확인해주세요.':\n    '詳細は「私の予約」でご確認ください。',\n  '트리플 홈으로 가기': 'TRIPLEホームに移動',\n  '{{regionName}} 여행 준비하러 가기': '{{regionName}} 旅行の準備をする',\n  '내 일정에 추가하기': '私の日程に追加',\n  '{{formattedSalePrice}}원': '{{formattedSalePrice}}円',\n  일시품절: '一時売り切れ',\n  '저장 {{formattedScrapsCount}}': '保存{{formattedScrapsCount}}',\n  원: 'ウォン',\n  '최대 금액 초과': '最大限度額を超過',\n  '{{flooredPrice}}천원': '⁇',\n  '{{formattedPrice}}원': '{{formattedPrice}}円',\n  '{{flooredPrice}}만{{convertedPrice}}':\n    '{{flooredPrice}}万{{convertedPrice}}',\n  '출처 {{httpsSchemeRemovedUrl}}': '出典 {{httpsSchemeRemovedUrl}}',\n  '{{distance}} 이내': '{{distance}} 以内',\n  '{{formattedNightlyPrice}}원': '{{formattedNightlyPrice}}円',\n  '작성자가 삭제한 댓글입니다.': 'ユーザーが削除したコメントです。',\n  '다른 사용자의 신고로 블라인드 되었습니다.':\n    '他のユーザーの通報により表示できません。',\n  '내 답글': '私のコメント',\n  '내 댓글': '私の返信',\n  답글: 'コメント',\n  댓글: '返信',\n  '좋아요 {{likeReactionCount}}': 'いいね {{likeReactionCount}}',\n  답글달기: 'コメントする',\n  '이전 답글 더보기': '以前のコメントを見る',\n  '...더보기': '…もっと見る',\n  '포인트별 혜택 보기': 'ポイント別特典を見る',\n  리뷰: 'レビュー',\n  '개의 리뷰': '個のレビュー',\n  '{{numOfRestReviews}}개 리뷰 더보기':\n    '{{numOfRestReviews}}個のレビューをもっと見る',\n  '리뷰 쓰면 여행자 클럽 최대 3포인트!':\n    'レビューを書くと旅行者クラブ最大３ポイント！',\n  '{{formattedApplicableAmountAfterUsingCoupon}}원':\n    '{{formattedApplicableAmountAfterUsingCoupon}}円',\n  '{{title}}의 썸네일': '{{title}}のサムネイル',\n  '{{formattedBasePrice}}원': '{{formattedBasePrice}}円',\n  '셀프패키지 추가할인 가능': 'セルフパッケージの追加割引可能',\n  관광: '観光',\n  맛집: 'グルメ',\n  '솔직한 리뷰 110만개와 최신 여행 소식까지 앱에서 편하게 확인하세요!':\n    '솔직한 리뷰 110만개와 최신 여행 소식까지 앱에서 편하게 확인하세요',\n  '여기는 트리플 앱이 필요해요': '여기는 트리플 앱이 필요해요',\n  '사진/동영상': '写真/動画',\n  '별점 높은순': '星の高い順',\n  '별점 낮은순': '星の低い順',\n  '구매 인증 리뷰': '購入認証レビュー',\n} as const\n"
  },
  {
    "path": "packages/i18n/src/locales/ko.ts",
    "content": "export default {\n  '앱에서 보기': '앱에서 보기',\n  닫기: '닫기',\n  트리플: '트리플',\n  '트리플 앱 설치하기': '트리플 앱 설치하기',\n  '가이드북, 일정짜기, 길찾기, 맛집': '가이드북, 일정짜기, 길찾기, 맛집',\n  '편하게 앱에서 보기': '편하게 앱에서 보기',\n  '아깝지만 나중에 받을게요': '아깝지만 나중에 받을게요',\n  전화하기: '전화하기',\n  '국제 전화 요금이 부과될 수 있습니다.':\n    '국제 전화 요금이 부과될 수 있습니다.',\n  '현지에서 길묻기': '현지에서 길묻기',\n  'grab-hocul': 'Grab 호출',\n  길찾기: '길찾기',\n  주소: '주소',\n  전화: '전화',\n  홈페이지: '홈페이지',\n  복사하기: '복사하기',\n  복사: '복사',\n  '로그인이 필요합니다.': '로그인이 필요합니다.',\n  '로그인하고 트리플을 더 편하게 이용하세요':\n    '로그인하고 트리플을\\n더 편하게 이용하세요🙂',\n  취소: '취소',\n  확인: '확인',\n  '일정 짜기부터 호텔, 투어・티켓 예약까지! 트리플로 한 번에 여행 준비하세요.':\n    '일정 짜기부터 호텔, 투어・티켓 예약까지!\\n트리플로 한 번에 여행 준비하세요.',\n  '트리플 가기': '트리플 가기',\n  저장취소: '저장취소',\n  저장하기: '저장하기',\n  리뷰수정: '리뷰수정',\n  리뷰쓰기: '리뷰쓰기',\n  일정추가: '일정추가',\n  공유하기: '공유하기',\n  '근처의 추천 장소': '근처의 추천 장소',\n  '장소가 없습니다.': '장소가 없습니다.',\n  '더 많은 장소 보기': '더 많은 장소 보기',\n  관광명소: '관광명소',\n  음식점: '음식점',\n  호텔: '호텔',\n  '{{starRating}}성급': '{{starRating}}성급',\n  'Triple 항공 홈': 'Triple 항공 홈',\n  'Triple 숙소 홈': 'Triple 숙소 홈',\n  'Triple 투어 티켓 홈': 'Triple 투어 티켓 홈',\n  'Triple 홈': 'Triple 홈',\n  항공: '항공',\n  숙소: '숙소',\n  '투어 티켓': '투어 티켓',\n  '내 예약': '내 예약',\n  '댓글 수정 중': '댓글 수정 중',\n  '{{mentioningUserName}}님께 답글 작성 중':\n    '{{mentioningUserName}}님께 답글 작성 중',\n  '{{mentioningUserName}}님에게 작성한 답글 수정 중':\n    '{{mentioningUserName}}님에게 작성한 답글 수정 중',\n  '답글을 입력하세요.': '답글을 입력하세요.',\n  '댓글을 입력하세요.': '댓글을 입력하세요.',\n  등록: '등록',\n  광고: '광고',\n  '{{title}} 썸네일': '{{title}} 썸네일',\n  '소셜 리뷰': '소셜 리뷰',\n  '컨텐츠를 불러올 수 없습니다.': '컨텐츠를 불러올 수 없습니다.',\n  '컨텐츠를 로딩중입니다.': '컨텐츠를 로딩중입니다.',\n  '클립보드에 복사되었습니다.': '클립보드에 복사되었습니다.',\n  '링크를 복사했습니다.': '링크를 복사했습니다.',\n  '인증이 필요해요!': '인증이 필요해요!',\n  '예약을 위해서는 휴대폰 인증이 필요합니다. (최초 1회)':\n    '예약을 위해서는\\n휴대폰 인증이 필요합니다. (최초 1회)',\n  뒤로가기: '뒤로가기',\n  인증하기: '인증하기',\n  '문제가 발생했습니다.': '문제가 발생했습니다.',\n  '브라우저에서 사용해주세요.': '브라우저에서 사용해주세요.',\n  '브라우저를 완전히 종료하고 다시 접속해 주세요.':\n    '브라우저를 완전히 종료하고 다시 접속해 주세요.',\n  '브라우저 캐시를 모두 비워주세요.': '브라우저 캐시를 모두 비워주세요.',\n  '브라우저의 쿠키 차단 설정을 해제해주세요.':\n    '브라우저의 쿠키 차단 설정을 해제해주세요.',\n  '알 수 없는 에러가 발생했습니다. 잠시 후 다시 시도해 주세요.':\n    '알 수 없는 에러가 발생했습니다. 잠시 후 다시 시도해 주세요.',\n  남자: '남자',\n  여자: '여자',\n  선택완료: '선택완료',\n  선택하기: '선택하기',\n  휴무일: '휴무일',\n  '영업준비중 {{todayBusinessHours}}': '영업준비중 {{todayBusinessHours}}',\n  리뷰보기: '리뷰보기',\n  지도보기: '지도보기',\n  '놓치기 아까운 이 지역 꿀 정보': '놓치기 아까운\\n이 지역 꿀 정보',\n  '놓치기 아까운 이 지역 꿀 정보 2': '놓치기 아까운 이 지역 꿀 정보',\n  '여행 정보 더보기': '여행 정보 더보기',\n  '트리플 앱에서 더보기': '트리플 앱에서 더보기',\n  '영업중 {{todayBusinessHours}}': '영업중 {{todayBusinessHours}}',\n  '더이상 운영하지 않습니다.': '더이상 운영하지 않습니다.',\n  '이곳의 첫 번째 사진을 올려주세요.': '이곳의 첫 번째 사진을 올려주세요.',\n  '답글을 삭제하시겠습니까?': '답글을 삭제하시겠습니까?',\n  '댓글을 삭제하시겠습니까?': '댓글을 삭제하시겠습니까?',\n  '삭제되었습니다.': '삭제되었습니다.',\n  '수정을 취소하시겠습니까? 수정한 내용은 저장되지 않습니다.':\n    '수정을 취소하시겠습니까?\\n수정한 내용은 저장되지 않습니다.',\n  '이전 댓글 더보기': '이전 댓글 더보기',\n  '아직 댓글이 없어요. 가장 먼저 댓글을 작성해보세요!':\n    '아직 댓글이 없어요.\\n가장 먼저 댓글을 작성해보세요!',\n  수정하기: '수정하기',\n  삭제하기: '삭제하기',\n  '삭제하겠습니까? 삭제하면 적립된 리뷰 포인트도 함께 사라집니다.':\n    '삭제하겠습니까? 삭제하면 적립된 리뷰 포인트도 함께 사라집니다.',\n  신고하기: '신고하기',\n  최근여행: '최근여행',\n  '최근 6개월 내에 방문한 여행의 리뷰만 모아 볼 수 있습니다.':\n    '최근 6개월 내에 방문한 여행의 리뷰만 모아 볼 수 있습니다.',\n  '이곳의 첫 번째 리뷰를 올려주세요.': '이곳의 첫 번째 리뷰를 올려주세요.',\n  '선택한 조건의 리뷰가 없습니다.': '선택한 조건의 리뷰가 없습니다.',\n  '다녀온 여행지의 리뷰를 남겨보세요.': '다녀온 여행지의\\n리뷰를 남겨보세요.',\n  '전체 리뷰 보기': '전체 리뷰 보기',\n  '탈퇴한 사용자입니다.': '탈퇴한 사용자입니다.',\n  추천순: '추천순',\n  최신순: '최신순',\n  '접근할 수 없는 링크입니다.': '접근할 수 없는 링크입니다.',\n  '웹에서 보기': '웹에서 보기',\n  '트리플에서 보기': '트리플에서 보기',\n  '내 일정으로 담기': '내 일정으로 담기',\n  보기: '보기',\n  바로가기: '바로가기',\n  더보기: '더보기',\n  '신고가 접수되어 블라인드 처리되었습니다.':\n    '신고가 접수되어 블라인드 처리되었습니다.',\n  '{{visitYear}}년 {{visitMonth}}월 여행':\n    '{{visitYear}}년 {{visitMonth}}월 여행',\n  '{{reviewsCount}}개의 리뷰': '{{reviewsCount}}개의 리뷰',\n  '쿠폰 받기': '쿠폰 받기',\n  '이미 받은 쿠폰입니다.': '이미 받은 쿠폰입니다.',\n  '쿠폰 받기 완료': '쿠폰 받기 완료',\n  '쿠폰을 받았습니다! 쿠폰함에서 확인할 수 있어요~!':\n    '쿠폰을 받았습니다!\\n쿠폰함에서 확인할 수 있어요~!',\n  '쿠폰을 모두 받았습니다. 쿠폰함에서 확인할 수 있어요~!':\n    '쿠폰을 모두 받았습니다.\\n쿠폰함에서 확인할 수 있어요~!',\n  '쿠폰함에서 쿠폰을 확인하세요.': '쿠폰함에서 쿠폰을 확인하세요.',\n  '쿠폰을 모두 받았습니다. (이미 받은 쿠폰 제외) 쿠폰함에서 확인할 수 있어요~!':\n    '쿠폰을 모두 받았습니다.\\n(이미 받은 쿠폰 제외)\\n쿠폰함에서 확인할 수 있어요~!',\n  '쿠폰함 가기': '쿠폰함 가기',\n  '쿠폰 확인': '쿠폰 확인',\n  '쿠폰 다운로드 안내': '쿠폰 다운로드 안내',\n  쿠폰할인: '쿠폰할인',\n  가능: '가능',\n  쿠폰할인가: '쿠폰할인가',\n  쿠폰적용시: '쿠폰적용시',\n  무료: '무료',\n  '내 예약에서 확인': '내 예약에서 확인',\n  '예약이 접수되었습니다.': '예약이\\n접수되었습니다.',\n  '자세한 사항은 내 예약에서 확인해주세요.':\n    '자세한 사항은 내 예약에서 확인해주세요.',\n  '트리플 홈으로 가기': '트리플 홈으로 가기',\n  '{{regionName}} 여행 준비하러 가기': '{{regionName}} 여행 준비하러 가기',\n  '내 일정에 추가하기': '내 일정에 추가하기',\n  '{{formattedSalePrice}}원': '{{formattedSalePrice}}원',\n  일시품절: '일시품절',\n  '저장 {{formattedScrapsCount}}': '저장 {{formattedScrapsCount}}',\n  원: '원',\n  '최대 금액 초과': '최대 금액 초과',\n  '{{flooredPrice}}천원': '{{flooredPrice}}천원',\n  '{{formattedPrice}}원': '{{formattedPrice}}원',\n  '{{flooredPrice}}만{{convertedPrice}}':\n    '{{flooredPrice}}만{{convertedPrice}}',\n  '출처 {{httpsSchemeRemovedUrl}}': '출처 {{httpsSchemeRemovedUrl}}',\n  '{{distance}} 이내': '{{distance}} 이내',\n  '{{formattedNightlyPrice}}원': '{{formattedNightlyPrice}}원',\n  '작성자가 삭제한 댓글입니다.': '작성자가 삭제한 댓글입니다.',\n  '다른 사용자의 신고로 블라인드 되었습니다.':\n    '다른 사용자의 신고로 블라인드 되었습니다.',\n  '내 답글': '내 답글',\n  '내 댓글': '내 댓글',\n  답글: '답글',\n  댓글: '댓글',\n  '좋아요 {{likeReactionCount}}': '좋아요 {{likeReactionCount}}',\n  답글달기: '답글달기',\n  '이전 답글 더보기': '이전 답글 더보기',\n  '...더보기': '…더보기',\n  '포인트별 혜택 보기': '포인트별 혜택 보기',\n  리뷰: '리뷰',\n  '개의 리뷰': '개의 리뷰',\n  '{{numOfRestReviews}}개 리뷰 더보기': '{{numOfRestReviews}}개 리뷰 더보기',\n  '리뷰 쓰면 여행자 클럽 최대 3포인트!': '리뷰 쓰면 여행자 클럽 최대 3포인트!',\n  '{{formattedApplicableAmountAfterUsingCoupon}}원':\n    '{{formattedApplicableAmountAfterUsingCoupon}}원',\n  '{{title}}의 썸네일': '{{title}}의 썸네일',\n  '{{formattedBasePrice}}원': '{{formattedBasePrice}}원',\n  '셀프패키지 추가할인 가능': '셀프패키지 추가할인 가능',\n  관광: '관광',\n  맛집: '맛집',\n  '솔직한 리뷰 110만개와 최신 여행 소식까지 앱에서 편하게 확인하세요!':\n    '솔직한 리뷰 110만개와 최신 여행 소식까지 앱에서 편하게 확인하세요!',\n  '여기는 트리플 앱이 필요해요': '여기는 트리플 앱이 필요해요',\n  '사진/동영상': '사진/동영상',\n  '별점 높은순': '별점 높은순',\n  '별점 낮은순': '별점 낮은순',\n  '구매 인증 리뷰': '구매 인증 리뷰',\n} as const\n"
  },
  {
    "path": "packages/i18n/src/locales/zh-TW.ts",
    "content": "export default {\n  '앱에서 보기': '在App裡查看',\n  닫기: '關閉',\n  트리플: 'Triple',\n  '트리플 앱 설치하기': '立即下載 Triple App',\n  '가이드북, 일정짜기, 길찾기, 맛집': '攻略、計畫行程、路線導航、餐廳',\n  '편하게 앱에서 보기': '在App裡輕鬆查看',\n  '아깝지만 나중에 받을게요': '暫時不下載使用',\n  전화하기: '致電',\n  '국제 전화 요금이 부과될 수 있습니다.': '可能需追加國際電話費用',\n  '현지에서 길묻기': '在當地問路',\n  'grab-hocul': '呼叫Grab',\n  길찾기: '路線導航',\n  주소: '地址',\n  전화: '電話',\n  홈페이지: '網頁',\n  복사하기: '複製',\n  복사: '複製',\n  '로그인이 필요합니다.': '需要登入',\n  '로그인하고 트리플을 더 편하게 이용하세요': '登入Triple，使用更輕鬆',\n  취소: '取消',\n  확인: '確認',\n  '일정 짜기부터 호텔, 투어・티켓 예약까지! 트리플로 한 번에 여행 준비하세요.':\n    '從計畫行程到當地遊、票券預約！\\n透過Triple一次完成各種旅行準備',\n  '트리플 가기': '前往Triple',\n  저장취소: '取消收藏',\n  저장하기: '收藏',\n  리뷰수정: '修改評論',\n  리뷰쓰기: '撰寫評論',\n  일정추가: '新增行程',\n  공유하기: '分享',\n  '근처의 추천 장소': '附近推薦地點',\n  '장소가 없습니다.': '無附近地點資訊',\n  '더 많은 장소 보기': '查看更多地點',\n  관광명소: '景點',\n  음식점: '餐廳',\n  호텔: '飯店',\n  '{{starRating}}성급': '{{starRating}}星級',\n  'Triple 항공 홈': 'Triple 機票首頁',\n  'Triple 숙소 홈': 'Triple 住宿首頁',\n  'Triple 투어 티켓 홈': 'Triple 當地遊、票券首頁',\n  'Triple 홈': 'Triple 首頁',\n  항공: '機票',\n  숙소: '住宿',\n  '투어 티켓': '當地遊、票券',\n  '내 예약': '我的預約',\n  '댓글 수정 중': '修改評論中',\n  '{{mentioningUserName}}님께 답글 작성 중': '正在回覆{{mentioningUserName}}',\n  '{{mentioningUserName}}님에게 작성한 답글 수정 중':\n    '正在修改給{{mentioningUserName}}的回覆',\n  '답글을 입력하세요.': '新增回覆',\n  '댓글을 입력하세요.': '新增留言',\n  등록: '發佈',\n  광고: '廣告',\n  '{{title}} 썸네일': '代表圖片',\n  '소셜 리뷰': '社群評論',\n  '컨텐츠를 불러올 수 없습니다.': '無法導入文章',\n  '컨텐츠를 로딩중입니다.': '正在導入文章中',\n  '클립보드에 복사되었습니다.': '已複製至剪貼簿',\n  '링크를 복사했습니다.': '已複製連結',\n  '인증이 필요해요!': '需要進行認證',\n  '예약을 위해서는 휴대폰 인증이 필요합니다. (최초 1회)':\n    '進行預約前須進行手機認證 (首次預約時)',\n  뒤로가기: '返回',\n  인증하기: '認證',\n  '문제가 발생했습니다.': '已發送簡訊',\n  '브라우저에서 사용해주세요.': '請使用網頁進行',\n  '브라우저를 완전히 종료하고 다시 접속해 주세요.':\n    '請完全關閉網頁後再嘗試進行',\n  '브라우저 캐시를 모두 비워주세요.': '請清除網頁快取紀錄',\n  '브라우저의 쿠키 차단 설정을 해제해주세요.': '請解除封鎖網頁Cookie設定',\n  '알 수 없는 에러가 발생했습니다. 잠시 후 다시 시도해 주세요.':\n    '發生了不明障礙，請稍後再嘗試進行',\n  남자: '男性',\n  여자: '女性',\n  선택완료: '選擇完成',\n  선택하기: '選擇',\n  휴무일: '休息日',\n  '영업준비중 {{todayBusinessHours}}': '營業準備中 {{todayBusinessHours}}',\n  리뷰보기: '查看評論',\n  지도보기: '查看地圖',\n  '놓치기 아까운 이 지역 꿀 정보': '不能錯過的旅遊秘笈',\n  '놓치기 아까운 이 지역 꿀 정보 2': '不能錯過的旅遊秘笈',\n  '여행 정보 더보기': '查看更多旅行資訊',\n  '트리플 앱에서 더보기': '在Triple裡查看更多',\n  '영업중 {{todayBusinessHours}}': '營業中 {{todayBusinessHours}}',\n  '더이상 운영하지 않습니다.': '已歇業',\n  '이곳의 첫 번째 사진을 올려주세요.': '在這裡留下第一張照片吧',\n  '답글을 삭제하시겠습니까?': '確定要刪除回覆嗎？',\n  '댓글을 삭제하시겠습니까?': '確定要刪除留言嗎？',\n  '삭제되었습니다.': '已刪除',\n  '수정을 취소하시겠습니까? 수정한 내용은 저장되지 않습니다.':\n    '確定要取消修改嗎？\\n修改的內容並不會保留儲存',\n  '이전 댓글 더보기': '查看更多之前的留言',\n  '아직 댓글이 없어요. 가장 먼저 댓글을 작성해보세요!':\n    '目前還沒有留言，快來留下第一個留言吧',\n  수정하기: '修改',\n  삭제하기: '刪除',\n  '삭제하겠습니까? 삭제하면 적립된 리뷰 포인트도 함께 사라집니다.':\n    '確定要刪除嗎？刪除後先前因為評論得到的Triple點數也會消失。',\n  신고하기: '檢舉',\n  최근여행: '最新旅行',\n  '최근 6개월 내에 방문한 여행의 리뷰만 모아 볼 수 있습니다.':\n    '篩選最近6個月內撰寫的旅行評論',\n  '이곳의 첫 번째 리뷰를 올려주세요.': '在這裡留下第一個評論吧',\n  '선택한 조건의 리뷰가 없습니다.': '選擇條件沒有評論',\n  '다녀온 여행지의 리뷰를 남겨보세요.': '留下造訪過旅行地的評論吧',\n  '전체 리뷰 보기': '查看全部評論',\n  '탈퇴한 사용자입니다.': '已經註銷的用戶',\n  추천순: '依推薦排序',\n  최신순: '依最新排序',\n  '접근할 수 없는 링크입니다.': '已失效的連結',\n  '웹에서 보기': '在網頁裡查看',\n  '트리플에서 보기': '在Triple裡查看',\n  '내 일정으로 담기': '複製至我的行程',\n  보기: '查看',\n  바로가기: '馬上查看',\n  더보기: '查看更多',\n  '신고가 접수되어 블라인드 처리되었습니다.': '因受到檢舉已被屏蔽處理',\n  '{{visitYear}}년 {{visitMonth}}월 여행':\n    '{{visitYear}}年 {{visitMonth}}月 旅行',\n  '{{reviewsCount}}개의 리뷰': '{{reviewsCount}}個評論',\n  '쿠폰 받기': '領取折價券',\n  '이미 받은 쿠폰입니다.': '已經領取過的折價券',\n  '쿠폰 받기 완료': '折價券領取完成',\n  '쿠폰을 받았습니다! 쿠폰함에서 확인할 수 있어요~!':\n    '折價券已領取！\\n可以在我的折價券裡確認',\n  '쿠폰을 모두 받았습니다. 쿠폰함에서 확인할 수 있어요~!':\n    '折價券已全部領取！\\n可以在我的折價券裡確認',\n  '쿠폰함에서 쿠폰을 확인하세요.': '我的折價券裡確認領取的折價券',\n  '쿠폰을 모두 받았습니다. (이미 받은 쿠폰 제외) 쿠폰함에서 확인할 수 있어요~!':\n    '折價券已全部領取！\\n(已經領取過的折價券除外)\\n可以在我的折價券裡確認',\n  '쿠폰함 가기': '前往我的折價券',\n  '쿠폰 확인': '確認折價券',\n  '쿠폰 다운로드 안내': '折價券領取須知',\n  쿠폰할인: '折價券優惠',\n  가능: '可使用',\n  쿠폰할인가: '折價券金額',\n  쿠폰적용시: '使用折價券時',\n  무료: '免費',\n  '내 예약에서 확인': '在我的預約裡確認',\n  '예약이 접수되었습니다.': '預約正在處理中',\n  '자세한 사항은 내 예약에서 확인해주세요.': '詳細資訊請在我的預約裡確認',\n  '트리플 홈으로 가기': '前往Triple首頁',\n  '{{regionName}} 여행 준비하러 가기': '準備前往{{regionName}} 的旅行',\n  '내 일정에 추가하기': '新增至我的行程',\n  '{{formattedSalePrice}}원': 'NTW{{formattedSalePrice}}',\n  일시품절: '暂时缺货',\n  '저장 {{formattedScrapsCount}}': '收藏 {{formattedScrapsCount}}',\n  원: 'NTW',\n  '최대 금액 초과': '超過最高金額上限',\n  '{{flooredPrice}}천원': '??',\n  '{{formattedPrice}}원': 'NTW{{formattedPrice}}',\n  '{{flooredPrice}}만{{convertedPrice}}': '??',\n  '출처 {{httpsSchemeRemovedUrl}}': '出處 {{httpsSchemeRemovedUrl}}',\n  '{{distance}} 이내': '{{distance}} 以內',\n  '{{formattedNightlyPrice}}원': 'NTW{{formattedNightlyPrice}}',\n  '작성자가 삭제한 댓글입니다.': '已被本人刪除的留言',\n  '다른 사용자의 신고로 블라인드 되었습니다.':\n    '因受到其他使用者檢舉已被屏蔽處理',\n  '내 답글': '我的回覆',\n  '내 댓글': '我的留言',\n  답글: '回覆',\n  댓글: '留言',\n  '좋아요 {{likeReactionCount}}': '讚 {{likeReactionCount}}',\n  답글달기: '回覆',\n  '이전 답글 더보기': '查看更多之前的回覆',\n  '...더보기': '...查看更多',\n  '포인트별 혜택 보기': '查看點數優惠',\n  리뷰: '評論',\n  '개의 리뷰': '個評論',\n  '{{numOfRestReviews}}개 리뷰 더보기': '查看剩餘{{numOfRestReviews}}個評論',\n  '리뷰 쓰면 여행자 클럽 최대 3포인트!':\n    '撰寫評論時，最多可獲得旅行者俱樂部3點獎勵！',\n  '{{formattedApplicableAmountAfterUsingCoupon}}원':\n    'NTW{{formattedApplicableAmountAfterUsingCoupon}}',\n  '{{title}}의 썸네일': '{{title}}的代表圖片',\n  '{{formattedBasePrice}}원': 'NTW{{formattedBasePrice}}',\n  '셀프패키지 추가할인 가능': '自助套裝行程有更多追加優惠',\n  관광: '觀光',\n  맛집: '餐廳',\n  '솔직한 리뷰 110만개와 최신 여행 소식까지 앱에서 편하게 확인하세요!':\n    '솔직한 리뷰 110만개와 최신 여행 소식까지 앱에서 편하게 확인하세요',\n  '여기는 트리플 앱이 필요해요': '여기는 트리플 앱이 필요해요',\n  '사진/동영상': '攝影/錄像',\n  '별점 높은순': '評分高的順序',\n  '별점 낮은순': '評分低的順序',\n  '구매 인증 리뷰': '購買認證評論',\n} as const\n"
  },
  {
    "path": "packages/i18n/src/types.ts",
    "content": "import ko from './locales/ko'\n\nexport type I18nKeys = typeof ko\n"
  },
  {
    "path": "packages/i18n/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/i18n/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/i18n/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/intersection-observer/README.md",
    "content": "# intersection-observer\n\nintersection 관련 컴포넌트 및 Hook을 제공합니다.\n\n## Usage\n\n### use-intersection\n\n기본 사용법은 아래와 같이 useIntersection의 사용하고자 하는 타입 ex) `HTMLDivElement`지정 그리고 `threshold, rootMargin` 값을 Optional 하게 사용하여 목적에 맞는 ref 를 생성 후 `isIntersecting`를 확인하고자 하는 태그에 해당 ref를 설정합니다.\n\n```js\nimport { useIntersection } from '@titicaca/intersection-observer';\n\nconst { ref, isIntersecting } = useIntersection<HTMLDivElement>({ threshold, rootMargin }\n\nreturn (\n    <div ref={ref}></div>\n)\n\n```\n"
  },
  {
    "path": "packages/intersection-observer/package.json",
    "content": "{\n  \"name\": \"@titicaca/intersection-observer\",\n  \"version\": \"14.2.3\",\n  \"description\": \"Shared IntersecionObserver component\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/intersection-observer\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/react-intersection-observer\": \"1.7.0\",\n    \"intersection-observer\": \"^0.12.2\"\n  },\n  \"devDependencies\": {\n    \"next\": \"^14.2.24\",\n    \"react\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"next\": \"^13.4 || ^14.0\",\n    \"react\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/intersection-observer/src/index.ts",
    "content": "import IntersectionObserver from './lazy-loaded-intersection-observer'\n\nexport { default as StaticIntersectionObserver } from './static-intersection-observer'\nexport { default as useIntersection } from './use-intersection'\nexport default IntersectionObserver\n"
  },
  {
    "path": "packages/intersection-observer/src/lazy-loaded-intersection-observer.tsx",
    "content": "import { FC } from 'react'\nimport dynamic from 'next/dynamic'\nimport { ReactIntersectionObserverProps } from '@titicaca/react-intersection-observer'\n\ninterface IntersectionObserverProps extends ReactIntersectionObserverProps {\n  safe?: boolean\n}\n\nasync function importReactIntersectionObserver() {\n  try {\n    if (\n      typeof window !== 'undefined' &&\n      !(\n        'IntersectionObserver' in window &&\n        'IntersectionObserverEntry' in window &&\n        'intersectionRatio' in window.IntersectionObserverEntry.prototype\n      )\n    ) {\n      // eslint-disable-next-line @typescript-eslint/ban-ts-comment\n      // @ts-ignore\n      await import('intersection-observer')\n    }\n\n    return import('@titicaca/react-intersection-observer')\n  } catch {\n    return Promise.resolve(\n      (({ children }) =>\n        children || null) as FC<ReactIntersectionObserverProps>,\n    )\n  }\n}\n\nconst Observer = dynamic(importReactIntersectionObserver)\n\nconst SafeObserver = dynamic(importReactIntersectionObserver, {\n  ssr: false,\n})\n\n/**\n * @deprecated StaticIntersectionObserver를 사용해 주세요.\n */\nexport default function IntersectionObserver({\n  safe,\n  ...props\n}: IntersectionObserverProps) {\n  return safe ? <SafeObserver {...props} /> : <Observer {...props} />\n}\n"
  },
  {
    "path": "packages/intersection-observer/src/static-intersection-observer.tsx",
    "content": "import 'intersection-observer'\nimport ReactIntersectionObserver from '@titicaca/react-intersection-observer'\n\nexport default ReactIntersectionObserver\n"
  },
  {
    "path": "packages/intersection-observer/src/use-intersection.ts",
    "content": "import 'intersection-observer'\nimport { useState, useEffect, useRef } from 'react'\n\nexport default function useIntersection<T extends Element>(\n  options?: IntersectionObserverInit,\n) {\n  const ref = useRef<T>(null)\n  const [isIntersecting, setIsIntersecting] = useState(false)\n\n  useEffect(() => {\n    if (!ref.current) {\n      return\n    }\n\n    function handleScroll([entry]: IntersectionObserverEntry[]) {\n      setIsIntersecting(entry.isIntersecting)\n    }\n\n    const observer = new IntersectionObserver(handleScroll, options)\n\n    observer.observe(ref.current)\n\n    return () => observer.disconnect()\n  }, [options])\n\n  return {\n    ref,\n    isIntersecting,\n  }\n}\n"
  },
  {
    "path": "packages/intersection-observer/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/intersection-observer/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/intersection-observer/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/meta-tags/README.md",
    "content": "# `@titicaca/meta-tags`\n\n현재 사용중인 메타태그들을 묶어둔 패키지입니다.\n\n## 기본 태그 (EssentialMeta)\n\n### Usage\n\n```tsx\nimport { EssentialMeta } from '@titicaca/meta-tags'\n\nreturn (\n  <EssentialMeta\n    description=\"모바일 여행 가이드 - 트리플\"\n    canonicalUrl=\"https://triple.guide/\"\n  />\n)\n```\n\n### 포함된 메타 태그\n\n#### [뷰포트 메타 태그](https://developer.mozilla.org/ko/docs/Mozilla/Mobile/Viewport_meta_tag)\n\n- 사용 가이드\n  - name: viewport\n  - 페이지의 viewport를 설정합니다\n  - content\n    - width: viewport의 크기를 지정한다.\n    - height: viewport의 높이를 지정한다.\n    - initial-scale: 페이지가 처음 로드될 때 줌 레벨을 조정한다.\n    - minimum-scale, maximum-scale: 사용자가 얼마나 페이지를 줌 인/아웃 할 수 있는지 조정한다.\n    - user-scalable: 사용자가 브라우저의 확대축소를 가능하게 할 것인지 정의.\n\n#### Description 메타 태그\n\n- 사용 가이드\n  - name: description\n  - content: 페이지의 설명을 정의합니다. `ex) 늦여름, 초가을에 떠나기 좋은 장소`\n\n#### 파비콘 메타 태그\n\n- 사용 가이드\n  - name: msapplication-TileImage\n  - content: 파비콘 이미지 url\n- 애플 터치 아이콘\n  - link tag로 설정가능\n  - name: apple-touch-icon-precomposed\n  - href: 터치 아이콘 이비지 url\n\n#### [레거시 문서 모드 지정](<https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/compatibility/jj676915(v=vs.85)>)\n\n- 사용 가이드\n  - httpEquiv: X-UA-Compatible\n  - content\n    - IE: 웹 페이지를 레거시 문서 모드로 제한할 경우 사용한다.\n\n## [애플 스마트 앱 배너](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html) (AppleSmartBannerMeta)\n\n### Usage\n\n```tsx\nimport { AppleSmartBannerMeta } from '@titicaca/meta-tags'\n\nreturn (\n  <EnvProvider appUrlScheme=\"triple\">\n    <AppleSmartBannerMeta appId=\"12345\" appPath=\"/\" />\n  </EnvProvider>\n)\n```\n\n#### 사용된 메타 태그\n\n- name: apple-itunes-app\n- content\n  - app-id : (필수) 앱의 고유 식별자입니다.\n  - Affiliate-data : (선택 사항) iTunes 계열사 인 경우 iTunes 계열사 문자열입니다.\n  - app-argument : (선택 사항) 네이티브 앱에 컨텍스트를 제공하는 URL입니다.\n\n## [페이스북 앱 링크](https://developers.facebook.com/docs/applinks) (FacebookAppLinkMeta)\n\n### Usage\n\n```tsx\nimport { FacebookAppLinkMeta } from '@titicaca/meta-tags'\n\nreturn (\n  <EnvProvider appUrlScheme=\"triple\">\n    <FacebookAppLinkMeta\n      appName=\"트리플\"\n      iosAppStoreId=\"1225499481\"\n      appPath=\"/\"\n      appPackageName=\"com.titicacacorp.triple\"\n    />\n  </EnvProvider>\n)\n```\n\n### 사용된 메타 태그\n\n#### IOS\n\n- 사용가이드\n  - IOS Url\n    - 필수 여부: ⭕\n    - name: al:ios:url `ex) com.titicacacorp.triple:///`\n    - content: IOS 앱 전용 커스텀 스키마\n  - 앱스토어 Id\n    - 필수 여부: ❌\n    - name: al:ios:app_store_id\n    - content: 앱스토어 앱 아이디`ex) 12345`\n  - 앱 이름\n    - 필수 여부: ❌\n    - name: al:ios:app_name\n    - content: 앱 이름 `ex) 트리플`\n\n#### 안드로이드\n\n- 사용가이드\n  - 안드로이드 Url\n    - 필수 여부: ❌\n    - name: al:android:url\n    - content: 안드로이드 앱 전용 커스텀 스키마 `ex) triple:///`\n  - 패키지 이름\n    - 필수 여부: ⭕\n    - name: al:android:package\n    - content: 증명된 패키지 이름 `ex) com.titicacacorp.triple`\n  - 클래스 이름\n    - 필수 여부: ❌\n    - name: al:android:class\n    - content: 증명된 활동 클래스 이름 `ex) org.applinks.DocsActivity`\n  - 앱 이름\n    - 필수 여부: ❌\n    - name: al:android:app_name\n    - content: 앱이름 `ex) 트리플`\n\n## [페이스북 오픈 그래프](https://developers.facebook.com/docs/sharing/webmasters/#markup) (FacebookOpenGraphMeta)\n\n### Usage\n\n```tsx\nimport { FacebookOpenGraphMeta } from '@titicaca/meta-tags'\n\nreturn (\n  <EnvProvider facebookAppId=\"136540730081853\">\n    <FacebookOpenGraphMeta\n      title=\"실시간 여행 가이드 - 트리플\"\n      description=\"전세계 맛집, 호텔, 관광지\"\n      canonicalUrl=\"https://triple.guide/\"\n      type=\"website\"\n      locale=\"ko_KR\"\n      image={{\n        url: 'https://assets.triple.guide/images/default-cover-image.jpg',\n        width: 1052,\n        height: 1052,\n      }}\n    />\n  </EnvProvider>\n)\n```\n\n### 사용된 태그\n\n#### 기본태그\n\n- 사용가이드\n  - Url\n    - name: og:url\n    - content: 페이지의 표준 url `ex) https://triple.guide/articles/2ca34d9f-57b8-4263-b1d1-beadccda92b1`\n  - 제목\n    - name: og:title\n    - content: 사이트 이름등의 브랜드 제목이 없는 콘텐츠 제목 `ex) 여행사 대리 3인방의 국내 인생 여행지`\n      - 라고 정의되어있는데요 `실시간 여행 가이드 - 트리플` 상관없을까요? - 사용하는 쪽에서 웬만하면 오버라이드해서 사용했으면 합니다\n  - 설명\n    - name: og:description\n    - content: 콘텐츠의 간단한 설명 `ex) 늦여름, 초가을에 떠나기 좋은 장소`\n  - 이미지\n    - name: og:image\n    - content: 콘텐츠를 공유할 때 표시되는 이미지의 url입니다. `ex) https://media.triple.guide/triple-cms/c_limit,h_1024,w_1024/01ae361e-93d8-42d9-8f52-a35316f49491.jpeg`\n    - 같이 쓰이는 태그\n      - 이미지 넓이/높이\n        - name: og:image:{width | height}\n          - content: 미리보기 이미지의 넓이/높이를 지정합니다 `ex) 1024`\n  - 앱 아이디\n    - name: fb:app_id\n    - content: 페이스북 전용 앱 id `ex) 12345`\n\n#### 추가 태그\n\n- 사용가이드\n  - 콘텐츠 타입\n    - name: og:type\n    - content: 미디어 유형, 기본값: website `ex) article`\n  - 언어\n    - name: og:locale\n    - content: 리소스의 언어, 기본값: en_US `ex) ko_kr`\n\n#### Theme meta 태그\n\n- 사용가이드\n  - 콘텐츠 타입\n    - name: theme-color, msapplication-TileColor\n    - content: 색상 (default: `#1FC1B6`, e.g. `#ffffff`, `rgb(0, 0, 0)`, `rgba(0, 0, 0, 0)`)\n\n## 구조화된 데이터\n\n- 관련 문서: [Google 검색에서 지원하는 구조화된 데이터 마크업](https://developers.google.com/search/docs/appearance/structured-data/search-gallery?hl=ko)\n- 테스트: [Google 리치 검색결과 테스트](https://search.google.com/test/rich-results?hl=ko) 사이트에서 url 혹은 코드로 구조화된 데이터 결과를 테스트할 수 있습니다.\n\n### 아티클 (Article)\n\n- 관련 문서 : [구조화된 기사(Article, NewsArticle, BlogPosting) 데이터](https://developers.google.com/search/docs/appearance/structured-data/article?hl=ko)\n- Usage\n\n```tsx\nimport { ArticleScript } from '@titicaca/meta-tags'\n\nreturn (\n  <ArticleScript\n    headline={headline}\n    image={[image1.sizes.large.url, image2.sizes.large.url]}\n    author={[{ name: author1.source.name }, { name: author2.source.name }]}\n    datePublished={exposedAt}\n  />\n)\n```\n\n- 속성\n  - headline: 아티클의 제목\n  - image: 아티클에 포함된 이미지 목록\n  - author: 아티클의 저자\n  - publisher: 아티클의 출판사\n  - datePublished: 아티클 출판일\n  - dateModified: 아티클 수정일\n\n### 탐색경로 (BreadcrumbList)\n\n- 관련 문서 : [구조화된 탐색경로(BreadcrumbList) 데이터](https://developers.google.com/search/docs/appearance/structured-data/breadcrumb?hl=ko)\n- Usage\n\n```tsx\nimport { BreadcrumbListScript } from '@titicaca/meta-tags'\n\nreturn (\n  <BreadcrumbListScript\n    itemListElement={\n      [\n        {\n          position: 1,\n          name: '아티클',\n          item: `${WEB_URL_BASE}/articles`,\n        },\n        {\n          position: 2,\n          name: title,\n          item: `${WEB_URL_BASE}/articles/${articleId}`,\n        },\n      ],\n    }\n  />\n)\n```\n\n- 속성\n  - position: 탐색 경로의 순서. 1부터 시작\n  - name: 탐색 경로의 제목\n  - item: 탐색 경로의 url\n\n### 지역 비지니스 (LocalBusiness)\n\n- 관련 문서 : [구조화된 지역 비즈니스(LocalBusiness) 데이터](https://developers.google.com/search/docs/appearance/structured-data/local-business?hl=ko)\n\n- 속성\n  - type: poiType으로 'restaurant', 'attraction', 'hotel' 중 하나여야 합니다.\n  - [LocalBusiness의 필수/권장 속성](https://developers.google.com/search/docs/appearance/structured-data/local-business?hl=ko#local-business-properties)을 참고하여 작성합니다.\n\n### 제품 (Product)\n\n- 관련 문서 : [구조화된 제품(Product, Review, Offer) 데이터](https://developers.google.com/search/docs/appearance/structured-data/product?hl=ko)\n\n- 속성\n  - [Product의 필수/권장 속성](https://developers.google.com/search/docs/appearance/structured-data/product?hl=ko#product-properties)을 참고하여 작성합니다.\n"
  },
  {
    "path": "packages/meta-tags/package.json",
    "content": "{\n  \"name\": \"@titicaca/meta-tags\",\n  \"version\": \"14.2.3\",\n  \"description\": \"Triple Web Application Meta tag modules\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/meta-tags\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"next\": \"^14.2.24\",\n    \"react\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/triple-web\": \"*\",\n    \"next\": \"^13.4 || ^14.0\",\n    \"react\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/meta-tags/src/app-router/generate-apple-smart-banner-meta.ts",
    "content": "import { Metadata } from 'next'\n\nimport { DEFAULT_APP_ID } from '../constants'\n\nexport function generateAppleSmartBannerMeta({\n  appId = DEFAULT_APP_ID,\n  appPath = '/',\n}: {\n  appId?: string\n  appPath?: string\n} = {}): Metadata {\n  const appUrlScheme = process.env.NEXT_PUBLIC_APP_URL_SCHEME || ''\n\n  return {\n    itunes: {\n      appId,\n      appArgument: `${appUrlScheme}://${appPath}`,\n    },\n  }\n}\n"
  },
  {
    "path": "packages/meta-tags/src/app-router/generate-common-meta.ts",
    "content": "import { Metadata } from 'next'\n\nexport function generateCommonMeta(): Metadata {\n  return {\n    icons: {\n      apple: 'https://triple.guide/icons/favicon-152x152.png',\n    },\n    manifest: '/manifest.json',\n  }\n}\n"
  },
  {
    "path": "packages/meta-tags/src/app-router/generate-essential-content-meta.ts",
    "content": "import { Metadata } from 'next'\n\nexport function generateEssentialContentMeta({\n  title,\n  description,\n  canonicalUrl,\n}: {\n  title?: string\n  description?: string\n  canonicalUrl?: string\n} = {}): Metadata {\n  return {\n    title: title || process.env.NEXT_PUBLIC_DEFAULT_PAGE_TITLE || '',\n    description:\n      description || process.env.NEXT_PUBLIC_DEFAULT_PAGE_DESCRIPTION || '',\n    alternates: {\n      canonical: canonicalUrl,\n    },\n  }\n}\n"
  },
  {
    "path": "packages/meta-tags/src/app-router/generate-facebook-app-link-meta.ts",
    "content": "import { Metadata } from 'next'\n\nimport { DEFAULT_APP_ID, DEFAULT_APP_PACKAGE_NAME } from '../constants'\n\nexport function generateFacebookAppLinkMeta({\n  appName: appNameFromProps,\n  appId = DEFAULT_APP_ID,\n  appPath = '/',\n  appPackageName = DEFAULT_APP_PACKAGE_NAME,\n}: {\n  appName?: string\n  appId?: string\n  appPath?: string\n  appPackageName?: string\n} = {}): Metadata {\n  const appName = appNameFromProps ?? '트리플'\n  const appUrlScheme = process.env.NEXT_PUBLIC_APP_URL_SCHEME || ''\n  const url = `${appUrlScheme}://${appPath}`\n\n  return {\n    appLinks: {\n      ios: {\n        app_name: appName,\n        url,\n        app_store_id: appId,\n      },\n      android: {\n        app_name: appName,\n        url,\n        package: appPackageName,\n      },\n    },\n  }\n}\n"
  },
  {
    "path": "packages/meta-tags/src/app-router/generate-facebook-open-graph-meta.ts",
    "content": "import { Metadata } from 'next'\nimport { OpenGraphType } from 'next/dist/lib/metadata/types/opengraph-types'\n\nimport { DEFAULT_OG_IMAGE } from '../constants'\n\nexport function generateFacebookOpenGraphMeta({\n  title: titleFromProps,\n  description: descriptionFromProps,\n  canonicalUrl,\n  type = 'website',\n  locale = 'ko_KR',\n  image = DEFAULT_OG_IMAGE,\n}: {\n  title?: string\n  description?: string\n  canonicalUrl?: string\n  type?: OpenGraphType\n  locale?: string\n  image?: { url: string; width?: number; height?: number }\n} = {}): Metadata {\n  const title = titleFromProps || process.env.NEXT_PUBLIC_DEFAULT_PAGE_TITLE\n  const description =\n    descriptionFromProps || process.env.NEXT_PUBLIC_DEFAULT_PAGE_DESCRIPTION\n  const url =\n    canonicalUrl ||\n    (process.env.NEXT_PUBLIC_WEB_URL_BASE\n      ? `${process.env.NEXT_PUBLIC_WEB_URL_BASE}/auth-web`\n      : undefined)\n\n  return {\n    openGraph: {\n      title,\n      description,\n      url,\n      type,\n      locale,\n      images: image,\n    },\n  }\n}\n"
  },
  {
    "path": "packages/meta-tags/src/app-router/generate-triple-default-meta.ts",
    "content": "import { Metadata } from 'next'\n\nimport { generateCommonMeta } from './generate-common-meta'\nimport { generateEssentialContentMeta } from './generate-essential-content-meta'\nimport { generateFacebookOpenGraphMeta } from './generate-facebook-open-graph-meta'\nimport { generateFacebookAppLinkMeta } from './generate-facebook-app-link-meta'\nimport { generateAppleSmartBannerMeta } from './generate-apple-smart-banner-meta'\n\nexport function generateTripleDefaultMeta(): Metadata {\n  return {\n    ...generateCommonMeta(),\n    ...generateEssentialContentMeta(),\n    ...generateFacebookOpenGraphMeta(),\n    ...generateFacebookAppLinkMeta(),\n    ...generateAppleSmartBannerMeta(),\n  }\n}\n"
  },
  {
    "path": "packages/meta-tags/src/app-router/index.ts",
    "content": "export * from './generate-common-meta'\nexport * from './generate-apple-smart-banner-meta'\nexport * from './generate-essential-content-meta'\nexport * from './generate-facebook-app-link-meta'\nexport * from './generate-facebook-open-graph-meta'\nexport * from './generate-triple-default-meta'\n"
  },
  {
    "path": "packages/meta-tags/src/constants.ts",
    "content": "export const DEFAULT_APP_ID = '1225499481'\nexport const DEFAULT_APP_PACKAGE_NAME = 'com.titicacacorp.triple'\nexport const DEFAULT_THEME_COLOR = '#1FC1B6'\nexport const DEFAULT_OG_IMAGE = {\n  url: 'https://assets.triple.guide/images/og-tag-app_download@4x.png',\n  width: 260,\n  height: 260,\n}\n"
  },
  {
    "path": "packages/meta-tags/src/index.ts",
    "content": "export * from './types'\nexport * from './app-router'\nexport * from './pages-router'\nexport * from './structured-data'\n"
  },
  {
    "path": "packages/meta-tags/src/pages-router/apple-smart-banner-meta.tsx",
    "content": "import Head from 'next/head'\nimport { useEnv } from '@titicaca/triple-web'\n\nimport { DEFAULT_APP_ID } from '../constants'\n\nexport function AppleSmartBannerMeta({\n  appId = DEFAULT_APP_ID,\n  appPath = '/',\n}: {\n  appId?: string\n  appPath?: string\n}) {\n  const { appUrlScheme } = useEnv()\n\n  return (\n    <Head>\n      <meta\n        name=\"apple-itunes-app\"\n        content={`app-id=${appId}, app-argument=${appUrlScheme}://${appPath}`}\n      />\n    </Head>\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/pages-router/common-meta.tsx",
    "content": "import Head from 'next/head'\n\nexport function CommonMeta() {\n  return (\n    <Head>\n      <meta charSet=\"utf-8\" />\n      <meta httpEquiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n      <meta\n        name=\"viewport\"\n        content=\"width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover\"\n      />\n      <meta\n        name=\"msapplication-TileImage\"\n        content=\"https://triple.guide/icons/favicon-144x144.png\"\n      />\n      <meta name=\"msapplication-TileColor\" content=\"#1FC1B6\" />\n      <link\n        rel=\"apple-touch-icon-precomposed\"\n        href=\"https://triple.guide/icons/favicon-152x152.png\"\n      />\n      <link rel=\"manifest\" href=\"/manifest.json\" />\n    </Head>\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/pages-router/essential-content-meta.test.tsx",
    "content": "/* eslint-disable testing-library/no-node-access */\nimport { render } from '@testing-library/react'\nimport { useEnv } from '@titicaca/triple-web'\n\nimport { EssentialContentMeta } from './essential-content-meta'\n\njest.mock('next/head', () => {\n  function MockHead({ children }: { children: Array<React.ReactElement> }) {\n    return <>{children}</>\n  }\n  return {\n    __esModule: true,\n    default: MockHead,\n  }\n})\njest.mock('@titicaca/triple-web')\n\ndescribe('EssentialContentMeta', () => {\n  const title = '모바일 여행 가이드북 - 트리플'\n  const description = '최고의 여행 가이드에요! 이것만 있으면 만사형통!'\n  const canonicalUrl = 'https://triple.guide/hotels'\n\n  beforeEach(() => {\n    const mockedUseEnv = useEnv as jest.MockedFunction<typeof useEnv>\n\n    mockedUseEnv.mockImplementation(\n      () =>\n        ({\n          defaultPageTitle: title,\n          defaultPageDescription: description,\n        }) as ReturnType<typeof useEnv>,\n    )\n  })\n\n  it('should render title.', () => {\n    render(<EssentialContentMeta title={title} />)\n\n    expect(document.title).toBe(title)\n  })\n\n  it('should render description.', () => {\n    render(<EssentialContentMeta description={description} />)\n\n    const element = document.querySelector('meta[name=\"description\"]')\n\n    expect(element).toHaveAttribute('content', description)\n  })\n\n  it('should render canonical url tag.', () => {\n    render(<EssentialContentMeta canonicalUrl={canonicalUrl} />)\n\n    const element = document.querySelector('link[rel=\"canonical\"]')\n\n    expect(element).toHaveAttribute('href', canonicalUrl)\n  })\n\n  it('should not render canonical url tag when canonicalUrl prop is not provided.', () => {\n    render(<EssentialContentMeta />)\n\n    const element = document.querySelector('link[rel=\"canonical\"]')\n\n    expect(element).not.toBeInTheDocument()\n  })\n})\n"
  },
  {
    "path": "packages/meta-tags/src/pages-router/essential-content-meta.tsx",
    "content": "import Head from 'next/head'\nimport { useEnv } from '@titicaca/triple-web'\n\nexport function EssentialContentMeta({\n  title: titleFromProps,\n  description: descriptionFromProps,\n  canonicalUrl,\n}: {\n  title?: string\n  description?: string\n  canonicalUrl?: string\n}) {\n  const { defaultPageTitle, defaultPageDescription } = useEnv()\n\n  const title = titleFromProps || defaultPageTitle\n  const description = descriptionFromProps || defaultPageDescription\n\n  return (\n    <Head>\n      <title key=\"title\">{title}</title>\n      <meta name=\"description\" content={description} />\n\n      {canonicalUrl ? (\n        <link key=\"canonical-url\" rel=\"canonical\" href={canonicalUrl} />\n      ) : null}\n    </Head>\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/pages-router/facebook-app-link-meta.tsx",
    "content": "import Head from 'next/head'\nimport { useEnv, useTranslation } from '@titicaca/triple-web'\n\nimport { DEFAULT_APP_ID, DEFAULT_APP_PACKAGE_NAME } from '../constants'\n\nexport function FacebookAppLinkMeta({\n  appName,\n  appId = DEFAULT_APP_ID,\n  appPath = '/',\n  appPackageName = DEFAULT_APP_PACKAGE_NAME,\n}: {\n  appName?: string\n  appId?: string\n  appPath?: string\n  appPackageName?: string\n}) {\n  const t = useTranslation()\n\n  const { appUrlScheme } = useEnv()\n\n  return (\n    <Head>\n      <meta\n        key=\"al-ios-app-name\"\n        property=\"al:ios:app_name\"\n        content={appName ?? t('트리플')}\n      />\n      <meta\n        key=\"al-android-app-name\"\n        property=\"al:android:app_name\"\n        content={appName ?? t('트리플')}\n      />\n      <meta\n        key=\"al-ios-url\"\n        property=\"al:ios:url\"\n        content={`${appUrlScheme}://${appPath}`}\n      />\n      <meta\n        key=\"al-ios-app-store-id\"\n        property=\"al:ios:app_store_id\"\n        content={appId}\n      />\n      <meta\n        key=\"al-android-url\"\n        property=\"al:android:url\"\n        content={`${appUrlScheme}://${appPath}`}\n      />\n      <meta\n        key=\"al-android-package\"\n        property=\"al:android:package\"\n        content={appPackageName}\n      />\n    </Head>\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/pages-router/facebook-open-graph-meta.tsx",
    "content": "import Head from 'next/head'\nimport { useEnv } from '@titicaca/triple-web'\n\nimport { DEFAULT_OG_IMAGE } from '../constants'\n\nexport function FacebookOpenGraphMeta({\n  title: titleFromProps,\n  description: descriptionFromProps,\n  canonicalUrl,\n  type = 'website',\n  locale = 'ko_KR',\n  image = DEFAULT_OG_IMAGE,\n}: {\n  title?: string\n  description?: string\n  canonicalUrl: string\n  type?: string\n  locale?: string\n  image?: { url: string; width?: number; height?: number }\n}) {\n  const { facebookAppId, defaultPageTitle, defaultPageDescription } = useEnv()\n\n  const title = titleFromProps || defaultPageTitle\n  const description = descriptionFromProps || defaultPageDescription\n\n  return (\n    <Head>\n      <meta key=\"og-title\" property=\"og:title\" content={title} />\n      <meta key=\"og-url\" property=\"og:url\" content={canonicalUrl} />\n      <meta key=\"og-type\" property=\"og:type\" content={type} />\n      <meta key=\"og-locale\" property=\"og:locale\" content={locale} />\n      <meta key=\"og-image\" property=\"og:image\" content={image?.url} />\n      {image?.width && image?.height ? (\n        <>\n          <meta\n            key=\"og-image-width\"\n            property=\"og:image:width\"\n            content={image.width.toString()}\n          />\n          <meta\n            key=\"og-image-height\"\n            property=\"og:image:height\"\n            content={image.height.toString()}\n          />\n        </>\n      ) : null}\n      <meta\n        key=\"og-description\"\n        property=\"og:description\"\n        content={description}\n      />\n      <meta key=\"fb-app-id\" property=\"fb:app_id\" content={facebookAppId} />\n    </Head>\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/pages-router/index.ts",
    "content": "export { EssentialContentMeta } from './essential-content-meta'\nexport { CommonMeta } from './common-meta'\nexport { AppleSmartBannerMeta } from './apple-smart-banner-meta'\nexport { FacebookAppLinkMeta } from './facebook-app-link-meta'\nexport { FacebookOpenGraphMeta } from './facebook-open-graph-meta'\nexport { ThemeColorMeta } from './theme-color-meta'\n"
  },
  {
    "path": "packages/meta-tags/src/pages-router/theme-color-meta.tsx",
    "content": "import Head from 'next/head'\n\nimport { ThemeColor } from '../types'\nimport { DEFAULT_THEME_COLOR } from '../constants'\n\nexport function ThemeColorMeta({\n  content = DEFAULT_THEME_COLOR,\n}: {\n  content?: ThemeColor | string\n}) {\n  return (\n    <Head>\n      <meta key=\"theme:color\" name=\"theme-color\" content={content} />\n      <meta\n        key=\"msapplication:tileColor\"\n        name=\"msapplication-TileColor\"\n        content={content}\n      />\n    </Head>\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/structured-data/article-script.tsx",
    "content": "import Script from 'next/script'\n\nimport { createScript } from '../utils'\nimport { ArticleScriptProps } from '../types'\n\nexport function ArticleScript(props: ArticleScriptProps) {\n  const articleScript = createScript(props, 'Article')\n\n  return (\n    <Script\n      id=\"article-script\"\n      type=\"application/ld+json\"\n      dangerouslySetInnerHTML={{\n        __html: JSON.stringify(articleScript, null, '\\t'),\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/structured-data/breadcrumb-list-script.tsx",
    "content": "import Script from 'next/script'\n\nimport { createScript } from '../utils'\nimport { BreadcrumbListScriptProps } from '../types'\n\nexport function BreadcrumbListScript(props: BreadcrumbListScriptProps) {\n  const breadcrumbScript = createScript(props, 'BreadcrumbList')\n\n  return (\n    <Script\n      id=\"breadcrumb-list-script\"\n      type=\"application/ld+json\"\n      dangerouslySetInnerHTML={{\n        __html: JSON.stringify(breadcrumbScript, null, '\\t'),\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/structured-data/discussion-forum-posting-script.tsx",
    "content": "import Script from 'next/script'\n\nimport { createScript } from '../utils'\nimport { DiscussionForumPostingScriptProps } from '../types'\n\nexport function DiscussionForumPostingScript(\n  props: DiscussionForumPostingScriptProps,\n) {\n  const discussionForumPosting = createScript(props, 'DiscussionForumPosting')\n\n  return (\n    <Script\n      id=\"discussion-forum-posting-script\"\n      type=\"application/ld+json\"\n      dangerouslySetInnerHTML={{\n        __html: JSON.stringify(discussionForumPosting, null, '\\t'),\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/structured-data/index.ts",
    "content": "export { ArticleScript } from './article-script'\nexport { BreadcrumbListScript } from './breadcrumb-list-script'\nexport { ProductScript } from './product-script'\nexport { LocalBusinessScript } from './local-business-script'\nexport { ReviewScript } from './review-script'\nexport { QaPageScript } from './qa-page-script'\nexport { DiscussionForumPostingScript } from './discussion-forum-posting-script'\n"
  },
  {
    "path": "packages/meta-tags/src/structured-data/local-business-script.tsx",
    "content": "import Script from 'next/script'\n\nimport { SCHEMA_SCRIPT_TYPE_MAP, createScript } from '../utils'\nimport { LocalBusinessScriptProps } from '../types'\n\nexport function LocalBusinessScript({\n  type,\n  ...props\n}: LocalBusinessScriptProps) {\n  const localBusinessScript = createScript(props, SCHEMA_SCRIPT_TYPE_MAP[type])\n\n  return (\n    <Script\n      id=\"local-business-script\"\n      type=\"application/ld+json\"\n      dangerouslySetInnerHTML={{\n        __html: JSON.stringify(localBusinessScript, null, '\\t'),\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/structured-data/product-script.tsx",
    "content": "import Script from 'next/script'\n\nimport { createScript } from '../utils'\nimport { ProductScriptProps } from '../types'\n\nexport function ProductScript(props: ProductScriptProps) {\n  const productScript = createScript(props, 'Product')\n\n  return (\n    <Script\n      id=\"product-script\"\n      type=\"application/ld+json\"\n      dangerouslySetInnerHTML={{\n        __html: JSON.stringify(productScript, null, '\\t'),\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/structured-data/qa-page-script.tsx",
    "content": "import Script from 'next/script'\n\nimport { createScript } from '../utils'\nimport { QaPageScriptProps } from '../types'\n\nexport function QaPageScript(props: QaPageScriptProps) {\n  const qaPageScript = createScript(props, 'QAPage')\n\n  return (\n    <Script\n      id=\"qa-page-script\"\n      type=\"application/ld+json\"\n      dangerouslySetInnerHTML={{\n        __html: JSON.stringify(qaPageScript, null, '\\t'),\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/structured-data/review-script.tsx",
    "content": "import Script from 'next/script'\n\nimport { createScript } from '../utils'\nimport { ReviewScriptProps } from '../types'\n\nexport function ReviewScript({ reviews }: ReviewScriptProps) {\n  const reviewScript = reviews.map((review) => createScript(review, 'Review'))\n\n  return (\n    <Script\n      id=\"review-script\"\n      type=\"application/ld+json\"\n      dangerouslySetInnerHTML={{\n        __html: JSON.stringify(reviewScript, null, '\\t'),\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/meta-tags/src/types/index.ts",
    "content": "export * from './schema'\nexport * from './structured-data-script-props'\n"
  },
  {
    "path": "packages/meta-tags/src/types/schema.ts",
    "content": "import { SCHEMA_SCRIPT_TYPE_MAP } from '../utils'\n\nexport interface Author {\n  type?: 'Person' | 'Organization'\n  name: string\n  url?: string\n}\n\nexport interface ListItem {\n  position: number\n  name: string\n  item?: string\n}\n\nexport interface AddressSchema {\n  streetAddress?: string\n  addressLocality?: string\n  addressRegion?: string\n  postalCode?: string\n  addressCountry?: string\n}\n\nexport interface GeoSchema {\n  latitude: number\n  longitude: number\n}\n\nexport interface AggregateRatingSchema {\n  ratingCount: number\n  reviewCount?: number\n  ratingValue: number\n  bestRating?: number\n  worstRating?: number\n}\n\nexport interface AggregateOfferSchema {\n  lowPrice: number\n  priceCurrency: string\n  highPrice?: number\n  offerCount?: number\n  availability?: ItemAvailability\n}\n\nexport interface ReviewSchema {\n  itemReviewed: {\n    type: keyof typeof SCHEMA_SCRIPT_TYPE_MAP\n    name: string // poi or product title\n  }\n  author: Author\n  publisher?: Author\n  description?: string\n  datePublished?: string\n  inLanguage?: string\n  reviewRating: Pick<\n    AggregateRatingSchema,\n    'ratingValue' | 'bestRating' | 'worstRating'\n  >\n}\n\nexport enum ItemAvailability {\n  BackOrder = 'BackOrder', // 이월 주문된 상품\n  Discontinued = 'Discontinued', // 단종된 상품\n  InStock = 'InStock', // 재고 있음\n  InStoreOnly = 'InStoreOnly', // 매장에서만 구매 가능\n  LimitedAvailability = 'LimitedAvailability', // 상품 재고가 한정적\n  OnlineOnly = 'OnlineOnly', // 온라인에서만 구매 가능\n  OutOfStock = 'OutOfStock', // 현재 재고가 없는 상품\n  PreOrder = 'PreOrder', // 선주문할 수 있는 상품\n  PreSale = 'PreSale', // 정식 버전 출시 전에 주문 및 배송 가능\n  SoldOut = 'SoldOut', // 매진된 상품\n}\n\nexport interface OpeningHoursSpecificationSchema {\n  dayOfWeek: string\n  opens: string\n  closes: string\n}\n\ntype Global = 'inherit' | 'initial' | 'revert' | 'unset'\ntype RGB = `rgb(${number}, ${number}, ${number})`\ntype RGBA = `rgba(${number}, ${number}, ${number}, ${number})`\ntype HEX = `#${string}`\n\nexport type ThemeColor = RGB | RGBA | HEX | Global\n\nexport type InteractionStatisticType = 'LikeAction' | 'CommentAction'\n\nexport interface InteractionStatistic {\n  interactionType: {\n    type: InteractionStatisticType\n  }\n  userInteractionCount: number\n}\n\nexport interface CommentType {\n  text: string\n  author: Author\n  datePublished: string\n  interactionStatistic?: InteractionStatistic[]\n  comment?: CommentType[]\n}\n\nexport interface Answer {\n  text: string\n  upvoteCount: number\n  url: string\n  datePublished: string\n  author: Author\n  comment?: CommentType[]\n}\n"
  },
  {
    "path": "packages/meta-tags/src/types/structured-data-script-props.ts",
    "content": "import { SCHEMA_SCRIPT_TYPE_MAP } from '../utils'\n\nimport {\n  AddressSchema,\n  AggregateOfferSchema,\n  AggregateRatingSchema,\n  Answer,\n  Author,\n  CommentType,\n  GeoSchema,\n  InteractionStatistic,\n  ListItem,\n  OpeningHoursSpecificationSchema,\n  ReviewSchema,\n} from './schema'\n\nexport interface ArticleScriptProps {\n  headline: string\n  image?: string[]\n  datePublished?: string\n  dateModified?: string\n  author?: Author[]\n  publisher?: Author[]\n}\n\nexport interface BreadcrumbListScriptProps {\n  itemListElement: Required<ListItem>[]\n}\n\nexport interface LocalBusinessScriptProps {\n  type: keyof Omit<typeof SCHEMA_SCRIPT_TYPE_MAP, 'tna'>\n  name: string\n  description?: string\n  image?: string\n  url?: string\n  telephone?: string\n  address: AddressSchema\n  aggregateRating?: AggregateRatingSchema\n  geo?: GeoSchema\n  menu?: string[]\n  servesCuisine?: string[]\n  review?: Omit<ReviewSchema, 'itemReviewed'>[]\n  priceRange?: string\n  openingHoursSpecification?: OpeningHoursSpecificationSchema[]\n}\n\nexport interface ProductScriptProps {\n  name: string\n  description?: string\n  image?: string\n  aggregateRating?: AggregateRatingSchema\n  offers: AggregateOfferSchema\n  review?: Omit<ReviewSchema, 'itemReviewed'>[]\n}\n\nexport interface ReviewScriptProps {\n  reviews: ReviewSchema[]\n}\n\nexport interface DiscussionForumPostingScriptProps {\n  headline: string\n  text: string\n  url: string\n  author: Author\n  image?: string[]\n  datePublished: string\n  interactionStatistic: InteractionStatistic[]\n  comment: CommentType[]\n}\n\nexport interface QaPageScriptProps {\n  mainEntity: {\n    type: 'Question'\n    name: string\n    text: string\n    answerCount: number\n    upvoteCount: number\n    datePublished: string\n    author: Author\n    suggestedAnswer: Answer[]\n    acceptedAnswer?: Answer\n  }\n}\n"
  },
  {
    "path": "packages/meta-tags/src/utils.ts",
    "content": "export const SCHEMA_SCRIPT_TYPE_MAP = {\n  restaurant: 'FoodEstablishment',\n  attraction: 'LocalBusiness',\n  hotel: 'Hotel',\n  tna: 'Product',\n}\n\nconst SCHEMA_TYPE_MAP: Record<string, string> = {\n  address: 'PostalAddress',\n  aggregateRating: 'AggregateRating',\n  aggregateOffer: 'AggregateOffer',\n  offers: 'AggregateOffer',\n  openingHoursSpecification: 'OpeningHoursSpecification',\n  review: 'Review',\n  author: 'Person',\n  publisher: 'Organization',\n  geo: 'GeoCoordinates',\n  reviewRating: 'Rating',\n  itemListElement: 'ListItem',\n  interactionStatistic: 'InteractionCounter',\n  answer: 'Answer',\n  comment: 'Comment',\n}\n\ntype Formatter = (value: string) => string | undefined\n\nconst VALUE_FORMATTER_MAP: Record<string, Formatter> = {\n  datePublished: toISOString,\n  dateModified: toISOString,\n  availability: formatAvailability,\n}\n\nexport function createScript<T extends object>(data: T, type: string) {\n  const script = pipe(filterValidValue, addSchemaType, formatValue)(data)\n  return {\n    '@context': 'http://schema.org',\n    '@type': type,\n    ...script,\n  }\n}\n\nfunction filterValidValue<T extends object>(originObj: T): T {\n  return Object.entries(originObj)\n    .filter(isValidValue)\n    .reduce<T>(\n      (obj, [key, value]) =>\n        mergeObj(obj, {\n          [key]: isObject(value)\n            ? filterValidValue(value)\n            : isArrayOfObject(value)\n              ? value.map((item: object) => filterValidValue(item))\n              : value,\n        }),\n      {} as T,\n    )\n}\n\nfunction addSchemaType<T extends object>(originObj: T): T {\n  return Object.entries(originObj).reduce((obj, [key, value]) => {\n    if (key === 'type') {\n      return mergeObj({ '@type': value }, obj)\n    }\n\n    if (isObject(value)) {\n      return mergeObj(obj, {\n        [key]: mergeObj(\n          key in SCHEMA_TYPE_MAP ? { '@type': SCHEMA_TYPE_MAP[key] } : {},\n          addSchemaType(value),\n        ),\n      })\n    }\n\n    if (isArrayOfObject(value)) {\n      const arrayValue = (value as object[]).map((item) =>\n        mergeObj(\n          key in SCHEMA_TYPE_MAP ? { '@type': SCHEMA_TYPE_MAP[key] } : {},\n          addSchemaType(item),\n        ),\n      )\n      return mergeObj(obj, { [key]: arrayValue })\n    }\n\n    return mergeObj(obj, { [key]: value })\n  }, {} as T)\n}\n\nfunction formatValue<T extends object>(originObj: T): T {\n  return Object.entries(originObj).reduce((obj, [key, value]) => {\n    if (key in VALUE_FORMATTER_MAP) {\n      const formatter = VALUE_FORMATTER_MAP[key]\n\n      if (isObject(value)) {\n        return mergeObj(obj, { [key]: formatValue(value) })\n      }\n\n      if (Array.isArray(value)) {\n        const formattedValue = isArrayOfObject(value)\n          ? value.map((item) => formatValue(item))\n          : value.map((item) => formatter(item))\n        return mergeObj(obj, { [key]: formattedValue })\n      }\n\n      return mergeObj(obj, { [key]: formatter(value) })\n    }\n\n    return mergeObj(obj, { [key]: value })\n  }, {} as T)\n}\n\nfunction formatAvailability(availability: string) {\n  return `https://schema.org/${availability}`\n}\n\nfunction toISOString(dateString: string) {\n  if (!dateString) {\n    return\n  }\n\n  const date = new Date(dateString)\n  const isValidDate = date instanceof Date && !isNaN(date.getTime())\n\n  return isValidDate ? date.toISOString() : undefined\n}\n\nfunction mergeObj<T1 extends object, T2 extends object>(obj1: T1, obj2: T2) {\n  return { ...obj1, ...obj2 }\n}\n\nfunction isObject<T>(data: T) {\n  return typeof data === 'object' && !Array.isArray(data) && data !== null\n}\n\nfunction isArrayOfObject<T>(data: T) {\n  return Array.isArray(data) && data.every((item) => isObject(item))\n}\n\nfunction isValidValue<T>([_, value]: [key: string, value: T]) {\n  if (isObject(value)) {\n    return Object.values(value as object).length > 0\n  }\n\n  if (Array.isArray(value)) {\n    return value.length > 0\n  }\n\n  return value !== null && value !== undefined\n}\n\nfunction pipe<T extends object>(...functions: ((arg: T) => T)[]) {\n  return function _pipe(value: T) {\n    return functions.reduce((acc, fn) => fn(acc), value)\n  }\n}\n"
  },
  {
    "path": "packages/meta-tags/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/meta-tags/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/meta-tags/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/middlewares/README.md",
    "content": "# `@titicaca/middlewares`\n\n트리플 서비스에서 공통으로 사용할 미들웨어 함수들을 제공합니다.  \n새로운 미들웨어를 추가하거나 확장할 수 있습니다.\n\n## Description\n\n현재 `@titicaca/middlewares` 패키지에서 제공해주는 2가지 미들웨어에 대한 설명입니다.\n\n### old-ios-cookie\n\n트리플 iOS 6.5.0 미만 버전에서 쿠키가 설정되지 않는 오류를 우화하기 위한 cookie fixation 로직입니다.\n\n[관련 PR](https://github.com/titicacadev/triple-frontend/pull/2635)\n\n### refresh-session\n\n페이지 진입 단계에서 사용자 인증 처리가 필요할 경우 사용하는 미들웨어 입니다.\n\n해당 미들웨어에서는 다음 순서로 사용자 인증 여부를 확인합니다.\n\n1. 요청 헤더에 포함된 쿠키로 /user/me를 호출합니다.\n2. 401 응답을 받으면, refresh 요청을 보내서 토큰을 갱신합니다.\n3. 갱신된 토큰을 response의 _set-cookie_ header와 set-cookie와 request의 _cookie_ header에 전달합니다.\n4. 브라우저는 response의 _set-cookie_ 를 통해 브라우저 쿠키값을 갱신합니다.\n\n## Usage\n\n적용하고자 하는 Next.js 프로젝트 루트에 `middleware.ts` 파일을 추가합니다.\n기본적으로 세션을 갱신하는 로직을 실행하는 미들웨어를 제공합니다.\n\n```typescript\n// middleware.ts\n\nimport { constructMiddleware, oldIosCookiesMiddleware } from './middlewares'\n\nexport default constructMiddleware([\n  oldTripleIosCookiesMiddleware,\n  testMiddleware,\n])\n```\n\n여러 개의 미들웨어가 필요한 경우애는, `constructMiddleware` 함수로 미들웨어들을 연속적으로 실행할 수 있습니다.\n\n## 새로운 미들웨어 추가하기\n\n`constructMiddleware` 함수가 인자로 받는 middleware factory 함수 타입은 다음과 같습니다.\n\n```typescript\ntype MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware\n```\n\n각 middleware는 `CustomMiddleware`를 반환해야 합니다. 그렇지 않으면 middleware chain이 정상적으로 동작하지 않게 됩니다.\n\n```typescript\nexport function myCustomMiddleware(customMiddleware: NextMiddleware) {\n  return function middleware(request: NextRequest, event: NextFetchEvent) {\n    // ...\n    return customMiddleware(request, event)\n  }\n}\n```\n"
  },
  {
    "path": "packages/middlewares/package.json",
    "content": "{\n  \"name\": \"@titicaca/middlewares\",\n  \"version\": \"14.2.3\",\n  \"description\": \"Triple Web Application Middleware modules\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/middleware\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"cookie\": \"^1.0.2\",\n    \"semver\": \"^7.6.3\",\n    \"set-cookie-parser\": \"^2.7.1\",\n    \"universal-cookie\": \"^8.0.1\",\n    \"uuid\": \"^11.1.0\"\n  },\n  \"devDependencies\": {\n    \"@titicaca/constants\": \"workspace:*\",\n    \"@titicaca/fetcher\": \"workspace:*\",\n    \"@titicaca/triple-web-utils\": \"workspace:*\",\n    \"@titicaca/view-utilities\": \"workspace:*\",\n    \"@types/semver\": \"^7.5.8\",\n    \"@types/set-cookie-parser\": \"^2.4.10\",\n    \"next\": \"^14.2.24\",\n    \"react\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/fetcher\": \"*\",\n    \"@titicaca/triple-web-utils\": \"*\",\n    \"next\": \"^13.4 || ^14.0\",\n    \"react\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/middlewares/src/chain.ts",
    "content": "import { NextMiddleware, NextResponse } from 'next/server'\n\nimport { MiddlewareFactory } from './types'\n\nexport function chain(\n  functions: MiddlewareFactory[],\n  index = 0,\n): NextMiddleware {\n  const current = functions[index]\n\n  if (current) {\n    const next = chain(functions, index + 1)\n    return current(next)\n  }\n\n  return () => {\n    return NextResponse.next()\n  }\n}\n"
  },
  {
    "path": "packages/middlewares/src/index.ts",
    "content": "import { chain } from './chain'\nimport type { MiddlewareFactory } from './types'\nimport {\n  refreshSessionMiddleware,\n  refreshSessionMiddlewareNext13,\n} from './refresh-session'\nimport { setWebDeviceIdMiddleware } from './set-web-device-id'\n\nexport const constructMiddleware = (functions: MiddlewareFactory[]) =>\n  chain([...functions, refreshSessionMiddleware, setWebDeviceIdMiddleware])\nexport const constructMiddlewareNext13 = (functions: MiddlewareFactory[]) =>\n  chain([...functions, refreshSessionMiddlewareNext13])\n\nexport * from './types'\nexport { chain } from './chain'\nexport {\n  refreshSessionMiddleware,\n  refreshSessionMiddlewareNext13,\n} from './refresh-session'\nexport { setWebDeviceIdMiddleware } from './set-web-device-id'\n"
  },
  {
    "path": "packages/middlewares/src/refresh-session/index.ts",
    "content": "export { refreshSessionMiddleware } from './next-14'\nexport { refreshSessionMiddleware as refreshSessionMiddlewareNext13 } from './next-13'\n"
  },
  {
    "path": "packages/middlewares/src/refresh-session/next-13.ts",
    "content": "import { type ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies'\nimport {\n  NextFetchEvent,\n  NextMiddleware,\n  NextRequest,\n  NextResponse,\n} from 'next/server'\nimport {\n  get,\n  post,\n  handle401Error,\n  NEED_REFRESH_IDENTIFIER,\n  captureHttpError,\n} from '@titicaca/fetcher'\nimport { parseString, splitCookiesString } from 'set-cookie-parser'\nimport { TP_SE, TP_TK } from '@titicaca/constants'\n\nimport { applySetCookie } from '../utils/apply-set-cookie'\n\n/**\n *\n * next v13 이하에서 사용하는 refreshSessionMiddleware\n */\nexport function refreshSessionMiddleware(next: NextMiddleware) {\n  return async function middleware(\n    request: NextRequest,\n    event: NextFetchEvent,\n  ) {\n    const response = (await next(request, event)) as NextResponse\n    const url = request.nextUrl\n\n    const isPageUrl = url.pathname.match('^/((?!(api|static|.*\\\\..*|_next)).*)')\n    if (!isPageUrl) {\n      return response\n    }\n\n    const allCookies = request.cookies.getAll()\n\n    const isSessionExisted = allCookies.some(\n      ({ name }) => name === TP_TK || name === TP_SE,\n    )\n    const cookies = deriveAllCookies(allCookies)\n\n    if (!isSessionExisted) {\n      return response\n    }\n\n    const options = {\n      cookie: cookies,\n      withApiUriBase: true,\n    }\n\n    /**\n     * /users/session/verify는 아래와 같은 상태값을 갖습니다.\n     * 200 : TP_SE와 TP_TK가 모두 유효한 경우\n     * 401 : TP_SE가 유효하지 않고 TP_TK가 유효한 경우\n     * 403 : TP_TK가 모두 유효하지 않은 경우\n     */\n    const firstTrialResponse = await get<\n      unknown,\n      { status: number; exception: string; message: string }\n    >('/api/users/session/verify', options)\n\n    const checkFirstTrialResponse = await handle401Error(firstTrialResponse)\n\n    if (checkFirstTrialResponse !== NEED_REFRESH_IDENTIFIER) {\n      captureHttpError(firstTrialResponse)\n      const setCookie = firstTrialResponse.headers.get('set-cookie')\n      if (setCookie) {\n        const setCookies = splitCookiesString(setCookie)\n        setCookies.forEach((cookie) => {\n          const { name, value, ...rest } = parseString(cookie)\n          response.cookies.set(name, value, { ...(rest as ResponseCookie) })\n        })\n        applySetCookie(request, response)\n      }\n      return response\n    }\n\n    /**\n     * /web-session/token은 TP-TK의 유효성을 확인해서 TP_TK, TP_SE, x-soto-session 응답합니다.\n     */\n    const refreshResponse = await post('/api/users/web-session/token', options)\n    captureHttpError(refreshResponse)\n\n    const setCookie = refreshResponse.headers.get('set-cookie')\n\n    if (setCookie) {\n      const setCookies = splitCookiesString(setCookie)\n      setCookies.forEach((cookie) => {\n        const { name, value, ...rest } = parseString(cookie)\n        response.cookies.set(name, value, { ...(rest as ResponseCookie) })\n      })\n      applySetCookie(request, response)\n    }\n    return response\n  }\n}\n\nfunction deriveAllCookies(cookies: { name: string; value: string }[]) {\n  return cookies.map(({ name, value }) => [name, value].join('=')).join('; ')\n}\n"
  },
  {
    "path": "packages/middlewares/src/refresh-session/next-14.ts",
    "content": "import { type ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies'\nimport {\n  NextFetchEvent,\n  NextMiddleware,\n  NextRequest,\n  NextResponse,\n} from 'next/server'\nimport {\n  get,\n  post,\n  handle401Error,\n  NEED_REFRESH_IDENTIFIER,\n  captureHttpError,\n} from '@titicaca/fetcher'\nimport { parseString } from 'set-cookie-parser'\nimport { TP_SE, TP_TK } from '@titicaca/constants'\nimport { serialize, SerializeOptions } from 'cookie'\n\nimport { getDomain } from '../utils/get-domain'\nimport { applySetCookie } from '../utils/apply-set-cookie'\n\n/**\n *\n * next v14 이상에서 사용하는 refreshSessionMiddleware\n */\nexport function refreshSessionMiddleware(next: NextMiddleware) {\n  return async function middleware(\n    request: NextRequest,\n    event: NextFetchEvent,\n  ) {\n    const response = (await next(request, event)) as NextResponse\n    const url = request.nextUrl\n\n    const isPageUrl = url.pathname.match('^/((?!(api|static|.*\\\\..*|_next)).*)')\n    if (!isPageUrl) {\n      return response\n    }\n\n    const allCookies = request.cookies.getAll()\n\n    const isSessionExisted = allCookies.some(\n      ({ name }) => name === TP_TK || name === TP_SE,\n    )\n    const cookies = deriveAllCookies(allCookies)\n\n    if (!isSessionExisted) {\n      return response\n    }\n\n    const options = {\n      cookie: cookies,\n      withApiUriBase: true,\n    }\n\n    /**\n     * /users/session/verify는 아래와 같은 상태값을 갖습니다.\n     * 200 : TP_SE와 TP_TK가 모두 유효한 경우\n     * 401 : TP_SE가 유효하지 않고 TP_TK가 유효한 경우\n     * 403 : TP_TK가 모두 유효하지 않은 경우\n     */\n    const firstTrialResponse = await get<\n      unknown,\n      { status: number; exception: string; message: string }\n    >('/api/users/session/verify', options)\n\n    const checkFirstTrialResponse = await handle401Error(firstTrialResponse)\n    if (checkFirstTrialResponse !== NEED_REFRESH_IDENTIFIER) {\n      captureHttpError(firstTrialResponse)\n      const setCookieHeader = firstTrialResponse.headers.getSetCookie()\n      if (setCookieHeader) {\n        const setCookie = changeSetCookieDomainOnLocalhost(\n          request,\n          setCookieHeader,\n        )\n        setCookie.forEach((cookie) => {\n          const { name, value, ...rest } = parseString(cookie)\n          response.cookies.set(name, value, { ...(rest as ResponseCookie) })\n        })\n        applySetCookie(request, response)\n      }\n      return response\n    }\n\n    /**\n     * /web-session/token은 TP-TK의 유효성을 확인해서 TP_TK, TP_SE, x-soto-session 응답합니다.\n     */\n    const refreshResponse = await post('/api/users/web-session/token', options)\n    captureHttpError(refreshResponse)\n\n    const setCookieHeader = refreshResponse.headers.getSetCookie()\n\n    if (setCookieHeader) {\n      const setCookie = changeSetCookieDomainOnLocalhost(\n        request,\n        setCookieHeader,\n      )\n      setCookie.forEach((cookie) => {\n        const { name, value, ...rest } = parseString(cookie)\n        response.cookies.set(name, value, { ...(rest as ResponseCookie) })\n      })\n    }\n    applySetCookie(request, response)\n    return response\n  }\n}\n\nfunction deriveAllCookies(cookies: { name: string; value: string }[]) {\n  return cookies.map(({ name, value }) => [name, value].join('=')).join('; ')\n}\n\nfunction changeSetCookieDomainOnLocalhost(\n  request: NextRequest,\n  setCookie: string[],\n) {\n  const domain = getDomain(request)\n  if (domain !== 'localhost') {\n    return setCookie\n  }\n\n  const domainChangedSetCookies = setCookie.map((cookie) => {\n    const { domain: originalDomain, ...rest } = parseString(cookie)\n    return { domain, ...rest }\n  })\n\n  return domainChangedSetCookies.map((cookie) => {\n    return serialize(cookie.name, cookie.value, cookie as SerializeOptions)\n  })\n}\n"
  },
  {
    "path": "packages/middlewares/src/set-web-device-id.ts",
    "content": "import {\n  NextFetchEvent,\n  NextMiddleware,\n  NextRequest,\n  NextResponse,\n} from 'next/server'\nimport { v4 as uuidV4 } from 'uuid'\nimport { X_TRIPLE_WEB_DEVICE_ID } from '@titicaca/constants'\n\nimport { getIsTripleApp } from './utils/get-triple-app'\nimport { applySetCookie } from './utils/apply-set-cookie'\nimport { getDomain } from './utils/get-domain'\n\nexport function setWebDeviceIdMiddleware(next: NextMiddleware) {\n  return async function middleware(\n    request: NextRequest,\n    event: NextFetchEvent,\n  ) {\n    const response = (await next(request, event)) as NextResponse\n    const isTripleApp = getIsTripleApp(request)\n\n    if (isTripleApp) {\n      return response\n    }\n\n    const allCookies = request.cookies.getAll()\n    const hasWebDeviceId = allCookies.some(\n      ({ name }) => name === X_TRIPLE_WEB_DEVICE_ID,\n    )\n\n    if (!hasWebDeviceId && response?.cookies) {\n      const randomWebDeviceId = uuidV4()\n      response.cookies.set(X_TRIPLE_WEB_DEVICE_ID, randomWebDeviceId, {\n        secure: true,\n        domain: getDomain(request),\n      })\n      applySetCookie(request, response)\n    }\n\n    return response\n  }\n}\n"
  },
  {
    "path": "packages/middlewares/src/types.ts",
    "content": "import { NextMiddleware } from 'next/server'\n\nexport type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware\n"
  },
  {
    "path": "packages/middlewares/src/utils/apply-set-cookie.ts",
    "content": "import {\n  RequestCookies,\n  ResponseCookies,\n} from 'next/dist/compiled/@edge-runtime/cookies'\nimport { NextRequest, NextResponse } from 'next/server'\n\n/** Reference: https://github.com/vercel/next.js/discussions/50374#discussioncomment-6732402\n * response.cookies.set 사용 시 response의 set-cookie로 값이 적용되기 때문에 이를 response의 headers.cookie로 설정해주는 함수입니다.\n *\n */\nexport function applySetCookie(req: NextRequest, res: NextResponse) {\n  const setCookies = new ResponseCookies(res.headers)\n  const newReqHeaders = new Headers(req.headers)\n  const newReqCookies = new RequestCookies(newReqHeaders)\n  setCookies.getAll().forEach((cookie) => newReqCookies.set(cookie))\n\n  const dummyRes = NextResponse.next({ request: { headers: newReqHeaders } })\n\n  dummyRes.headers.forEach((value, key) => {\n    if (\n      key === 'x-middleware-override-headers' ||\n      key.startsWith('x-middleware-request-')\n    ) {\n      res.headers.set(key, value)\n    }\n  })\n}\n"
  },
  {
    "path": "packages/middlewares/src/utils/get-domain.ts",
    "content": "import { parseUrl } from '@titicaca/view-utilities'\nimport { NextRequest } from 'next/server'\n\nexport function getDomain(request: NextRequest) {\n  const hostFromRequest = request.headers.get('host')\n  const isLocalhost = hostFromRequest?.split(':')[0] === 'localhost'\n  const { host } = parseUrl(process.env.NEXT_PUBLIC_WEB_URL_BASE)\n\n  return isLocalhost ? 'localhost' : `.${host}`\n}\n"
  },
  {
    "path": "packages/middlewares/src/utils/get-triple-app.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { clientAppRegex } from '@titicaca/triple-web-utils'\n\nexport function getIsTripleApp(request: NextRequest) {\n  const userAgent = request.headers.get('User-Agent')\n  const tripleAppMetadata = userAgent ? clientAppRegex.exec(userAgent) : null\n\n  return !!tripleAppMetadata\n}\n"
  },
  {
    "path": "packages/middlewares/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/middlewares/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/middlewares/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/react-hooks/README.md",
    "content": "# React-hooks\n\nReact Custom Hook 들을 제공합니다.\n\n## Usage\n\n### use-fetch\n\n```\nimport { useFetch } from '@titicaca/react-hooks';\n\nconst { response, data, error, loading } = useFetch(url, options)\n```\n\n### use-body-scroll-lock\n\n```\nimport { useBodyScrollLock } from '@titicaca/react-hooks';\n\nuseBodyScrollLock(lockFlag: boolean)\n```\n\n### useScrollToAnchor\n\n기본 사용법은 아래와 같이 alias 에 key/value 방식으로 컴포넌트에 지정한 `anchor` 이름과 맵핑 됩니다.\n\n```js\nuseScrollToAnchor({\n  /* scroll target */\n  alias: { 'equipping-pois': 'equipping-pois' },\n})\n\nreturn\n;<Section anchor=\"equipping-pois\">...</Section>\n```\n\n`alias` 값을 지정하지 않을 경우 location.hash 값 id 로 해서 타깃 엘리먼트를 찾아 자동 스크롤이 동작합니다.\n\n```js\nconsole.log(location.hash) // #my-hash\n\nuseScrollToAnchor({})\n```\n\n위와 같은 케이스일 경우 `<Component anchor=\"my-hash\" />` 로 자동 스크롤 됩니다.\n\n#### Parameters\n\n```ts\ntype Options = {\n  /** 스크롤 위치 상대값 */\n  offset?: number\n  /** 스크롤 시작 딜레이 타임 */\n  delayTime?: number\n  /** 스크롤 시간길이, 단위는 ms */\n  duration?: number\n  /** 스크롤 시작 방향 */\n  align?: 'top' | 'bottom' | 'middle'\n  /** 스크롤 타겟 엘리먼트 */\n  alias?: {\n    [key: string]: string\n  }\n}\n```\n\n##### alias\n\n한 페이지내에 여러개의 스크롤 타깃이 존재하는 경우 `alias` 에 key/value 형태로 여러개를 설정할 수 있습니다.\n\n```js\nuseScrollToAnchor({\n  /* scroll target */\n  alias: {\n    'hash-key1': 'target-element1',\n    'hash-key2': 'target-element2',\n  },\n})\n```\n\n### useDebouncedState\n\nvalue와 timeout을 파라미터로 받습니다. value가 업데이트 될 때 timeout만큼 업데이트를 디바운스한 결과와 진행중인 debounce를 취소하는 함수를 반환합니다.\n\n#### 예시\n\n짧은 시간 동안 많이 업데이트 되는 input 값을 천천히 업데이트 되도록 할 때 사용할 수 있습니다.\n\n```tsx\nconst [inputValue, setInputValue] = useState('')\nconst { debounced: debouncedValue } = useDebouncedState(inputValue, 500)\n\nuseEffect(() => {\n  fetchAPI(debouncedValue) // 500ms에 한 번씩 실행됨\n}, [debounceValue])\n\nreturn (\n  <input\n    value={inputValue}\n    onChange={(e) => setInputValue(e.currentTarget.value)}\n  />\n)\n```\n\n`clearDebounce` 함수는 검색 창에 api 요청을 통한 자동 완성을 구현할 때 사용합니다.\n엔터를 치면 지연 없이 바로 검색이 되게 api 요청을 보내고,\n중복 요청을 하지 않도록 debounce를 취소합니다.\n\n```tsx\nconst [inputValue, setInputValue] = useState('')\nconst { debounced: debouncedValue, clearDebounce } = useDebouncedState(\n  inputValue,\n  500,\n)\n\nuseEffect(() => {\n  fetchAPI(debouncedValue) // 500ms에 한 번씩 실행됨\n}, [debounceValue])\n\nreturn (\n  <input\n    value={inputValue}\n    onChange={(e) => setInputValue(e.currentTarget.value)}\n    // 주의! onEnter는 실제 인터페이스가 아닙니다.\n    onEnter={(e) => {\n      clearDebounce()\n      fetchAPI(e.currentTarget.value)\n    }}\n  />\n)\n```\n\n### useLottie\n\nJson형태로 내 보낸 Adobe After Effects 애니메이션을 지정된 컴포넌트에서 렌더링 할수 있도록 도와줍니다.\nanimationData와 player 옵션들을 파라미터로 받습니다.\n\n#### 예시\n\n```tsx\nimport { useLottie } from '@titicaca/react-hooks'\n\nconst LottieContainer = styled.div`\n  width: 57px;\n  height: 57px;\n`\n\nexport function Lottie() {\n  const { animationRef } = useLottie<HTMLDivElement>({\n    data: logos,\n    rendererSettings: {\n      viewBoxSize: `0 0 57px 57px`,\n    },\n  })\n\n  return <LottieContainer ref={animationRef} />\n}\n```\n"
  },
  {
    "path": "packages/react-hooks/package.json",
    "content": "{\n  \"name\": \"@titicaca/react-hooks\",\n  \"version\": \"14.2.3\",\n  \"description\": \"triple frontend custom hooks\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/react-hooks\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/fetcher\": \"workspace:*\",\n    \"lottie-web\": \"^5.13.0\",\n    \"react-fast-compare\": \"^3.2.2\",\n    \"scroll-to-element\": \"^2.0.3\"\n  },\n  \"devDependencies\": {\n    \"@types/scroll-to-element\": \"^2.0.5\",\n    \"react\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/react-hooks/src/hooks.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport isChromatic from 'chromatic/isChromatic'\nimport { useEffect } from 'react'\nimport { styled } from 'styled-components'\n\nimport logos from './mocks/lottie.sample.json'\nimport { useLottie } from './use-lottie'\nimport { useScrollToAnchor } from './use-scroll-to-anchor'\nimport { useScrollToElement } from './use-scroll-to-element'\nimport { useVisibilityChange } from './use-visibility-change'\n\nexport default {\n  title: 'react-hooks / hooks',\n  parameters: {\n    story: {\n      inline: false,\n      iframeHeight: 500,\n    },\n  },\n} as Meta\n\nconst LottieContainer = styled.div`\n  width: 57px;\n  height: 57px;\n`\n\nexport function ScrollElement() {\n  const { scrollToElement } = useScrollToElement()\n\n  useEffect(() => {\n    scrollToElement(document.getElementById('app'), {\n      offset: 52,\n      duration: 600,\n    })\n  }, [scrollToElement])\n\n  return (\n    <div>\n      <div style={{ height: '200vh' }} />\n      <div id=\"app\">\n        <h1>App</h1>\n      </div>\n    </div>\n  )\n}\n\nfunction ScrollToAnchorComponent({ useAlias }: { useAlias: boolean }) {\n  useEffect(() => {\n    window.history.pushState(null, '', '#app')\n  }, [])\n\n  useScrollToAnchor({\n    ...(useAlias\n      ? {\n          alias: {\n            app: 'alias',\n          },\n        }\n      : {}),\n  })\n\n  return (\n    <div>\n      <div style={{ height: '200vh' }} />\n      <div id=\"app\">\n        <h1>App</h1>\n      </div>\n      <div style={{ height: '200vh' }} />\n      <div id=\"alias\">\n        <h1>Alias</h1>\n      </div>\n    </div>\n  )\n}\n\ninterface ScrollToAnchorCustomArgs {\n  useAlias: boolean\n}\n\nexport const ScrollToAnchor: StoryObj<ScrollToAnchorCustomArgs> = {\n  render: ({ useAlias }) => {\n    return (\n      <ScrollToAnchorComponent key={Math.random() * 10} useAlias={useAlias} />\n    )\n  },\n\n  args: {\n    useAlias: false,\n  },\n}\n\nexport function VisibilityChange() {\n  useVisibilityChange((visible) => {\n    if (visible) {\n      window.alert('visible !')\n    }\n  }, [])\n\n  return <div>useVisibilityChange</div>\n}\n\nexport function Lottie() {\n  const { animationRef } = useLottie<HTMLDivElement>({\n    data: logos,\n    rendererSettings: {\n      viewBoxSize: `0 0 57px 57px`,\n    },\n    autoplay: !isChromatic(),\n  })\n\n  return <LottieContainer ref={animationRef} />\n}\n"
  },
  {
    "path": "packages/react-hooks/src/index.ts",
    "content": "export * from './use-body-scroll-lock'\nexport * from './use-debounce'\nexport * from './use-error-handler'\nexport * from './use-fetch'\nexport * from './use-interval'\nexport * from './use-local-storage'\nexport * from './use-lottie'\nexport * from './use-scroll-to-anchor'\nexport * from './use-scroll-to-element'\nexport * from './use-session-storage'\nexport * from './use-visibility-change'\n"
  },
  {
    "path": "packages/react-hooks/src/mocks/lottie.sample.json",
    "content": "{\n  \"v\": \"5.7.3\",\n  \"fr\": 30,\n  \"ip\": 0,\n  \"op\": 200,\n  \"w\": 57,\n  \"h\": 57,\n  \"nm\": \"flight_loading_blue\",\n  \"ddd\": 0,\n  \"assets\": [],\n  \"layers\": [\n    {\n      \"ddd\": 0,\n      \"ind\": 1,\n      \"ty\": 4,\n      \"nm\": \"flight_loading_purple 윤곽선\",\n      \"sr\": 1,\n      \"ks\": {\n        \"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n        \"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n        \"p\": {\n          \"a\": 1,\n          \"k\": [\n            {\n              \"i\": { \"x\": 0.667, \"y\": 1 },\n              \"o\": { \"x\": 0.333, \"y\": 0 },\n              \"t\": 150,\n              \"s\": [-37.01724137931034, 41.60344827586207, 0],\n              \"to\": [66.667, -13.333, 0],\n              \"ti\": [-66.667, 13.333, 0]\n            },\n            {\n              \"t\": 180.00000733155,\n              \"s\": [94.01724137931035, 15.39655172413793, 0]\n            }\n          ],\n          \"ix\": 2\n        },\n        \"a\": { \"a\": 0, \"k\": [87, 87, 0], \"ix\": 1 },\n        \"s\": {\n          \"a\": 0,\n          \"k\": [32.758620689655174, 32.758620689655174, 100],\n          \"ix\": 6\n        }\n      },\n      \"ao\": 0,\n      \"shapes\": [\n        {\n          \"ty\": \"gr\",\n          \"it\": [\n            {\n              \"ind\": 0,\n              \"ty\": \"sh\",\n              \"ix\": 1,\n              \"ks\": {\n                \"a\": 0,\n                \"k\": {\n                  \"i\": [\n                    [0.153, -0.041],\n                    [0, 0],\n                    [0, 0],\n                    [-0.636, -0.981],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0.562, -0.144],\n                    [0, 0],\n                    [0, 0],\n                    [-0.549, -0.977],\n                    [0, 0],\n                    [0, 0],\n                    [-2.63, 0.694],\n                    [0, 0],\n                    [0, 0],\n                    [2.233, 4.88],\n                    [0, 0],\n                    [4.23, -1.115],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0.389, 0]\n                  ],\n                  \"o\": [\n                    [0, 0],\n                    [0, 0],\n                    [-1.082, 0.378],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [-0.451, -0.363],\n                    [0, 0],\n                    [0, 0],\n                    [-1.037, 0.363],\n                    [0, 0],\n                    [0, 0],\n                    [1.415, 2.275],\n                    [0, 0],\n                    [0, 0],\n                    [5.074, -1.496],\n                    [0, 0],\n                    [-1.848, -3.706],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [-0.319, -0.238],\n                    [-0.155, 0]\n                  ],\n                  \"v\": [\n                    [-22.187, -32.986],\n                    [-30.622, -30.76],\n                    [-30.76, -30.717],\n                    [-31.676, -28.068],\n                    [-8.761, 7.281],\n                    [-34.754, 14.132],\n                    [-47.28, 1.913],\n                    [-47.398, 1.809],\n                    [-49.005, 1.46],\n                    [-54.913, 3.019],\n                    [-55.053, 3.062],\n                    [-56.025, 5.615],\n                    [-42.576, 29.454],\n                    [-42.448, 29.672],\n                    [-35.637, 32.353],\n                    [48.6, 10.154],\n                    [48.857, 10.083],\n                    [54.34, -1.861],\n                    [54.227, -2.1],\n                    [43.63, -6.517],\n                    [16.146, 0.727],\n                    [-20.503, -32.576],\n                    [-20.633, -32.682],\n                    [-21.723, -33.047]\n                  ],\n                  \"c\": true\n                },\n                \"ix\": 2\n              },\n              \"nm\": \"패스 1\",\n              \"mn\": \"ADBE Vector Shape - Group\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"fl\",\n              \"c\": {\n                \"a\": 0,\n                \"k\": [0.592000026329, 0.372999991623, 0.995999983245, 1],\n                \"ix\": 4\n              },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 5 },\n              \"r\": 1,\n              \"bm\": 0,\n              \"nm\": \"칠 1\",\n              \"mn\": \"ADBE Vector Graphic - Fill\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"tr\",\n              \"p\": { \"a\": 0, \"k\": [88.503, 86.297], \"ix\": 2 },\n              \"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n              \"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n              \"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n              \"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n              \"nm\": \"변형\"\n            }\n          ],\n          \"nm\": \"그룹 1\",\n          \"np\": 2,\n          \"cix\": 2,\n          \"bm\": 0,\n          \"ix\": 1,\n          \"mn\": \"ADBE Vector Group\",\n          \"hd\": false\n        }\n      ],\n      \"ip\": 150.000006109625,\n      \"op\": 200.000008146167,\n      \"st\": 150.000006109625,\n      \"bm\": 0\n    },\n    {\n      \"ddd\": 0,\n      \"ind\": 2,\n      \"ty\": 4,\n      \"nm\": \"flight_loading_pink 윤곽선\",\n      \"sr\": 1,\n      \"ks\": {\n        \"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n        \"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n        \"p\": {\n          \"a\": 1,\n          \"k\": [\n            {\n              \"i\": { \"x\": 0.667, \"y\": 1 },\n              \"o\": { \"x\": 0.333, \"y\": 0 },\n              \"t\": 100,\n              \"s\": [-37.01724137931034, 41.60344827586207, 0],\n              \"to\": [66.667, -13.333, 0],\n              \"ti\": [-66.667, 13.333, 0]\n            },\n            {\n              \"t\": 130.000005295009,\n              \"s\": [94.01724137931035, 15.39655172413793, 0]\n            }\n          ],\n          \"ix\": 2\n        },\n        \"a\": { \"a\": 0, \"k\": [87, 87, 0], \"ix\": 1 },\n        \"s\": {\n          \"a\": 0,\n          \"k\": [32.758620689655174, 32.758620689655174, 100],\n          \"ix\": 6\n        }\n      },\n      \"ao\": 0,\n      \"shapes\": [\n        {\n          \"ty\": \"gr\",\n          \"it\": [\n            {\n              \"ind\": 0,\n              \"ty\": \"sh\",\n              \"ix\": 1,\n              \"ks\": {\n                \"a\": 0,\n                \"k\": {\n                  \"i\": [\n                    [0.153, -0.041],\n                    [0, 0],\n                    [0, 0],\n                    [-0.636, -0.981],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0.562, -0.144],\n                    [0, 0],\n                    [0, 0],\n                    [-0.549, -0.977],\n                    [0, 0],\n                    [0, 0],\n                    [-2.63, 0.694],\n                    [0, 0],\n                    [0, 0],\n                    [2.233, 4.88],\n                    [0, 0],\n                    [4.23, -1.115],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0.389, 0]\n                  ],\n                  \"o\": [\n                    [0, 0],\n                    [0, 0],\n                    [-1.082, 0.378],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [-0.451, -0.363],\n                    [0, 0],\n                    [0, 0],\n                    [-1.037, 0.363],\n                    [0, 0],\n                    [0, 0],\n                    [1.415, 2.275],\n                    [0, 0],\n                    [0, 0],\n                    [5.074, -1.496],\n                    [0, 0],\n                    [-1.848, -3.706],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [-0.319, -0.238],\n                    [-0.155, 0]\n                  ],\n                  \"v\": [\n                    [-22.187, -32.986],\n                    [-30.622, -30.76],\n                    [-30.76, -30.717],\n                    [-31.676, -28.068],\n                    [-8.761, 7.281],\n                    [-34.754, 14.132],\n                    [-47.28, 1.913],\n                    [-47.398, 1.809],\n                    [-49.005, 1.46],\n                    [-54.913, 3.019],\n                    [-55.053, 3.062],\n                    [-56.025, 5.615],\n                    [-42.576, 29.454],\n                    [-42.448, 29.672],\n                    [-35.637, 32.353],\n                    [48.6, 10.154],\n                    [48.857, 10.083],\n                    [54.34, -1.861],\n                    [54.227, -2.1],\n                    [43.63, -6.517],\n                    [16.146, 0.727],\n                    [-20.503, -32.576],\n                    [-20.633, -32.682],\n                    [-21.723, -33.047]\n                  ],\n                  \"c\": true\n                },\n                \"ix\": 2\n              },\n              \"nm\": \"패스 1\",\n              \"mn\": \"ADBE Vector Shape - Group\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"fl\",\n              \"c\": {\n                \"a\": 0,\n                \"k\": [0.991999966491, 0.180000005984, 0.411999990426, 1],\n                \"ix\": 4\n              },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 5 },\n              \"r\": 1,\n              \"bm\": 0,\n              \"nm\": \"칠 1\",\n              \"mn\": \"ADBE Vector Graphic - Fill\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"tr\",\n              \"p\": { \"a\": 0, \"k\": [88.503, 86.297], \"ix\": 2 },\n              \"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n              \"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n              \"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n              \"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n              \"nm\": \"변형\"\n            }\n          ],\n          \"nm\": \"그룹 1\",\n          \"np\": 2,\n          \"cix\": 2,\n          \"bm\": 0,\n          \"ix\": 1,\n          \"mn\": \"ADBE Vector Group\",\n          \"hd\": false\n        }\n      ],\n      \"ip\": 100.000004073084,\n      \"op\": 150.000006109625,\n      \"st\": 100.000004073084,\n      \"bm\": 0\n    },\n    {\n      \"ddd\": 0,\n      \"ind\": 3,\n      \"ty\": 4,\n      \"nm\": \"flight_loading 윤곽선\",\n      \"sr\": 1,\n      \"ks\": {\n        \"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n        \"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n        \"p\": {\n          \"a\": 1,\n          \"k\": [\n            {\n              \"i\": { \"x\": 0.667, \"y\": 1 },\n              \"o\": { \"x\": 0.333, \"y\": 0 },\n              \"t\": 50,\n              \"s\": [-37.01724137931034, 41.60344827586207, 0],\n              \"to\": [66.667, -13.333, 0],\n              \"ti\": [-66.667, 13.333, 0]\n            },\n            {\n              \"t\": 80.0000032584668,\n              \"s\": [94.01724137931035, 15.39655172413793, 0]\n            }\n          ],\n          \"ix\": 2\n        },\n        \"a\": { \"a\": 0, \"k\": [87, 87, 0], \"ix\": 1 },\n        \"s\": {\n          \"a\": 0,\n          \"k\": [32.758620689655174, 32.758620689655174, 100],\n          \"ix\": 6\n        }\n      },\n      \"ao\": 0,\n      \"shapes\": [\n        {\n          \"ty\": \"gr\",\n          \"it\": [\n            {\n              \"ind\": 0,\n              \"ty\": \"sh\",\n              \"ix\": 1,\n              \"ks\": {\n                \"a\": 0,\n                \"k\": {\n                  \"i\": [\n                    [0.153, -0.041],\n                    [0, 0],\n                    [0, 0],\n                    [-0.636, -0.981],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0.562, -0.144],\n                    [0, 0],\n                    [0, 0],\n                    [-0.549, -0.977],\n                    [0, 0],\n                    [0, 0],\n                    [-2.63, 0.694],\n                    [0, 0],\n                    [0, 0],\n                    [2.233, 4.88],\n                    [0, 0],\n                    [4.23, -1.115],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0.389, 0]\n                  ],\n                  \"o\": [\n                    [0, 0],\n                    [0, 0],\n                    [-1.082, 0.378],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [-0.451, -0.363],\n                    [0, 0],\n                    [0, 0],\n                    [-1.037, 0.363],\n                    [0, 0],\n                    [0, 0],\n                    [1.415, 2.275],\n                    [0, 0],\n                    [0, 0],\n                    [5.074, -1.496],\n                    [0, 0],\n                    [-1.848, -3.706],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [-0.319, -0.238],\n                    [-0.155, 0]\n                  ],\n                  \"v\": [\n                    [-22.187, -32.986],\n                    [-30.622, -30.76],\n                    [-30.76, -30.717],\n                    [-31.676, -28.068],\n                    [-8.761, 7.281],\n                    [-34.754, 14.132],\n                    [-47.28, 1.913],\n                    [-47.398, 1.809],\n                    [-49.005, 1.46],\n                    [-54.913, 3.019],\n                    [-55.053, 3.062],\n                    [-56.025, 5.615],\n                    [-42.576, 29.454],\n                    [-42.448, 29.672],\n                    [-35.637, 32.353],\n                    [48.6, 10.154],\n                    [48.857, 10.083],\n                    [54.34, -1.861],\n                    [54.227, -2.1],\n                    [43.63, -6.517],\n                    [16.146, 0.727],\n                    [-20.503, -32.576],\n                    [-20.633, -32.682],\n                    [-21.723, -33.047]\n                  ],\n                  \"c\": true\n                },\n                \"ix\": 2\n              },\n              \"nm\": \"패스 1\",\n              \"mn\": \"ADBE Vector Shape - Group\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"fl\",\n              \"c\": {\n                \"a\": 0,\n                \"k\": [0.149000010771, 0.808000033509, 0.760999971278, 1],\n                \"ix\": 4\n              },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 5 },\n              \"r\": 1,\n              \"bm\": 0,\n              \"nm\": \"칠 1\",\n              \"mn\": \"ADBE Vector Graphic - Fill\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"tr\",\n              \"p\": { \"a\": 0, \"k\": [88.503, 86.297], \"ix\": 2 },\n              \"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n              \"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n              \"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n              \"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n              \"nm\": \"변형\"\n            }\n          ],\n          \"nm\": \"그룹 1\",\n          \"np\": 2,\n          \"cix\": 2,\n          \"bm\": 0,\n          \"ix\": 1,\n          \"mn\": \"ADBE Vector Group\",\n          \"hd\": false\n        }\n      ],\n      \"ip\": 50.0000020365418,\n      \"op\": 100.000004073084,\n      \"st\": 50.0000020365418,\n      \"bm\": 0\n    },\n    {\n      \"ddd\": 0,\n      \"ind\": 4,\n      \"ty\": 4,\n      \"nm\": \"flight_loading_blue 윤곽선\",\n      \"sr\": 1,\n      \"ks\": {\n        \"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n        \"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n        \"p\": {\n          \"a\": 1,\n          \"k\": [\n            {\n              \"i\": { \"x\": 0.667, \"y\": 1 },\n              \"o\": { \"x\": 0.333, \"y\": 0 },\n              \"t\": 0,\n              \"s\": [-37.01724137931034, 41.60344827586207, 0],\n              \"to\": [66.667, -13.333, 0],\n              \"ti\": [-66.667, 13.333, 0]\n            },\n            {\n              \"t\": 30.0000012219251,\n              \"s\": [94.01724137931035, 15.39655172413793, 0]\n            }\n          ],\n          \"ix\": 2\n        },\n        \"a\": { \"a\": 0, \"k\": [87, 87, 0], \"ix\": 1 },\n        \"s\": {\n          \"a\": 0,\n          \"k\": [32.758620689655174, 32.758620689655174, 100],\n          \"ix\": 6\n        }\n      },\n      \"ao\": 0,\n      \"shapes\": [\n        {\n          \"ty\": \"gr\",\n          \"it\": [\n            {\n              \"ind\": 0,\n              \"ty\": \"sh\",\n              \"ix\": 1,\n              \"ks\": {\n                \"a\": 0,\n                \"k\": {\n                  \"i\": [\n                    [0.153, -0.041],\n                    [0, 0],\n                    [0, 0],\n                    [-0.636, -0.981],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0.562, -0.144],\n                    [0, 0],\n                    [0, 0],\n                    [-0.549, -0.977],\n                    [0, 0],\n                    [0, 0],\n                    [-2.63, 0.694],\n                    [0, 0],\n                    [0, 0],\n                    [2.233, 4.88],\n                    [0, 0],\n                    [4.23, -1.115],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0.389, 0]\n                  ],\n                  \"o\": [\n                    [0, 0],\n                    [0, 0],\n                    [-1.082, 0.378],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [-0.451, -0.363],\n                    [0, 0],\n                    [0, 0],\n                    [-1.037, 0.363],\n                    [0, 0],\n                    [0, 0],\n                    [1.415, 2.275],\n                    [0, 0],\n                    [0, 0],\n                    [5.074, -1.496],\n                    [0, 0],\n                    [-1.848, -3.706],\n                    [0, 0],\n                    [0, 0],\n                    [0, 0],\n                    [-0.319, -0.238],\n                    [-0.155, 0]\n                  ],\n                  \"v\": [\n                    [-22.187, -32.986],\n                    [-30.622, -30.76],\n                    [-30.76, -30.717],\n                    [-31.676, -28.068],\n                    [-8.761, 7.281],\n                    [-34.754, 14.132],\n                    [-47.28, 1.913],\n                    [-47.398, 1.809],\n                    [-49.005, 1.46],\n                    [-54.913, 3.019],\n                    [-55.053, 3.062],\n                    [-56.025, 5.615],\n                    [-42.576, 29.454],\n                    [-42.448, 29.672],\n                    [-35.637, 32.353],\n                    [48.6, 10.154],\n                    [48.857, 10.083],\n                    [54.34, -1.861],\n                    [54.227, -2.1],\n                    [43.63, -6.517],\n                    [16.146, 0.727],\n                    [-20.503, -32.576],\n                    [-20.633, -32.682],\n                    [-21.723, -33.047]\n                  ],\n                  \"c\": true\n                },\n                \"ix\": 2\n              },\n              \"nm\": \"패스 1\",\n              \"mn\": \"ADBE Vector Shape - Group\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"fl\",\n              \"c\": {\n                \"a\": 0,\n                \"k\": [0.081999999402, 0.588000009574, 1, 1],\n                \"ix\": 4\n              },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 5 },\n              \"r\": 1,\n              \"bm\": 0,\n              \"nm\": \"칠 1\",\n              \"mn\": \"ADBE Vector Graphic - Fill\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"tr\",\n              \"p\": { \"a\": 0, \"k\": [88.503, 86.297], \"ix\": 2 },\n              \"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n              \"s\": { \"a\": 0, \"k\": [100, 100], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n              \"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n              \"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n              \"nm\": \"변형\"\n            }\n          ],\n          \"nm\": \"그룹 1\",\n          \"np\": 2,\n          \"cix\": 2,\n          \"bm\": 0,\n          \"ix\": 1,\n          \"mn\": \"ADBE Vector Group\",\n          \"hd\": false\n        }\n      ],\n      \"ip\": 0,\n      \"op\": 50.0000020365418,\n      \"st\": 0,\n      \"bm\": 0\n    },\n    {\n      \"ddd\": 0,\n      \"ind\": 5,\n      \"ty\": 4,\n      \"nm\": \"모양 레이어 4\",\n      \"sr\": 1,\n      \"ks\": {\n        \"o\": {\n          \"a\": 1,\n          \"k\": [\n            {\n              \"i\": { \"x\": [0.667], \"y\": [1] },\n              \"o\": { \"x\": [0.333], \"y\": [0] },\n              \"t\": 150,\n              \"s\": [0]\n            },\n            { \"t\": 160.000006516934, \"s\": [100] }\n          ],\n          \"ix\": 11\n        },\n        \"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n        \"p\": {\n          \"a\": 0,\n          \"k\": [3.8491379310344827, 4.668103448275862, 0],\n          \"ix\": 2\n        },\n        \"a\": { \"a\": 0, \"k\": [0, 0, 0], \"ix\": 1 },\n        \"s\": {\n          \"a\": 0,\n          \"k\": [32.910948275862076, 32.910948275862076, 100],\n          \"ix\": 6\n        }\n      },\n      \"ao\": 0,\n      \"shapes\": [\n        {\n          \"ty\": \"gr\",\n          \"it\": [\n            {\n              \"ty\": \"rc\",\n              \"d\": 1,\n              \"s\": { \"a\": 0, \"k\": [68.799, 68.799], \"ix\": 2 },\n              \"p\": { \"a\": 0, \"k\": [0, 0], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 10, \"ix\": 4 },\n              \"nm\": \"사각형 패스 1\",\n              \"mn\": \"ADBE Vector Shape - Rect\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"fl\",\n              \"c\": {\n                \"a\": 0,\n                \"k\": [0.941176470588, 0.905882352941, 1, 1],\n                \"ix\": 4\n              },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 5 },\n              \"r\": 2,\n              \"bm\": 0,\n              \"nm\": \"칠 1\",\n              \"mn\": \"ADBE Vector Graphic - Fill\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"tr\",\n              \"p\": { \"a\": 0, \"k\": [74.908, 72.505], \"ix\": 2 },\n              \"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n              \"s\": { \"a\": 0, \"k\": [251.464, 251.464], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n              \"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n              \"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n              \"nm\": \"변형\"\n            }\n          ],\n          \"nm\": \"사각형 1\",\n          \"np\": 3,\n          \"cix\": 2,\n          \"bm\": 0,\n          \"ix\": 1,\n          \"mn\": \"ADBE Vector Group\",\n          \"hd\": false\n        }\n      ],\n      \"ip\": 150.000006109625,\n      \"op\": 200.000008146167,\n      \"st\": -31.0000012626559,\n      \"bm\": 0\n    },\n    {\n      \"ddd\": 0,\n      \"ind\": 6,\n      \"ty\": 4,\n      \"nm\": \"모양 레이어 3\",\n      \"sr\": 1,\n      \"ks\": {\n        \"o\": {\n          \"a\": 1,\n          \"k\": [\n            {\n              \"i\": { \"x\": [0.667], \"y\": [1] },\n              \"o\": { \"x\": [0.333], \"y\": [0] },\n              \"t\": 100,\n              \"s\": [0]\n            },\n            { \"t\": 110.000004480392, \"s\": [100] }\n          ],\n          \"ix\": 11\n        },\n        \"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n        \"p\": {\n          \"a\": 0,\n          \"k\": [3.8491379310344827, 4.668103448275862, 0],\n          \"ix\": 2\n        },\n        \"a\": { \"a\": 0, \"k\": [0, 0, 0], \"ix\": 1 },\n        \"s\": {\n          \"a\": 0,\n          \"k\": [32.910948275862076, 32.910948275862076, 100],\n          \"ix\": 6\n        }\n      },\n      \"ao\": 0,\n      \"shapes\": [\n        {\n          \"ty\": \"gr\",\n          \"it\": [\n            {\n              \"ty\": \"rc\",\n              \"d\": 1,\n              \"s\": { \"a\": 0, \"k\": [68.799, 68.799], \"ix\": 2 },\n              \"p\": { \"a\": 0, \"k\": [0, 0], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 10, \"ix\": 4 },\n              \"nm\": \"사각형 패스 1\",\n              \"mn\": \"ADBE Vector Shape - Rect\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"fl\",\n              \"c\": {\n                \"a\": 0,\n                \"k\": [0.998851102941, 0.914282346239, 0.938834575578, 1],\n                \"ix\": 4\n              },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 5 },\n              \"r\": 2,\n              \"bm\": 0,\n              \"nm\": \"칠 1\",\n              \"mn\": \"ADBE Vector Graphic - Fill\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"tr\",\n              \"p\": { \"a\": 0, \"k\": [74.908, 72.505], \"ix\": 2 },\n              \"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n              \"s\": { \"a\": 0, \"k\": [251.464, 251.464], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n              \"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n              \"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n              \"nm\": \"변형\"\n            }\n          ],\n          \"nm\": \"사각형 1\",\n          \"np\": 3,\n          \"cix\": 2,\n          \"bm\": 0,\n          \"ix\": 1,\n          \"mn\": \"ADBE Vector Group\",\n          \"hd\": false\n        }\n      ],\n      \"ip\": 100.000004073084,\n      \"op\": 160.000006516934,\n      \"st\": -21.0000008553475,\n      \"bm\": 0\n    },\n    {\n      \"ddd\": 0,\n      \"ind\": 7,\n      \"ty\": 4,\n      \"nm\": \"모양 레이어 2\",\n      \"sr\": 1,\n      \"ks\": {\n        \"o\": {\n          \"a\": 1,\n          \"k\": [\n            {\n              \"i\": { \"x\": [0.667], \"y\": [1] },\n              \"o\": { \"x\": [0.333], \"y\": [0] },\n              \"t\": 50,\n              \"s\": [0]\n            },\n            { \"t\": 60.0000024438501, \"s\": [100] }\n          ],\n          \"ix\": 11\n        },\n        \"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n        \"p\": {\n          \"a\": 0,\n          \"k\": [3.8491379310344827, 4.668103448275862, 0],\n          \"ix\": 2\n        },\n        \"a\": { \"a\": 0, \"k\": [0, 0, 0], \"ix\": 1 },\n        \"s\": {\n          \"a\": 0,\n          \"k\": [32.910948275862076, 32.910948275862076, 100],\n          \"ix\": 6\n        }\n      },\n      \"ao\": 0,\n      \"shapes\": [\n        {\n          \"ty\": \"gr\",\n          \"it\": [\n            {\n              \"ty\": \"rc\",\n              \"d\": 1,\n              \"s\": { \"a\": 0, \"k\": [68.799, 68.799], \"ix\": 2 },\n              \"p\": { \"a\": 0, \"k\": [0, 0], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 10, \"ix\": 4 },\n              \"nm\": \"사각형 패스 1\",\n              \"mn\": \"ADBE Vector Shape - Rect\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"fl\",\n              \"c\": {\n                \"a\": 0,\n                \"k\": [0.874509803922, 0.972549019608, 0.964705882353, 1],\n                \"ix\": 4\n              },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 5 },\n              \"r\": 2,\n              \"bm\": 0,\n              \"nm\": \"칠 1\",\n              \"mn\": \"ADBE Vector Graphic - Fill\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"tr\",\n              \"p\": { \"a\": 0, \"k\": [74.908, 72.505], \"ix\": 2 },\n              \"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n              \"s\": { \"a\": 0, \"k\": [251.464, 251.464], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n              \"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n              \"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n              \"nm\": \"변형\"\n            }\n          ],\n          \"nm\": \"사각형 1\",\n          \"np\": 3,\n          \"cix\": 2,\n          \"bm\": 0,\n          \"ix\": 1,\n          \"mn\": \"ADBE Vector Group\",\n          \"hd\": false\n        }\n      ],\n      \"ip\": 50.0000020365418,\n      \"op\": 110.000004480392,\n      \"st\": -11.0000004480392,\n      \"bm\": 0\n    },\n    {\n      \"ddd\": 0,\n      \"ind\": 8,\n      \"ty\": 4,\n      \"nm\": \"모양 레이어 1\",\n      \"sr\": 1,\n      \"ks\": {\n        \"o\": { \"a\": 0, \"k\": 100, \"ix\": 11 },\n        \"r\": { \"a\": 0, \"k\": 0, \"ix\": 10 },\n        \"p\": {\n          \"a\": 0,\n          \"k\": [3.8491379310344827, 4.668103448275862, 0],\n          \"ix\": 2\n        },\n        \"a\": { \"a\": 0, \"k\": [0, 0, 0], \"ix\": 1 },\n        \"s\": {\n          \"a\": 0,\n          \"k\": [32.910948275862076, 32.910948275862076, 100],\n          \"ix\": 6\n        }\n      },\n      \"ao\": 0,\n      \"shapes\": [\n        {\n          \"ty\": \"gr\",\n          \"it\": [\n            {\n              \"ty\": \"rc\",\n              \"d\": 1,\n              \"s\": { \"a\": 0, \"k\": [68.799, 68.799], \"ix\": 2 },\n              \"p\": { \"a\": 0, \"k\": [0, 0], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 10, \"ix\": 4 },\n              \"nm\": \"사각형 패스 1\",\n              \"mn\": \"ADBE Vector Shape - Rect\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"fl\",\n              \"c\": {\n                \"a\": 0,\n                \"k\": [0.850980392157, 0.933333333333, 1, 1],\n                \"ix\": 4\n              },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 5 },\n              \"r\": 2,\n              \"bm\": 0,\n              \"nm\": \"칠 1\",\n              \"mn\": \"ADBE Vector Graphic - Fill\",\n              \"hd\": false\n            },\n            {\n              \"ty\": \"tr\",\n              \"p\": { \"a\": 0, \"k\": [74.908, 72.505], \"ix\": 2 },\n              \"a\": { \"a\": 0, \"k\": [0, 0], \"ix\": 1 },\n              \"s\": { \"a\": 0, \"k\": [251.464, 251.464], \"ix\": 3 },\n              \"r\": { \"a\": 0, \"k\": 0, \"ix\": 6 },\n              \"o\": { \"a\": 0, \"k\": 100, \"ix\": 7 },\n              \"sk\": { \"a\": 0, \"k\": 0, \"ix\": 4 },\n              \"sa\": { \"a\": 0, \"k\": 0, \"ix\": 5 },\n              \"nm\": \"변형\"\n            }\n          ],\n          \"nm\": \"사각형 1\",\n          \"np\": 3,\n          \"cix\": 2,\n          \"bm\": 0,\n          \"ix\": 1,\n          \"mn\": \"ADBE Vector Group\",\n          \"hd\": false\n        }\n      ],\n      \"ip\": 0,\n      \"op\": 60.0000024438501,\n      \"st\": 0,\n      \"bm\": 0\n    }\n  ],\n  \"markers\": []\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-body-scroll-lock.ts",
    "content": "import { useEffect } from 'react'\n\nexport interface BodyScrollLockState {\n  scrollTop: number\n  style?: Partial<{\n    top: string\n    overflow: string\n    width: string\n    position: string\n  }>\n}\n\nconst bodyScrollLockStates: BodyScrollLockState[] = []\n\nexport function useBodyScrollLock(lock = false) {\n  useEffect(() => {\n    const body = document.body as HTMLBodyElement\n\n    if (lock) {\n      const { top, overflow, width, position } = body.style\n      const scrollTop = document?.documentElement?.scrollTop ?? 0\n\n      const state = {\n        scrollTop,\n        style: { top, overflow, width, position },\n      }\n\n      bodyScrollLockStates.push(state)\n\n      body.style.top = `-${scrollTop}px`\n      body.style.overflow = 'hidden'\n      body.style.width = '100%'\n      body.style.position = 'fixed'\n    } else {\n      const state = bodyScrollLockStates.pop()\n\n      if (state && bodyScrollLockStates.length === 0) {\n        const { scrollTop, style = {} } = state\n\n        for (const [key, value] of Object.entries(style)) {\n          body.style[key as keyof BodyScrollLockState['style']] = value ?? ''\n        }\n\n        document.documentElement.scrollTop = scrollTop || 0\n      }\n    }\n  }, [lock]) // eslint-disable-line react-hooks/exhaustive-deps\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-debounce.test.ts",
    "content": "import { renderHook, act } from '@testing-library/react'\n\nimport { useDebouncedState } from './use-debounce'\n\ndescribe('state debounce 훅', () => {\n  it('should return same value with input.', () => {\n    const { result } = renderHook(() => useDebouncedState(42, 500))\n\n    expect(result.current.debounced).toBe(42)\n  })\n\n  it('should change value after given timeout.', async () => {\n    const { result, rerender } = renderHook(\n      ({ value }) => useDebouncedState(value, 500),\n      {\n        initialProps: { value: 42 },\n      },\n    )\n\n    rerender({ value: 50 })\n    expect(result.current.debounced).toBe(42)\n\n    await act(\n      () =>\n        new Promise((resolve) => {\n          setTimeout(resolve, 500)\n        }),\n    )\n\n    expect(result.current.debounced).toBe(50)\n  })\n\n  it('should clear timeout running clearDebounce callback.', async () => {\n    const { result, rerender } = renderHook(\n      ({ value }) => useDebouncedState(value, 500),\n      {\n        initialProps: { value: 42 },\n      },\n    )\n\n    rerender({ value: 50 })\n    expect(result.current.debounced).toBe(42)\n\n    await act(() => new Promise((resolve) => setTimeout(resolve, 100)))\n    result.current.clearDebounce()\n\n    await act(\n      () =>\n        new Promise((resolve) => {\n          setTimeout(resolve, 400)\n        }),\n    )\n\n    expect(result.current.debounced).toBe(42)\n  })\n})\n"
  },
  {
    "path": "packages/react-hooks/src/use-debounce.ts",
    "content": "import { useState, useEffect, useRef, useCallback } from 'react'\n\nexport function useDebouncedState<T>(\n  value: T,\n  timeout: number,\n): { debounced: T; clearDebounce: () => void } {\n  const [debounced, setDebounced] = useState(value)\n  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  const clearDebounce = useCallback(() => {\n    const currentTimerId = timerRef.current\n\n    if (currentTimerId) {\n      clearTimeout(currentTimerId)\n      timerRef.current = null\n    }\n  }, [])\n\n  useEffect(() => {\n    const timerId = setTimeout(() => {\n      setDebounced(value)\n    }, timeout)\n\n    timerRef.current = timerId\n\n    return () => {\n      clearTimeout(timerId)\n      timerRef.current = null\n    }\n  }, [timeout, value])\n\n  return { debounced, clearDebounce }\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-error-handler.ts",
    "content": "import { useState } from 'react'\n\n/**\n * 콜백 함수에서 발생한 에러를 React ErrorBoundary가 잡을 수 있도록 해주는 함수\n */\nexport function useErrorHandler() {\n  const [error, setError] = useState<unknown>(null)\n\n  if (error) {\n    throw error\n  }\n\n  return setError\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-fetch.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { useEffect, useState } from 'react'\nimport isEqual from 'react-fast-compare'\nimport { get } from '@titicaca/fetcher'\n\ninterface FetchResponse {\n  data?: any\n  response?: Response\n  error?: Error\n}\n\ninterface FetchStatus extends FetchResponse {\n  loading: boolean\n}\n\nconst createFetchError = (response: Response): Error => {\n  const err = new Error(`${response.status} ${response.statusText}`)\n  err.name = 'FetchError'\n  return err\n}\n\nexport function useFetch(url: string, options?: any): FetchStatus {\n  const [fetchResponse, setFetchResponse] = useState<FetchResponse | null>(null)\n  const [fetchOptions, setFetchOptions] = useState(options)\n\n  useEffect(() => {\n    if (!isEqual(fetchOptions, options)) {\n      setFetchOptions(options)\n    }\n  }, [options, fetchOptions])\n\n  useEffect(() => {\n    async function fetchData() {\n      setFetchResponse(null)\n\n      const response = await get(\n        url,\n        ...(fetchOptions ? [{ ...fetchOptions }] : []),\n      )\n\n      try {\n        if (response.ok === true) {\n          const { parsedBody: data } = response\n\n          setFetchResponse({\n            data,\n            response: response as unknown as Response,\n          })\n        } else {\n          setFetchResponse({\n            error: createFetchError(response as unknown as Response),\n          })\n        }\n      } catch (error) {\n        if (error instanceof Error || error === undefined) {\n          setFetchResponse({ error })\n        }\n      }\n    }\n\n    fetchData()\n  }, [url, fetchOptions])\n\n  return {\n    loading: fetchResponse === null,\n    ...fetchResponse,\n  }\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-interval.test.ts",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useInterval } from './use-interval'\n\ndescribe('useInterval hook test', () => {\n  beforeEach(() => {\n    jest.useFakeTimers()\n  })\n\n  afterEach(() => {\n    jest.clearAllTimers()\n    jest.clearAllMocks()\n  })\n\n  afterAll(() => {\n    jest.useRealTimers()\n  })\n\n  test('delay가 null인 경우 setInterval을 실행하지 않는다.', () => {\n    const callbackFn = jest.fn()\n    const intervalSpy = jest.spyOn(window, 'setInterval')\n\n    renderHook(() => useInterval(callbackFn, null))\n\n    expect(intervalSpy).not.toHaveBeenCalled()\n  })\n\n  test('delay가 null이 아닌 경우 setInterval이 실행되고 delay ms 후 콜백 함수가 실행된다.', () => {\n    const callbackFn = jest.fn()\n    const intervalSpy = jest.spyOn(window, 'setInterval')\n    const delay = 1000\n\n    renderHook(() => useInterval(callbackFn, delay))\n\n    expect(intervalSpy).toHaveBeenCalledTimes(1)\n    expect(intervalSpy).toHaveBeenCalledWith(expect.any(Function), delay)\n\n    jest.advanceTimersByTime(delay)\n\n    expect(intervalSpy).toHaveBeenCalledTimes(1)\n    expect(callbackFn).toHaveBeenCalled()\n  })\n\n  test('unmount 시 clearTimeout 실행되고 콜백 함수는 실행되지 않는다.', () => {\n    const callbackFn = jest.fn()\n    const clearIntervalSpy = jest.spyOn(window, 'clearInterval')\n\n    const { unmount } = renderHook(() => useInterval(callbackFn, 100))\n\n    unmount()\n\n    expect(clearIntervalSpy).toHaveBeenCalledTimes(1)\n    expect(callbackFn).toHaveBeenCalledTimes(0)\n  })\n\n  test('실행 중 callback function 변경되어도 delay 간격으로 callback 함수 실행한다.', () => {\n    const callbackFn = jest.fn()\n    const delay = 500\n    const intervalSpy = jest.spyOn(window, 'setInterval')\n    const clearIntervalSpy = jest.spyOn(window, 'clearInterval')\n    const initialProps = { callbackFn, delay }\n    const { rerender } = renderHook(\n      (props) => useInterval(props.callbackFn, props.delay),\n      { initialProps },\n    )\n\n    jest.advanceTimersByTime(delay / 2)\n\n    const newCallbackFn = jest.fn()\n\n    rerender({ callbackFn: newCallbackFn, delay })\n\n    expect(clearIntervalSpy).not.toHaveBeenCalled()\n    expect(intervalSpy).toHaveBeenCalledTimes(1)\n\n    jest.advanceTimersByTime(delay / 2)\n\n    expect(callbackFn).not.toHaveBeenCalled()\n    expect(newCallbackFn).toHaveBeenCalled()\n\n    jest.advanceTimersByTime(delay / 2)\n    expect(newCallbackFn).toHaveBeenCalledTimes(1)\n  })\n\n  test('delay 값이 바뀌었을 때 setInterval 재정의한다.', () => {\n    const callbackFn = jest.fn()\n    let delay = 500\n    const intervalSpy = jest.spyOn(window, 'setInterval')\n    const clearIntervalSpy = jest.spyOn(window, 'clearInterval')\n    const { rerender } = renderHook(() => {\n      useInterval(callbackFn, delay)\n    })\n\n    delay = 1000\n\n    rerender()\n\n    expect(clearIntervalSpy).toHaveBeenCalled()\n    expect(intervalSpy).toHaveBeenCalledTimes(2)\n  })\n})\n"
  },
  {
    "path": "packages/react-hooks/src/use-interval.ts",
    "content": "import { useEffect, useRef } from 'react'\n\n/**\n * @param {number|null} delay null인 경우 setInterval을 일시 중지한다.\n */\n\nexport function useInterval(\n  callback?: () => void,\n  delay: number | null = null,\n) {\n  const savedCallback = useRef<() => void>()\n\n  useEffect(() => {\n    savedCallback.current = callback\n  }, [callback])\n\n  useEffect(() => {\n    function tick() {\n      if (savedCallback.current) {\n        savedCallback.current()\n      }\n    }\n    if (delay !== null) {\n      const id = setInterval(tick, delay)\n      return () => clearInterval(id)\n    }\n  }, [delay])\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-local-storage.test.ts",
    "content": "import { act, renderHook } from '@testing-library/react'\n\nimport { useLocalStorage } from './use-local-storage'\n\nafterEach(() => {\n  localStorage.clear()\n})\n\ntest('initialValue를 전달하지 않고 기존 값이 없으면 null을 반환한다.', () => {\n  const { result } = renderHook(() => useLocalStorage('test_key'))\n\n  expect(localStorage.getItem('test_key')).toBeNull()\n  expect(result.current[0]).toBeNull()\n})\n\ntest('initialValue를 전달하지 않고 기존 값이 있으면 그 값을 반환한다.', () => {\n  localStorage.setItem('test_key', 'saved')\n\n  const { result } = renderHook(() => useLocalStorage('test_key'))\n\n  expect(localStorage.getItem('test_key')).toBe('saved')\n  expect(result.current[0]).toBe('saved')\n})\n\ntest('initialValue를 전달하고 기존 값이 없으면 초기값을 설정한다.', () => {\n  const { result } = renderHook(() => useLocalStorage('test_key', 'initial'))\n\n  expect(localStorage.getItem('test_key')).toBe('initial')\n  expect(result.current[0]).toBe('initial')\n})\n\ntest('initialValue를 전달하고 기존 값이 있으면 초기값을 덮어쓰지 않는다.', () => {\n  localStorage.setItem('test_key', 'saved')\n\n  const { result } = renderHook(() => useLocalStorage('test_key', 'initial'))\n\n  expect(localStorage.getItem('test_key')).toBe('saved')\n  expect(result.current[0]).toBe('saved')\n})\n\ntest('값을 변경한다.', () => {\n  const { result } = renderHook(() => useLocalStorage('test_key'))\n\n  act(() => {\n    result.current[1]('updated')\n  })\n\n  expect(localStorage.getItem('test_key')).toBe('updated')\n  expect(result.current[0]).toBe('updated')\n})\n\ntest('값을 지운다.', () => {\n  localStorage.setItem('test_key', 'saved')\n\n  const { result } = renderHook(() => useLocalStorage('test_key'))\n\n  act(() => {\n    result.current[2]()\n  })\n\n  expect(localStorage.getItem('test_key')).toBeNull()\n  expect(result.current[0]).toBeNull()\n})\n"
  },
  {
    "path": "packages/react-hooks/src/use-local-storage.ts",
    "content": "import { useCallback, useEffect, useSyncExternalStore } from 'react'\n\nconst inMemoryStorage = new Map<string, string | null>()\n\nconst handlers = new Set<(key: string) => void>()\n\nfunction trigger(key: string) {\n  for (const handler of Array.from(handlers)) {\n    handler(key)\n  }\n}\n\nfunction getSnapshot(key: string, initialValue: string | undefined) {\n  try {\n    return localStorage.getItem(key) ?? initialValue ?? null\n  } catch {\n    inMemoryStorage.set(key, initialValue ?? null)\n    return initialValue ?? null\n  }\n}\n\nfunction getServerSnapshot(initialValue: string | undefined) {\n  return initialValue ?? null\n}\n\nexport function useLocalStorage(\n  key: string,\n): [string | null, (value: string) => void, () => void]\nexport function useLocalStorage(\n  key: string,\n  initialValue: string,\n): [string, (value: string) => void, () => void]\nexport function useLocalStorage(\n  key: string,\n  initialValue?: string,\n): [string | null, (value: string) => void, () => void] {\n  const value = useSyncExternalStore(\n    useCallback(\n      (onStoreChange) => {\n        const onChange = (localKey: string) => {\n          if (key === localKey) {\n            onStoreChange()\n          }\n        }\n        handlers.add(onChange)\n        return () => handlers.delete(onChange)\n      },\n      [key],\n    ),\n    () => getSnapshot(key, initialValue),\n    () => getServerSnapshot(initialValue),\n  )\n\n  const set = useCallback(\n    (value: string) => {\n      try {\n        localStorage.setItem(key, value)\n      } catch {\n        inMemoryStorage.set(key, value)\n      }\n\n      trigger(key)\n    },\n    [key],\n  )\n\n  const remove = useCallback(() => {\n    try {\n      localStorage.removeItem(key)\n    } catch {\n      inMemoryStorage.delete(key)\n    }\n\n    trigger(key)\n  }, [key])\n\n  useEffect(() => {\n    if (typeof initialValue === 'undefined') {\n      return\n    }\n\n    try {\n      if (localStorage.getItem(key) === null) {\n        localStorage.setItem(key, initialValue)\n      }\n    } catch {\n      if (inMemoryStorage.get(key) === undefined) {\n        inMemoryStorage.set(key, initialValue)\n      }\n    }\n\n    trigger(key)\n  }, [initialValue, key])\n\n  return [value, set, remove]\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-lottie.tsx",
    "content": "import Lottie, { SVGRendererConfig, AnimationItem } from 'lottie-web'\nimport { useRef, useEffect, useState } from 'react'\n\nexport function useLottie<T extends HTMLElement>({\n  loop = true,\n  autoplay = true,\n  path,\n  data,\n  rendererSettings,\n}: {\n  loop?: boolean\n  autoplay?: boolean\n  path?: string\n  data?: unknown\n  rendererSettings?: SVGRendererConfig\n}) {\n  const [animation, setAnimation] = useState<AnimationItem>()\n  const animationRef = useRef<T>(null)\n\n  useEffect(\n    () => {\n      if (animationRef.current) {\n        const animation = Lottie.loadAnimation({\n          container: animationRef.current,\n          renderer: 'svg',\n          loop,\n          autoplay,\n          path,\n          animationData: data,\n          rendererSettings,\n        })\n\n        setAnimation(animation)\n\n        return () => {\n          animation.destroy()\n        }\n      }\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [],\n  )\n\n  return { animation, animationRef }\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-scroll-to-anchor.ts",
    "content": "import { useEffect } from 'react'\nimport scrollToElement from 'scroll-to-element'\n\nexport function useScrollToAnchor(option?: {\n  offset?: number\n  delayTime?: number\n  duration?: number\n  align?: 'top' | 'bottom' | 'middle'\n  alias?: {\n    [key: string]: string\n  }\n}) {\n  useEffect(() => {\n    const {\n      offset = -52, // HACK: 헤더 높이\n      delayTime = 1500,\n      alias,\n      duration,\n      align,\n    } = option || {}\n\n    const replacedHash = window.location.hash\n      ? window.location.hash.replace(/^#/, '')\n      : ''\n\n    const canonicalHash = (alias || {})[replacedHash] || replacedHash\n\n    setTimeout(() => {\n      const el = document.getElementById(canonicalHash)\n\n      if (el) {\n        scrollToElement(el, { offset, duration, align })\n      }\n    }, delayTime)\n  }, []) // eslint-disable-line react-hooks/exhaustive-deps\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-scroll-to-element.ts",
    "content": "import { useMemo, useRef } from 'react'\nimport scrollToElement from 'scroll-to-element'\n\n/**\n * 주어진 DOM 엘리먼트로 스크롤하는 함수를 제공하는 훅\n *\n * 현재 스크롤 되고 있는지를 반환하는 `isScrolling` 함수와 스크롤 함수를 반환합니다.\n */\nexport function useScrollToElement() {\n  const scrollingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  return useMemo(\n    () => ({\n      isScrolling: () => {\n        return scrollingTimerRef.current !== null\n      },\n      scrollToElement: (\n        el: Parameters<typeof scrollToElement>[0] | null,\n        options?: Parameters<typeof scrollToElement>[1],\n      ) => {\n        if (!el) {\n          return\n        }\n        const duration = options?.duration || 1000\n        const prevScrollTimer = scrollingTimerRef.current\n\n        if (prevScrollTimer) {\n          clearTimeout(prevScrollTimer)\n        }\n\n        scrollingTimerRef.current = setTimeout(() => {\n          scrollingTimerRef.current = null\n        }, duration)\n        scrollToElement(el, options)\n      },\n    }),\n    [],\n  )\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-session-storage.test.ts",
    "content": "import { act, renderHook } from '@testing-library/react'\n\nimport { useSessionStorage } from './use-session-storage'\n\nafterEach(() => {\n  sessionStorage.clear()\n})\n\ntest('initialValue를 전달하지 않고 기존 값이 없으면 null을 반환한다.', () => {\n  const { result } = renderHook(() => useSessionStorage('test_key'))\n\n  expect(sessionStorage.getItem('test_key')).toBeNull()\n  expect(result.current[0]).toBeNull()\n})\n\ntest('initialValue를 전달하지 않고 기존 값이 있으면 그 값을 반환한다.', () => {\n  sessionStorage.setItem('test_key', 'saved')\n\n  const { result } = renderHook(() => useSessionStorage('test_key'))\n\n  expect(sessionStorage.getItem('test_key')).toBe('saved')\n  expect(result.current[0]).toBe('saved')\n})\n\ntest('initialValue를 전달하고 기존 값이 없으면 초기값을 설정한다.', () => {\n  const { result } = renderHook(() => useSessionStorage('test_key', 'initial'))\n\n  expect(sessionStorage.getItem('test_key')).toBe('initial')\n  expect(result.current[0]).toBe('initial')\n})\n\ntest('initialValue를 전달하고 기존 값이 있으면 초기값을 덮어쓰지 않는다.', () => {\n  sessionStorage.setItem('test_key', 'saved')\n\n  const { result } = renderHook(() => useSessionStorage('test_key', 'initial'))\n\n  expect(sessionStorage.getItem('test_key')).toBe('saved')\n  expect(result.current[0]).toBe('saved')\n})\n\ntest('값을 변경한다.', () => {\n  const { result } = renderHook(() => useSessionStorage('test_key'))\n\n  act(() => {\n    result.current[1]('updated')\n  })\n\n  expect(sessionStorage.getItem('test_key')).toBe('updated')\n  expect(result.current[0]).toBe('updated')\n})\n\ntest('값을 지운다.', () => {\n  sessionStorage.setItem('test_key', 'saved')\n\n  const { result } = renderHook(() => useSessionStorage('test_key'))\n\n  act(() => {\n    result.current[2]()\n  })\n\n  expect(sessionStorage.getItem('test_key')).toBeNull()\n  expect(result.current[0]).toBeNull()\n})\n"
  },
  {
    "path": "packages/react-hooks/src/use-session-storage.ts",
    "content": "import { useCallback, useEffect, useSyncExternalStore } from 'react'\n\nconst inMemoryStorage = new Map<string, string | null>()\n\nconst handlers = new Set<(key: string) => void>()\n\nfunction trigger(key: string) {\n  for (const handler of Array.from(handlers)) {\n    handler(key)\n  }\n}\n\nfunction getSnapshot(key: string, initialValue: string | undefined) {\n  try {\n    return sessionStorage.getItem(key) ?? initialValue ?? null\n  } catch {\n    inMemoryStorage.set(key, initialValue ?? null)\n    return initialValue ?? null\n  }\n}\n\nfunction getServerSnapshot(initialValue: string | undefined) {\n  return initialValue ?? null\n}\n\nexport function useSessionStorage(\n  key: string,\n): [string | null, (value: string) => void, () => void]\nexport function useSessionStorage(\n  key: string,\n  initialValue: string,\n): [string, (value: string) => void, () => void]\nexport function useSessionStorage(\n  key: string,\n  initialValue?: string,\n): [string | null, (value: string) => void, () => void] {\n  const value = useSyncExternalStore(\n    useCallback(\n      (onStoreChange) => {\n        const onChange = (localKey: string) => {\n          if (key === localKey) {\n            onStoreChange()\n          }\n        }\n        handlers.add(onChange)\n        return () => handlers.delete(onChange)\n      },\n      [key],\n    ),\n    () => getSnapshot(key, initialValue),\n    () => getServerSnapshot(initialValue),\n  )\n\n  const set = useCallback(\n    (value: string) => {\n      try {\n        sessionStorage.setItem(key, value)\n      } catch {\n        inMemoryStorage.set(key, value)\n      }\n\n      trigger(key)\n    },\n    [key],\n  )\n\n  const remove = useCallback(() => {\n    try {\n      sessionStorage.removeItem(key)\n    } catch {\n      inMemoryStorage.delete(key)\n    }\n\n    trigger(key)\n  }, [key])\n\n  useEffect(() => {\n    if (typeof initialValue === 'undefined') {\n      return\n    }\n\n    try {\n      if (sessionStorage.getItem(key) === null) {\n        sessionStorage.setItem(key, initialValue)\n      }\n    } catch {\n      if (inMemoryStorage.get(key) === undefined) {\n        inMemoryStorage.set(key, initialValue)\n      }\n    }\n\n    trigger(key)\n  }, [initialValue, key])\n\n  return [value, set, remove]\n}\n"
  },
  {
    "path": "packages/react-hooks/src/use-visibility-change.ts",
    "content": "import { useEffect } from 'react'\n\nexport function useVisibilityChange(\n  onChange: (visible: boolean) => void,\n  deps: unknown[] = [],\n) {\n  useEffect(() => {\n    function handleChange() {\n      onChange(!document.hidden)\n    }\n\n    document.addEventListener('visibilitychange', handleChange)\n\n    return () => {\n      document.removeEventListener('visibilitychange', handleChange)\n    }\n  }, deps) // eslint-disable-line react-hooks/exhaustive-deps\n}\n"
  },
  {
    "path": "packages/react-hooks/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/react-hooks/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/react-hooks/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/router/README.md",
    "content": "# `@titicaca/router`\n\n페이지 라우팅 관련 컴포넌트와 훅을 모으는 패키지입니다.\n\n## link\n\n`LocalLink`, `ExternalLink`, `useHrefToProps`를 제공합니다.\n`LocalLink`와 `ExternalLink`는 a 태그를 감싸서 트리플 앱과 일반 브라우저 모두에서 작동하게 만드는 링크 컴포넌트입니다.\n`useHrefToProps` 훅은 기존 inlink, outlink로 구성된 href를 ExternalLink 컴포넌트의 prop으로 변환하는 함수를 반환합니다.\n\n### 빠른 시작\n\n같은 프로젝트의 페이지로 이동할 때는 `LocalLink`를 사용합니다.\n\n```tsx\n<LocalLink target=\"new\" href=\"/1\">\n  <a>상세 페이지로 이동하기</a>\n</LocalLink>\n```\n\n기타 모든 URL로 이동할 때는 `ExternalLink`를 사용합니다.\n\n```tsx\n<ExternalLink target=\"current\" href=\"/air/some-air-url\">\n  <a>항공 웹으로 이동하기</a>\n</ExternalLink>\n```\n\n```tsx\n<ExternalLink target=\"new\" href=\"https://google.com\">\n  <a>구글링 해보세요</a>\n</ExternalLink>\n```\n\ninlink나 outlink로 완성되어 있어 일일이 props를 설정하기 어려울 때 `useHrefToProps`훅을 사용합니다.\n\n```tsx\nconst convertHrefToProps = useHrefToProps()\n\n<ExternalLink {...convertHrefToProps(href)}>\n```\n\n[자세히 읽기](./src/link/README.md)\n\n## 패키지의 목적\n\n**TL; DR** 앱, 웹 상관 없이 링크를 추가할 때 사용할 수 있는 컴포넌트를 만들고 싶었습니다.\n\n트리플 웹 서비스의 많은 부분에서 javascript 함수를 이용해 페이지를 이동합니다.\n앱에서 새 창을 열려면 `<a target=\"_blank\">`가 아니라 history-context에서 제공하는\n`navigate`나 `openWindow` 등의 함수를 호출해야 하기 때문입니다.\n\n이 방법을 사용하면서 형식을 잘 갖춘 a 태그를 만들지 않게 되었습니다.\n`href`를 넣지 않아도 링크는 잘 작동하고,\n심지어 a 태그가 아닌 div, span 등 모든 엘리먼트가 링크로 작동하게 됩니다.\n하지만 이는 HTML 표준 방식이 아니기 때문에 크롤러나 스크린 리더 등에서 문제가 생길 수 있습니다.\n\n그래서 기존에 사용하던 `navigate`나 `openWindow`와 같은 기능을 제공하면서\n올바른 형식을 갖춘 a 태그를 생성해주는 컴포넌트를 구현하고 싶었습니다.\n\n한편, next.js는 같은 next.js 앱 안에서 이동할 때\n클라이언트 사이드에서 페이지 이동이 가능한 API를 제공합니다.\n그 중 `Link` 컴포넌트는 이 컴포넌트의 작동 방식과 유사합니다.\n하지만 앱 안에서 새 창으로 페이지를 열 때 분기가 필요하여\n결국 `push`나 `replace` 함수를 사용하게 됩니다.\n이 패키지의 `LocalLink` 컴포넌트는 next.js의 `Link` 컴포넌트처럼 작동하면서,\n동시에 `target` prop을 통해 앱 내 새 창 열기도 지원하기 때문에 쉽게 사용할 수 있습니다.\n\na 태그의 일반적인 작동 방식을 최대한 살리면서\n유사한 인터페이스로 트리플 앱 내 라우팅도 지원하는 컴포넌트를 구현하는 것이 목표입니다.\n"
  },
  {
    "path": "packages/router/package.json",
    "content": "{\n  \"name\": \"@titicaca/router\",\n  \"version\": \"14.2.3\",\n  \"description\": \"Triple Universal Router Component and Functions\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/router\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/triple-web-to-native-interfaces\": \"1.11.0\",\n    \"@titicaca/view-utilities\": \"workspace:*\",\n    \"qs\": \"^6.14.0\"\n  },\n  \"devDependencies\": {\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"@titicaca/triple-web-test-utils\": \"workspace:*\",\n    \"@types/qs\": \"^6.9.18\",\n    \"next\": \"^14.2.24\",\n    \"react\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/triple-web\": \"*\",\n    \"@titicaca/triple-web-to-native-interfaces\": \"1.11.0\",\n    \"next\": \"^13.4 || ^14.0\",\n    \"react\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/router/src/common/add-web-url-base.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport { useWebUrlBaseAdder } from './add-web-url-base'\n\nconst MOCK_URL_BASE = 'https://triple.guide'\n\nconst wrapper = createTestWrapper({\n  envProvider: {\n    appUrlScheme: 'dev-soto',\n    webUrlBase: MOCK_URL_BASE,\n    basePath: '/',\n    facebookAppId: '',\n    defaultPageTitle: '',\n    defaultPageDescription: '',\n    afOnelinkId: '',\n    afOnelinkPid: '',\n    afOnelinkSubdomain: '',\n  },\n})\n\ntest('useEnv에서 webUrlBase를 가져와서 주어진 href에 붙입니다.', () => {\n  const { result } = renderHook(useWebUrlBaseAdder, {\n    wrapper,\n  })\n\n  const addWebUrlBaseToHref = result.current\n  const path = '/base'\n\n  expect(addWebUrlBaseToHref(path)).toBe(`${MOCK_URL_BASE}${path}`)\n})\n\ntest('주어진 href가 /이면 그냥 baseURL을 반환합니다.', () => {\n  const { result } = renderHook(useWebUrlBaseAdder, {\n    wrapper,\n  })\n\n  const addWebUrlBaseToHref = result.current\n  const path = '/'\n\n  expect(addWebUrlBaseToHref(path)).toEqual(MOCK_URL_BASE)\n})\n"
  },
  {
    "path": "packages/router/src/common/add-web-url-base.ts",
    "content": "import { useEnv } from '@titicaca/triple-web'\nimport { generateUrl, parseUrl } from '@titicaca/view-utilities'\n\nexport function useWebUrlBaseAdder() {\n  const { webUrlBase } = useEnv()\n\n  const addWebUrlBaseToHref = (href: string) => {\n    const { scheme, host } = parseUrl(webUrlBase)\n\n    if (href === '/') {\n      return webUrlBase\n    }\n\n    return generateUrl({ scheme, host }, href)\n  }\n\n  return addWebUrlBaseToHref\n}\n"
  },
  {
    "path": "packages/router/src/common/default-router.ts",
    "content": "import { ANCHOR_TARGET_MAP, TargetProps } from './target'\nimport { HrefProps } from './types'\n\nexport default function useDefaultRouter() {\n  const defaultRouter = ({ href, target }: HrefProps & TargetProps) => {\n    const windowTarget = ANCHOR_TARGET_MAP[target]\n    window.open(\n      href,\n      windowTarget,\n      windowTarget === '_blank' ? 'noopener' : undefined,\n    )\n  }\n\n  return defaultRouter\n}\n"
  },
  {
    "path": "packages/router/src/common/disabled-link-notifier.test.tsx",
    "content": "/* eslint-disable jest/no-conditional-expect */\n/* TODO: jest/no-conditional-expect 해결하기 */\nimport { act, renderHook, screen } from '@testing-library/react'\nimport { ClientAppName } from '@titicaca/triple-web'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport { useDisabledLinkNotifierCreator } from './disabled-link-notifier'\n\nfunction createWrapper({\n  isPublic,\n  sessionAvailable,\n}: {\n  isPublic: boolean\n  sessionAvailable: boolean\n}) {\n  return createTestWrapper({\n    clientAppProvider: isPublic\n      ? null\n      : {\n          device: { autoplay: 'always', networkType: 'unknown' },\n          metadata: { name: ClientAppName.iOS, version: '5.13.0' },\n        },\n    sessionProvider: {\n      user: sessionAvailable\n        ? {\n            name: 'TripleTester',\n            provider: 'TRIPLE',\n            country: 'ko',\n            lang: 'ko',\n            unregister: null,\n            photo: 'images.source',\n            mileage: {\n              badges: [{ icon: { imageUrl: '' } }],\n              level: 1,\n              point: 0,\n            },\n            uid: 'test',\n          }\n        : null,\n    },\n  })\n}\n\ndescribe('allowSource가 \"all\"일 때 앱 여부, 세션 여부에 상관없이 아무 처리를 하지 않습니다.', () => {\n  test.each([\n    [true, true],\n    [true, false],\n    [false, true],\n    [false, false],\n  ])('isPublic: %s, sessionAvailable: %s', (isPublic, sessionAvailable) => {\n    const {\n      result: { current: createDisabledLinkNotifier },\n    } = renderHook(useDisabledLinkNotifierCreator, {\n      wrapper: createWrapper({ isPublic, sessionAvailable }),\n    })\n\n    const notifier = createDisabledLinkNotifier({ allowSource: 'all' })\n\n    expect(notifier).toBeUndefined()\n  })\n})\n\ndescribe('allowSource가 \"app\"일 때 앱이 아니면 앱 설치 유도 모달 표시 함수를 호출합니다.', () => {\n  test.each([\n    [true, true, true],\n    [true, false, true],\n    // [false, true, false],\n    // [false, false, false],\n  ])(\n    'isPublic: %s, sessionAvailable: %s, 호출 여부: %s',\n    (isPublic, sessionAvailable, transitionModalFunctionCalled) => {\n      const {\n        result: { current: createDisabledLinkNotifier },\n      } = renderHook(useDisabledLinkNotifierCreator, {\n        wrapper: createWrapper({ isPublic, sessionAvailable }),\n      })\n\n      const notifier = createDisabledLinkNotifier({ allowSource: 'app' })\n\n      if (transitionModalFunctionCalled) {\n        expect(notifier).toEqual(expect.any(Function))\n      } else {\n        expect(notifier).toBeUndefined()\n      }\n\n      if (notifier) {\n        act(() => {\n          notifier()\n        })\n        expect(transitionModalFunctionCalled).toBe(true)\n        expect(screen.getByText('여기는 트리플 앱이 필요해요')).toBeVisible()\n      } else {\n        expect(transitionModalFunctionCalled).toBe(false)\n\n        expect(\n          screen.getByText('여기는 트리플 앱이 필요해요'),\n        ).not.toBeVisible()\n      }\n    },\n  )\n})\n\ndescribe('allowSource가 \"app-with-session\"일 때 앱이 아니면 앱 설치 유도 모달을, 인증 정보가 없으면 로그인 유도 모달을 표시함수를 호출합니다.', () => {\n  test.each([\n    [true, true, 'showAppInstallCtaModal'],\n    [true, false, 'showAppInstallCtaModal'],\n    [false, true, undefined],\n    [false, false, 'showLoginCtaModal'],\n  ])(\n    'isPublic: %s, sessionAvailable: %s, 호출 함수: %s',\n    (isPublic, sessionAvailable, functionType) => {\n      const {\n        result: { current: createDisabledLinkNotifier },\n      } = renderHook(useDisabledLinkNotifierCreator, {\n        wrapper: createWrapper({ isPublic, sessionAvailable }),\n      })\n\n      const notifier = createDisabledLinkNotifier({\n        allowSource: 'app-with-session',\n      })\n\n      expect(notifier).toEqual(\n        functionType === undefined ? undefined : expect.any(Function),\n      )\n\n      if (notifier) {\n        act(() => {\n          notifier()\n        })\n\n        if (functionType === 'showLoginCtaModal') {\n          expect(screen.getByText('로그인이 필요합니다.')).toBeVisible()\n        } else if (functionType === 'showAppInstallCtaModal') {\n          expect(screen.getByText('여기는 트리플 앱이 필요해요')).toBeVisible()\n        }\n      }\n    },\n  )\n})\n\ndescribe('allowSource가 \"none\"이면 항상 알림을 표시합니다.', () => {\n  test.each([\n    [true, true],\n    [true, false],\n    [false, true],\n    [false, false],\n  ])('isPublic: %s, sessionAvailable: %s', (isPublic, sessionAvailable) => {\n    const alert = jest.fn()\n    const {\n      result: { current: createDisabledLinkNotifier },\n    } = renderHook(useDisabledLinkNotifierCreator, {\n      wrapper: createWrapper({ isPublic, sessionAvailable }),\n      initialProps: { alert },\n    })\n\n    const notifier = createDisabledLinkNotifier({\n      allowSource: 'none',\n    })\n\n    expect(notifier).toBeDefined()\n\n    if (notifier) {\n      notifier()\n\n      expect(alert).toHaveBeenCalled()\n    }\n  })\n})\n"
  },
  {
    "path": "packages/router/src/common/disabled-link-notifier.ts",
    "content": "import {\n  useClientApp,\n  useLoginCtaModal,\n  useSessionAvailability,\n  useAppInstallCtaModal,\n  useTranslation,\n} from '@titicaca/triple-web'\n\nexport type AllowSource = 'all' | 'app' | 'app-with-session' | 'none'\n\nexport interface AllowSourceProps {\n  /**\n   * 링크가 작동하는 환경을 설정합니다.\n   * `all`, `app`, `app-with-session`, `none` 네 가지를 사용할 수 있습니다.\n   * 기본 값은 `all`.\n   */\n  allowSource?: AllowSource\n}\n\nexport function useDisabledLinkNotifierCreator({\n  alert = defaultAlert,\n}: {\n  alert?: (message: string) => void\n} = {}) {\n  const t = useTranslation()\n  const app = useClientApp()\n  const sessionAvailable = useSessionAvailability()\n  const { show: showAppInstallCtaModal } = useAppInstallCtaModal()\n  const { show: showLoginCtaModal } = useLoginCtaModal()\n\n  const createDisabledLinkNotifier = ({\n    allowSource = 'all',\n  }: AllowSourceProps) => {\n    if (allowSource === 'none') {\n      return () => {\n        alert(t('접근할 수 없는 링크입니다.'))\n      }\n    }\n\n    if (!app && (allowSource === 'app' || allowSource === 'app-with-session')) {\n      return () => showAppInstallCtaModal()\n    }\n\n    if (sessionAvailable === false && allowSource === 'app-with-session') {\n      return () => showLoginCtaModal()\n    }\n  }\n\n  return createDisabledLinkNotifier\n}\n\nfunction defaultAlert(message: string) {\n  if (typeof window !== 'undefined') {\n    window.alert(message)\n  }\n}\n"
  },
  {
    "path": "packages/router/src/common/router-guarded-link.test.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\n\nimport { useDisabledLinkNotifierCreator } from './disabled-link-notifier'\nimport { RouterGuardedLink } from './router-guarded-link'\n\njest.mock('../common/disabled-link-notifier')\n\nafterEach(() => {\n  ;(\n    useDisabledLinkNotifierCreator as jest.MockedFunction<\n      typeof useDisabledLinkNotifierCreator\n    >\n  ).mockClear()\n})\n\ndescribe('라우팅할 수 없는 환경일 때', () => {\n  let notifier: (() => void) | undefined\n\n  beforeEach(() => {\n    notifier = mockDisabledLinkNotifierCreatorHook(true)\n  })\n\n  test('버튼을 렌더링합니다.', () => {\n    render(<RouterGuardedLink href=\"\">테스트링크</RouterGuardedLink>)\n\n    expect(screen.getByRole('button')).toBeInTheDocument()\n  })\n\n  test('클릭하면 disabled link notifier를 호출합니다.', () => {\n    render(<RouterGuardedLink href=\"\">테스트링크</RouterGuardedLink>)\n\n    const button = screen.getByRole('button')\n\n    fireEvent.click(button)\n\n    expect(notifier).toHaveBeenCalled()\n  })\n\n  test('className prop을 전달합니다.', () => {\n    const className = 'TEST_CLASS_NAME'\n    render(\n      <RouterGuardedLink href=\"\" className={className}>\n        테스트링크\n      </RouterGuardedLink>,\n    )\n\n    const button = screen.getByRole('button')\n\n    expect(button).toHaveClass(className)\n  })\n})\n\ndescribe('라우팅할 수 있는 환경일 때', () => {\n  beforeEach(() => {\n    mockDisabledLinkNotifierCreatorHook(false)\n  })\n\n  test('앵커 태그를 렌더링합니다.', () => {\n    render(<RouterGuardedLink href=\"/foo\">테스트링크</RouterGuardedLink>)\n\n    const link = screen.getByRole('link')\n\n    expect(link).toBeInTheDocument()\n  })\n\n  test('className prop을 클래스로 전달합니다.', () => {\n    const className = 'TEST_CLASS_NAME'\n    render(\n      <RouterGuardedLink href=\"/foo\" className={className}>\n        테스트링크\n      </RouterGuardedLink>,\n    )\n\n    const link = screen.getByRole('link')\n\n    expect(link).toHaveClass(className)\n  })\n\n  test('relList prop을 rel로 전달합니다.', () => {\n    const relList: Parameters<typeof RouterGuardedLink>[0]['relList'] = [\n      'external',\n    ]\n\n    render(\n      <RouterGuardedLink href=\"/foo\" relList={relList}>\n        테스트링크\n      </RouterGuardedLink>,\n    )\n\n    const link = screen.getByRole('link')\n\n    expect(link).toHaveAttribute('rel', expect.stringContaining('external'))\n  })\n})\n\nfunction mockDisabledLinkNotifierCreatorHook(disabled: boolean) {\n  if (disabled) {\n    const notifier = jest.fn()\n\n    ;(\n      useDisabledLinkNotifierCreator as jest.MockedFunction<\n        typeof useDisabledLinkNotifierCreator\n      >\n    ).mockReturnValue(() => notifier)\n\n    return notifier\n  }\n\n  ;(\n    useDisabledLinkNotifierCreator as jest.MockedFunction<\n      typeof useDisabledLinkNotifierCreator\n    >\n  ).mockReturnValue(() => undefined)\n}\n"
  },
  {
    "path": "packages/router/src/common/router-guarded-link.tsx",
    "content": "import { AnchorHTMLAttributes, PropsWithChildren } from 'react'\n\nimport {\n  AllowSourceProps,\n  useDisabledLinkNotifierCreator,\n} from '../common/disabled-link-notifier'\n\nimport { RelListProps, useRel } from './use-rel'\n\n/**\n * 조건부 라우팅 검사 로직을 자식 a 엘리먼트에 주입하는 컴포넌트\n */\nexport function RouterGuardedLink({\n  relList = [],\n  allowSource = 'all',\n  children,\n  className,\n  ...restProps\n}: PropsWithChildren<\n  Partial<Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'rel'>> &\n    RelListProps &\n    AllowSourceProps\n>) {\n  const rel = useRel(relList)\n  const createDisabledLinkNotifier = useDisabledLinkNotifierCreator()\n\n  const disabledLinkNotifier = createDisabledLinkNotifier({ allowSource })\n\n  if (disabledLinkNotifier !== undefined) {\n    return (\n      <button className={className} onClick={() => disabledLinkNotifier()}>\n        {children}\n      </button>\n    )\n  }\n\n  return (\n    <a className={className} rel={rel} {...restProps}>\n      {children}\n    </a>\n  )\n}\n"
  },
  {
    "path": "packages/router/src/common/target.ts",
    "content": "export type TargetType = 'current' | 'new' | 'browser'\n\nexport interface TargetProps {\n  /**\n   * 페이지를 이동할 목표를 설정합니다.\n   * `current`, `new`, `browser` 세 가지를 사용할 수 있습니다.\n   * 각각 현재 창, 새 창(새 웹뷰), 기본 브라우저(앱에서만 작동)를 의미합니다.\n   */\n  target: TargetType\n}\n\nexport const ANCHOR_TARGET_MAP: {\n  [key in TargetType]: string\n} = {\n  current: '_self',\n  new: '_blank',\n  browser: '_blank',\n}\n"
  },
  {
    "path": "packages/router/src/common/types.ts",
    "content": "import { AllowSourceProps } from './disabled-link-notifier'\nimport { TargetProps } from './target'\nimport { RelListProps } from './use-rel'\n\nexport interface HrefProps {\n  href: string\n}\n\ntype LnbTargetType = 'trip' | 'zone' | 'region'\n\nexport interface AppSpecificLinkProps {\n  /**\n   *lnb를 위한 속성값. type과 id를 전달 받습니다\n   *@param lnbTarget\n   */\n  lnbTarget?: {\n    type: LnbTargetType\n    id: string\n  }\n  /**\n   *인앱 웹뷰 상단 네비게이션 바를 가립니다.\n   *@param noNavbar\n   */\n  noNavbar?: boolean\n  /**\n   *네비게이션 스택이 아닌 팝업으로 화면을 뛰우빈다 (lnb X)\n   *웹뷰 최상단에서 아래로 당기면 화면을 닫습니다 (iosOnly)\n   *@param swipeToClose\n   */\n  swipeToClose?: boolean\n  /**\n   *lnb를 위한 속성값. type과 id를 전달 받습니다\n   *네비게이션 스택이 아닌 팝업으로 화면을 뛰웁니다. (lnb X)\n   *웹뷰 최상단에서 아래로 당겨도 화면을 닫지 않습니다. (iosOnly)\n   *@param lnbTarget\n   */\n  shouldPresent?: boolean\n}\n\n/**\n * LocalLink, ExternalLink 공통으로 쓰는 props\n */\nexport type LinkCommonProps = {\n  /**\n   * anchor를 클릭했을 때 작동하는 핸들러입니다.\n   * `allowSource` 조건에 의해 사용할 수 없는 링크이면 클릭해도 작동하지 않습니다.\n   */\n  onClick?: () => void\n  className?: string\n} & HrefProps &\n  TargetProps &\n  RelListProps &\n  AllowSourceProps &\n  AppSpecificLinkProps\n"
  },
  {
    "path": "packages/router/src/common/use-rel.test.ts",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useRel } from './use-rel'\n\ntest('noopener, noreferer를 추가합니다.', () => {\n  const {\n    result: { current },\n  } = renderHook(useRel)\n\n  expect(current).toEqual(expect.stringContaining('noopener'))\n  expect(current).toEqual(expect.stringContaining('noreferer'))\n})\n\ntest('파라미터로 새로운 rel 값을 추가할 수 있습니다.', () => {\n  const {\n    result: { current },\n  } = renderHook(useRel, { initialProps: ['author', 'bookmark'] })\n\n  expect(current).toEqual(expect.stringContaining('author'))\n  expect(current).toEqual(expect.stringContaining('bookmark'))\n})\n\ntest('같은 값을 추가해도 하나만 출력합니다.', () => {\n  const {\n    result: { current },\n  } = renderHook(useRel, { initialProps: ['noopener', 'bookmark', 'bookmark'] })\n\n  expect(\n    current.split(' ').filter((value) => value === 'noopener'),\n  ).toHaveLength(1)\n  expect(\n    current.split(' ').filter((value) => value === 'bookmark'),\n  ).toHaveLength(1)\n})\n"
  },
  {
    "path": "packages/router/src/common/use-rel.ts",
    "content": "/**\n * https://developer.mozilla.org/ko/docs/Web/HTML/Link_types\n */\ntype LinkType =\n  | 'alternate'\n  | 'author'\n  | 'bookmark'\n  | 'external'\n  | 'help'\n  | 'license'\n  | 'next'\n  | 'nofollow'\n  | 'noopener'\n  | 'noreferrer'\n  | 'prev'\n  | 'search'\n  | 'tag'\n\nexport interface RelListProps {\n  /**\n   * anchor 엘리먼트의 링크 유형 목록입니다.\n   * 중복을 알아서 제거하기 때문에 미리 제거할 필요 없습니다.\n   * @link https://developer.mozilla.org/ko/docs/Web/HTML/Link_types\n   */\n  relList?: LinkType[]\n}\n\nexport function useRel(relList: LinkType[] = []): string {\n  return Array.from(new Set(['noopener', 'noreferer', ...relList])).join(' ')\n}\n"
  },
  {
    "path": "packages/router/src/external/hook.ts",
    "content": "import useDefaultRouter from '../common/default-router'\nimport {\n  AllowSourceProps,\n  useDisabledLinkNotifierCreator,\n} from '../common/disabled-link-notifier'\nimport { TargetProps } from '../common/target'\nimport { AppSpecificLinkProps, HrefProps } from '../common/types'\nimport { OpenOutlinkOptions } from '../links'\n\nimport { useExternalHrefHandler } from './href-handler'\n\nexport default function useExternalRouter() {\n  const customRouter = useExternalHrefHandler()\n  const createDisabledLinkNotifier = useDisabledLinkNotifierCreator()\n  const defaultRouter = useDefaultRouter()\n\n  const routeExternally = ({\n    allowSource,\n    href,\n    target,\n    lnbTarget,\n    noNavbar,\n    shouldPresent,\n    swipeToClose,\n    title,\n  }: AllowSourceProps &\n    HrefProps &\n    TargetProps &\n    AppSpecificLinkProps &\n    Pick<OpenOutlinkOptions, 'title'>) => {\n    const notifyDisabledLink = createDisabledLinkNotifier({ allowSource })\n\n    if (notifyDisabledLink !== undefined) {\n      notifyDisabledLink()\n\n      return\n    }\n\n    let hrefHandled = false\n\n    customRouter({\n      href,\n      target,\n      lnbTarget,\n      noNavbar,\n      shouldPresent,\n      swipeToClose,\n      title,\n      stopDefaultHandler: () => {\n        hrefHandled = true\n      },\n    })\n\n    if (hrefHandled === false) {\n      defaultRouter({ href, target })\n    }\n  }\n\n  return routeExternally\n}\n"
  },
  {
    "path": "packages/router/src/external/href-handler.ts",
    "content": "import { useClientApp } from '@titicaca/triple-web'\n\nimport { useWebUrlBaseAdder } from '../common/add-web-url-base'\nimport { TargetProps } from '../common/target'\nimport { AppSpecificLinkProps, HrefProps } from '../common/types'\nimport { OpenOutlinkOptions, useOpenInlink, useOpenOutlink } from '../links'\n\nimport { checkHrefIsAbsoluteUrl } from './utils'\n\nexport function useExternalHrefHandler() {\n  const app = useClientApp()\n  const addWebUrlBase = useWebUrlBaseAdder()\n  const openInlink = useOpenInlink()\n  const openOutlink = useOpenOutlink()\n\n  const handleHrefExternally = ({\n    href,\n    target,\n    lnbTarget,\n    noNavbar,\n    shouldPresent,\n    swipeToClose,\n    title,\n    stopDefaultHandler,\n  }: HrefProps &\n    TargetProps &\n    AppSpecificLinkProps &\n    Pick<OpenOutlinkOptions, 'title'> & { stopDefaultHandler: () => void }) => {\n    const outOfTriple = checkHrefIsAbsoluteUrl(href)\n\n    if (target === 'current' && app && outOfTriple === true) {\n      stopDefaultHandler()\n\n      return\n    }\n\n    if (target === 'new' && app) {\n      stopDefaultHandler()\n\n      if (outOfTriple === true) {\n        openOutlink(href, { title })\n      } else {\n        openInlink(href, {\n          lnb: lnbTarget,\n          noNavbar,\n          shouldPresent,\n          swipeToClose,\n        })\n      }\n\n      return\n    }\n\n    if (target === 'browser' && app) {\n      stopDefaultHandler()\n\n      openOutlink(outOfTriple ? href : addWebUrlBase(href), {\n        target: 'browser',\n        title,\n      })\n    }\n  }\n\n  return handleHrefExternally\n}\n"
  },
  {
    "path": "packages/router/src/external/index.ts",
    "content": "export { ExternalLink } from './link'\nexport { default as useExternalRouter } from './hook'\n"
  },
  {
    "path": "packages/router/src/external/link.tsx",
    "content": "import { MouseEventHandler, PropsWithChildren, useEffect } from 'react'\nimport { useClientApp } from '@titicaca/triple-web'\n\nimport { ANCHOR_TARGET_MAP } from '../common/target'\nimport { RouterGuardedLink } from '../common/router-guarded-link'\nimport { LinkCommonProps } from '../common/types'\n\nimport { useExternalHrefHandler } from './href-handler'\nimport { checkHrefIsAbsoluteUrl } from './utils'\n\nexport function ExternalLink({\n  href,\n  target,\n  relList = [],\n  allowSource,\n  title,\n  lnbTarget,\n  noNavbar,\n  swipeToClose,\n  shouldPresent,\n  onClick,\n  onError,\n  className,\n  children,\n}: PropsWithChildren<\n  LinkCommonProps & {\n    /**\n     * 새로 열 창의 제목을 지정합니다. 외부 URL이고 target이 \"new\"이거나 \"browser\"일 때만 작동합니다.\n     */\n    title?: string\n    /**\n     * 링크 규칙 결정에 오류가 있을 때 핸들러입니다.\n     * 앱에서 트리플 외부 URL을 현재 창으로 열 수 없습니다.\n     */\n    onError?: (error: Error) => void\n  }\n>) {\n  const app = useClientApp()\n  const handleHrefExternally = useExternalHrefHandler()\n\n  const hrefIsAbsoluteUrl = checkHrefIsAbsoluteUrl(href)\n  const forbiddenLinkCondition =\n    app && hrefIsAbsoluteUrl && target === 'current'\n\n  const handleClick: MouseEventHandler<HTMLAnchorElement> = (e) => {\n    if (onClick) {\n      onClick()\n    }\n\n    handleHrefExternally({\n      href,\n      target,\n      lnbTarget,\n      noNavbar,\n      shouldPresent,\n      swipeToClose,\n      title,\n      stopDefaultHandler: () => {\n        e.preventDefault()\n      },\n    })\n  }\n\n  useEffect(\n    () => {\n      if (forbiddenLinkCondition && onError) {\n        onError(new Error('현재 창에서 절대 경로로 이동할 수 없습니다.'))\n      }\n    },\n    // onError 변경에 대응하지 않습니다.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [forbiddenLinkCondition],\n  )\n\n  return (\n    <RouterGuardedLink\n      className={className}\n      href={href}\n      relList={hrefIsAbsoluteUrl ? ['external', ...relList] : relList}\n      allowSource={forbiddenLinkCondition ? 'none' : allowSource}\n      onClick={handleClick}\n      target={ANCHOR_TARGET_MAP[target]}\n    >\n      {children}\n    </RouterGuardedLink>\n  )\n}\n"
  },
  {
    "path": "packages/router/src/external/utils.ts",
    "content": "import { parseUrl } from '@titicaca/view-utilities'\n\nexport function checkHrefIsAbsoluteUrl(href: string): boolean {\n  const { host } = parseUrl(href)\n  return !!host\n}\n"
  },
  {
    "path": "packages/router/src/href-to-props/index.ts",
    "content": "export { useHrefToProps } from './use-href-to-props'\n"
  },
  {
    "path": "packages/router/src/href-to-props/use-href-to-props.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\nimport { ClientAppName } from '@titicaca/triple-web'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\nimport { checkIfRoutable } from '@titicaca/view-utilities'\n\nimport { useHrefToProps } from './use-href-to-props'\n\njest.mock('@titicaca/view-utilities', () => ({\n  ...jest.requireActual('@titicaca/view-utilities'),\n  checkIfRoutable: jest.fn(),\n}))\n\nfunction createWrapper({ isPublic }: { isPublic: boolean }) {\n  return createTestWrapper({\n    clientAppProvider: isPublic\n      ? null\n      : {\n          device: { autoplay: 'always', networkType: 'unknown' },\n          metadata: { name: ClientAppName.iOS, version: '5.13.0' },\n        },\n  })\n}\n\ndescribe('href', () => {\n  test('href에서 트리플 도메인을 제거합니다.', () => {\n    const {\n      result: { current: hrefToProps },\n    } = renderHook(useHrefToProps, {\n      wrapper: createWrapper({ isPublic: true }),\n    })\n\n    const webUrlBase = 'https://triple-dev.titicaca-corp.com'\n    const path = '/my-path'\n    const { href } = hrefToProps(`${webUrlBase}${path}`)\n\n    expect(href).toEqual(path)\n  })\n\n  test('href에서 inlink를 제거합니다.', () => {\n    const {\n      result: { current: hrefToProps },\n    } = renderHook(useHrefToProps, {\n      wrapper: createWrapper({ isPublic: true }),\n    })\n\n    const path = '/my-path'\n    const { href } = hrefToProps(`/inlink?path=${encodeURIComponent(path)}`)\n\n    expect(href).toEqual(path)\n  })\n\n  test('href에서 outlink를 제거합니다.', () => {\n    const {\n      result: { current: hrefToProps },\n    } = renderHook(useHrefToProps, {\n      wrapper: createWrapper({ isPublic: true }),\n    })\n\n    const path = 'https://www.google.com/my-path'\n    const { href } = hrefToProps(`/outlink?url=${encodeURIComponent(path)}`)\n\n    expect(href).toEqual(path)\n  })\n})\n\ndescribe('target', () => {\n  test('일반 브라우저 환경에선 target을 \"current\"로 설정합니다.', () => {\n    const {\n      result: { current: hrefToProps },\n    } = renderHook(useHrefToProps, {\n      wrapper: createWrapper({ isPublic: true }),\n    })\n\n    const path = '/my-path'\n    const { target } = hrefToProps(path)\n\n    expect(target).toBe('current')\n  })\n\n  test('앱에선 target을 \"new\"로 설정합니다.', () => {\n    const {\n      result: { current: hrefToProps },\n    } = renderHook(useHrefToProps, {\n      wrapper: createWrapper({ isPublic: false }),\n    })\n\n    const path = '/my-path'\n    const { target } = hrefToProps(path)\n\n    expect(target).toBe('new')\n  })\n\n  test('outlink의 target이 \"browser\"이면 target을 \"browser\"로 설정합니다.', () => {\n    const runTest = (isPublic: boolean) => {\n      const {\n        result: { current: hrefToProps },\n      } = renderHook(useHrefToProps, {\n        wrapper: createWrapper({ isPublic }),\n      })\n\n      const path = `/outlink?url=${encodeURIComponent(\n        '/my-path',\n      )}&target=browser`\n      const { target } = hrefToProps(path)\n\n      expect(target).toBe('browser')\n    }\n\n    runTest(true)\n    runTest(false)\n  })\n})\n\ndescribe('allowSource', () => {\n  const routablePath = 'ROUTABLE_PATH'\n\n  beforeEach(() => {\n    ;(\n      checkIfRoutable as jest.MockedFunction<typeof checkIfRoutable>\n    ).mockImplementation(({ href }) => {\n      return href === routablePath\n    })\n  })\n\n  test('routable한 링크는 allowSource를 \"all\"로 설정합니다.', () => {\n    const {\n      result: { current: hrefToProps },\n    } = renderHook(useHrefToProps, {\n      wrapper: createWrapper({ isPublic: false }),\n    })\n\n    const { allowSource } = hrefToProps(routablePath)\n\n    expect(allowSource).toBe('all')\n  })\n\n  test('routable하지 않은 링크는 allowSource를 \"app-with-session\"으로 설정합니다.', () => {\n    const {\n      result: { current: hrefToProps },\n    } = renderHook(useHrefToProps, {\n      wrapper: createWrapper({ isPublic: false }),\n    })\n\n    const { allowSource } = hrefToProps('not-routable')\n\n    expect(allowSource).toBe('app-with-session')\n  })\n\n  test('inlink는 path가 routable하면 allowSource를 \"app\"으로 설정합니다.', () => {\n    const {\n      result: { current: hrefToProps },\n    } = renderHook(useHrefToProps, {\n      wrapper: createWrapper({ isPublic: false }),\n    })\n\n    const href = `/inlink?path=${encodeURIComponent(routablePath)}`\n    const { allowSource } = hrefToProps(href)\n\n    expect(allowSource).toBe('app')\n  })\n\n  test('inlink의 _web_expand 파라미터가 있으면 routable하지 않아도 allowSource를 \"all\"로 설정합니다.', () => {\n    const {\n      result: { current: hrefToProps },\n    } = renderHook(useHrefToProps, {\n      wrapper: createWrapper({ isPublic: false }),\n    })\n\n    const href = `/inlink?path=${encodeURIComponent(\n      '/my-wonderful-path',\n    )}&_web_expand=true`\n    const { allowSource } = hrefToProps(href)\n\n    expect(allowSource).toBe('all')\n  })\n})\n"
  },
  {
    "path": "packages/router/src/href-to-props/use-href-to-props.ts",
    "content": "import { useCallback } from 'react'\nimport qs, { ParsedQs } from 'qs'\nimport {\n  checkIfRoutable,\n  generateUrl,\n  parseUrl,\n} from '@titicaca/view-utilities'\nimport { useClientApp, useEnv } from '@titicaca/triple-web'\n\nimport { AllowSource } from '../common/disabled-link-notifier'\nimport { TargetType } from '../common/target'\n\n/**\n * 주어진 href가 절대 경로일 때 트리플의 URL이면 상대 경로로 바꿉니다.\n */\nfunction removeTripleDomain({\n  href,\n  webUrlBase,\n}: {\n  href: string\n  webUrlBase: string\n}): string {\n  const { host } = parseUrl(href)\n  const { host: webUrlBaseHost } = parseUrl(webUrlBase)\n\n  if (!host) {\n    return href\n  }\n\n  // 절대 경로\n  if (host === webUrlBaseHost) {\n    // 트리플 URL\n    return generateUrl(\n      {\n        scheme: undefined,\n        host: undefined,\n      },\n      href,\n    )\n  }\n\n  // 외부 URL\n  return href\n}\n\n/**\n * query 파라미터의 타입을 string | undefined 타입으로 좁히는 함수\n * @param value query 파라미터로 들어있던 값\n */\nfunction stringifyParsedQuery(\n  value: string | ParsedQs | (string | ParsedQs)[] | undefined,\n): string | undefined {\n  if (Array.isArray(value)) {\n    return stringifyParsedQuery(value)\n  }\n\n  if (typeof value === 'object') {\n    throw new Error(`Query parameter is not string type. ${value}`)\n  }\n\n  return value\n}\n\n/**\n * inlink, outlink 등 앱 브릿지로 사용하는 URL의 query 속 실제 URL을 반환합니다.\n * 그 외의 URL은 원본을 그대로 반환합니다.\n */\nfunction stripAppBridge(href: string): string {\n  const { path, query } = parseUrl(href)\n\n  if (path === '/inlink') {\n    const { path } = qs.parse(query || '')\n\n    const normalizedPath = stringifyParsedQuery(path)\n\n    if (!normalizedPath) {\n      throw new Error('inlink has no path.')\n    }\n    return normalizedPath\n  }\n\n  if (path === '/outlink') {\n    const { url } = qs.parse(query || '')\n\n    const normalizedUrl = stringifyParsedQuery(url)\n\n    if (!normalizedUrl) {\n      throw new Error('outlink has no url.')\n    }\n    return normalizedUrl\n  }\n\n  return href\n}\n\n/**\n * 다양한 종류의 URL을 a tag에 사용할 수 있는 값으로 다듬습니다.\n * @param href\n */\nfunction canonizeHref({\n  href,\n  webUrlBase,\n}: {\n  href: string\n  webUrlBase: string\n}) {\n  return removeTripleDomain({ href: stripAppBridge(href), webUrlBase })\n}\n\n/**\n * URL이 열리는 target을 설정합니다.\n *\n * * 'browser': 앱 내 브라우저가 아닌 앱 기본 브라우저에서 열립니다. 웹에선 새 창으로 열립니다.\n * * 'new': 새 창에서 열립니다.\n * * 'current': 현재 창에서 열립니다.\n *\n * outlink의 target search param이 browser로 설정되어 있으면 browser를 반환합니다.\n * 웹에선 현재창, 앱에선 새 창으로 설정합니다.\n * `navigate` 함수의 작동 방식을 옮겨온 것이기 때문에 이렇게 정해져있습니다.\n */\nfunction getTarget({\n  href,\n  isPublic,\n}: {\n  href: string\n  isPublic: boolean\n}): TargetType {\n  const { path, query } = parseUrl(href)\n\n  if (path === '/outlink') {\n    const { target } = qs.parse(query || '')\n\n    if (target === 'browser') {\n      return 'browser'\n    }\n  }\n\n  return isPublic ? 'current' : 'new'\n}\n\n/**\n * allowSource를 결정합니다.\n *\n * routable한 링크는 모든 소스에서 열릴 수 있습니다.\n * routable하지 않으면 세션이 있는 app에서만 열립니다.\n * inlink이고 _web_expand 파라미터가 설정되어 있으면, routable 하지 않아도 웹에서 열립니다.\n */\nfunction getAllowSource({\n  href,\n  webUrlBase,\n}: {\n  href: string\n  webUrlBase: string\n}): AllowSource {\n  const { path, query } = parseUrl(href)\n\n  if (path === '/inlink') {\n    const { _web_expand: expandable } = qs.parse(query || '')\n\n    if (expandable) {\n      return 'all'\n    }\n\n    return checkIfRoutable({ href: stripAppBridge(href) })\n      ? 'app'\n      : 'app-with-session'\n  }\n\n  return checkIfRoutable({ href: canonizeHref({ href, webUrlBase }) })\n    ? 'all'\n    : 'app-with-session'\n}\n\n/**\n * href를 ExternalLink의 prop으로 변환하는 함수를 반환하는 훅\n * @returns href를 ExternalLink 컴포넌트의 prop으로 변환하는 함수\n */\nexport function useHrefToProps(params?: {\n  /**\n   * 변환 과정에서 발생한 에러 핸들러입니다.\n   */\n  onError?: (error: Error) => void\n}): (\n  /**\n   * inlink나 outlink를 포함하는 href\n   */\n  href: string,\n) => {\n  /**\n   * inlink, outlink는 각각 query에 들어있는 path나 URL을 빼냅니다.\n   * 이후 트리플 도메인의 절대 경로(`https://triple.guide/...`)는 scheme과 host를 제거합니다.\n   */\n  href: string\n  /**\n   * 현재 환경이 웹일 땐 `current`, 앱일 땐 `new`를 반환합니다.\n   * 단, outlink의 target query가 browser이면 `browser`를 반환합니다.\n   */\n  target: TargetType\n  /**\n   * | `checkIfRoutable` | inlink with `_web_expand` |       inlink       |       그 외        |\n   * | ----------------: | :-----------------------: | :----------------: | :----------------: |\n   * |              true |           `all`           |       `app`        |       `all`        |\n   * |             false |    `app-with-session`     | `app-with-session` | `app-with-session` |\n   */\n  allowSource: AllowSource\n} {\n  const { webUrlBase } = useEnv()\n  const app = useClientApp()\n\n  const { onError } = params || {}\n\n  return useCallback(\n    (href) => {\n      try {\n        return {\n          href: canonizeHref({ href, webUrlBase }),\n          target: getTarget({ href, isPublic: !app }),\n          allowSource: getAllowSource({ href, webUrlBase }),\n        }\n      } catch (error) {\n        if (onError) {\n          onError(error as Error)\n        }\n\n        return { href, target: 'new', allowSource: 'app-with-session' }\n      }\n    },\n    [app, onError, webUrlBase],\n  )\n}\n"
  },
  {
    "path": "packages/router/src/index.ts",
    "content": "export * from './links'\nexport * from './local'\nexport * from './external'\nexport * from './navigate'\nexport { useHrefToProps } from './href-to-props'\n"
  },
  {
    "path": "packages/router/src/links/index.ts",
    "content": "export * from './use-make-inlink'\nexport * from './use-make-outlink'\nexport * from './use-open-inlink'\nexport * from './use-open-native-link'\nexport * from './use-open-outlink'\n"
  },
  {
    "path": "packages/router/src/links/use-make-inlink.ts",
    "content": "import { useCallback } from 'react'\nimport { useClientApp, useEnv } from '@titicaca/triple-web'\nimport { generateUrl } from '@titicaca/view-utilities'\nimport qs from 'qs'\n\ntype InlinkLnbType = 'trip' | 'zone' | 'region'\n\nexport interface MakeInlinkOptions {\n  /**\n   * true 일 때, inlink path에 basePath를 포함합니다.\n   * Next.js <Link> 컴포넌트와 함께 사용합니다.\n   */\n  local?: boolean\n  /**\n   * lnb를 위한 속성값. type과 id를 전달 받습니다.\n   */\n  lnb?: {\n    type: InlinkLnbType\n    id: string\n  }\n  /**\n   * 인앱 웹뷰 상단 네비게이션 바를 가립니다.\n   */\n  noNavbar?: boolean\n  /**\n   * 네비게이션 스택이 아닌 팝업으로 화면을 띄웁니다. (lnb가 없습니다.)\n   * 웹뷰 최상단에서 아래로 당기면 화면을 닫습니다. (iOS only)\n   */\n  swipeToClose?: boolean\n  /**\n   * 네비게이션 스택이 아닌 팝업으로 화면을 띄웁니다. (lnb가 없습니다.)\n   * 웹뷰 최상단에서 아래로 당겨도 화면을 닫지 않습니다. (iOS only)\n   */\n  shouldPresent?: boolean\n}\n\nexport function useMakeInlink() {\n  const clientApp = useClientApp()\n  const { appUrlScheme, basePath } = useEnv()\n\n  const makeInlink = useCallback(\n    (\n      /**\n       * Inlink로 만들 relative URL.\n       */\n      path: string,\n      options?: MakeInlinkOptions,\n    ) => {\n      if (!clientApp) {\n        return path\n      }\n\n      return generateUrl({\n        scheme: appUrlScheme,\n        path: '/inlink',\n        query: qs.stringify({\n          path: generateUrl({\n            path: options?.local ? basePath + path : path,\n            query: qs.stringify({\n              ...(options?.lnb\n                ? getLnb(options.lnb.type, options.lnb.id)\n                : undefined),\n              _triple_no_navbar: options?.noNavbar,\n              _triple_swipe_to_close: options?.swipeToClose,\n              _triple_should_present: options?.shouldPresent,\n            }),\n          }),\n        }),\n      })\n    },\n    [clientApp, appUrlScheme, basePath],\n  )\n\n  return makeInlink\n}\n\nfunction getLnb(type: InlinkLnbType, id: string) {\n  switch (type) {\n    case 'region':\n      return { _triple_lnb_region_id: id }\n    case 'trip':\n      return { _triple_lnb_trip_id: id }\n    case 'zone':\n      return { _triple_lnb_zone_id: id }\n  }\n}\n"
  },
  {
    "path": "packages/router/src/links/use-make-outlink.ts",
    "content": "import { useCallback } from 'react'\nimport { useClientApp, useEnv } from '@titicaca/triple-web'\nimport { generateUrl } from '@titicaca/view-utilities'\nimport qs from 'qs'\n\nexport interface MakeOutlinkOptions {\n  /**\n   * - browser: 해당 url을 외부 브라우저로 엽니다.\n   */\n  target?: 'browser'\n  /**\n   * 브라우저 타이틀.\n   */\n  title?: string\n}\n\nexport function useMakeOutlink() {\n  const clientApp = useClientApp()\n  const { appUrlScheme } = useEnv()\n\n  const makeOutlink = useCallback(\n    (\n      /**\n       * Outlink로 만들 absolute URL.\n       */\n      url: string,\n      options?: MakeOutlinkOptions,\n    ) => {\n      if (!clientApp) {\n        return url\n      }\n\n      return generateUrl({\n        scheme: appUrlScheme,\n        path: '/outlink',\n        query: qs.stringify({\n          url,\n          target: options?.target,\n          title: options?.title,\n        }),\n      })\n    },\n    [clientApp, appUrlScheme],\n  )\n\n  return makeOutlink\n}\n"
  },
  {
    "path": "packages/router/src/links/use-open-inlink.test.ts",
    "content": "import { ClientAppName, useClientApp, useEnv } from '@titicaca/triple-web'\nimport { renderHook } from '@testing-library/react'\n\nimport { useOpenInlink } from './use-open-inlink'\n\njest.mock('@titicaca/triple-web')\n\nbeforeEach(() => {\n  Object.defineProperty(window, 'location', {\n    value: {\n      href: '/',\n    },\n    writable: true,\n  })\n  ;(useEnv as jest.MockedFunction<typeof useEnv>).mockReturnValue({\n    appUrlScheme: 'triple-test',\n    basePath: '/',\n    afOnelinkId: '',\n    afOnelinkPid: '',\n    afOnelinkSubdomain: '',\n    defaultPageDescription: '',\n    defaultPageTitle: '',\n    facebookAppId: '',\n    webUrlBase: '',\n  })\n})\n\ntest('인 앱 웹뷰에서는 inlink를 사용합니다.', () => {\n  ;(useClientApp as jest.MockedFunction<typeof useClientApp>).mockReturnValue({\n    device: { autoplay: 'always', networkType: 'unknown' },\n    metadata: { name: ClientAppName.Android, version: '1.0.0' },\n  })\n\n  const { result } = renderHook(() => useOpenInlink())\n\n  result.current('/test-path')\n\n  // Assuming makeInlink is a function that formats the URL correctly\n  expect(window.location.href).toBe(\n    `triple-test:///inlink?path=${encodeURIComponent('/test-path')}`,\n  )\n})\n\ntest('인 앱 웹뷰가 아니면 그냥 path로 이동합니다.', () => {\n  ;(useClientApp as jest.MockedFunction<typeof useClientApp>).mockReturnValue(\n    null,\n  )\n\n  const { result } = renderHook(() => useOpenInlink())\n\n  result.current('/test-path')\n\n  expect(window.location.href).toBe('/test-path')\n})\n"
  },
  {
    "path": "packages/router/src/links/use-open-inlink.ts",
    "content": "import { useCallback } from 'react'\n\nimport { useMakeInlink, MakeInlinkOptions } from './use-make-inlink'\n\nexport type OpenInlinkOptions = MakeInlinkOptions\n\nexport function useOpenInlink() {\n  const makeInlink = useMakeInlink()\n\n  const openInlink = useCallback(\n    (\n      /**\n       * Inlink로 만들 relative URL.\n       */\n      path: string,\n      options?: OpenInlinkOptions,\n    ) => {\n      const href = makeInlink(path, options)\n      window.location.href = href\n    },\n    [makeInlink],\n  )\n\n  return openInlink\n}\n"
  },
  {
    "path": "packages/router/src/links/use-open-native-link.test.tsx",
    "content": "import React from 'react'\nimport { ClientAppName } from '@titicaca/triple-web'\nimport { renderHook } from '@testing-library/react'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport { useOpenNativeLink } from './use-open-native-link'\n\nconst showAppInstallCtaModalMockFn = jest.fn()\n\njest.mock('@titicaca/triple-web', () => ({\n  ...jest.requireActual('@titicaca/triple-web'),\n  useEnv: jest.fn().mockReturnValue({\n    appUrlScheme: 'triple-test',\n    webUrlBase: 'https://triple.guide',\n  }),\n  useAppInstallCtaModal: jest\n    .fn()\n    .mockImplementation(() => ({ show: showAppInstallCtaModalMockFn })),\n  useSessionAvailability: jest.fn(),\n}))\n\nbeforeEach(() => {\n  Object.defineProperty(window, 'location', {\n    value: {\n      href: '/',\n    },\n    writable: true,\n  })\n})\n\nafterEach(() => {\n  jest.clearAllMocks()\n})\n\njest.spyOn(React, 'useEffect').mockImplementation(() => jest.fn())\n\ntest('인 앱 웹뷰에서는 딥링크로 이동합니다.', () => {\n  const { result } = renderHook(() => useOpenNativeLink(), {\n    wrapper: createTestWrapper({\n      clientAppProvider: {\n        device: { autoplay: 'always', networkType: 'unknown' },\n        metadata: { name: ClientAppName.Android, version: '6.5.0' },\n      },\n    }),\n  })\n\n  result.current('/test-path')\n\n  // Assuming makeInlink is a function that formats the URL correctly\n  expect(window.location.href).toBe('triple-test:///test-path')\n})\n\ntest('인 앱 웹뷰가 아니면 AppInstallCtaModal을 엽니다.', () => {\n  const { result } = renderHook(() => useOpenNativeLink(), {\n    wrapper: createTestWrapper(),\n  })\n\n  result.current('/test-path')\n\n  expect(showAppInstallCtaModalMockFn).toHaveBeenCalled()\n})\n"
  },
  {
    "path": "packages/router/src/links/use-open-native-link.ts",
    "content": "import { useCallback } from 'react'\nimport {\n  useClientApp,\n  useEnv,\n  useAppInstallCtaModal,\n} from '@titicaca/triple-web'\n\nexport function useOpenNativeLink() {\n  const app = useClientApp()\n  const { appUrlScheme } = useEnv()\n  const { show: showAppInstallCtaModal } = useAppInstallCtaModal()\n\n  const openNativeLink = useCallback(\n    (\n      /**\n       * 딥링크 path.\n       */\n      path: string,\n    ) => {\n      if (!app) {\n        return showAppInstallCtaModal()\n      }\n\n      window.location.href = `${appUrlScheme}://${path}`\n    },\n    [app, appUrlScheme, showAppInstallCtaModal],\n  )\n\n  return openNativeLink\n}\n"
  },
  {
    "path": "packages/router/src/links/use-open-outlink.test.ts",
    "content": "import { ClientAppName, useClientApp, useEnv } from '@titicaca/triple-web'\nimport { renderHook } from '@testing-library/react'\n\nimport { useOpenOutlink } from './use-open-outlink'\n\njest.mock('@titicaca/triple-web')\n\nbeforeEach(() => {\n  Object.defineProperty(window, 'location', {\n    value: {\n      href: '/',\n    },\n    writable: true,\n  })\n  ;(useEnv as jest.MockedFunction<typeof useEnv>).mockReturnValue({\n    appUrlScheme: 'triple-test',\n    basePath: '/',\n    afOnelinkId: '',\n    afOnelinkPid: '',\n    afOnelinkSubdomain: '',\n    defaultPageDescription: '',\n    defaultPageTitle: '',\n    facebookAppId: '',\n    webUrlBase: '',\n  })\n})\n\ntest('인 앱 웹뷰에서는 outlink를 사용합니다.', () => {\n  ;(useClientApp as jest.MockedFunction<typeof useClientApp>).mockReturnValue({\n    device: { autoplay: 'always', networkType: 'unknown' },\n    metadata: { name: ClientAppName.Android, version: '1.0.0' },\n  })\n\n  const { result } = renderHook(() => useOpenOutlink())\n\n  result.current('/test-path')\n\n  // Assuming makeInlink is a function that formats the URL correctly\n  expect(window.location.href).toBe(\n    `triple-test:///outlink?url=${encodeURIComponent('/test-path')}`,\n  )\n})\n\ntest('인 앱 웹뷰가 아니면 그냥 url로 이동합니다.', () => {\n  ;(useClientApp as jest.MockedFunction<typeof useClientApp>).mockReturnValue(\n    null,\n  )\n\n  const { result } = renderHook(() => useOpenOutlink())\n\n  result.current('/test-path')\n\n  expect(window.location.href).toBe('/test-path')\n})\n"
  },
  {
    "path": "packages/router/src/links/use-open-outlink.ts",
    "content": "import { useCallback } from 'react'\n\nimport { MakeOutlinkOptions, useMakeOutlink } from './use-make-outlink'\n\nexport type OpenOutlinkOptions = MakeOutlinkOptions\n\nexport function useOpenOutlink() {\n  const makeOutlink = useMakeOutlink()\n\n  const openOutlink = useCallback(\n    (\n      /**\n       * Outlink로 만들 absolute URL.\n       */\n      url: string,\n      options?: OpenOutlinkOptions,\n    ) => {\n      const href = makeOutlink(url, options)\n      window.location.href = href\n    },\n    [makeOutlink],\n  )\n\n  return openOutlink\n}\n"
  },
  {
    "path": "packages/router/src/local/base-path.test.ts",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useBasePathAdder } from './base-path'\n\njest.mock('next/router', () => ({\n  useRouter: jest.fn().mockReturnValue({ basePath: '/test-env' }),\n}))\n\ntest('주어진 href에 basePath를 더합니다.', () => {\n  const href =\n    '/5b700a4e-4b0f-4266-81db-eb42f834bdd9?regionId=71476976-cf9a-4ae8-a60f-76e6fb26900d'\n\n  const { result } = renderHook(() => useBasePathAdder())\n\n  expect(result.current(href)).toBe(\n    '/test-env/5b700a4e-4b0f-4266-81db-eb42f834bdd9?regionId=71476976-cf9a-4ae8-a60f-76e6fb26900d',\n  )\n})\n\ntest('주어진 href가 \"/\"이면 그냥 basePath를 반환합니다.', () => {\n  const href = '/'\n\n  const { result } = renderHook(() => useBasePathAdder())\n\n  expect(result.current(href)).toBe('/test-env')\n})\n"
  },
  {
    "path": "packages/router/src/local/base-path.ts",
    "content": "import { parseUrl, generateUrl } from '@titicaca/view-utilities'\nimport { useRouter } from 'next/router'\n\nexport function useBasePathAdder() {\n  const { basePath } = useRouter()\n\n  return (href: string): string => addBasePath(href, basePath)\n}\n\nfunction addBasePath(href: string, basePath: string): string {\n  const { path, ...rest } = parseUrl(href)\n  const newPath = path === '/' ? basePath : `${basePath}${path}`\n\n  return generateUrl({ path: newPath, ...rest })\n}\n"
  },
  {
    "path": "packages/router/src/local/hook.ts",
    "content": "import {\n  AllowSourceProps,\n  useDisabledLinkNotifierCreator,\n} from '../common/disabled-link-notifier'\nimport { TargetProps } from '../common/target'\nimport { AppSpecificLinkProps, HrefProps } from '../common/types'\nimport useDefaultRouter from '../common/default-router'\n\nimport { useBasePathAdder } from './base-path'\nimport { NextjsRoutingOptions, useLocalHrefHandler } from './href-handler'\n\nexport default function useLocalRouter() {\n  const customRouter = useLocalHrefHandler()\n  const addBasePath = useBasePathAdder()\n  const createDisabledLinkNotifier = useDisabledLinkNotifierCreator()\n  const defaultRouter = useDefaultRouter()\n\n  const routeLocally = async ({\n    allowSource,\n    href,\n    target,\n    lnbTarget,\n    noNavbar,\n    shouldPresent,\n    swipeToClose,\n    replace,\n    scroll,\n  }: AllowSourceProps &\n    HrefProps &\n    TargetProps &\n    AppSpecificLinkProps &\n    NextjsRoutingOptions) => {\n    const notifyDisabledLink = createDisabledLinkNotifier({ allowSource })\n\n    if (notifyDisabledLink !== undefined) {\n      notifyDisabledLink()\n\n      return\n    }\n\n    let hrefHandled = false\n\n    await customRouter({\n      href,\n      target,\n      lnbTarget,\n      noNavbar,\n      shouldPresent,\n      swipeToClose,\n      replace,\n      scroll,\n      isKeyPressing: false,\n      stopDefaultHandler: () => {\n        hrefHandled = true\n      },\n    })\n\n    if (hrefHandled === false) {\n      const hrefWithBasePath = addBasePath(href)\n\n      defaultRouter({ href: hrefWithBasePath, target })\n    }\n  }\n\n  return routeLocally\n}\n"
  },
  {
    "path": "packages/router/src/local/href-handler.ts",
    "content": "import { useRouter } from 'next/router'\nimport { useClientApp } from '@titicaca/triple-web'\n\nimport { useWebUrlBaseAdder } from '../common/add-web-url-base'\nimport { AppSpecificLinkProps, HrefProps } from '../common/types'\nimport { TargetProps } from '../common/target'\nimport { useOpenInlink, useOpenOutlink } from '../links'\n\nimport { useBasePathAdder } from './base-path'\n\nexport interface NextjsRoutingOptions {\n  /**\n   * 현재 창을 history에 남기지 않고 이동합니다. target=\"current\"일 때만 작동합니다.\n   */\n  replace?: boolean\n  /**\n   * 현재창에서 라우팅할 때 페이지 스크롤을 상단으로 올릴지 여부를 결정합니다.\n   * 기본 값 true\n   */\n  scroll?: boolean\n  /**\n   * 현재창에서 URL을 변경할 때 data fetching(getServerSideProps, getStaticProps, getInitialProps)을 하지 않습니다.\n   * 기본 값 false\n   */\n  shallow?: boolean\n}\n\nexport function useLocalHrefHandler() {\n  const router = useRouter()\n  const app = useClientApp()\n  const openInlink = useOpenInlink()\n  const openOutlink = useOpenOutlink()\n  const addWebUrlBase = useWebUrlBaseAdder()\n  const addBasePath = useBasePathAdder()\n\n  const handleNextjsRouting = async (\n    href: string,\n    { replace, scroll = true, shallow = false }: NextjsRoutingOptions,\n  ): Promise<void> => {\n    const success = await router[replace ? 'replace' : 'push'](\n      href,\n      undefined,\n      {\n        scroll,\n        shallow,\n      },\n    )\n    if (success && scroll) {\n      window.scrollTo(0, 0)\n    }\n  }\n\n  const handleHrefLocally = async ({\n    href,\n    target,\n    lnbTarget,\n    noNavbar,\n    shouldPresent,\n    swipeToClose,\n    replace,\n    scroll,\n    shallow,\n    isKeyPressing,\n    stopDefaultHandler,\n  }: HrefProps &\n    TargetProps &\n    NextjsRoutingOptions &\n    AppSpecificLinkProps & {\n      isKeyPressing: boolean\n      stopDefaultHandler: () => void\n    }) => {\n    if (target === 'current' && isKeyPressing === false) {\n      stopDefaultHandler()\n\n      await handleNextjsRouting(href, { replace, scroll, shallow })\n\n      return\n    }\n\n    const finalHref = addBasePath(href)\n\n    if (target === 'new' && app) {\n      stopDefaultHandler()\n\n      openInlink(finalHref, {\n        lnb: lnbTarget,\n        noNavbar,\n        shouldPresent,\n        swipeToClose,\n      })\n\n      return\n    }\n\n    if (target === 'browser' && app) {\n      stopDefaultHandler()\n\n      openOutlink(addWebUrlBase(finalHref), {\n        target: 'browser',\n      })\n    }\n  }\n\n  return handleHrefLocally\n}\n"
  },
  {
    "path": "packages/router/src/local/index.ts",
    "content": "export { LocalLink } from './link'\nexport { default as useLocalRouter } from './hook'\n"
  },
  {
    "path": "packages/router/src/local/link.tsx",
    "content": "import { MouseEvent, MouseEventHandler, PropsWithChildren } from 'react'\n\nimport { ANCHOR_TARGET_MAP } from '../common/target'\nimport { RouterGuardedLink } from '../common/router-guarded-link'\nimport { LinkCommonProps } from '../common/types'\n\nimport { useBasePathAdder } from './base-path'\nimport { NextjsRoutingOptions, useLocalHrefHandler } from './href-handler'\n\n/**\n * https://github.com/vercel/next.js/blob/7d48241949bc7bac7b8e30fda6be71f37286886f/packages/next/client/link.tsx#L64\n * which 속성은 deprecated 됐다고 하여 사용하지 않습니다.\n *\n * @param e 앵커 태그 클릭 이벤트\n */\nfunction isKeyPressingClick(e: MouseEvent<HTMLAnchorElement>): boolean {\n  return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey\n}\n\n/**\n * 같은 도메인의 페이지로 이동할 때 사용하는 링크 컴포넌트\n * href에 basePath를 제외한 값을 넣습니다.\n */\nexport function LocalLink({\n  href,\n  target,\n  relList,\n  allowSource,\n  replace,\n  scroll = true,\n  shallow = false,\n  lnbTarget,\n  noNavbar,\n  swipeToClose,\n  shouldPresent,\n  onClick,\n  className,\n  children,\n}: PropsWithChildren<LinkCommonProps & NextjsRoutingOptions>) {\n  const handleHrefLocally = useLocalHrefHandler()\n  const addBasePath = useBasePathAdder()\n\n  const finalHref = addBasePath(href)\n\n  const handleClick: MouseEventHandler<HTMLAnchorElement> = async (e) => {\n    if (onClick) {\n      onClick()\n    }\n\n    await handleHrefLocally({\n      href,\n      target,\n      lnbTarget,\n      noNavbar,\n      shouldPresent,\n      swipeToClose,\n      replace,\n      scroll,\n      shallow,\n      isKeyPressing: isKeyPressingClick(e),\n      stopDefaultHandler: () => {\n        e.preventDefault()\n      },\n    })\n  }\n\n  return (\n    <RouterGuardedLink\n      className={className}\n      href={finalHref}\n      relList={relList}\n      allowSource={allowSource}\n      onClick={handleClick}\n      target={ANCHOR_TARGET_MAP[target]}\n    >\n      {children}\n    </RouterGuardedLink>\n  )\n}\n"
  },
  {
    "path": "packages/router/src/navigate/canonization.spec.ts",
    "content": "import canonizeTargetAddress from './canonization'\n\ndescribe('canonizeTargetAddress', () => {\n  const resourceId = '79b938c5-1b4c-45e1-9c9f-6551adae38a2'\n  const webUrlBase = 'https://triple.guide'\n\n  it('should canonize triple web url to path', () => {\n    expect(\n      canonizeTargetAddress({\n        href: `https://triple.guide/articles/${resourceId}?_triple_no_navbar`,\n        webUrlBase,\n        expandInlinkStrictly: true,\n      }),\n    ).toBe(`/articles/${resourceId}?_triple_no_navbar`)\n  })\n\n  it('should canonize external url as is', () => {\n    const url = 'https://google.com'\n    expect(\n      canonizeTargetAddress({\n        href: url,\n        webUrlBase,\n        expandInlinkStrictly: true,\n      }),\n    ).toBe(url)\n  })\n\n  it('should canonize inlink url in not-strict mode', () => {\n    const path = `/articles/${resourceId}?_triple_no_navbar`\n    expect(\n      canonizeTargetAddress({\n        href: `/inlink?path=${encodeURIComponent(path)}`,\n        webUrlBase,\n        expandInlinkStrictly: false,\n      }),\n    ).toBe(path)\n  })\n\n  it('should not canonize inlink url in strict mode', () => {\n    const path = `/articles/${resourceId}?_triple_no_navbar`\n    expect(\n      canonizeTargetAddress({\n        href: `/inlink?path=${encodeURIComponent(path)}`,\n        webUrlBase,\n        expandInlinkStrictly: true,\n      }),\n    ).toBe(`/inlink?path=${encodeURIComponent(path)}`)\n  })\n\n  it('should canonize inlink url in strict mode conditionally', () => {\n    const path = `/articles/${resourceId}?_triple_no_navbar`\n    expect(\n      canonizeTargetAddress({\n        href: `/inlink?path=${encodeURIComponent(path)}&_web_expand`,\n        webUrlBase,\n        expandInlinkStrictly: true,\n      }),\n    ).toBe(path)\n  })\n\n  it('should canonize outlink external url', () => {\n    const url = 'https://google.com'\n    expect(\n      canonizeTargetAddress({\n        href: `/outlink?url=${encodeURIComponent(url)}`,\n        webUrlBase,\n        expandInlinkStrictly: true,\n      }),\n    ).toBe(url)\n  })\n\n  it('should canonize outlink internal url', () => {\n    const path = `/articles/${resourceId}?_triple_no_navbar`\n    const url = `${webUrlBase}${path}`\n    expect(\n      canonizeTargetAddress({\n        href: `/outlink?url=${encodeURIComponent(url)}`,\n        webUrlBase,\n        expandInlinkStrictly: true,\n      }),\n    ).toBe(path)\n  })\n})\n"
  },
  {
    "path": "packages/router/src/navigate/canonization.ts",
    "content": "import { parse } from 'qs'\nimport { generateUrl, parseUrl } from '@titicaca/view-utilities'\n\nexport default function canonizeTargetAddress({\n  href,\n  webUrlBase,\n  expandInlinkStrictly,\n  allowRawOutlink,\n}: {\n  href: string\n  webUrlBase: string\n  /**\n   * /inlink를 풀어야 하는지 여부를 결정합니다. true로 설정하면 /inlink의 path\n   * 부분을 반환합니다.\n   */\n  expandInlinkStrictly: boolean\n  /**\n   * Canonized URL을 사용하는 곳에서 /outlink 형식의 링크를 지원하는지 여부를\n   * 명시합니다. In-app 브라우저에서는 사용 가능하나, 일반 브라우저에서는\n   * 불가능하므로 일반 브라우저에서 동작하는 경우 false를 할당합니다.\n   *\n   * 이 경우, /outlink의 url 인자로 전달된 주소를 반환하여 해당 페이지로의\n   * Navigation이 일어날 수 있도록 합니다.\n   */\n  allowRawOutlink?: boolean\n}): string {\n  const { host: webUrlBaseHost } = parseUrl(webUrlBase)\n  const { host, path, query, ...rest } = parseUrl(href)\n\n  if (host && webUrlBaseHost === host) {\n    return generateUrl({ path, query, ...rest, scheme: undefined })\n  } else if (host) {\n    return href\n  } else if (path === '/inlink') {\n    const { path, _web_expand: expandable } = parse(query as string)\n    const forceExpand = expandable !== undefined\n\n    return !expandInlinkStrictly || forceExpand ? (path as string) : href\n  } else if (!allowRawOutlink && path === '/outlink') {\n    const { url } = parse(query as string)\n\n    return canonizeTargetAddress({\n      href: url as string,\n      webUrlBase,\n      expandInlinkStrictly,\n    })\n  }\n\n  return href\n}\n"
  },
  {
    "path": "packages/router/src/navigate/index.ts",
    "content": "export * from './use-navigate'\nexport * from './use-isomorphic-navigate'\n"
  },
  {
    "path": "packages/router/src/navigate/use-isomorphic-navigate.ts",
    "content": "// eslint-disable-next-line import/no-named-as-default\nimport Router from 'next/router'\nimport {\n  closeWindow as _closeWindow,\n  backOrClose as _backOrClose,\n  hasAccessibleTripleNativeClients,\n} from '@titicaca/triple-web-to-native-interfaces'\n\nasync function pushRouter(\n  path: string,\n  asPathOrScrollPosition?: string | [number, number],\n  scrollTo?: [number, number],\n) {\n  const asPath =\n    typeof asPathOrScrollPosition === 'string' ? asPathOrScrollPosition : ''\n  const scrollPosition: [number, number] = Array.isArray(asPathOrScrollPosition)\n    ? asPathOrScrollPosition\n    : scrollTo || [0, 0]\n\n  asPath ? await Router.push(path, asPath) : await Router.push(path)\n\n  window.scrollTo(...scrollPosition)\n}\n\nasync function replaceRouter(\n  path: string,\n  asPathOrScrollPosition?: string | [number, number],\n  scrollTo?: [number, number],\n) {\n  const asPath =\n    typeof asPathOrScrollPosition === 'string' ? asPathOrScrollPosition : ''\n  const scrollPosition: [number, number] = Array.isArray(asPathOrScrollPosition)\n    ? asPathOrScrollPosition\n    : scrollTo || [0, 0]\n\n  asPath ? await Router.replace(path, asPath) : await Router.replace(path)\n\n  window.scrollTo(...scrollPosition)\n}\n\nfunction backOrClose() {\n  if (!hasAccessibleTripleNativeClients()) {\n    return history.back()\n  } else {\n    return _backOrClose()\n  }\n}\n\nfunction closeWindow() {\n  return !hasAccessibleTripleNativeClients() ? window.close() : _closeWindow()\n}\n\nfunction asyncBack(backer = Router.back): Promise<void> {\n  return new Promise((resolve) => {\n    const handler = () => {\n      Router.events.off('hashChangeComplete', handler)\n      resolve()\n    }\n\n    Router.events.on('hashChangeComplete', handler)\n\n    backer()\n  })\n}\n\nexport function useIsomorphicNavigate() {\n  return {\n    pushRouter,\n    replaceRouter,\n    asyncBack,\n    backOrClose,\n    closeWindow,\n  }\n}\n"
  },
  {
    "path": "packages/router/src/navigate/use-navigate.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\nimport { useEnv, ClientAppName } from '@titicaca/triple-web'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport { useNavigate } from './use-navigate'\n\nconst appInstallCtaModalShowMockFn = jest.fn()\nconst loginModalShowMockFn = jest.fn()\nconst openOutlinkMockFn = jest.fn()\nconst openNativeLinkMockFn = jest.fn()\n\njest.mock('@titicaca/triple-web', () => ({\n  ...jest.requireActual('@titicaca/triple-web'),\n  useEnv: jest.fn().mockReturnValue({\n    webUrlBase: 'https://triple.guide',\n  }),\n  useAppInstallCtaModal: jest\n    .fn()\n    .mockImplementation(() => ({ show: appInstallCtaModalShowMockFn })),\n  useLoginCtaModal: jest.fn().mockImplementation(() => ({\n    show: loginModalShowMockFn,\n  })),\n  useSessionAvailability: jest.fn().mockReturnValue(false),\n}))\n\njest.mock('../links', () => ({\n  ...jest.requireActual('../links'),\n  useOpenOutlink: jest.fn().mockImplementation(() => openOutlinkMockFn),\n  useOpenNativeLink: jest.fn().mockImplementation(() => openNativeLinkMockFn),\n}))\n\ndescribe('브라우저', () => {\n  describe('routable한 href를 가진 URL로 호출하면 현재 창에서 라우팅합니다.', () => {\n    const { webUrlBase } = useEnv()\n    const routablePath = '/login'\n\n    test.each([\n      [routablePath],\n      [`${webUrlBase}${routablePath}`],\n      [`/inlink?path=${encodeURIComponent(routablePath)}&_web_expand=true`],\n      [`/outlink?url=${encodeURIComponent(`${webUrlBase}${routablePath}`)}`],\n    ])('href: %s', (href) => {\n      const changeLocationHref = jest.fn()\n\n      const {\n        result: {\n          current: { navigate },\n        },\n      } = renderHook(useNavigate, {\n        initialProps: { changeLocationHref },\n        wrapper: createTestWrapper(),\n      })\n\n      navigate(href)\n\n      expect(changeLocationHref).toHaveBeenCalledWith(routablePath)\n    })\n  })\n\n  describe('routable하지 않은 href를 가진 URL로 호출하면 앱 설치 유도 모달을 표시합니다.', () => {\n    const { webUrlBase } = useEnv()\n\n    const notRoutablePath = '/i/am/not/routable'\n\n    test.each([\n      [notRoutablePath],\n      [`${webUrlBase}${notRoutablePath}`],\n      [`/inlink?path=${encodeURIComponent(notRoutablePath)}&_web_expand=true`],\n      [`/outlink?url=${encodeURIComponent(`${webUrlBase}${notRoutablePath}`)}`],\n    ])('href: %s', (href) => {\n      const changeLocationHref = jest.fn()\n\n      const {\n        result: {\n          current: { navigate },\n        },\n      } = renderHook(useNavigate, {\n        initialProps: { changeLocationHref },\n        wrapper: createTestWrapper(),\n      })\n\n      navigate(href)\n\n      expect(changeLocationHref).not.toHaveBeenCalled()\n      expect(appInstallCtaModalShowMockFn).toHaveBeenCalledTimes(1)\n\n      appInstallCtaModalShowMockFn.mockRestore()\n    })\n  })\n\n  test('inlink에 web_expand 파라미터가 없으면 routable하더라도 앱 설치 유도 모달을 표시합니다.', () => {\n    const changeLocationHref = jest.fn()\n    const {\n      result: {\n        current: { navigate },\n      },\n    } = renderHook(useNavigate, {\n      initialProps: { changeLocationHref },\n      wrapper: createTestWrapper(),\n    })\n\n    navigate(`/inlink?path=${encodeURIComponent('/login')}`)\n\n    expect(changeLocationHref).not.toHaveBeenCalled()\n    expect(appInstallCtaModalShowMockFn).toHaveBeenCalledTimes(1)\n\n    appInstallCtaModalShowMockFn.mockRestore()\n  })\n})\n\ndescribe('앱', () => {\n  describe('세션이 없고 routable하지 않은 href를 가지고 있는 URL로 호출하면 로그인 유도 모달을 표시합니다.', () => {\n    const { webUrlBase } = useEnv()\n    const href = '/i/am/not/routable/url'\n\n    test.each([\n      [href],\n      [`${webUrlBase}${href}`],\n      [`/inlink?path=${encodeURIComponent(href)}`],\n      [`/outlink?url=${encodeURIComponent(`${webUrlBase}${href}`)}`],\n    ])('href: %s', (href) => {\n      const changeLocationHref = jest.fn()\n\n      const {\n        result: {\n          current: { navigate },\n        },\n      } = renderHook(useNavigate, {\n        initialProps: { changeLocationHref },\n        wrapper: createTestWrapper({\n          clientAppProvider: {\n            device: { autoplay: 'always', networkType: 'unknown' },\n            metadata: { name: ClientAppName.Android, version: '6.5.0' },\n          },\n        }),\n      })\n\n      navigate(href)\n\n      expect(changeLocationHref).not.toHaveBeenCalled()\n      expect(loginModalShowMockFn).toHaveBeenCalledTimes(1)\n\n      loginModalShowMockFn.mockRestore()\n    })\n  })\n\n  test('절대 경로로 호출하면 outlink로 엽니다.', () => {\n    const href = 'https://www.google.com'\n    const changeLocationHref = jest.fn()\n\n    const {\n      result: {\n        current: { navigate },\n      },\n    } = renderHook(useNavigate, {\n      initialProps: { changeLocationHref },\n      wrapper: createTestWrapper({\n        clientAppProvider: {\n          device: { autoplay: 'always', networkType: 'unknown' },\n          metadata: { name: ClientAppName.Android, version: '1.0.0' },\n        },\n        sessionProvider: {\n          user: {\n            name: 'TripleTester',\n            provider: 'TRIPLE',\n            country: 'ko',\n            lang: 'ko',\n            unregister: null,\n            photo: 'images.source',\n            mileage: {\n              badges: [{ icon: { imageUrl: '' } }],\n              level: 1,\n              point: 0,\n            },\n            uid: 'test',\n          },\n        },\n      }),\n    })\n\n    navigate(href)\n\n    expect(openOutlinkMockFn).toHaveBeenCalledWith(href, undefined)\n  })\n\n  test('상대 경로이면 네이티브 앱 URL로 간주하고 엽니다.', () => {\n    const href = '/articles/sample-id'\n    const changeLocationHref = jest.fn()\n\n    const {\n      result: {\n        current: { navigate },\n      },\n    } = renderHook(useNavigate, {\n      initialProps: { changeLocationHref },\n      wrapper: createTestWrapper({\n        clientAppProvider: {\n          device: { autoplay: 'always', networkType: 'unknown' },\n          metadata: { name: ClientAppName.Android, version: '1.0.0' },\n        },\n        sessionProvider: {\n          user: {\n            name: 'TripleTester',\n            provider: 'TRIPLE',\n            country: 'ko',\n            lang: 'ko',\n            unregister: null,\n            photo: 'images.source',\n            mileage: {\n              badges: [{ icon: { imageUrl: '' } }],\n              level: 1,\n              point: 0,\n            },\n            uid: 'test',\n          },\n        },\n      }),\n    })\n\n    navigate(href)\n\n    expect(openNativeLinkMockFn).toHaveBeenCalledWith(href)\n  })\n})\n"
  },
  {
    "path": "packages/router/src/navigate/use-navigate.ts",
    "content": "import {\n  checkIfRoutable,\n  generateUrl,\n  parseUrl,\n} from '@titicaca/view-utilities'\nimport { useCallback } from 'react'\nimport {\n  useClientApp,\n  useEnv,\n  useSessionAvailability,\n  useLoginCtaModal,\n  useAppInstallCtaModal,\n} from '@titicaca/triple-web'\nimport { hasAccessibleTripleNativeClients } from '@titicaca/triple-web-to-native-interfaces'\nimport qs from 'qs'\n\nimport { OpenOutlinkOptions, useOpenNativeLink, useOpenOutlink } from '../links'\n\nimport canonizeTargetAddress from './canonization'\n\nexport function useNavigate({\n  changeLocationHref = defaultChangeLocationHref,\n  appInstallCtaTriggeredEventAction,\n}: {\n  changeLocationHref?: (href: string) => void\n  appInstallCtaTriggeredEventAction?: string\n} = {}) {\n  const { webUrlBase, appUrlScheme } = useEnv()\n  const sessionAvailable = useSessionAvailability()\n  const { show: showAppInstallCtaModal } = useAppInstallCtaModal()\n  const { show: showLoginCtaModal } = useLoginCtaModal()\n  const app = useClientApp()\n  const openOutlink = useOpenOutlink()\n  const openNativeLink = useOpenNativeLink()\n\n  const navigateInBrowser = useCallback(\n    (rawHref: string) => {\n      const href = canonizeTargetAddress({\n        href: rawHref,\n        webUrlBase,\n        expandInlinkStrictly: true,\n      })\n\n      if (checkIfRoutable({ href })) {\n        changeLocationHref(href)\n        return\n      }\n\n      showAppInstallCtaModal({\n        triggeredEventAction: appInstallCtaTriggeredEventAction,\n        deepLink: href,\n      })\n    },\n    [\n      changeLocationHref,\n      showAppInstallCtaModal,\n      webUrlBase,\n      appInstallCtaTriggeredEventAction,\n    ],\n  )\n\n  const navigateInApp = useCallback(\n    (rawHref: string, options?: OpenOutlinkOptions) => {\n      const canonizedHref = canonizeTargetAddress({\n        href: rawHref,\n        webUrlBase,\n        expandInlinkStrictly: false,\n        /* Routability 체크에만 사용하므로 /outlink를 해체합니다. */\n        allowRawOutlink: false,\n      })\n\n      if (\n        sessionAvailable === false &&\n        !checkIfRoutable({ href: canonizedHref })\n      ) {\n        showLoginCtaModal()\n\n        return\n      }\n\n      const { scheme, path, query, hash } = parseUrl(rawHref)\n\n      if (scheme === 'http' || scheme === 'https') {\n        openOutlink(rawHref, options)\n      } else {\n        const appPath = generateUrl({ path, query, hash })\n        openNativeLink(appPath)\n      }\n    },\n    [\n      openNativeLink,\n      openOutlink,\n      sessionAvailable,\n      showLoginCtaModal,\n      webUrlBase,\n    ],\n  )\n\n  const openWindow = useCallback(\n    (rawHref: string, options?: OpenOutlinkOptions) => {\n      if (!hasAccessibleTripleNativeClients()) {\n        window.open(rawHref, undefined, 'noopener')\n        return\n      }\n\n      if (!appUrlScheme) {\n        return\n      }\n\n      const { href, scheme, host = '' } = parseUrl(rawHref)\n\n      if (scheme === 'http' || scheme === 'https') {\n        const outlinkParams = qs.stringify({\n          url: href,\n          ...(options || {}),\n        })\n\n        window.location.href = generateUrl({\n          scheme: appUrlScheme,\n          path: '/outlink',\n          query: outlinkParams,\n        })\n      } else if (!scheme && !host) {\n        if (sessionAvailable === true || checkIfRoutable({ href: rawHref })) {\n          window.location.href = generateUrl({\n            scheme: appUrlScheme,\n            path: '/inlink',\n            query: `path=${encodeURIComponent(rawHref)}`,\n          })\n        } else {\n          showLoginCtaModal()\n        }\n      }\n    },\n    [appUrlScheme, sessionAvailable, showLoginCtaModal],\n  )\n\n  return {\n    navigate: app ? navigateInApp : navigateInBrowser,\n    openWindow,\n  }\n}\n\nfunction defaultChangeLocationHref(href: string) {\n  window.location.href = href\n}\n"
  },
  {
    "path": "packages/router/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/router/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/router/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/scroll-to-element/README.md",
    "content": "# scroll-to-element\n\n스크롤 액션 인터페이스를 관리하는 패키지입니다.\n\n## Usage\n\n### scrollToElement\n\nparameter로 받은 `element` 위치로 스크롤하는 인터페이스입니다.\n\n```tsx\nimport scrollToElement from '@ititcaca/scroll-to-element'\n\nconst element = document.getElementById(`${id}`)\n\nscrollToElement(element, {\n  offset: -52,\n})\n\nscrollToElement()\n```\n\n#### Parameters\n\nelement : Element\n\n- 스크롤을 위치시키고 싶은 element를 의미합니다.\n\noptions : ScrollOptions\n\n- offset : 최종 위치에 오프셋을 추가합니다.\n- align : Element의 정렬을 의미합니다.\n- duration : 애니메이션 시간을 의미합니다.\n\n```ts\ninterface ScrollOptions {\n  offset: number\n  align?: 'top' | 'middle' | 'bottom'\n  duration?: number\n}\n```\n\n## Refererence\n\n- https://github.com/willhoag/scroll-to-element\n"
  },
  {
    "path": "packages/scroll-to-element/package.json",
    "content": "{\n  \"name\": \"@titicaca/scroll-to-element\",\n  \"version\": \"14.2.3\",\n  \"description\": \"Scroll Functions for Triple service applications\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/scroll-to-element\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@tweenjs/tween.js\": \"^18.6.4\"\n  }\n}\n"
  },
  {
    "path": "packages/scroll-to-element/src/index.ts",
    "content": "export { default as scrollToElement } from './scroll-to-element'\n"
  },
  {
    "path": "packages/scroll-to-element/src/scroll-position-calculators.test.ts",
    "content": "import {\n  initialScrollPosition,\n  calculateScrollOffset,\n} from './scroll-position-calculators'\n\njest\n  .spyOn(document.documentElement, 'scrollTop', 'get')\n  .mockImplementation(() => 100)\n\njest\n  .spyOn(document.documentElement, 'scrollLeft', 'get')\n  .mockImplementation(() => 10)\n\njest\n  .spyOn(document.documentElement, 'scrollHeight', 'get')\n  .mockImplementation(() => 500)\n\njest\n  .spyOn(document.documentElement, 'clientHeight', 'get')\n  .mockImplementation(() => 300)\n\ntest('initialScrollPosition()은 현재 스크롤의 위치 정보를 반환합니다.', () => {\n  const { top, left } = initialScrollPosition()\n\n  expect(top).toBe(100)\n  expect(left).toBe(10)\n})\n\ntest('calculateScrollOffset()은 스크롤 오프셋을 반환합니다.', () => {\n  const element = document.createElement('div')\n\n  element.getBoundingClientRect = jest.fn(() => ({\n    x: 0,\n    y: 0,\n    width: 120,\n    height: 120,\n    top: 900,\n    right: 0,\n    bottom: 0,\n    left: 0,\n    toJSON: () => {},\n  }))\n\n  const scrollOffset = calculateScrollOffset({ element, offset: -52 })\n\n  expect(scrollOffset).toBe(200)\n})\n"
  },
  {
    "path": "packages/scroll-to-element/src/scroll-position-calculators.ts",
    "content": "export function initialScrollPosition(): {\n  top: number\n  left: number\n} {\n  const y =\n    window.pageYOffset || (document.documentElement || document.body).scrollTop\n  const x =\n    window.pageXOffset || (document.documentElement || document.body).scrollLeft\n\n  return { top: y, left: x }\n}\n\nexport function calculateScrollOffset({\n  element,\n  offset = 0,\n  alignment,\n}: {\n  element: Element\n  offset?: number\n  alignment?: string\n}): number {\n  const html = document.documentElement\n  const clientHeight = html.clientHeight\n\n  const documentHeight = getDocumentHeight()\n  const scrollPosition = deriveScrollPosition(element, alignment)\n\n  const maxScrollPosition = documentHeight - clientHeight\n\n  return Math.min(\n    scrollPosition + offset + window.pageYOffset,\n    maxScrollPosition,\n  )\n}\n\nfunction getDocumentHeight(): number {\n  const body = document.body\n  const html = document.documentElement\n\n  return Math.max(\n    body.scrollHeight,\n    body.offsetHeight,\n    html.clientHeight,\n    html.scrollHeight,\n    html.offsetHeight,\n  )\n}\n\nfunction deriveScrollPosition(element: Element, alignment?: string): number {\n  const elementRect = element.getBoundingClientRect()\n  const html = document.documentElement\n  const clientHeight = html.clientHeight\n\n  if (alignment === 'bottom') {\n    return elementRect.bottom - clientHeight\n  } else if (alignment === 'middle') {\n    return elementRect.bottom - clientHeight / 2 - elementRect.height / 2\n  } else {\n    return elementRect.top\n  }\n}\n"
  },
  {
    "path": "packages/scroll-to-element/src/scroll-to-element.ts",
    "content": "import { scroll } from './scroll'\nimport { calculateScrollOffset } from './scroll-position-calculators'\nimport { ScrollOptions } from './types'\n\nexport default function scrollToElement(\n  element: Element,\n  options: ScrollOptions,\n) {\n  if (!element) {\n    return\n  }\n\n  return scroll({\n    x: 0,\n    y: calculateScrollOffset({\n      element,\n      offset: options.offset,\n      alignment: options.align,\n    }),\n    options,\n  })\n}\n"
  },
  {
    "path": "packages/scroll-to-element/src/scroll.ts",
    "content": "import TWEEN from '@tweenjs/tween.js'\n\nimport { ScrollOptions } from './types'\nimport { initialScrollPosition } from './scroll-position-calculators'\n\nexport function scroll({\n  x,\n  y,\n  options,\n}: {\n  x: number\n  y: number\n  options: ScrollOptions\n}) {\n  const startPosition = initialScrollPosition()\n\n  const tween = new TWEEN.Tween(startPosition)\n\n  tween\n    .to({ top: y, left: x })\n    .easing(TWEEN.Easing.Circular.Out)\n    .duration(options.duration || 1000)\n    .onUpdate((option) => window.scrollTo(option.left || 0, option.top || 0))\n    .start()\n\n  animate()\n\n  return tween\n}\n\nfunction animate() {\n  requestAnimationFrame(animate)\n\n  TWEEN.update()\n}\n"
  },
  {
    "path": "packages/scroll-to-element/src/types.ts",
    "content": "export interface ScrollOptions {\n  offset: number\n  align?: 'top' | 'middle' | 'bottom'\n  duration?: number\n}\n"
  },
  {
    "path": "packages/scroll-to-element/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/scroll-to-element/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/scroll-to-element/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/standard-action-handler/README.md",
    "content": "# standard-action-handler\n\n웹 프로젝트들이 공통으로 사용할 액션을 정의합니다. `/web-action`으로 시작하는 URL 형식으로 액션을 표현합니다.\n\n## Paths\n\n- ### `/web-action/serial`\n  - URL로 표현되는 액션을 순차로 수행합니다.\n  - parameter\n    - { path, query }\n      - query : actions[]=[action1]&actions[]=[action2]...\n    - options : ContextOptions\n    - handler : action을 수행할 핸들러\n- ### `/web-action/cta`\n  - CTA 링크로 navigate합니다.\n  - parameter\n    - { path }\n    - options : ContextOptions\n- ### `/web-action/fetch-api`\n  - Parameter로 정의한 API 호출을 수행합니다.\n  - parameter\n    - { path, query }\n      - query : path=[apiPath]&method=[method]&body=[body]\n- ### `/web-action/show-toast`\n  - Toast 메시지를 출력합니다.\n  - parameter\n    - { path, query }\n      - query : text=[토스트로 출력할 텍스트]\n- ### `/web-action/share`\n  - 현재 페이지의 URL을 복사합니다.\n  - parameter\n    - { path }\n- ### `/web-action/copy-to-clipboard`\n  - 텍스트를 클립보드에 복사합니다.\n  - parameter\n    - { path, query }\n      - query : text=[복사할 텍스트]\n- ### `/web-action/new-window`\n  - href를 새 창에서 엽니다.\n  - parameter\n    - {path, query}\n      - query : href=[이동할 URL]\n- ### `/web-action/require-triple-client`\n  - 페이지가 트리플 앱 내에서 열렸는 지 확인합니다. 트리플 앱 외부에서 열린 경우 앱 설치 유도 모달을 띄웁니다.\n  - parameter\n    - { path, query }\n      - query: url=[이동할 URL, web-action url도 가능]\n\n### How to make URL\n\nexample\n\n```\n/** show-toast URL */\nconst action1 = `/web-action/show-toast?text='토스트 메시지'`\n/** fetch-api URL */\nconst action2 = `/web-action/fetch-api?method=GET&path='/api/apiPath'`\n\n/** serial URL */\nconst webAction = `/web-action/serial?actions[]=${action1}&actions[]=${action2}`\n\n```\n\n### 새로운 Action 추가하기\n\nstandard-action-handler 내에 action을 정의하고 index.ts파일의 handler에 해당 action을 추가합니다. URL을 판단하여 액션을 실행하기 때문에 모든 action에서 path는 parameter에 필수적으로 포함되어야 합니다.\n\n## Parameter\n\n### initialize(options)\n\n```\n{\n  options?: ContextOptions\n}\n\nContextOptions: {\n    cta?: string\n    /** execute 함수에서 사용할 navigate */\n    navigate: (\n      rawHref: string,\n      parmas?: NavigateOptions\n    ) => string | undefined | void\n    /** new-window action에서 사용할 routeExternally */\n    routeExternally?: ({\n      href, target,\n    }: {\n      href: string\n      target: TargetType\n    }) => void\n  }\n```\n\ninitialize의 parameter로 사용되는 `navigate`는 실행되는 환경과 세션 등을 고려하여 전달받은 URL(rawHref)로 이동하는 역할을 합니다.\n\nContextOptions 중 하나인 `routeExternally`는 이동하고자 하는 URL을 새 창으로 열 때 사용합니다. 새 창 열기 기능을 위한 함수이기 때문에 `target` parameter는 `'current' | 'new' | 'browser'` 중 `'new'`로 지정되어 있습니다.\n\n### execute(url, params)\n\n```\n{\n  /** 이동할 URL */\n  url: string\n  /** navigate options */\n  params?: NavigateOptions\n}\n\nNavigateOptions: {\n  target?: string\n  title?: string\n  [key:string]: unknown\n}\n```\n\n## How to use\n\ninitialize 메소드에서 return된 execute 함수를 사용합니다.\n\n### example\n\n```\nimport { useHistoryFunctions } from '@titicaca/react-contexts'\nimport { initialize } from '@titicaca/standard-action-handler'\nimprot { useExternalRouter } from '@titicaca/router'\n\nconst { navigate } = useHistoryFunctions()\nconst routeExternally = useExternalRouter()\n\nconst handleStandardActions = initialize({ navigate, routeExternally })\n\nhandleStandardActions(href, {})\n\n```\n"
  },
  {
    "path": "packages/standard-action-handler/package.json",
    "content": "{\n  \"name\": \"@titicaca/standard-action-handler\",\n  \"version\": \"14.2.3\",\n  \"description\": \"Standard action handler for Triple service applications\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/standard-action-handler\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/fetcher\": \"workspace:*\",\n    \"@titicaca/router\": \"workspace:*\",\n    \"@titicaca/scroll-to-element\": \"workspace:*\",\n    \"@titicaca/tds-ui\": \"workspace:*\",\n    \"@titicaca/triple-web-to-native-interfaces\": \"1.11.0\",\n    \"@titicaca/view-utilities\": \"workspace:*\",\n    \"qs\": \"^6.14.0\"\n  },\n  \"devDependencies\": {\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"@types/qs\": \"^6.9.18\",\n    \"react\": \"^18.3.1\",\n    \"react-dom\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/triple-web\": \"*\",\n    \"@titicaca/triple-web-to-native-interfaces\": \"1.11.0\",\n    \"react\": \"^18.0\",\n    \"react-dom\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/converse.tsx",
    "content": "import qs from 'qs'\nimport { createRoot } from 'react-dom/client'\nimport { Modal } from '@titicaca/tds-ui'\nimport { authGuardedFetchers, NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\n\nimport { ContextOptions, WebActionParams } from './types'\n\nconst HASH_CONVERSE_MODAL = 'hash.converse-modal'\n\ntype ModalType = 'login' | 'error' | 'normal'\n\nconst NEED_LOGIN_CONTENT: {\n  type: ModalType\n  title: string\n  description: string\n} = {\n  type: 'login',\n  title: '로그인이 필요합니다.',\n  description: '로그인하고 트리플을\\n더 편하게 이용하세요🙂',\n}\n\nexport default async function converse({\n  url: { path, query } = {},\n  options: { navigate } = {},\n}: WebActionParams) {\n  if (path === '/web-action/converse' && query && navigate) {\n    const { path: pathFromQuery } = qs.parse(query) as { path: string }\n\n    const { type, title, description } = await fetchApi(pathFromQuery)\n\n    if (type && title && description) {\n      window.history.pushState(null, '', `#${HASH_CONVERSE_MODAL}`)\n\n      const container = document.createElement('div')\n      const root = createRoot(container)\n\n      const { onConfirm, onCancel } = getOnActions(type, navigate)\n\n      const handlePopstate = () => {\n        root.unmount()\n        container.remove()\n\n        window.removeEventListener('popstate', handlePopstate)\n      }\n\n      window.addEventListener('popstate', handlePopstate)\n\n      root.render(\n        OpenModal({\n          title,\n          description,\n          onCancel,\n          onConfirm,\n        }),\n      )\n\n      return true\n    }\n\n    return false\n  }\n\n  return false\n}\n\nexport function OpenModal({\n  title,\n  description,\n  onCancel,\n  onConfirm,\n}: {\n  title: string\n  description: string\n  onCancel?: () => void\n  onConfirm: () => void\n}) {\n  return (\n    <Modal open onClose={onCancel}>\n      <Modal.Body>\n        <Modal.Title>{title}</Modal.Title>\n        <Modal.Description>{description}</Modal.Description>\n      </Modal.Body>\n      <Modal.Actions>\n        {onCancel && <Modal.Action onClick={onCancel}>취소</Modal.Action>}\n        <Modal.Action color=\"blue\" onClick={onConfirm}>\n          확인\n        </Modal.Action>\n      </Modal.Actions>\n    </Modal>\n  )\n}\n\nasync function fetchApi(\n  url: string,\n): Promise<{ type: ModalType; title: string; description: string }> {\n  const response = await authGuardedFetchers.post<\n    { title: string; description: string },\n    unknown\n  >(url, {\n    headers: {\n      'Content-Type': 'application/json',\n    },\n  })\n\n  if (response === NEED_LOGIN_IDENTIFIER || !response.ok) {\n    if (response === NEED_LOGIN_IDENTIFIER) {\n      return NEED_LOGIN_CONTENT\n    }\n    return {\n      type: 'error',\n      title: '안내',\n      description:\n        '서비스 이용이 원활하지 않습니다.\\n잠시 후 다시 이용해 주세요.',\n    }\n  } else {\n    const { title, description } = response.parsedBody\n\n    return { type: 'normal', title, description }\n  }\n}\n\nfunction getOnActions(\n  modalType: ModalType,\n  navigate: NonNullable<ContextOptions['navigate']>,\n) {\n  const closeModal = () => window.history.back()\n  if (modalType === 'login') {\n    return {\n      onConfirm: () => {\n        const loginUrl = window.location.href.replace(\n          `#${HASH_CONVERSE_MODAL}`,\n          '',\n        )\n        window.history.back()\n        navigate(`/login?returnUrl=${encodeURIComponent(loginUrl)}`)\n      },\n      onCancel: closeModal,\n    }\n  }\n\n  return {\n    onConfirm: closeModal,\n  }\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/copy-to-clipboard.ts",
    "content": "import qs from 'qs'\n\nimport { createClipboardCopier } from './services/copy'\nimport { WebActionParams } from './types'\n\nexport default async function copyToClipboard({\n  url: { query, path } = {},\n  t,\n}: WebActionParams) {\n  if (path === '/web-action/copy-to-clipboard' && query) {\n    const { text } = qs.parse(query || '') as { text?: string }\n\n    if (text) {\n      const textClipboardCopier = createClipboardCopier()\n\n      textClipboardCopier({\n        text,\n        message: t('클립보드에 복사되었습니다.'),\n      })\n    }\n\n    return true\n  }\n\n  return false\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/fetch-api.ts",
    "content": "import qs from 'qs'\n\nimport { WebActionParams } from './types'\n\nexport default async function fetchApi({\n  url: { path, query } = {},\n}: WebActionParams) {\n  if (path === '/web-action/fetch-api' && query) {\n    const {\n      path: apiPath,\n      method,\n      body,\n    } = qs.parse(query, {\n      ignoreQueryPrefix: true,\n    })\n\n    await fetch(apiPath as string, {\n      method: (method as string) || 'GET',\n      ...(body ? { headers: { 'content-type': 'application/json' } } : {}),\n      body: body as string,\n    })\n\n    return true\n  }\n\n  return false\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/handler.ts",
    "content": "import { parseUrl } from '@titicaca/view-utilities'\nimport { useTranslation } from '@titicaca/triple-web'\n\nimport { WebAction, ContextOptions, NavigateOptions } from './types'\n\nexport default class Handler {\n  private options: ContextOptions\n  private t: ReturnType<typeof useTranslation>\n  private handlers: WebAction[]\n\n  public constructor({\n    handlers,\n    t,\n    options,\n  }: {\n    handlers: WebAction[]\n    t: ReturnType<typeof useTranslation>\n    options: ContextOptions\n  }) {\n    this.handlers = handlers\n    this.t = t\n    this.options = options\n  }\n\n  public async execute(url: string, params?: NavigateOptions) {\n    const parsedUrl = parseUrl(url)\n\n    if (parsedUrl.path?.match(/^\\/web-action\\//)) {\n      for (const handler of this.handlers) {\n        const result = await handler({\n          url: parsedUrl,\n          t: this.t,\n          options: this.options,\n          handler: this,\n        })\n\n        if (result) {\n          return\n        }\n      }\n    } else {\n      this.options.navigate && this.options.navigate(url, params)\n    }\n  }\n\n  public toFunction() {\n    return this.execute.bind(this)\n  }\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/hook.ts",
    "content": "import { useNavigate, useExternalRouter } from '@titicaca/router'\nimport {\n  useClientApp,\n  useAppInstallCtaModal,\n  useTranslation,\n} from '@titicaca/triple-web'\n\nimport { initialize } from './initialize'\nimport { ContextOptions } from './types'\n\nexport function useStandardActionHandler(options?: ContextOptions) {\n  const { navigate } = useNavigate()\n  const routeExternally = useExternalRouter()\n  const { show } = useAppInstallCtaModal()\n  const app = useClientApp()\n  const t = useTranslation()\n\n  return initialize(t, {\n    navigate,\n    routeExternally,\n    showAppInstallCtaModal: show,\n    app,\n    ...options,\n  })\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/image-download.ts",
    "content": "import qs from 'qs'\n\nimport { WebActionParams } from './types'\n\nexport default async function imageDownload({\n  url: { path, query } = {},\n}: WebActionParams) {\n  if (path === '/web-action/image-download' && query) {\n    const { imageId } = qs.parse(query, { ignoreQueryPrefix: true })\n\n    const response = await fetch(`/api/images/media?ids=${imageId}`, {\n      method: 'GET',\n      headers: { 'content-type': 'application/json' },\n    })\n\n    const {\n      media: [\n        {\n          sizes: {\n            large: { url: imageUrl },\n          },\n        },\n      ],\n    } = await response.json()\n\n    const image = await fetch(imageUrl)\n    const blobImage = await image.blob()\n\n    const windowUrl = window.URL || window.webkitURL\n    const imageToDomString = windowUrl.createObjectURL(blobImage)\n\n    const downloadAnchor = document.createElement('a')\n    downloadAnchor.setAttribute('href', imageToDomString)\n    downloadAnchor.setAttribute('download', 'image.jpeg')\n    downloadAnchor.click()\n    downloadAnchor.remove()\n\n    // URL 더이상 사용되지 않아 메모리 누수를 방지\n    windowUrl.revokeObjectURL(imageToDomString)\n\n    return true\n  }\n\n  return false\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/index.ts",
    "content": "export * from './hook'\nexport * from './initialize'\nexport * from './types'\n"
  },
  {
    "path": "packages/standard-action-handler/src/initialize.ts",
    "content": "import { useTranslation } from '@titicaca/triple-web'\n\nimport Handler from './handler'\nimport serial from './serial'\nimport invokeCta from './invoke-cta'\nimport showToast from './show-toast'\nimport fetchApi from './fetch-api'\nimport share from './share'\nimport copyToClipboard from './copy-to-clipboard'\nimport newWindow from './new-window'\nimport imageDownload from './image-download'\nimport scrollToElement from './scroll-to-element'\nimport converse from './converse'\nimport { ContextOptions } from './types'\nimport requireTripleClient from './require-triple-client'\n\nexport function initialize(\n  t: ReturnType<typeof useTranslation>,\n  options: ContextOptions,\n) {\n  const handler = new Handler({\n    handlers: [\n      serial,\n      invokeCta,\n      showToast,\n      fetchApi,\n      share,\n      copyToClipboard,\n      newWindow,\n      imageDownload,\n      scrollToElement,\n      converse,\n      requireTripleClient,\n    ],\n    t,\n    options,\n  })\n\n  return handler.toFunction()\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/invoke-cta.ts",
    "content": "import { WebActionParams } from './types'\n\nexport default async function invokeCta({\n  url: { path } = {},\n  options: { navigate, cta } = {},\n}: WebActionParams) {\n  if (path === '/web-action/cta') {\n    if (cta && navigate) {\n      navigate(cta)\n\n      return true\n    }\n  }\n\n  return false\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/new-window.test.ts",
    "content": "import newWindow from './new-window'\n\ntest('새창열기 기능을 테스트합니다', () => {\n  const href = 'https://triple.guide/'\n  const routeExternally = jest.fn()\n\n  const navigate = () => {}\n\n  newWindow({\n    url: {\n      path: '/web-action/new-window',\n      query: `href=${encodeURIComponent(href)}`,\n    },\n    options: { navigate, routeExternally },\n  })\n\n  expect(routeExternally).toHaveBeenCalledWith({ href, target: 'new' })\n})\n"
  },
  {
    "path": "packages/standard-action-handler/src/new-window.ts",
    "content": "import qs from 'qs'\n\nimport { WebActionParams } from './types'\n\nexport default async function newWindow({\n  url: { path, query } = {},\n  options: { routeExternally } = {},\n}: WebActionParams) {\n  if (path === '/web-action/new-window' && query) {\n    const { href } = qs.parse(query) as {\n      href?: string\n    }\n    if (href && routeExternally) {\n      routeExternally({ href, target: 'new' })\n      return true\n    }\n  }\n  return false\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/require-triple-client.tsx",
    "content": "import qs from 'qs'\n\nimport { WebActionParams } from './types'\n\nexport default async function requireTripleClient({\n  url: { path, query } = {},\n  options: { app, showAppInstallCtaModal } = {},\n  handler,\n}: WebActionParams) {\n  if (path === '/web-action/require-triple-client' && query) {\n    if (!app) {\n      if (showAppInstallCtaModal) {\n        showAppInstallCtaModal()\n      }\n    } else {\n      const { url } = qs.parse(query)\n      if (typeof url !== 'string') {\n        return false\n      }\n      if (handler) {\n        await handler.execute(url)\n        return true\n      }\n    }\n  }\n  return false\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/scroll-to-element.ts",
    "content": "import qs from 'qs'\nimport { scrollToElement as scrollTo } from '@titicaca/scroll-to-element'\n\nimport { WebActionParams } from './types'\n\nexport default async function scrollToElement({\n  url: { path, query } = {},\n  options: { app } = {},\n}: WebActionParams) {\n  if (path === '/web-action/scroll-to-element' && query) {\n    const { hash } = qs.parse(query) as {\n      hash?: string\n    }\n\n    if (hash) {\n      const element = document.getElementById(hash)\n      element && scrollTo(element, { offset: app ? -102 : -110 })\n    }\n\n    return true\n  }\n\n  return false\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/serial.ts",
    "content": "import qs from 'qs'\n\nimport { WebActionParams } from './types'\n\nexport default async function serial({\n  url: { path, query } = {},\n  handler,\n}: WebActionParams) {\n  if (path === '/web-action/serial' && query) {\n    const { actions } = qs.parse(query, { ignoreQueryPrefix: true })\n\n    if (actions && handler) {\n      for (const actionUrl of actions as string[]) {\n        await handler.execute(actionUrl as string)\n        await sleep(0.4)\n      }\n    }\n\n    return true\n  }\n\n  return false\n}\n\nfunction sleep(seconds: number) {\n  return new Promise((resolve) => {\n    setTimeout(resolve, seconds * 1000)\n  })\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/services/copy.ts",
    "content": "import {\n  hasAccessibleTripleNativeClients,\n  showToast,\n} from '@titicaca/triple-web-to-native-interfaces'\n\nexport function createClipboardCopier() {\n  if (!hasAccessibleTripleNativeClients()) {\n    return typeof navigator !== 'undefined' && navigator.clipboard\n      ? copyWithClipboard\n      : copyWithDomApi\n  } else {\n    return copyTextNativeInterface\n  }\n}\n\nasync function copyWithClipboard({\n  text,\n  message,\n}: {\n  text: string\n  message: string\n}) {\n  await navigator.clipboard.writeText(text)\n\n  alert(message)\n}\n\nfunction copyWithDomApi({ text, message }: { text: string; message: string }) {\n  const inputElement = document.createElement('input')\n\n  inputElement.value = text\n  document.body.appendChild(inputElement)\n  inputElement.select()\n  document.execCommand('copy')\n  document.body.removeChild(inputElement)\n\n  alert(message)\n}\n\nfunction copyTextNativeInterface({\n  text,\n  message,\n}: {\n  text: string\n  message: string\n}) {\n  window.location.href = `${\n    process.env.NEXT_PUBLIC_APP_URL_SCHEME\n  }:///action/copy_to_clipboard?text=${encodeURIComponent(text)}`\n\n  showToast(message)\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/services/share.ts",
    "content": "import {\n  hasAccessibleTripleNativeClients,\n  shareLink,\n} from '@titicaca/triple-web-to-native-interfaces'\n\nimport { createClipboardCopier } from './copy'\n\nconst DEFAULT_IMAGE =\n  'https://assets.triple.guide/images/default-cover-image.jpg'\n\ninterface SharingParams {\n  title?: string | null\n  description?: string | null\n  image?: string | null\n  webUrl?: string | null\n  appUrl: string\n}\n\nexport function createShareUrl() {\n  if (!hasAccessibleTripleNativeClients()) {\n    return typeof navigator !== 'undefined' && 'share' in navigator\n      ? navigatorShare\n      : copyUrlToClipboard\n  } else {\n    return shareNativeInterface\n  }\n}\n\nfunction navigatorShare({ params }: { params: SharingParams }) {\n  const { title, description, webUrl } = params\n\n  navigator.share({\n    title: title as string,\n    text: description as string,\n    url: webUrl as string,\n  })\n}\n\nfunction copyUrlToClipboard({\n  params,\n  message,\n}: {\n  params: SharingParams\n  message: string\n}) {\n  const { webUrl } = params\n\n  const clipboardCopier = createClipboardCopier()\n\n  return clipboardCopier({\n    text: webUrl || window.location.href,\n    message,\n  })\n}\n\nfunction shareNativeInterface({\n  params,\n  webButtonTitle,\n  appButtonTitle,\n}: {\n  params: SharingParams\n  webButtonTitle: string\n  appButtonTitle: string\n}) {\n  const { title, description, image, webUrl, appUrl } = params\n\n  return shareLink({\n    link: webUrl as string,\n    title: title as string,\n    description: description as string,\n    imageUrl: image || DEFAULT_IMAGE,\n    buttons: [\n      {\n        title: webButtonTitle,\n        webUrl: webUrl as string,\n      },\n      {\n        title: appButtonTitle,\n        webUrl: webUrl as string,\n        appUrl,\n      },\n    ],\n  })\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/share.ts",
    "content": "import { generateUrl, parseUrl } from '@titicaca/view-utilities'\n\nimport { createShareUrl } from './services/share'\nimport { WebActionParams } from './types'\n\nexport default async function share({\n  url: { path } = {},\n  t,\n}: WebActionParams) {\n  if (path === '/web-action/share') {\n    const params = getSharingParams()\n    const shareUrl = createShareUrl()\n\n    shareUrl({\n      params,\n      message: t('링크를 복사했습니다.'),\n      webButtonTitle: t('웹에서 보기'),\n      appButtonTitle: t('트리플에서 보기'),\n    })\n\n    return true\n  }\n\n  return false\n}\n\nfunction getSharingParams() {\n  const title = getMetadata({ property: 'og:title' })\n  const description = getMetadata({ property: 'og:description' })\n  const image = getMetadata({ property: 'og:image' })\n  const webUrl = getMetadata({ property: 'og:url' })\n  const rawAppUrl = getMetadata({ property: 'al:ios:url' })\n\n  const { path: sharePath, query } = parseUrl(rawAppUrl as string)\n  const appUrl = generateUrl({ path: sharePath, query })\n\n  return { title, description, image, webUrl, appUrl }\n}\n\nfunction getMetadata({ property }: { property: string }) {\n  return document\n    .querySelector(`meta[property='${property}']`)\n    ?.getAttribute('content')\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/show-toast.ts",
    "content": "import qs from 'qs'\nimport { showToast as nativeShowToast } from '@titicaca/triple-web-to-native-interfaces'\n\nimport { WebActionParams } from './types'\n\nexport default async function showToast({\n  url: { path, query } = {},\n}: WebActionParams) {\n  if (path === '/web-action/show-toast' && query) {\n    const { text } = qs.parse(query, { ignoreQueryPrefix: true })\n\n    nativeShowToast(text as string)\n\n    return true\n  }\n\n  return false\n}\n"
  },
  {
    "path": "packages/standard-action-handler/src/types.ts",
    "content": "import { ClientAppValue, useTranslation } from '@titicaca/triple-web'\nimport { UrlElements } from '@titicaca/view-utilities'\n\nexport interface NavigateOptions {\n  target?: 'browser'\n  title?: string\n  [key: string]: unknown\n}\n\ntype TargetType = 'current' | 'new' | 'browser'\n\nexport interface ContextOptions {\n  cta?: string\n  navigate?: (\n    rawHref: string,\n    params?: NavigateOptions,\n  ) => string | undefined | void\n  routeExternally?: ({\n    href,\n    target,\n  }: {\n    href: string\n    target: TargetType\n  }) => void\n  app?: ClientAppValue\n  showAppInstallCtaModal?: () => void\n}\n\nexport interface WebActionParams {\n  url: UrlElements\n  t: ReturnType<typeof useTranslation>\n  options: ContextOptions\n  handler: {\n    execute: (url: string, params?: NavigateOptions) => Promise<void>\n  }\n}\n\nexport type WebAction = (webActionParams: WebActionParams) => Promise<boolean>\n"
  },
  {
    "path": "packages/standard-action-handler/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/standard-action-handler/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/standard-action-handler/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/tds-theme/package.json",
    "content": "{\n  \"name\": \"@titicaca/tds-theme\",\n  \"version\": \"14.2.3\",\n  \"description\": \"TDS theme\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/tds-theme\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"devDependencies\": {\n    \"react\": \"^18.3.1\",\n    \"styled-components\": \"^6.1.15\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^18.0\",\n    \"styled-components\": \"^6.0\"\n  }\n}\n"
  },
  {
    "path": "packages/tds-theme/src/foundations/colors.ts",
    "content": "export const colors = {\n  gray: 'rgba(58, 58, 58, 1)',\n  gray20: 'rgba(58, 58, 58, 0.02)',\n  gray50: 'rgba(58, 58, 58, 0.05)',\n  gray100: 'rgba(58, 58, 58, 0.1)',\n  gray200: 'rgba(58, 58, 58, 0.2)',\n  gray300: 'rgba(58, 58, 58, 0.3)',\n  gray400: 'rgba(58, 58, 58, 0.4)',\n  gray500: 'rgba(58, 58, 58, 0.5)',\n  gray600: 'rgba(58, 58, 58, 0.6)',\n  gray700: 'rgba(58, 58, 58, 0.7)',\n  gray800: 'rgba(58, 58, 58, 0.8)',\n  gray900: 'rgba(58, 58, 58, 0.9)',\n  brightGray: 'rgba(239, 239, 239, 1)',\n  blue: 'rgba(54, 143, 255, 1)',\n  blue60: 'rgba(54, 143, 255, 0.06)',\n  blue100: 'rgba(54, 143, 255, 0.1)',\n  blue500: 'rgba(54, 143, 255, 0.5)',\n  blue980: 'rgba(54, 143, 255, 0.98)',\n  mint: 'rgba(38, 206, 194, 1)',\n  mint100: 'rgba(38, 206, 194, 0.1)',\n  orange: 'rgba(255, 150, 35, 1)',\n  red: 'rgba(253, 46, 105, 1)',\n  red50: 'rgba(253, 46, 105, 0.05)',\n  red100: 'rgba(253, 46, 105, 0.1)',\n  deepOrange: 'rgba(255, 91, 46, 1)',\n  mediumRed: 'rgba(255, 33, 60, 1)',\n  deepRed: 'rgba(190, 0, 23, 1)',\n  purple: 'rgba(151, 95, 254, 1)',\n  purple100: 'rgba(151, 95, 254, 0.1)',\n  emerald: 'rgba(13, 208, 175, 1)',\n  white: 'rgba(255, 255, 255, 1)',\n  white600: 'rgba(255, 255, 255, 0.6)',\n  white900: 'rgba(255, 255, 255, 0.9)',\n  skyblue: 'rgba(55, 168, 255, 1)',\n  lightpurple: 'rgba(151, 95, 254, 1)',\n  black: 'rgba(34, 34, 34, 1)',\n\n  // genie\n  azul: 'rgba(31, 87, 250, 1)',\n  azul500: 'rgba(31, 87, 250, 0.5)',\n  teal: 'rgba(10, 219, 143, 1)',\n  teal100: 'rgba(10, 219, 143, 0.1)',\n  teal900: 'rgba(10, 219, 143, 0.9)',\n  vermilion: 'rgb(255, 97, 105, 1)',\n}\n"
  },
  {
    "path": "packages/tds-theme/src/global-style.ts",
    "content": "import { createGlobalStyle } from 'styled-components'\n\nexport const GlobalStyle = createGlobalStyle`\n  :root {\n    ${({ theme }) =>\n      Object.entries(theme.colors).map(\n        ([name, value]) => `--color-${name}: ${value};`,\n      )}\n  }\n\n  /*\n  1. Prevent padding and border from affecting element width. (https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice)\n  2. Remove default borders from all elements.\n  */\n\n  *,\n  &::before,\n  &::after {\n    box-sizing: inherit; /* 1 */\n    border-width: 0; /* 2 */\n    border-style: none; /* 2 */\n  }\n\n  /*\n  1. Use a consistent sensible line-height in all browsers.\n  2. Prevent adjustments of font size after orientation changes in iOS.\n  3. Use the user's configured 'sans' font-family by default.\n  4. Prevent padding and border from affecting element width. (https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice)\n  */\n\n  html {\n    line-height: normal; /* 1 */\n    text-size-adjust: none; /* 2 */\n    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; /* 3 */\n    box-sizing: border-box; /* 4 */\n  }\n\n\n  /*\n  1. Remove the margin in all browsers.\n  2. Inherit line-height from 'html' so users can set them as a class directly on the 'html' element.\n  */\n\n  body {\n    margin: 0; /* 1 */\n    line-height: inherit; /* 2 */\n    -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n    -webkit-touch-callout: none;\n  }\n\n  /*\n  1. Add the correct height in Firefox.\n  2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)\n  3. Ensure horizontal rules are visible by default.\n  */\n\n  hr {\n    height: 0; /* 1 */\n    color: inherit; /* 2 */\n    border-top-width: 1px; /* 3 */\n  }\n\n  /*\n  Add the correct text decoration in Chrome, Edge, and Safari.\n  */\n\n  abbr:where([title]) {\n    text-decoration: underline dotted;\n  }\n\n  /*\n  Remove the default font size and weight for headings.\n  */\n\n  h1,\n  h2,\n  h3,\n  h4,\n  h5,\n  h6 {\n    font-size: inherit;\n    font-weight: inherit;\n  }\n\n  /*\n  Reset links to optimize for opt-in styling instead of opt-out.\n  */\n\n  a {\n    color: inherit;\n    text-decoration: inherit;\n  }\n\n  /*\n  Add the correct font weight in Edge and Safari.\n  */\n\n  b,\n  strong {\n    font-weight: bolder;\n  }\n\n  /*\n  1. Use the user's configured 'mono' font family by default.\n  2. Correct the odd 'em' font sizing in all browsers.\n  */\n\n  code,\n  kbd,\n  samp,\n  pre {\n    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace; /* 1 */\n    font-size: 1em; /* 2 */\n  }\n\n  /*\n  Add the correct font size in all browsers.\n  */\n\n  small {\n    font-size: 80%;\n  }\n\n  /*\n  Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.\n  */\n\n  sub,\n  sup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n  }\n\n  sub {\n    bottom: -0.25em;\n  }\n\n  sup {\n    top: -0.5em;\n  }\n\n  /*\n  1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)\n  2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)\n  3. Remove gaps between table borders by default.\n  */\n\n  table {\n    text-indent: 0; /* 1 */\n    border-color: inherit; /* 2 */\n    border-collapse: collapse; /* 3 */\n    border-spacing: 0; /* 3 */\n  }\n\n  /*\n  1. Change the font styles in all browsers.\n  2. Remove the margin in Firefox and Safari.\n  3. Remove default padding in all browsers.\n  */\n\n  button,\n  input,\n  optgroup,\n  select,\n  textarea {\n    font-family: inherit; /* 1 */\n    font-size: 100%; /* 1 */\n    line-height: inherit; /* 1 */\n    color: inherit; /* 1 */\n    margin: 0; /* 2 */\n    padding: 0; /* 3 */\n  }\n\n  /*\n  Remove the inheritance of text transform in Edge and Firefox.\n  */\n\n  button,\n  select {\n    text-transform: none;\n  }\n\n  /*\n  1. Correct the inability to style clickable types in iOS and Safari.\n  2. Remove default button styles.\n  */\n\n  button {\n    appearance: button; /* 1 */\n    background-color: transparent; /* 2 */\n    background-image: none; /* 2 */\n  }\n\n  /*\n  Use the modern Firefox focus style for all focusable elements.\n  */\n\n  :-moz-focusring {\n    outline: auto;\n  }\n\n  /*\n  Remove the additional ':invalid' styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)\n  */\n\n  :-moz-ui-invalid {\n    box-shadow: none;\n  }\n\n  /*\n  Add the correct vertical alignment in Chrome and Firefox.\n  */\n\n  progress {\n    vertical-align: baseline;\n  }\n\n  /*\n  Correct the cursor style of increment and decrement buttons in Safari.\n  */\n\n  ::-webkit-inner-spin-button,\n  ::-webkit-outer-spin-button {\n    height: auto;\n  }\n\n  /*\n  1. Correct the odd appearance in Chrome and Safari.\n  2. Correct the outline style in Safari.\n  */\n\n  [type='search'] {\n    appearance: textfield; /* 1 */\n    outline-offset: -2px; /* 2 */\n  }\n\n  /*\n  Remove the inner padding in Chrome and Safari on macOS.\n  */\n\n  ::-webkit-search-decoration {\n    appearance: none;\n  }\n\n  /*\n  1. Correct the inability to style clickable types in iOS and Safari.\n  2. Change font properties to 'inherit' in Safari.\n  */\n\n  ::-webkit-file-upload-button {\n    appearance: button; /* 1 */\n    font: inherit; /* 2 */\n  }\n\n  /*\n  Add the correct display in Chrome and Safari.\n  */\n\n  summary {\n    display: list-item;\n  }\n\n  /*\n  Removes the default spacing and border for appropriate elements.\n  */\n\n  blockquote,\n  dl,\n  dd,\n  h1,\n  h2,\n  h3,\n  h4,\n  h5,\n  h6,\n  hr,\n  figure,\n  p,\n  pre {\n    margin: 0;\n  }\n\n  fieldset {\n    margin: 0;\n    padding: 0;\n  }\n\n  legend,\n  tr,\n  th,\n  td  {\n    padding: 0;\n  }\n\n  ol,\n  ul,\n  menu {\n    list-style: none;\n    margin: 0;\n    padding: 0;\n  }\n\n  /*\n  Prevent resizing textareas horizontally by default.\n  */\n\n  textarea {\n    resize: vertical;\n  }\n\n  /*\n  1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)\n  */\n\n  input::placeholder,\n  textarea::placeholder {\n    opacity: 1; /* 1 */\n  }\n\n  /*\n  Set the default cursor for buttons.\n  */\n\n  button,\n  [role=\"button\"] {\n    cursor: pointer;\n  }\n\n  /*\n  Make sure disabled buttons don't get the pointer cursor.\n  */\n  :disabled {\n    cursor: default;\n  }\n\n  /*\n  1. Make replaced elements 'display: block' by default. (https://github.com/mozdevs/cssremedy/issues/14)\n  2. Add 'vertical-align: middle' to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)\n    This can trigger a poorly considered lint error in some tools but is included by design.\n  */\n\n  img,\n  svg,\n  video,\n  canvas,\n  audio,\n  iframe,\n  embed,\n  object {\n    /* display: block; 1 */\n    vertical-align: middle; /* 2 */\n  }\n\n  /*\n  Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)\n  */\n\n  img,\n  video {\n    max-width: 100%;\n  }\n\n  /*\n  Ensure the default browser behavior of the 'hidden' attribute.\n  */\n\n  [hidden] {\n    display: none;\n  }\n\n  blockquote,\n  q {\n    quotes: none;\n  }\n\n  input, textarea {\n    font-family: inherit;\n  }\n\n  @media (prefers-color-scheme: dark) {\n    body {\n      background-color: ${({ theme }) => theme.colors.white};\n    }\n  }\n`\n"
  },
  {
    "path": "packages/tds-theme/src/index.ts",
    "content": "import './styled'\n\nexport * from './theme'\nexport * from './global-style'\n"
  },
  {
    "path": "packages/tds-theme/src/styled.ts",
    "content": "import { CSSProp } from 'styled-components'\n\nimport type { Theme } from './theme/types'\n\ndeclare module 'styled-components' {\n  export interface DefaultTheme extends Theme {}\n}\n\ndeclare module 'react' {\n  interface Attributes {\n    css?: CSSProp\n  }\n}\n"
  },
  {
    "path": "packages/tds-theme/src/theme/default.ts",
    "content": "import { colors } from '../foundations/colors'\n\nexport const defaultTheme = {\n  colors,\n}\n"
  },
  {
    "path": "packages/tds-theme/src/theme/index.ts",
    "content": "export * from './default'\nexport * from './types'\n"
  },
  {
    "path": "packages/tds-theme/src/theme/types.ts",
    "content": "import { defaultTheme } from './default'\n\nexport type Theme = typeof defaultTheme\n"
  },
  {
    "path": "packages/tds-theme/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/tds-theme/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/tds-theme/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/tds-ui/package.json",
    "content": "{\n  \"name\": \"@titicaca/tds-ui\",\n  \"version\": \"14.2.3\",\n  \"description\": \"TDS ui\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/tds-ui\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@egjs/flicking\": \"^3.9.3\",\n    \"@egjs/react-flicking\": \"^3.8.3\",\n    \"@emotion/is-prop-valid\": \"^1.3.1\",\n    \"@floating-ui/react\": \"^0.27.4\",\n    \"@titicaca/content-utilities\": \"9.16.0\",\n    \"@titicaca/intersection-observer\": \"workspace:*\",\n    \"@titicaca/tds-ui\": \"workspace:*\",\n    \"@titicaca/triple-fallback-action\": \"workspace:*\",\n    \"@titicaca/view-utilities\": \"workspace:*\",\n    \"@types/react-input-mask\": \"2.0.5\",\n    \"react-compound-slider\": \"^3.4.0\",\n    \"react-input-mask\": \"^2.0.4\",\n    \"react-roving-tabindex\": \"^3.2.0\"\n  },\n  \"devDependencies\": {\n    \"@titicaca/tds-theme\": \"workspace:*\",\n    \"react\": \"^18.3.1\",\n    \"styled-components\": \"^6.1.15\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/tds-theme\": \"*\",\n    \"react\": \"^18.0\",\n    \"styled-components\": \"^6.0\"\n  }\n}\n"
  },
  {
    "path": "packages/tds-ui/src/commons.ts",
    "content": "import * as CSS from 'csstype'\n\nexport type MarginPadding = Partial<\n  Record<\n    'top' | 'right' | 'bottom' | 'left',\n    CSS.Property.Margin<string | number>\n  >\n>\n\n/**\n * @deprecated type-definitions로 이동합니다.\n */\nexport type BaseSizes = 'small' | 'medium' | 'large'\n\n/**\n * @deprecated type-definitions로 이동합니다.\n */\nexport type GlobalSizes =\n  | 'mini'\n  | 'tiny'\n  | 'big'\n  | 'huge'\n  | 'massive'\n  | BaseSizes\n\nenum GlobalColorSet {\n  blue = '54, 143, 255',\n  gray = '58, 58, 58',\n  white = '255, 255, 255',\n  red = '255, 33, 60',\n}\n\nexport type GlobalColors = 'blue' | 'gray' | 'white' | 'red'\n\nexport function GetGlobalColor(colorString: GlobalColors | string) {\n  return GlobalColorSet[colorString as GlobalColors] || colorString // HACK: GlobalColors가 아닌 경우 colorString을 그대로 반환하게 되므로 에러 없음\n}\n\n/**\n * @deprecated type-definitions로 이동합니다.\n */\nexport type Ratio =\n  | '4:1'\n  | '5:3'\n  | '11:7'\n  | '4:3'\n  | '1:1'\n  | '10:11'\n  | '5:8'\n  | '9:5'\n\n/**\n * @deprecated type-definitions로 이동합니다.\n */\nexport type FrameRatioAndSizes =\n  | Exclude<GlobalSizes, 'tiny' | 'massive'>\n  | 'original'\n  | Ratio\n\nconst ratio = {\n  '4:1': '25%',\n  '9:5': '55.56%',\n  '5:3': '60%',\n  '11:7': '63.64%',\n  '4:3': '75%',\n  '1:1': '100%',\n  '10:11': '110%',\n  '5:8': '160%',\n}\n\nexport const MEDIA_FRAME_OPTIONS: {\n  [key in FrameRatioAndSizes]: string | undefined\n} = {\n  mini: ratio['4:1'],\n  small: ratio['5:3'],\n  medium: ratio['4:3'],\n  large: ratio['1:1'],\n  big: ratio['10:11'],\n  huge: ratio['5:8'],\n  original: undefined,\n  ...ratio,\n}\n\nexport type CarouselSizes = Exclude<\n  GlobalSizes,\n  'mini' | 'tiny' | 'huge' | 'massive'\n>\n"
  },
  {
    "path": "packages/tds-ui/src/components/accordion/accordion-content.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { Container } from '../container'\n\nimport { useAccordion } from './accordion-context'\n\ntype AccordionContentProps = PropsWithChildren\n\nexport const AccordionContent = ({\n  children,\n  ...props\n}: AccordionContentProps) => {\n  const { active, contentId } = useAccordion()\n\n  if (!active) {\n    return null\n  }\n\n  return (\n    <Container id={contentId} {...props}>\n      {children}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/accordion/accordion-context.tsx",
    "content": "import { createContext, useContext } from 'react'\n\nexport interface AccordionContextValue {\n  contentId: string\n  foldedId: string\n  active: boolean\n  onActiveChange: () => void\n}\n\nexport const AccordionContext = createContext<\n  AccordionContextValue | undefined\n>(undefined)\n\nexport function useAccordion() {\n  const context = useContext(AccordionContext)\n  if (!context) {\n    throw new Error()\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/accordion/accordion-folded.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { Container } from '../container'\n\nimport { useAccordion } from './accordion-context'\n\nexport type AccordionFoldedProps = PropsWithChildren\n\nexport const AccordionFolded = ({\n  children,\n  ...props\n}: AccordionFoldedProps) => {\n  const { active, foldedId } = useAccordion()\n\n  if (active) {\n    return null\n  }\n\n  return (\n    <Container\n      id={foldedId}\n      css={{\n        margin: '5px 0 0',\n      }}\n      {...props}\n    >\n      {children}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/accordion/accordion-title.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\n\nimport { useAccordion } from './accordion-context'\n\nconst folded =\n  'https://assets.triple.guide/images/ico-accordion-expand-fold@4x.png'\nconst unfolded =\n  'https://assets.triple.guide/images/ico-accordion-expand-more@4x.png'\n\nconst Title = styled.button<{ active: boolean }>`\n  position: relative;\n  display: block;\n  width: 100%;\n  text-align: start;\n\n  &::after {\n    position: absolute;\n    top: 0;\n    right: 0;\n    width: 34px;\n    height: 34px;\n    background-image: ${({ active }) =>\n      active ? `url(${folded}) ` : `url(${unfolded}) `};\n    background-size: 34px 34px;\n    background-position: 0 -7px;\n    background-repeat: no-repeat;\n    content: '';\n    cursor: pointer;\n  }\n`\n\nexport type AccordionTitleProps = PropsWithChildren\n\nexport const AccordionTitle = ({ children, ...props }: AccordionTitleProps) => {\n  const { active, contentId, foldedId, onActiveChange } = useAccordion()\n\n  return (\n    <Title\n      active={active}\n      aria-controls={`${contentId} ${foldedId}`}\n      aria-expanded={active}\n      onClick={onActiveChange}\n      {...props}\n    >\n      {children}\n    </Title>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/accordion/accordion.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Text } from '../text'\n\nimport { Accordion } from './accordion'\nimport { AccordionContent } from './accordion-content'\nimport { AccordionFolded } from './accordion-folded'\nimport { AccordionTitle } from './accordion-title'\n\nconst meta: Meta<typeof Accordion> = {\n  title: 'tds-ui (Disclosure) / Accordion',\n  component: Accordion,\n  argTypes: {\n    active: { control: 'boolean' },\n    children: {\n      table: {\n        disable: true,\n      },\n    },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: '콘텐츠를 확장하고 축소하는 뷰 컴포넌트입니다.',\n      },\n      story: {\n        inline: false,\n        iframeHeight: 200,\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const BusinessHours: StoryObj<typeof Accordion> = {\n  args: {\n    active: true,\n    children: (\n      <>\n        <AccordionTitle>\n          <Text bold>이용가능시간, 휴무일</Text>\n        </AccordionTitle>\n        <AccordionFolded>\n          <Text bold color=\"blue\">\n            오늘 09:00 - 18:00\n          </Text>\n        </AccordionFolded>\n        <AccordionContent>\n          <Text>\n            월<br />화<br />수<br />목<br />금<br />토<br />일\n          </Text>\n        </AccordionContent>\n      </>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/accordion/accordion.tsx",
    "content": "import { PropsWithChildren, useId } from 'react'\n\nimport { AccordionContext } from './accordion-context'\n\nexport interface AccordionProps extends PropsWithChildren {\n  /**\n   * true일 때는 Content, false일 때는 Folded가 보입니다.\n   */\n  active: boolean\n  /**\n   * active가 변경될 때 콜백\n   */\n  onActiveChange?: () => void\n}\n\nexport const Accordion = ({\n  children,\n  active,\n  onActiveChange,\n}: AccordionProps) => {\n  const contentId = useId()\n  const foldedId = useId()\n\n  return (\n    <AccordionContext.Provider\n      value={{\n        active,\n        contentId,\n        foldedId,\n        onActiveChange: () => onActiveChange?.(),\n      }}\n    >\n      {children}\n    </AccordionContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/accordion/index.ts",
    "content": "export * from './accordion-content'\nexport * from './accordion-folded'\nexport * from './accordion-title'\nexport * from './accordion'\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet/action-sheet-body.tsx",
    "content": "import { PropsWithChildren, ReactNode, forwardRef } from 'react'\nimport { styled, css } from 'styled-components'\n\nimport { Container } from '../container'\nimport { MarginPadding } from '../../commons'\nimport { safeAreaInsetMixin, SafeAreaInsetMixinProps } from '../../mixins'\n\nimport { ActionSheetTitle } from './action-sheet-title'\nimport { TRANSITION_DURATION } from './constants'\n\ninterface SheetProps {\n  $borderRadius: number\n  $bottomSpacing: number\n  $from: 'top' | 'bottom'\n  padding: MarginPadding\n}\n\nconst Sheet = styled.div<SheetProps>`\n  position: fixed;\n  width: 100%;\n  max-width: 768px;\n  background-color: var(--color-white);\n  padding-bottom: ${({ $from, $bottomSpacing }) =>\n    $from === 'top' ? 30 : $bottomSpacing}px;\n  padding-top: ${({ $from }) => ($from === 'bottom' ? 30 : 20)}px;\n  z-index: 9999;\n  outline: none;\n  ${({ $from, $borderRadius }) => {\n    switch ($from) {\n      case 'top':\n        return css`\n          top: 0;\n          border-radius: 0 0 ${$borderRadius}px ${$borderRadius}px;\n        `\n      case 'bottom':\n        return css<SafeAreaInsetMixinProps>`\n          bottom: 0;\n          border-radius: ${$borderRadius}px ${$borderRadius}px 0 0;\n          ${safeAreaInsetMixin};\n        `\n    }\n  }}\n  transition: transform ${TRANSITION_DURATION}ms ease-in;\n  transform: translateY(${({ $from }) => ($from === 'top' ? -100 : 100)}%);\n\n  &[data-transition='open'] {\n    transform: translateY(0);\n  }\n`\n\nconst Content = styled(Container)`\n  overflow: auto;\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n`\n\nexport interface ActionSheetBodyProps extends PropsWithChildren {\n  borderRadius: number\n  bottomSpacing: number\n  from: 'top' | 'bottom'\n  maxContentHeight?: string | number\n  title?: ReactNode\n  labelId: string\n  transitionStatus: string\n}\n\nexport const ActionSheetBody = forwardRef<HTMLDivElement, ActionSheetBodyProps>(\n  function ActionSheetBody(\n    {\n      children,\n      borderRadius,\n      bottomSpacing,\n      from,\n      maxContentHeight,\n      title,\n      labelId,\n      transitionStatus,\n      ...props\n    },\n    ref,\n  ) {\n    return (\n      <Sheet\n        ref={ref}\n        $borderRadius={borderRadius}\n        $bottomSpacing={bottomSpacing}\n        $from={from}\n        padding={{ bottom: bottomSpacing }}\n        aria-labelledby={labelId}\n        data-transition={transitionStatus}\n        {...props}\n      >\n        {title && (\n          <ActionSheetTitle labelId={labelId}>{title}</ActionSheetTitle>\n        )}\n        <Content\n          css={{\n            maxHeight: maxContentHeight,\n            padding: '0 25px',\n          }}\n        >\n          {children}\n        </Content>\n      </Sheet>\n    )\n  },\n)\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet/action-sheet-context.tsx",
    "content": "import { createContext, useContext } from 'react'\n\nexport interface ActionSheetContextValue {\n  open: boolean\n  onClose?: () => void\n}\n\nexport const ActionSheetContext = createContext<\n  ActionSheetContextValue | undefined\n>(undefined)\n\nexport function useActionSheet() {\n  const context = useContext(ActionSheetContext)\n  if (!context) {\n    throw new Error()\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet/action-sheet-item.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\nimport * as CSS from 'csstype'\n\nimport { useActionSheet } from './action-sheet-context'\n\nexport const ActionItemContainer = styled.div`\n  display: flex;\n  align-items: center;\n  height: 54px;\n`\n\nconst ItemText = styled.div<{\n  checked?: boolean\n  overflow?: CSS.Property.Overflow\n}>`\n  flex: 1;\n  height: 54px;\n  line-height: 54px;\n  font-size: 16px;\n  color: ${({ checked }) => (checked ? '#368fff' : '#3a3a3a')};\n  font-weight: ${({ checked }) => (checked ? 'bold' : 'normal')};\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  overflow: ${({ overflow }) => overflow ?? 'hidden'};\n`\n\nconst ItemButton = styled.a`\n  display: block;\n  height: 30px;\n  line-height: 30px;\n  padding: 0 17px;\n  border-radius: 15px;\n  background-color: #fafafa;\n  font-size: 12px;\n  font-weight: bold;\n  text-align: center;\n  color: #3a3a3a;\n`\n\nconst URL_BY_NAMES: { [key: string]: string } = {\n  save: 'https://assets.triple.guide/images/img-action-save@4x.png',\n  schedule: 'https://assets.triple.guide/images/img-action-schedule@4x.png',\n  share: 'https://assets.triple.guide/images/img-action-share@4x.png',\n  suggest: 'https://assets.triple.guide/images/img-action-suggest@4x.png',\n  review: 'https://assets.triple.guide/images/img-action-review@4x.png',\n  report: 'https://assets.triple.guide/images/img-action-report@4x.png',\n  delete: 'https://assets.triple.guide/images/img-action-delete@4x.png',\n  message: 'https://assets.triple.guide/images/img-action-message-4-x.png',\n  support: 'https://assets.triple.guide/images/img-action-support@3x.png',\n  notice: 'https://assets.triple.guide/images/img-action-notice@4x-v2.png',\n  url: 'https://assets.triple.guide/images/img-action-url.png',\n  phonecall: 'https://assets.triple.guide/images/img-action-phonecall.png',\n  plus: 'https://assets.triple.guide/images/partners-center/img-action-plus.svg',\n}\n\nconst CHECKED_ICON_URL = 'https://assets.triple.guide/images/checkbox-on.svg'\n\nconst ItemIcon = styled.img`\n  display: block;\n  width: 30px;\n  height: 30px;\n  margin-right: 9px;\n`\n\nconst CheckedIcon = styled.div`\n  margin-right: -5px;\n  width: 36px;\n  height: 36px;\n  background-size: 36px 36px;\n  background-image: url(${CHECKED_ICON_URL});\n  background-repeat: none;\n`\n\nexport interface ActionSheetItemProps extends PropsWithChildren {\n  buttonLabel?: string\n  icon?: string\n  checked?: boolean\n  overflow?: CSS.Property.Overflow\n  onClick?: () => unknown\n}\n\nexport const ActionSheetItem = ({\n  children,\n  buttonLabel,\n  icon,\n  checked,\n  overflow,\n  onClick,\n  ...props\n}: ActionSheetItemProps) => {\n  const { onClose } = useActionSheet()\n\n  const handleClick = () => {\n    onClick ? !onClick() && onClose?.() : onClose?.()\n  }\n\n  return (\n    <ActionItemContainer\n      onClick={buttonLabel ? undefined : handleClick}\n      {...props}\n    >\n      {icon ? <ItemIcon src={URL_BY_NAMES[icon]} /> : null}\n      <ItemText checked={checked} overflow={overflow}>\n        {children}\n      </ItemText>\n      {buttonLabel ? (\n        <ItemButton onClick={handleClick}>{buttonLabel}</ItemButton>\n      ) : null}\n      {checked ? <CheckedIcon /> : null}\n    </ActionItemContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet/action-sheet-overlay.tsx",
    "content": "import { styled } from 'styled-components'\nimport { FloatingOverlay } from '@floating-ui/react'\n\nimport { TRANSITION_DURATION } from './constants'\n\nexport const Overlay = styled(FloatingOverlay)<{ lockScroll: boolean }>`\n  position: fixed;\n  top: 0;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  width: 100vw;\n  background-color: rgba(58, 58, 58, 0.7);\n  z-index: 9999;\n  transition: opacity ${TRANSITION_DURATION}ms ease-in;\n  opacity: 0;\n\n  &[data-transition='open'] {\n    opacity: 1;\n  }\n`\n\nexport interface ActionSheetOverlayProps {\n  lockScroll?: boolean\n  transitionStatus: string\n}\n\nexport const ActionSheetOverlay = ({\n  transitionStatus,\n}: ActionSheetOverlayProps) => {\n  return <Overlay lockScroll data-transition={transitionStatus} />\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet/action-sheet-title.tsx",
    "content": "import { isValidElement, PropsWithChildren } from 'react'\n\nimport { Container } from '../container'\nimport { Text } from '../text'\n\ninterface ActionSheetTitleProps extends PropsWithChildren {\n  labelId: string\n}\n\nexport const ActionSheetTitle = ({\n  children,\n  labelId,\n}: ActionSheetTitleProps) => {\n  if (\n    typeof children === 'string' ||\n    typeof children === 'number' ||\n    typeof children === 'boolean'\n  ) {\n    return (\n      <Container\n        css={{\n          height: '16px',\n          margin: '0 0 10px 27px',\n        }}\n      >\n        <Text id={labelId} size=\"tiny\" bold color=\"gray700\">\n          {children}\n        </Text>\n      </Container>\n    )\n  }\n\n  if (isValidElement(children)) {\n    return children\n  }\n\n  return null\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet/action-sheet.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { styled, css } from 'styled-components'\n\nimport { ActionSheet } from './action-sheet'\nimport { ActionSheetItem } from './action-sheet-item'\n\nconst meta: Meta<typeof ActionSheet> = {\n  title: 'tds-ui (Overlay) / ActionSheet',\n  component: ActionSheet,\n  args: {\n    open: false,\n    from: 'bottom',\n    borderRadius: 12,\n    bottomSpacing: 13,\n    maxContentHeight: 'calc(100vh - 256px)',\n  },\n  argTypes: {\n    open: { control: 'boolean' },\n    borderRadius: { control: 'number' },\n    bottomSpacing: { control: 'number' },\n    from: {\n      control: 'radio',\n      options: ['top', 'bottom'],\n    },\n    children: {\n      table: {\n        disable: true,\n      },\n    },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 선택지를 제공하는 팝업 형태의 뷰 컴포넌트입니다.',\n      },\n      story: {\n        inline: false,\n        iframeHeight: 500,\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof ActionSheet>\n\nexport const Default: Story = {\n  args: {\n    open: true,\n    children: (\n      <>\n        <ActionSheetItem>메뉴 1</ActionSheetItem>\n        <ActionSheetItem>메뉴 2</ActionSheetItem>\n        <ActionSheetItem>메뉴 3</ActionSheetItem>\n        <ActionSheetItem>메뉴 4</ActionSheetItem>\n        <ActionSheetItem>메뉴 5</ActionSheetItem>\n        <ActionSheetItem>메뉴 6</ActionSheetItem>\n        <ActionSheetItem>메뉴 7</ActionSheetItem>\n        <ActionSheetItem>메뉴 8</ActionSheetItem>\n      </>\n    ),\n  },\n}\n\nexport const WithTextMenu: Story = {\n  args: {\n    open: true,\n    title: '샘플 액션 시트',\n    children: (\n      <>\n        <ActionSheetItem checked>메뉴 1</ActionSheetItem>\n        <ActionSheetItem checked={false}>메뉴 2</ActionSheetItem>\n        <ActionSheetItem icon=\"save\">샘플 메뉴</ActionSheetItem>\n        <ActionSheetItem buttonLabel=\"액션\">샘플 메뉴</ActionSheetItem>\n      </>\n    ),\n  },\n}\n\nexport const WithIconMenu: Story = {\n  parameters: {\n    docs: {\n      description: {\n        story: '아이콘 메뉴가 포함된 ActionSheet 입니다.',\n      },\n    },\n  },\n  args: {\n    open: true,\n    title: '샘플 액션 시트',\n    children: (\n      <>\n        <ActionSheetItem icon=\"save\">샘플 메뉴</ActionSheetItem>\n        <ActionSheetItem buttonLabel=\"액션\">샘플 메뉴</ActionSheetItem>\n      </>\n    ),\n  },\n}\n\nexport const WithForm: Story = {\n  parameters: {\n    docs: {\n      description: {\n        story: 'ActionSheet는 방향을 조절할 수 있습니다.',\n      },\n    },\n  },\n  args: {\n    open: true,\n    title: '샘플 액션 시트',\n    borderRadius: 0,\n    from: 'top',\n    maxContentHeight: 100,\n    children: (\n      <>\n        <ActionSheetItem icon=\"save\">샘플 메뉴</ActionSheetItem>\n        <ActionSheetItem buttonLabel=\"액션\">샘플 메뉴</ActionSheetItem>\n      </>\n    ),\n  },\n}\n\nconst Title = styled.h1`\n  padding: 10px 20px;\n  font-size: 24px;\n  font-weight: bold;\n`\nconst Help = styled.small`\n  padding: 0 20px;\n  color: gray;\n  font-size: 13px;\n`\nconst CustomHeader = ({ title, help }: { title: string; help: string }) => (\n  <>\n    <Title>{title}</Title>\n    <Help>{help}</Help>\n  </>\n)\n\nexport const WithCustomHeader: Story = {\n  args: {\n    open: true,\n    title: (\n      <CustomHeader title=\"여행일정\" help=\"출발일-도착일을 선택해주세요.\" />\n    ),\n    children: (\n      <>\n        <ActionSheetItem>메뉴 1</ActionSheetItem>\n        <ActionSheetItem>메뉴 2</ActionSheetItem>\n      </>\n    ),\n  },\n}\n\nexport const WithExtendStyle: Story = {\n  parameters: {\n    docs: {\n      description: {\n        story:\n          '스타일을 확장하여 사용할 때에는 ActionSheet의 css prop이나 ActionSheetItem의 css prop을 사용합니다. ',\n      },\n    },\n  },\n  args: {\n    open: true,\n    title: '샘플 액션 시트',\n  },\n  render: () => {\n    return (\n      <ActionSheet\n        open\n        css={css`\n          background-color: gray;\n\n          &.action-sheet-slide-enter-done {\n            & > div:last-child {\n              padding: 0 40px;\n            }\n          }\n        `}\n      >\n        <ActionSheetItem\n          icon=\"save\"\n          css={css`\n            padding: 0 40px;\n            background-color: aqua;\n          `}\n        >\n          샘플 메뉴\n        </ActionSheetItem>\n        <ActionSheetItem buttonLabel=\"액션\">샘플 메뉴</ActionSheetItem>\n      </ActionSheet>\n    )\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet/action-sheet.test.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { render, screen, waitFor } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\nimport { ThemeProvider } from 'styled-components'\nimport { defaultTheme } from '@titicaca/tds-theme'\n\nimport { ActionSheet } from './action-sheet'\n\nfunction ThemeWrapper({ children }: PropsWithChildren<unknown>) {\n  return <ThemeProvider theme={defaultTheme}>{children}</ThemeProvider>\n}\n\ntest('올바른 aria attributes를 가집니다.', () => {\n  const onClose = jest.fn()\n\n  render(\n    <ActionSheet open title=\"Title\" onClose={onClose}>\n      contents\n    </ActionSheet>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const modal = screen.getByRole('dialog')\n\n  expect(modal).toHaveAttribute('role', 'dialog')\n  expect(modal).toHaveAttribute('aria-modal', 'true')\n  expect(modal).toHaveAttribute('aria-labelledby', screen.getByText('Title').id)\n})\n\ntest('외부를 클릭하면 닫습니다.', async () => {\n  const user = userEvent.setup()\n\n  const onClose = jest.fn()\n\n  render(\n    <>\n      <button>outside</button>\n      <ActionSheet open title=\"Title\" onClose={onClose}>\n        contents\n      </ActionSheet>\n    </>,\n    { wrapper: ThemeWrapper },\n  )\n\n  await user.click(screen.getByText('outside'))\n\n  expect(onClose).toHaveBeenCalledTimes(1)\n})\n\ntest('ESC 키를 누르면 닫습니다.', async () => {\n  const user = userEvent.setup()\n\n  const onClose = jest.fn()\n\n  render(\n    <ActionSheet open title=\"Title\" onClose={onClose}>\n      contents\n    </ActionSheet>,\n    { wrapper: ThemeWrapper },\n  )\n\n  await user.keyboard('{Escape}')\n\n  expect(onClose).toHaveBeenCalledTimes(1)\n})\n\ntest('focus trap을 사용합니다.', async () => {\n  const user = userEvent.setup()\n\n  const onClose = jest.fn()\n\n  render(\n    <ActionSheet open title=\"Title\" onClose={onClose}>\n      <button>Button 1</button>\n      <button>Button 2</button>\n    </ActionSheet>,\n    { wrapper: ThemeWrapper },\n  )\n\n  await waitFor(() => expect(screen.getByRole('dialog')).toHaveFocus())\n\n  await user.tab()\n\n  await waitFor(() => expect(screen.getByText('Button 1')).toHaveFocus())\n\n  await user.tab()\n\n  await waitFor(() => expect(screen.getByText('Button 2')).toHaveFocus())\n\n  await user.tab()\n\n  await waitFor(() => expect(screen.getByText('Button 1')).toHaveFocus())\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet/action-sheet.tsx",
    "content": "import { useEffect, useId } from 'react'\nimport {\n  FloatingFocusManager,\n  FloatingPortal,\n  useDismiss,\n  useFloating,\n  useInteractions,\n  useRole,\n  useTransitionStatus,\n} from '@floating-ui/react'\n\nimport { FlexBox } from '../flex-box'\n\nimport { ActionSheetBody, ActionSheetBodyProps } from './action-sheet-body'\nimport { ActionSheetContext } from './action-sheet-context'\nimport { ActionSheetOverlay } from './action-sheet-overlay'\nimport { TRANSITION_DURATION } from './constants'\n\nexport interface ActionSheetProps\n  extends Pick<\n    Partial<ActionSheetBodyProps>,\n    | 'children'\n    | 'borderRadius'\n    | 'bottomSpacing'\n    | 'from'\n    | 'maxContentHeight'\n    | 'title'\n  > {\n  open?: boolean\n  lockScroll?: boolean\n  onClose?: () => void\n  onEnter?: () => void\n  onEntered?: () => void\n  onExit?: () => void\n  onExited?: () => void\n}\n\nexport const ActionSheet = ({\n  children,\n  open = false,\n  lockScroll = true,\n  title,\n  borderRadius = 12,\n  bottomSpacing = 13,\n  from = 'bottom',\n  maxContentHeight = 'calc(100vh - 256px)',\n  onClose,\n  onEnter,\n  onEntered,\n  onExit,\n  onExited,\n  ...props\n}: ActionSheetProps) => {\n  const labelId = useId()\n\n  const { context, refs } = useFloating({\n    open,\n    onOpenChange: (open) => (open ? undefined : onClose?.()),\n  })\n\n  const dismiss = useDismiss(context)\n  const role = useRole(context, { role: 'dialog' })\n\n  const { getFloatingProps } = useInteractions([dismiss, role])\n\n  const { isMounted, status } = useTransitionStatus(context, {\n    duration: TRANSITION_DURATION,\n  })\n\n  useEffect(() => {\n    if (status === 'open') {\n      onEnter?.()\n      const timeout = setTimeout(() => onEntered?.(), TRANSITION_DURATION)\n      return () => clearTimeout(timeout)\n    } else if (status === 'close') {\n      onExit?.()\n      const timeout = setTimeout(() => onExited?.(), TRANSITION_DURATION)\n      return () => clearTimeout(timeout)\n    }\n  }, [onEnter, onEntered, onExit, onExited, status])\n\n  return (\n    <ActionSheetContext.Provider value={{ open, onClose }}>\n      {isMounted ? (\n        <FloatingPortal>\n          {lockScroll && (\n            <ActionSheetOverlay\n              transitionStatus={status}\n              lockScroll={lockScroll}\n            />\n          )}\n          <FlexBox\n            flex\n            justifyContent=\"center\"\n            css={{\n              position: 'fixed',\n              left: 0,\n              right: 0,\n              top: 0,\n              bottom: 0,\n              zIndex: 9999,\n              ...(!lockScroll && { pointerEvents: 'none' }),\n            }}\n          >\n            <FloatingFocusManager\n              context={context}\n              initialFocus={refs.floating}\n            >\n              <ActionSheetBody\n                ref={refs.setFloating}\n                borderRadius={borderRadius}\n                bottomSpacing={bottomSpacing}\n                maxContentHeight={maxContentHeight}\n                from={from}\n                title={title}\n                labelId={labelId}\n                transitionStatus={status}\n                aria-modal\n                css={{ pointerEvents: 'auto' }}\n                {...getFloatingProps(props)}\n              >\n                {children}\n              </ActionSheetBody>\n            </FloatingFocusManager>\n          </FlexBox>\n        </FloatingPortal>\n      ) : null}\n    </ActionSheetContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet/constants.tsx",
    "content": "export const TRANSITION_DURATION = 120\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet/index.ts",
    "content": "export * from './action-sheet-body'\nexport * from './action-sheet-item'\nexport * from './action-sheet'\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet-select/action-sheet-select-button.tsx",
    "content": "import { styled } from 'styled-components'\nimport { HTMLAttributes, forwardRef } from 'react'\nimport { useMergeRefs } from '@floating-ui/react'\n\nimport {\n  FormFieldError,\n  FormFieldHelp,\n  FormFieldLabel,\n  useFormField,\n} from '../form-field'\nimport { Text } from '../text'\n\nimport { useActionSheetSelect } from './action-sheet-select-context'\n\nconst Button = styled.button`\n  display: block;\n  position: relative;\n  width: 100%;\n  padding: 16px;\n  border: 1px solid #efefef;\n  text-align: left;\n`\n\nconst ArrowDown = styled.span`\n  position: absolute;\n  top: 50%;\n  transform: translateY(-50%);\n  right: 16px;\n  display: inline-block;\n  width: 10px;\n  height: 24px;\n  background-size: 10px 24px;\n  background-repeat: no-repeat;\n  background-image: url('https://assets.triple.guide/images/ico-category-select@3x.png');\n`\n\nexport type ActionSheetSelectButtonProps = HTMLAttributes<HTMLButtonElement>\n\nexport const ActionSheetSelectButton = forwardRef<\n  HTMLButtonElement,\n  ActionSheetSelectButtonProps\n>(function ActionSheetSelectButton({ children, ...props }, ref) {\n  const { floating, interactions, value, error, help, label } =\n    useActionSheetSelect()\n  const {\n    errorId,\n    descriptionId,\n    inputId,\n    isError,\n    isRequired,\n    handleBlur,\n    handleFocus,\n  } = useFormField()\n\n  const hasLabel = !!label\n  const hasHelp = !!help\n\n  return (\n    <div>\n      {hasLabel ? <FormFieldLabel>{label}</FormFieldLabel> : null}\n      <Button\n        ref={useMergeRefs([ref, floating.refs.setReference])}\n        id={inputId}\n        aria-describedby={hasHelp && !isError ? descriptionId : undefined}\n        aria-errormessage={isError ? errorId : undefined}\n        aria-invalid={isError}\n        aria-required={isRequired}\n        {...interactions.getReferenceProps({\n          onBlur: handleBlur,\n          onFocus: handleFocus,\n          ...props,\n        })}\n      >\n        <Text size=\"large\" alpha={value ? 1 : 0.5}>\n          {children}\n        </Text>\n        <ArrowDown />\n      </Button>\n      {error ? (\n        <FormFieldError>{error}</FormFieldError>\n      ) : help ? (\n        <FormFieldHelp>{help}</FormFieldHelp>\n      ) : null}\n    </div>\n  )\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet-select/action-sheet-select-context.tsx",
    "content": "import {\n  UseFloatingReturn,\n  useInteractions,\n  useTransitionStatus,\n} from '@floating-ui/react'\nimport { MutableRefObject, createContext, useContext } from 'react'\n\nexport interface ActionSheetSelectContextValue {\n  floating: UseFloatingReturn\n  interactions: ReturnType<typeof useInteractions>\n  transitionStatus: ReturnType<typeof useTransitionStatus>\n  activeIndex: number | null\n  listRef: MutableRefObject<(HTMLElement | null)[]>\n  labelId: string\n  value: string | undefined\n  open: boolean\n  disabled: boolean | undefined\n  error: string | undefined\n  help: string | undefined\n  label: string | undefined\n  handleChange: (value: string, index: number) => void\n}\n\nexport const ActionSheetSelectContext = createContext<\n  ActionSheetSelectContextValue | undefined\n>(undefined)\n\nexport function useActionSheetSelect() {\n  const context = useContext(ActionSheetSelectContext)\n  if (!context) {\n    throw new Error()\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet-select/action-sheet-select-option.tsx",
    "content": "import { useListItem } from '@floating-ui/react'\nimport { PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\n\nimport { useActionSheetSelect } from './action-sheet-select-context'\n\nconst StyledButton = styled.button`\n  display: flex;\n  align-items: center;\n  width: 100%;\n  height: 54px;\n  color: #3a3a3a;\n  font-weight: normal;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  overflow: hidden;\n\n  &[aria-selected='true'] {\n    color: #368fff;\n    font-weight: bold;\n  }\n`\n\nexport interface ActionSheetSelectOptionProps extends PropsWithChildren {\n  value: string\n}\n\nexport const ActionSheetSelectOption = ({\n  children,\n  value,\n  ...props\n}: ActionSheetSelectOptionProps) => {\n  const {\n    activeIndex,\n    interactions,\n    value: contextValue,\n    handleChange,\n  } = useActionSheetSelect()\n  const { ref, index } = useListItem()\n\n  const isActive = activeIndex === index\n  const isSelected = value === contextValue\n\n  const handleClick = () => {\n    handleChange(value, index)\n  }\n\n  return (\n    <StyledButton\n      ref={ref}\n      role=\"option\"\n      tabIndex={isActive ? 0 : -1}\n      aria-selected={isSelected}\n      {...interactions.getItemProps({\n        ...props,\n        onClick: handleClick,\n      })}\n    >\n      {children}\n    </StyledButton>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet-select/action-sheet-select-options.tsx",
    "content": "import {\n  FloatingFocusManager,\n  FloatingList,\n  FloatingPortal,\n} from '@floating-ui/react'\n\nimport { ActionSheetOverlay } from '../action-sheet/action-sheet-overlay'\nimport {\n  ActionSheetBody,\n  ActionSheetBodyProps,\n} from '../action-sheet/action-sheet-body'\nimport { FlexBox } from '../flex-box'\n\nimport { useActionSheetSelect } from './action-sheet-select-context'\n\nexport type ActionSheetSelectOptions = Pick<\n  ActionSheetBodyProps,\n  | 'children'\n  | 'borderRadius'\n  | 'bottomSpacing'\n  | 'from'\n  | 'maxContentHeight'\n  | 'title'\n>\n\nexport const ActionSheetSelectOptions = ({\n  children,\n  borderRadius,\n  bottomSpacing,\n  from,\n  maxContentHeight,\n  title,\n  ...props\n}: ActionSheetSelectOptions) => {\n  const { floating, interactions, transitionStatus, labelId, listRef } =\n    useActionSheetSelect()\n\n  const { context, refs } = floating\n  const { getFloatingProps } = interactions\n  const { isMounted, status } = transitionStatus\n\n  if (!isMounted) {\n    return null\n  }\n\n  return (\n    <FloatingPortal>\n      <ActionSheetOverlay transitionStatus={status} />\n      <FlexBox\n        flex\n        justifyContent=\"center\"\n        css={{\n          position: 'fixed',\n          left: 0,\n          right: 0,\n          top: 0,\n          bottom: 0,\n          zIndex: 9999,\n        }}\n      >\n        <FloatingFocusManager context={context}>\n          <FloatingList elementsRef={listRef}>\n            <ActionSheetBody\n              ref={refs.setFloating}\n              borderRadius={borderRadius}\n              bottomSpacing={bottomSpacing}\n              maxContentHeight={maxContentHeight}\n              from={from}\n              title={title}\n              labelId={labelId}\n              transitionStatus={status}\n              {...getFloatingProps(props)}\n            >\n              {children}\n            </ActionSheetBody>\n          </FloatingList>\n        </FloatingFocusManager>\n      </FlexBox>\n    </FloatingPortal>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet-select/action-sheet-select.stories.tsx",
    "content": "import type { Meta, StoryFn } from '@storybook/react'\nimport { useState } from 'react'\n\nimport { ActionSheetSelect } from './action-sheet-select'\nimport { ActionSheetSelectButton } from './action-sheet-select-button'\nimport { ActionSheetSelectOption } from './action-sheet-select-option'\nimport { ActionSheetSelectOptions } from './action-sheet-select-options'\n\nconst meta: Meta<typeof ActionSheetSelect> = {\n  title: 'tds-ui (Form) / ActionSheetSelect',\n  component: ActionSheetSelect,\n  parameters: {\n    docs: {\n      story: {\n        inline: false,\n        iframeHeight: 500,\n      },\n    },\n  },\n}\n\nexport default meta\n\nconst list = [\n  ['a', 'Option A'],\n  ['b', 'Option B'],\n  ['c', 'Option C'],\n]\n\nexport const Basic: StoryFn<typeof ActionSheetSelect> = () => {\n  const [value, setValue] = useState<string | undefined>(undefined)\n\n  return (\n    <ActionSheetSelect\n      label=\"Label\"\n      help=\"Help message\"\n      required\n      value={value}\n      onChange={setValue}\n    >\n      <ActionSheetSelectButton>\n        {list.find((item) => item[0] === value)?.[1] ?? '선택하기'}\n      </ActionSheetSelectButton>\n      <ActionSheetSelectOptions title=\"select\">\n        {list.map((item) => (\n          <ActionSheetSelectOption key={item[0]} value={item[0]}>\n            {item[1]}\n          </ActionSheetSelectOption>\n        ))}\n      </ActionSheetSelectOptions>\n    </ActionSheetSelect>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet-select/action-sheet-select.tsx",
    "content": "import {\n  useClick,\n  useDismiss,\n  useFloating,\n  useInteractions,\n  useListNavigation,\n  useRole,\n  useTransitionStatus,\n} from '@floating-ui/react'\nimport { PropsWithChildren, useCallback, useId, useRef, useState } from 'react'\n\nimport { TRANSITION_DURATION } from '../action-sheet/constants'\nimport { FormField } from '../form-field'\n\nimport { ActionSheetSelectContext } from './action-sheet-select-context'\n\nexport interface ActionSheetSelectProps extends PropsWithChildren {\n  value?: string\n  disabled?: boolean\n  error?: string\n  help?: string\n  label?: string\n  required?: boolean\n  onChange?: (value: string) => void\n}\n\nexport const ActionSheetSelect = ({\n  children,\n  value,\n  disabled,\n  error,\n  help,\n  label,\n  required,\n  onChange,\n}: ActionSheetSelectProps) => {\n  const [open, setOpen] = useState(false)\n  const [activeIndex, setActiveIndex] = useState<number | null>(null)\n  const [selectedIndex, setSelectedIndex] = useState<number | null>(null)\n  const listRef = useRef<(HTMLElement | null)[]>([])\n  const labelId = useId()\n\n  const floating = useFloating({\n    open,\n    onOpenChange: setOpen,\n  })\n\n  const { context } = floating\n\n  const click = useClick(context)\n  const dismiss = useDismiss(context)\n  const role = useRole(context, { role: 'listbox' })\n\n  const listNav = useListNavigation(context, {\n    listRef,\n    activeIndex,\n    selectedIndex,\n    loop: true,\n    onNavigate: setActiveIndex,\n  })\n\n  const interactions = useInteractions([click, dismiss, role, listNav])\n\n  const transitionStatus = useTransitionStatus(context, {\n    duration: TRANSITION_DURATION,\n  })\n\n  const handleChange = useCallback(\n    (value: string, index: number) => {\n      setSelectedIndex(index)\n      setOpen(false)\n      onChange?.(value)\n    },\n    [onChange],\n  )\n\n  const isDisabled = !!disabled\n  const isError = !!error\n  const isRequired = !!required\n\n  return (\n    <ActionSheetSelectContext.Provider\n      value={{\n        floating,\n        interactions,\n        transitionStatus,\n        listRef,\n        labelId,\n        activeIndex,\n        value,\n        open,\n        disabled,\n        error,\n        help,\n        label,\n        handleChange,\n      }}\n    >\n      <FormField\n        isDisabled={isDisabled}\n        isError={isError}\n        isRequired={isRequired}\n      >\n        {children}\n      </FormField>\n    </ActionSheetSelectContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/action-sheet-select/index.ts",
    "content": "export * from './action-sheet-select-button'\nexport * from './action-sheet-select-option'\nexport * from './action-sheet-select-options'\nexport * from './action-sheet-select'\n"
  },
  {
    "path": "packages/tds-ui/src/components/alert/alert.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Alert } from './alert'\n\nconst meta: Meta<typeof Alert> = {\n  title: 'tds-ui (Overlay) / Alert',\n  component: Alert,\n  args: {\n    confirmText: '확인',\n  },\n  argTypes: {\n    open: { type: 'boolean' },\n    title: { type: 'string' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 주의를 주거나 알림을 제공하는 뷰 컴포넌트입니다.',\n      },\n      story: {\n        inline: false,\n        iframeHeight: 500,\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const Default: StoryObj<typeof Alert> = {\n  args: {\n    open: true,\n    title: '항공사 예약번호',\n    children: '대한항공 L5W4NW',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/alert/alert.tsx",
    "content": "import { ReactNode } from 'react'\n\nimport { Modal } from '../modal'\n\nexport interface AlertProps {\n  children?: ReactNode\n  title?: string\n  open?: boolean\n  confirmText?: ReactNode\n  onClose?: () => void\n  onConfirm?: () => boolean | unknown\n}\n\nexport const Alert = ({\n  children,\n  title,\n  open,\n  confirmText = '확인',\n  onClose,\n  onConfirm,\n  ...props\n}: AlertProps) => {\n  const handleConfirm = () => {\n    onConfirm ? !onConfirm() && onClose?.() : onClose?.()\n  }\n\n  return (\n    <Modal open={open} onClose={onClose}>\n      <Modal.Body {...props}>\n        {title && <Modal.Title>{title}</Modal.Title>}\n        {children && <Modal.Description>{children}</Modal.Description>}\n      </Modal.Body>\n      <Modal.Actions>\n        <Modal.Action color=\"blue\" onClick={handleConfirm}>\n          {confirmText}\n        </Modal.Action>\n      </Modal.Actions>\n    </Modal>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/alert/index.ts",
    "content": "export * from './alert'\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/basic-button.tsx",
    "content": "import { css } from 'styled-components'\nimport type { Theme } from '@titicaca/tds-theme'\n\nimport { buttonBaseMixin, ButtonBaseMixinProps } from './button-base'\n\ntype Color = keyof Theme['colors']\n\nconst BASIC_INVERTED_COLORS: Partial<Record<Color, string>> = {\n  blue: '#368fff',\n  gray: '#3a3a3a',\n}\n\nexport interface BasicButtonOwnProps extends ButtonBaseMixinProps {\n  color?: Color\n  /**\n   * Compact 버튼을 사용합니다. Normal 및 Basic 버튼에서만 사용할 수 있습니다.\n   */\n  compact?: boolean\n  /**\n   * Inverted 버튼을 사용합니다. Basic 버튼에서만 사용할 수 있습니다.\n   */\n  inverted?: boolean\n}\n\nexport const basicButtonMixin = ({\n  color = 'blue',\n  compact,\n  inverted,\n  ...props\n}: BasicButtonOwnProps) => css`\n  ${buttonBaseMixin(props)}\n  border: 1px solid var(--color-gray200);\n  border-radius: 4px;\n  background-color: transparent;\n  color: #3a3a3a;\n  padding: ${compact ? '7px 12px' : '14px 12px'};\n\n  ${inverted &&\n  css`\n    border: 1px solid ${BASIC_INVERTED_COLORS[color]};\n    background-color: ${BASIC_INVERTED_COLORS[color]};\n    color: white;\n  `}\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/button-base.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { ButtonBase } from './button-base'\n\ntest('type attribute 기본값은 button 입니다.', () => {\n  render(<ButtonBase>Default</ButtonBase>)\n\n  expect(screen.getByText('Default')).toHaveAttribute('type', 'button')\n})\n\ntest('type attribute를 변경할 수 있습니다.', () => {\n  render(\n    <>\n      <ButtonBase type=\"button\">Button</ButtonBase>\n      <ButtonBase type=\"submit\">Submit</ButtonBase>\n      <ButtonBase type=\"reset\">Reset</ButtonBase>\n    </>,\n  )\n\n  expect(screen.getByText('Button')).toHaveAttribute('type', 'button')\n  expect(screen.getByText('Submit')).toHaveAttribute('type', 'submit')\n  expect(screen.getByText('Reset')).toHaveAttribute('type', 'reset')\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/button-base.tsx",
    "content": "import { ButtonHTMLAttributes, PropsWithChildren } from 'react'\nimport { styled, css } from 'styled-components'\nimport { Property } from 'csstype'\nimport type { Theme } from '@titicaca/tds-theme'\n\nimport { GetGlobalColor, MarginPadding } from '../../commons'\nimport { unit } from '../../utils/unit'\nimport { marginMixin, MarginMixinProps } from '../../mixins'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nimport { ButtonSize } from './types'\n\nconst SIZES: Record<ButtonSize, ReturnType<typeof css>> = {\n  tiny: css`\n    font-size: 13px;\n    line-height: 16px;\n  `,\n  small: css`\n    font-size: 14px;\n    line-height: 17px;\n  `,\n  large: css`\n    font-size: 16px;\n  `,\n}\n\nexport interface ButtonBaseMixinProps extends MarginMixinProps {\n  /**\n   * Basic 및 Normal 버튼에서는 항상 `true` 입니다.\n   */\n  bold?: boolean\n  floated?: Property.Float\n  /**\n   * 버튼이 전체 너비 영역을 차지합니다.\n   */\n  fluid?: boolean\n  /**\n   * 버튼 사이즈\n   */\n  size?: ButtonSize\n  margin?: MarginPadding\n  lineHeight?: number | string\n  textAlpha?: number\n  textColor?: keyof Theme['colors']\n}\n\nexport type ButtonBaseProps = ButtonBaseMixinProps &\n  PropsWithChildren &\n  ButtonHTMLAttributes<HTMLButtonElement>\n\nexport const buttonBaseMixin = ({\n  bold,\n  floated = 'none',\n  fluid,\n  size = 'tiny',\n  lineHeight,\n  textAlpha = 1,\n  textColor = 'gray',\n  margin,\n}: ButtonBaseMixinProps) => css`\n  display: inline-block;\n  color: rgba(${GetGlobalColor(textColor)}, ${textAlpha});\n  float: ${floated};\n  font-weight: ${bold ? 'bold' : 500};\n  text-align: center;\n\n  ${lineHeight &&\n  css`\n    line-height: ${unit(lineHeight)};\n  `};\n\n  ${fluid &&\n  css`\n    width: 100%;\n    display: block;\n  `};\n\n  ${marginMixin({ margin })}\n\n  ${SIZES[size]}\n\n  &:disabled {\n    opacity: 0.3;\n  }\n`\n\nexport const ButtonBase = styled.button\n  .withConfig({ shouldForwardProp })\n  .attrs((props) => ({\n    /* stylelint-disable-next-line property-no-unknown */\n    type: props.type ?? 'button',\n  }))<ButtonBaseMixinProps>(buttonBaseMixin)\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/button-container.ts",
    "content": "import { styled, css } from 'styled-components'\nimport * as CSS from 'csstype'\n\nimport { Container } from '../container'\n\nimport { ButtonBase } from './button-base'\n\nexport interface ButtonContainerProps {\n  floated?: CSS.Property.Float\n}\n\nexport const ButtonContainer = styled(Container)<ButtonContainerProps>`\n  text-align: center;\n\n  ${ButtonBase} {\n    float: ${({ floated }) => floated || 'none'};\n    display: inline-block;\n    margin: 0 5px;\n\n    &:first-child {\n      ${({ floated }) => {\n        if (floated === 'left') {\n          return css`\n            margin-left: 0;\n            margin-right: 5px;\n          `\n        } else if (floated === 'right') {\n          return css`\n            margin-left: 5px;\n            margin-right: 0;\n          `\n        }\n      }};\n    }\n  }\n\n  &::after {\n    content: '';\n    display: block;\n    clear: both;\n  }\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/button-group.ts",
    "content": "import { styled } from 'styled-components'\n\nimport { Container } from '../container'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nimport { ButtonBase } from './button-base'\n\nexport interface ButtonGroupProps {\n  horizontalGap?: number\n  buttonCount?: number\n}\n\nexport const ButtonGroup = styled(Container).withConfig({\n  shouldForwardProp,\n})<ButtonGroupProps>`\n  width: 100%;\n  display: flex;\n  align-items: center;\n  gap: ${({ horizontalGap = 0 }) => horizontalGap}px;\n\n  ${ButtonBase} {\n    flex: 1;\n  }\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/button-icon.tsx",
    "content": "import { styled, css } from 'styled-components'\n\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\ntype ButtonIconSize = 'tiny' | 'small'\n\nconst BUTTON_ICON_STYLES: Record<ButtonIconSize, ReturnType<typeof css>> = {\n  tiny: css`\n    width: 15px;\n    height: 12px;\n    background-size: 15px 12px;\n    margin: 2px 5px 0 0;\n  `,\n  small: css`\n    width: 16px;\n    height: 16px;\n    background-size: 16px 16px;\n    margin: 0 3px 0 -3px;\n  `,\n}\n\nexport interface ButtonIconProps {\n  size?: ButtonIconSize\n  src?: string\n}\n\nexport const ButtonIcon = styled.div.withConfig({\n  shouldForwardProp,\n})<ButtonIconProps>`\n  display: inline-block;\n  ${({ size = 'tiny' }) => BUTTON_ICON_STYLES[size]};\n  vertical-align: text-top;\n  background-image: url(${({ src }) => src});\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/button.stories.tsx",
    "content": "import type { Meta, StoryFn, StoryObj } from '@storybook/react'\n\nimport { Button } from './button'\nimport { ButtonContainer } from './button-container'\nimport { ButtonGroup } from './button-group'\nimport { ButtonIcon } from './button-icon'\n\nconst meta: Meta<typeof Button> = {\n  title: 'tds-ui (Form) / Button',\n  component: Button,\n}\n\nexport default meta\n\nexport const Normal: StoryObj<typeof Button> = {\n  args: {\n    children: 'Normal',\n  },\n}\n\nexport const Basic: StoryObj<typeof Button> = {\n  args: {\n    children: 'Basic',\n    basic: true,\n  },\n}\n\nexport const Disabled: StoryFn<typeof Button> = () => {\n  return (\n    <>\n      <Button disabled>Normal</Button>\n      <Button basic disabled>\n        Basic\n      </Button>\n    </>\n  )\n}\n\nexport const Compact: StoryFn<typeof Button> = () => {\n  return (\n    <>\n      <Button compact>Normal</Button>\n      <Button basic compact>\n        Basic\n      </Button>\n    </>\n  )\n}\n\nexport const Size: StoryFn<typeof Button> = () => {\n  return (\n    <>\n      <Button size=\"tiny\">Tiny</Button>\n      <Button size=\"small\">Small</Button>\n      <Button size=\"large\">Large</Button>\n    </>\n  )\n}\n\nexport const Fluid: StoryFn<typeof Button> = () => {\n  return (\n    <>\n      <Button fluid>Normal</Button>\n      <Button basic fluid>\n        Basic\n      </Button>\n    </>\n  )\n}\n\nexport const Icon: StoryFn<typeof Button> = () => {\n  return (\n    <>\n      <Button>\n        <ButtonIcon\n          src=\"https://assets.triple-dev.titicaca-corp.com/images/save@4x.png\"\n          size=\"tiny\"\n        />\n        Tiny\n      </Button>\n      <Button basic>\n        <ButtonIcon\n          src=\"https://triple-dev.titicaca-corp.com/content/static/images/index@4x.png\"\n          size=\"small\"\n        />\n        Small\n      </Button>\n    </>\n  )\n}\n\nexport const BlockIcons: StoryFn<typeof Button> = () => {\n  return (\n    <>\n      <Button icon=\"saveEmpty\">saveEmpty</Button>\n      <Button icon=\"saveFilled\">saveFilled</Button>\n      <Button icon=\"starEmpty\">starEmpty</Button>\n      <Button icon=\"starFilled\">starFilled</Button>\n      <Button icon=\"map\">map</Button>\n      <Button icon=\"share\">share</Button>\n      <Button icon=\"schedule\">schedule</Button>\n    </>\n  )\n}\n\nexport const WithButtonGroup: StoryObj<typeof ButtonGroup> = {\n  render: (args) => {\n    return (\n      <ButtonGroup {...args}>\n        <Button basic color=\"gray\" size=\"small\">\n          현지에서 길묻기\n        </Button>\n        <Button basic inverted color=\"blue\" size=\"small\">\n          길찾기\n        </Button>\n      </ButtonGroup>\n    )\n  },\n\n  args: {\n    horizontalGap: 10,\n    buttonCount: 2,\n  },\n}\n\nexport const WithIconButtonGroup: StoryObj<typeof ButtonGroup> = {\n  render: (args) => {\n    return (\n      <ButtonGroup {...args}>\n        <Button icon=\"saveEmpty\">저장하기</Button>\n        <Button icon=\"schedule\">일정추가</Button>\n        <Button icon=\"starEmpty\">리뷰쓰기</Button>\n        <Button icon=\"share\">공유하기</Button>\n      </ButtonGroup>\n    )\n  },\n\n  args: {\n    horizontalGap: 22,\n  },\n}\n\nexport const WithButtonContainer: StoryObj<typeof ButtonContainer> = {\n  render: (args) => {\n    return (\n      <ButtonContainer {...args}>\n        <Button basic color=\"gray\" size=\"small\">\n          버튼 1\n        </Button>\n        <Button basic inverted color=\"blue\" size=\"small\">\n          버튼 2\n        </Button>\n      </ButtonContainer>\n    )\n  },\n\n  args: {\n    floated: 'none',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/button.tsx",
    "content": "import { ButtonHTMLAttributes, PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\n\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nimport { basicButtonMixin, BasicButtonOwnProps } from './basic-button'\nimport { ButtonBase } from './button-base'\nimport { iconButtonMixin, IconButtonMixinProps } from './icon-button'\nimport { normalButtonMixin, NormalButtonMixinProps } from './normal-button'\n\nexport interface ButtonOwnProps\n  extends BasicButtonOwnProps,\n    Omit<IconButtonMixinProps, 'icon'>,\n    NormalButtonMixinProps {\n  /**\n   * Basic 유형 버튼을 사용합니다.\n   */\n  basic?: boolean\n  /**\n   * Block Icon 유형 버튼을 사용합니다.\n   */\n  icon?: IconButtonMixinProps['icon']\n}\n\nexport type ButtonProps = ButtonOwnProps &\n  PropsWithChildren &\n  ButtonHTMLAttributes<HTMLButtonElement>\n\nexport const Button = styled(ButtonBase).withConfig({\n  shouldForwardProp,\n})<ButtonProps>((props) => {\n  if (props.basic) {\n    return basicButtonMixin({\n      ...props,\n      bold: true,\n      size: props.size || 'small',\n      textAlpha: props.textAlpha || 0.5,\n      textColor: props.textColor || 'gray',\n    })\n  }\n\n  if (props.icon) {\n    return iconButtonMixin({\n      ...props,\n      icon: props.icon,\n      size: props.size || 'tiny',\n      textAlpha: props.textAlpha || 0.5,\n      textColor: props.textColor || 'gray',\n    })\n  }\n\n  return normalButtonMixin({\n    ...props,\n    bold: true,\n    borderRadius: props.borderRadius ?? 21,\n    size: props.size || 'tiny',\n    textAlpha: props.textAlpha,\n    textColor: props.textColor || 'white',\n  })\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/icon-button.tsx",
    "content": "import { css } from 'styled-components'\n\nimport { buttonBaseMixin, ButtonBaseMixinProps } from './button-base'\nimport { ButtonSize } from './types'\n\nconst ICON_BUTTON_URLS = {\n  saveEmpty: 'https://assets.triple.guide/images/btn-end-save-off@4x.png',\n  saveFilled: 'https://assets.triple.guide/images/btn-end-save-on@4x.png',\n  starEmpty: 'https://assets.triple.guide/images/btn-end-review@4x.png',\n  starFilled: 'https://assets.triple.guide/images/btn-end-review-on@4x.png',\n  map: 'https://assets.triple.guide/images/btn-end-search-place@2x.png',\n  share: 'https://assets.triple.guide/images/btn-com-share@3x.png',\n  schedule: 'https://assets.triple.guide/images/btn-end-schedule@4x.png',\n} as const\n\nexport type Icon = keyof typeof ICON_BUTTON_URLS\n\nconst ICON_PADDINGS: Partial<Record<ButtonSize, ReturnType<typeof css>>> = {\n  tiny: css({ padding: '12px' }),\n}\n\nexport interface IconButtonMixinProps extends ButtonBaseMixinProps {\n  icon: Icon\n}\n\nexport const iconButtonMixin = ({\n  icon,\n  size = 'tiny',\n  ...props\n}: IconButtonMixinProps) => css`\n  ${buttonBaseMixin({ size, ...props })}\n  ${ICON_PADDINGS[size]}\n\n  &::before {\n    content: '';\n    display: block;\n    height: 30px;\n    background-size: 30px;\n    background-position: center;\n    background-repeat: no-repeat;\n    background-image: url('${ICON_BUTTON_URLS[icon]}');\n  }\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/index.ts",
    "content": "export * from './button-base'\nexport * from './button-container'\nexport * from './button-group'\nexport * from './button-icon'\nexport * from './button'\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/normal-button.tsx",
    "content": "import { css } from 'styled-components'\nimport type { Theme } from '@titicaca/tds-theme'\n\nimport { buttonBaseMixin, ButtonBaseMixinProps } from './button-base'\nimport { ButtonSize } from './types'\n\nconst NORMAL_PADDINGS: Partial<Record<ButtonSize, ReturnType<typeof css>>> = {\n  tiny: css({ padding: '13px 25px' }),\n  small: css({ padding: '14px 25px' }),\n  large: css({ padding: '16px 25px' }),\n}\n\nconst COMPACT_NORMAL_PADDINGS: Partial<\n  Record<ButtonSize, ReturnType<typeof css>>\n> = {\n  tiny: css({ padding: '9px 15px' }),\n}\n\nexport interface NormalButtonMixinProps extends ButtonBaseMixinProps {\n  borderRadius?: number\n  /**\n   * Compact 버튼을 사용합니다. Normal 및 Basic 버튼에서만 사용할 수 있습니다.\n   */\n  compact?: boolean\n  color?: keyof Theme['colors']\n}\n\nexport const normalButtonMixin = ({\n  borderRadius,\n  compact,\n  color = 'blue',\n  size = 'tiny',\n  ...props\n}: NormalButtonMixinProps) => css`\n  ${buttonBaseMixin({ size, ...props })}\n  border-radius: ${borderRadius ? `${borderRadius}px` : undefined};\n  background-color: ${({ theme }) => theme.colors[color]};\n  color: #fff;\n\n  ${compact ? COMPACT_NORMAL_PADDINGS[size] : NORMAL_PADDINGS[size]}\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/button/types.ts",
    "content": "export type ButtonSize = 'tiny' | 'small' | 'large'\n"
  },
  {
    "path": "packages/tds-ui/src/components/carousel/carousel-item.tsx",
    "content": "import { MouseEventHandler, PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\nimport { StaticIntersectionObserver } from '@titicaca/intersection-observer'\n\nimport { CarouselSizes } from '../../commons'\n\nconst CAROUSEL_WIDTH_SIZES = {\n  small: '140px',\n  medium: '153px',\n  large: '270px',\n  big: '275px',\n}\n\nconst CAROUSEL_LEFT_SPACING_SIZES = {\n  small: '10px',\n  medium: '10px',\n  large: '15px',\n  big: '10px',\n}\n\nconst Item = styled.li<{ size?: CarouselSizes }>`\n  display: inline-block;\n  position: relative;\n  width: ${({ size }) => CAROUSEL_WIDTH_SIZES[size || 'small']};\n  vertical-align: top;\n  white-space: normal;\n  cursor: pointer;\n\n  &:not(:first-child) {\n    margin-left: ${({ size }) => CAROUSEL_LEFT_SPACING_SIZES[size || 'small']};\n  }\n`\n\nexport function CarouselItem({\n  size,\n  children,\n  threshold,\n  onImpress,\n  onClick,\n  ...props\n}: PropsWithChildren<{\n  size?: CarouselSizes\n  threshold?: number\n  onImpress?: () => void\n  onClick?: MouseEventHandler<HTMLLIElement>\n}>) {\n  if (onImpress) {\n    return (\n      <Item onClick={onClick} size={size} {...props}>\n        <StaticIntersectionObserver\n          threshold={threshold || 0.5}\n          onChange={({ isIntersecting }: { isIntersecting: boolean }) => {\n            if (isIntersecting) {\n              onImpress()\n            }\n          }}\n        >\n          <div>{children}</div>\n        </StaticIntersectionObserver>\n      </Item>\n    )\n  }\n\n  return (\n    <Item onClick={onClick} size={size} {...props}>\n      {children}\n    </Item>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/carousel/carousel.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Carousel } from './carousel'\nimport IMAGES from './mocks/carousel.sample.json'\nimport { CarouselItem } from './carousel-item'\n\nconst meta: Meta<typeof Carousel> = {\n  title: 'tds-ui (Carousel) / Carousel ',\n  component: Carousel,\n  parameters: {\n    docs: {\n      description: {\n        component: 'Only CSS로 작성된 Carousel 컴포넌트 입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Carousel>\n\nexport const Default: Story = {\n  args: {\n    children: (\n      <>\n        {IMAGES.map((image, key) => (\n          <CarouselItem key={key} size=\"big\">\n            <img src={image.sizes.large.url} alt=\"test\" />\n          </CarouselItem>\n        ))}\n      </>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/carousel/carousel.tsx",
    "content": "import { css, styled } from 'styled-components'\nimport { PropsWithChildren, useRef } from 'react'\n\nimport { marginMixin, MarginMixinProps } from '../../mixins'\n\nimport { CarouselItem } from './carousel-item'\n\ninterface CarouselBaseProps extends MarginMixinProps {\n  containerPadding?: { left: number; right: number }\n}\n\nconst CarouselBase = styled.ul<CarouselBaseProps>`\n  padding-bottom: 10px;\n  ${marginMixin}\n  white-space: nowrap;\n  overflow: scroll hidden;\n  -webkit-overflow-scrolling: touch;\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n\n  ${({ containerPadding }) =>\n    containerPadding &&\n    css`\n      li:first-child {\n        margin-left: ${containerPadding.left || 0}px;\n      }\n\n      li:last-child {\n        margin-right: ${containerPadding.right || 0}px;\n      }\n    `};\n`\n\nexport type CarouselProps = PropsWithChildren<CarouselBaseProps>\n\nexport function Carousel({\n  children,\n  margin,\n  containerPadding,\n  ...props\n}: PropsWithChildren<CarouselProps>) {\n  const carouselRef = useRef<HTMLUListElement>(null)\n\n  return (\n    <CarouselBase\n      ref={carouselRef}\n      margin={margin}\n      containerPadding={containerPadding}\n      {...props}\n    >\n      {children}\n    </CarouselBase>\n  )\n}\n\nCarousel.Item = CarouselItem\n"
  },
  {
    "path": "packages/tds-ui/src/components/carousel/index.ts",
    "content": "export * from './carousel'\n"
  },
  {
    "path": "packages/tds-ui/src/components/carousel/mocks/carousel.sample.json",
    "content": "[\n  {\n    \"description\": null,\n    \"id\": \"c9ae4d27-b1b6-4842-9359-f4fd040da65f\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/c9ae4d27-b1b6-4842-9359-f4fd040da65f.jpeg\"\n      },\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/c9ae4d27-b1b6-4842-9359-f4fd040da65f.jpeg\"\n      },\n      \"smallSquare\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/c9ae4d27-b1b6-4842-9359-f4fd040da65f.jpeg\"\n      }\n    },\n    \"sourceUrl\": \"http://blog.naver.com/dowoonu00/220986693726\",\n    \"title\": null\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/jkchoi1021/220961631124\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1f1b8655-9979-414b-9a71-fed9e5355ceb.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1f1b8655-9979-414b-9a71-fed9e5355ceb.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1f1b8655-9979-414b-9a71-fed9e5355ceb.jpeg\"\n      }\n    },\n    \"description\": \"1521년 탐험가 마젤란이 항해 도중 세부에 도착해 세운 십자가로 관광객들과 현지인들의 발길이 끊이지 않는 곳이다.\",\n    \"id\": \"1f1b8655-9979-414b-9a71-fed9e5355ceb\",\n    \"title\": \"마젤란이 남기고 간 십자가\"\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/dowoonu00/220986693726\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/da0ee33e-ffe3-4342-ac64-50fb18944551.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/da0ee33e-ffe3-4342-ac64-50fb18944551.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/da0ee33e-ffe3-4342-ac64-50fb18944551.jpeg\"\n      }\n    },\n    \"description\": \"십자가가 보존되어 있는 팔각정 내부 천장을 올려다보면 당시 세례 의식 장면을 기록해 놓은 그림을 만나볼 수 있다.\",\n    \"id\": \"da0ee33e-ffe3-4342-ac64-50fb18944551\",\n    \"title\": \"세례의 순간을 담은 천장\"\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/rldudal0070/220932259105\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1373942a-30d9-46b3-bce6-08cfa9e407f0.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1373942a-30d9-46b3-bce6-08cfa9e407f0.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1373942a-30d9-46b3-bce6-08cfa9e407f0.jpeg\"\n      }\n    },\n    \"description\": null,\n    \"id\": \"1373942a-30d9-46b3-bce6-08cfa9e407f0\",\n    \"title\": null\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/rldudal0070/220932259105\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc.jpeg\"\n      }\n    },\n    \"description\": null,\n    \"id\": \"1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc\",\n    \"title\": null\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/rldudal0070/220932259105\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/5a9681a1-7f24-4c7f-96fe-33558ba5fd67.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/5a9681a1-7f24-4c7f-96fe-33558ba5fd67.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/5a9681a1-7f24-4c7f-96fe-33558ba5fd67.jpeg\"\n      }\n    },\n    \"description\": null,\n    \"id\": \"5a9681a1-7f24-4c7f-96fe-33558ba5fd67\",\n    \"title\": null\n  }\n]\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox/checkbox-base.tsx",
    "content": "import { forwardRef, InputHTMLAttributes } from 'react'\nimport { styled } from 'styled-components'\n\nimport { visuallyHiddenCss } from '../visually-hidden'\n\ntype CheckboxVariant = 'square' | 'round'\n\nconst CheckboxBaseInput = styled.input({}, visuallyHiddenCss)\n\nconst CheckboxBaseControl = styled.div<{\n  variant: CheckboxVariant\n  checkboxSize: number\n  checkboxColor: string\n}>`\n  position: relative;\n  width: ${({ checkboxSize }) => checkboxSize}px;\n  height: ${({ checkboxSize }) => checkboxSize}px;\n  border: 1px solid var(--color-gray200);\n  border-radius: ${({ variant }) => (variant === 'square' ? '6px' : '50%')};\n  background-color: white;\n\n  ${CheckboxBaseInput}:checked + & {\n    border-color: ${({ checkboxColor }) => checkboxColor};\n    background-color: ${({ checkboxColor }) => checkboxColor};\n  }\n\n  ${CheckboxBaseInput}:focus-visible + & {\n    outline: -webkit-focus-ring-color auto 1px;\n    outline-offset: 2px;\n  }\n`\n\nconst CheckboxBaseSvg = styled.svg`\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  aspect-ratio: 1 / 1;\n  height: 50%;\n`\n\nexport interface CheckboxBaseProps\n  extends InputHTMLAttributes<HTMLInputElement> {\n  variant?: CheckboxVariant\n  checkboxSize?: number\n  checkboxColor?: string\n}\n\nexport const CheckboxBase = forwardRef<HTMLInputElement, CheckboxBaseProps>(\n  function CheckboxBase(\n    {\n      variant = 'square',\n      checkboxSize = 26,\n      checkboxColor = 'var(--color-blue)',\n      ...props\n    },\n    ref,\n  ) {\n    return (\n      <div>\n        <CheckboxBaseInput ref={ref} type=\"checkbox\" {...props} />\n        <CheckboxBaseControl\n          variant={variant}\n          checkboxSize={checkboxSize}\n          checkboxColor={checkboxColor}\n        >\n          <CheckboxBaseSvg\n            viewBox=\"0 0 12 11\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n            focusable={false}\n            aria-hidden\n          >\n            <path\n              d=\"M1 5L5 9L11 2.5\"\n              stroke=\"white\"\n              strokeWidth=\"1.5\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </CheckboxBaseSvg>\n        </CheckboxBaseControl>\n      </div>\n    )\n  },\n)\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox/checkbox.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Checkbox } from './checkbox'\n\nconst meta: Meta<typeof Checkbox> = {\n  title: 'tds-ui (Form) / Checkbox ',\n  component: Checkbox,\n  args: {\n    variant: 'square',\n  },\n  argTypes: {\n    name: { type: 'string' },\n    variant: {\n      control: 'radio',\n      options: ['square', 'round'],\n    },\n    checkboxSize: {\n      control: 'number',\n      description: '체크박스의 크기를 지정합니다.',\n      value: 26,\n    },\n    checked: { type: 'boolean' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자가 수많은 선택 사항에서 여러 개를 선택할 수 있도록 제공하는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Checkbox>\n\nexport const Square: Story = {\n  args: { name: 'checkbox' },\n}\n\nexport const SquareChecked: Story = {\n  args: {\n    name: 'checkbox',\n    checked: true,\n  },\n}\n\nexport const Round: Story = {\n  args: {\n    name: 'checkbox',\n    variant: 'round',\n  },\n}\n\nexport const RoundChecked: Story = {\n  args: {\n    name: 'checkbox',\n    variant: 'round',\n    checked: true,\n  },\n}\n\nexport const WithText: Story = {\n  args: {\n    name: 'checkbox',\n    variant: 'square',\n    checked: true,\n    children: '개인정보 동의',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox/checkbox.tsx",
    "content": "import {\n  ChangeEventHandler,\n  forwardRef,\n  PropsWithChildren,\n  useContext,\n} from 'react'\nimport { styled } from 'styled-components'\n\nimport { Text } from '../text'\nimport { CheckboxGroupContext } from '../checkbox-group'\n\nimport { CheckboxBase, CheckboxBaseProps } from './checkbox-base'\n\nconst CheckboxLabel = styled.label`\n  display: flex;\n  cursor: pointer;\n  align-items: center;\n  margin-bottom: 20px;\n\n  &:last-child {\n    margin-bottom: 0;\n  }\n`\n\nconst CheckboxText = styled(Text)`\n  flex: 1;\n`\n\nexport interface CheckboxProps extends CheckboxBaseProps, PropsWithChildren {}\n\nexport const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(\n  function Checkbox(\n    {\n      children,\n      variant = 'square',\n      checkboxSize,\n      checkboxColor,\n      name,\n      checked,\n      value,\n      onChange,\n      ...props\n    },\n    ref,\n  ) {\n    const group = useContext(CheckboxGroupContext)\n\n    const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {\n      if (group) {\n        const nextValue = event.target.checked\n          ? group.value.concat(event.target.value)\n          : group.value.filter((value) => event.target.value !== value)\n        group?.onChange?.(nextValue)\n      } else {\n        onChange?.(event)\n      }\n    }\n\n    return (\n      <CheckboxLabel>\n        <CheckboxText>{children}</CheckboxText>\n        <CheckboxBase\n          {...props}\n          ref={ref}\n          variant={variant}\n          checkboxSize={checkboxSize}\n          checkboxColor={checkboxColor}\n          name={name ?? group?.name}\n          checked={\n            checked ??\n            (value ? group?.value?.includes(value.toString()) : undefined)\n          }\n          value={value}\n          aria-errormessage={group?.errorId}\n          aria-invalid={group?.isError}\n          aria-required={group?.isRequired}\n          onChange={handleChange}\n        />\n      </CheckboxLabel>\n    )\n  },\n)\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox/index.ts",
    "content": "export * from './checkbox-base'\nexport * from './checkbox'\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox-group/checkbox-group-context.tsx",
    "content": "import { createContext } from 'react'\n\nexport interface CheckboxGroupContextValue {\n  descriptionId: string\n  errorId: string\n  isDisabled: boolean\n  isError: boolean\n  isFocused: boolean\n  isRequired: boolean\n  name?: string\n  value: string[]\n  onChange?: (value: string[]) => void\n}\n\nexport const CheckboxGroupContext = createContext<\n  CheckboxGroupContextValue | undefined\n>(undefined)\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox-group/checkbox-group-error.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { Container } from '../container'\nimport { Text } from '../text'\n\nimport { useCheckboxGroup } from './use-checkbox-group'\n\nexport type CheckboxGroupErrorProps = PropsWithChildren\n\nexport const CheckboxGroupError = ({ children }: CheckboxGroupErrorProps) => {\n  const checkboxGroup = useCheckboxGroup()\n\n  return (\n    <Container css={{ padding: '6px 0 0' }}>\n      <Text color=\"red\" size=\"tiny\" id={checkboxGroup.errorId}>\n        {children}\n      </Text>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox-group/checkbox-group-help.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { Container } from '../container'\nimport { Text } from '../text'\n\nimport { useCheckboxGroup } from './use-checkbox-group'\n\nexport type CheckboxGroupHelpProps = PropsWithChildren\n\nexport const CheckboxGroupHelp = ({ children }: CheckboxGroupHelpProps) => {\n  const checkboxGroup = useCheckboxGroup()\n\n  return (\n    <Container\n      css={{\n        padding: '6px 0 0',\n      }}\n    >\n      <Text alpha={0.5} size=\"tiny\" id={checkboxGroup.descriptionId}>\n        {children}\n      </Text>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox-group/checkbox-group-label.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled, css } from 'styled-components'\n\nimport { Text } from '../text'\n\nimport { useCheckboxGroup } from './use-checkbox-group'\n\ninterface LabelProps {\n  isError: boolean\n  isRequired: boolean\n  isFocused: boolean\n}\n\nconst Label = styled(Text)<LabelProps>`\n  margin-bottom: 6px;\n\n  ${({ isFocused }) =>\n    isFocused &&\n    css`\n      color: var(--color-blue);\n    `}\n\n  ${({ isError }) =>\n    isError &&\n    css`\n      color: var(--color-red);\n    `}\n\n  ${({ isRequired }) =>\n    isRequired &&\n    css`\n      &::after {\n        content: ${isRequired ? \"'*'\" : undefined};\n        display: inline;\n        color: var(--color-mediumRed);\n        font-weight: normal;\n        margin-left: 4px;\n      }\n    `}\n`\n\nexport type CheckboxGroupLabelProps = PropsWithChildren\n\nexport const CheckboxGroupLabel = ({ children }: CheckboxGroupLabelProps) => {\n  const checkboxGroup = useCheckboxGroup()\n\n  return (\n    <Label\n      as=\"legend\"\n      size=\"tiny\"\n      isError={checkboxGroup.isError}\n      isRequired={checkboxGroup.isRequired}\n      isFocused={checkboxGroup.isFocused}\n    >\n      {children}\n    </Label>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox-group/checkbox-group.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Checkbox } from '../checkbox'\n\nimport { CheckboxGroup } from './checkbox-group'\n\nconst meta: Meta<typeof CheckboxGroup> = {\n  title: 'tds-ui (Form) / CheckboxGroup',\n  component: CheckboxGroup,\n  args: {\n    value: [],\n    disabled: false,\n    required: false,\n    children: (\n      <>\n        <Checkbox value=\"a\">Option A</Checkbox>\n        <Checkbox value=\"b\">Option B</Checkbox>\n        <Checkbox value=\"c\">Option C</Checkbox>\n      </>\n    ),\n  },\n  argTypes: {\n    name: { type: 'string' },\n    disabled: { type: 'boolean' },\n    required: { type: 'boolean' },\n    label: { type: 'string' },\n    error: { if: { arg: 'help', truthy: false }, type: 'string' },\n    help: { if: { arg: 'error', truthy: false }, type: 'string' },\n    children: {\n      table: {\n        disable: true,\n      },\n    },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: 'Checkbox를 그룹화하는 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof CheckboxGroup>\n\nexport const Default: Story = {\n  args: {\n    name: 'options',\n    label: 'Label',\n  },\n}\n\nexport const Disabled: Story = {\n  args: {\n    name: 'options',\n    label: 'Label',\n    disabled: true,\n  },\n}\n\nexport const Required: Story = {\n  args: {\n    name: 'options',\n    label: 'Label',\n    required: true,\n  },\n}\n\nexport const WithHelpMessage: Story = {\n  args: {\n    name: 'options',\n    label: 'Label',\n    help: 'Help message',\n    children: (\n      <>\n        <Checkbox value=\"a\">Option A</Checkbox>\n        <Checkbox value=\"b\">Option B</Checkbox>\n        <Checkbox value=\"c\">Option C</Checkbox>\n      </>\n    ),\n  },\n}\n\nexport const WithErrorMessage: Story = {\n  args: {\n    name: 'options',\n    label: 'Label',\n    error: 'Error message',\n    children: (\n      <>\n        <Checkbox value=\"a\">Option A</Checkbox>\n        <Checkbox value=\"b\">Option B</Checkbox>\n        <Checkbox value=\"c\">Option C</Checkbox>\n      </>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox-group/checkbox-group.tsx",
    "content": "import { FocusEventHandler, HTMLAttributes, useId, useState } from 'react'\n\nimport { CheckboxGroupContext } from './checkbox-group-context'\nimport { CheckboxGroupLabel } from './checkbox-group-label'\nimport { CheckboxGroupError } from './checkbox-group-error'\nimport { CheckboxGroupHelp } from './checkbox-group-help'\n\nexport interface CheckboxGroupProps\n  extends Omit<HTMLAttributes<HTMLFieldSetElement>, 'onChange'> {\n  name?: string\n  value?: string[]\n  disabled?: boolean\n  required?: boolean\n  label?: string\n  error?: string\n  help?: string\n  onBlur?: FocusEventHandler\n  onChange?: (value: string[]) => void\n  onFocus?: FocusEventHandler\n}\n\nexport const CheckboxGroup = ({\n  children,\n  name,\n  value = [],\n  disabled = false,\n  required = false,\n  label,\n  error,\n  help,\n  onBlur,\n  onChange,\n  onFocus,\n  ...props\n}: CheckboxGroupProps) => {\n  const descriptionId = useId()\n  const errorId = useId()\n  const [isFocused, setIsFocused] = useState(false)\n\n  const handleBlur: FocusEventHandler = (event) => {\n    setIsFocused(false)\n    onBlur?.(event)\n  }\n\n  const handleFocus: FocusEventHandler = (event) => {\n    setIsFocused(true)\n    onFocus?.(event)\n  }\n\n  const isError = !!error\n\n  return (\n    <CheckboxGroupContext.Provider\n      value={{\n        descriptionId,\n        errorId,\n        isDisabled: disabled,\n        isError,\n        isFocused,\n        isRequired: required,\n        name,\n        value,\n        onChange,\n      }}\n    >\n      <fieldset\n        aria-describedby={descriptionId}\n        onBlur={handleBlur}\n        onFocus={handleFocus}\n        {...props}\n      >\n        {label ? <CheckboxGroupLabel>{label}</CheckboxGroupLabel> : null}\n        <div>{children}</div>\n        {error ? (\n          <CheckboxGroupError>{error}</CheckboxGroupError>\n        ) : help ? (\n          <CheckboxGroupHelp>{help}</CheckboxGroupHelp>\n        ) : null}\n      </fieldset>\n    </CheckboxGroupContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox-group/index.ts",
    "content": "export * from './checkbox-group-context'\nexport * from './checkbox-group'\nexport * from './use-checkbox-group'\n"
  },
  {
    "path": "packages/tds-ui/src/components/checkbox-group/use-checkbox-group.tsx",
    "content": "import { useContext } from 'react'\n\nimport { CheckboxGroupContext } from './checkbox-group-context'\n\nexport function useCheckboxGroup() {\n  const context = useContext(CheckboxGroupContext)\n  if (!context) {\n    throw new Error('CheckboxGroupContext가 없습니다.')\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/confirm/confirm.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Confirm } from './confirm'\n\nconst meta: Meta<typeof Confirm> = {\n  title: 'tds-ui (Form) / Confirm',\n  component: Confirm,\n  args: {\n    cancelText: '취소',\n    confirmText: '확인',\n  },\n  argTypes: {\n    open: { type: 'boolean' },\n    title: { type: 'string' },\n    cancelText: { type: 'string' },\n    confirmText: { type: 'string' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 다음 행동을 결정하도록 도와주는 뷰 컴포넌트입니다.',\n      },\n      story: {\n        inline: false,\n        iframeHeight: 500,\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const Default: StoryObj<typeof Confirm> = {\n  args: {\n    open: true,\n    title: '제목입니다.',\n    children: '본문입니다.',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/confirm/confirm.tsx",
    "content": "import { ReactNode } from 'react'\n\nimport { Modal } from '../modal'\n\nexport interface ConfirmProps {\n  children?: ReactNode\n  title?: string\n  open?: boolean\n  cancelText?: string\n  confirmText?: string\n  confirmTextColor?: string\n  disableConfirm?: boolean\n  onClose?: () => void\n  onCancel?: () => boolean | unknown\n  onConfirm?: () => boolean | unknown\n}\n\nexport const Confirm = ({\n  children,\n  title,\n  open,\n  cancelText = '취소',\n  confirmText = '확인',\n  confirmTextColor = 'blue',\n  disableConfirm = false,\n  onClose,\n  onCancel,\n  onConfirm,\n  ...props\n}: ConfirmProps) => {\n  const handleCancel = () => {\n    onCancel ? !onCancel() && onClose?.() : onClose?.()\n  }\n\n  const handleConfirm = () => {\n    if (disableConfirm) {\n      return\n    }\n\n    onConfirm ? !onConfirm() && onClose?.() : onClose?.()\n  }\n\n  return (\n    <Modal open={open} onClose={onClose}>\n      <Modal.Body {...props}>\n        {title && <Modal.Title>{title}</Modal.Title>}\n        {children && <Modal.Description>{children}</Modal.Description>}\n      </Modal.Body>\n      <Modal.Actions>\n        <Modal.Action color=\"gray\" onClick={handleCancel}>\n          {cancelText}\n        </Modal.Action>\n        <Modal.Action\n          color={confirmTextColor}\n          onClick={handleConfirm}\n          disabled={disableConfirm}\n        >\n          {confirmText}\n        </Modal.Action>\n      </Modal.Actions>\n    </Modal>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/confirm/index.ts",
    "content": "export * from './confirm'\n"
  },
  {
    "path": "packages/tds-ui/src/components/confirm-selector/confirm-selector-base.tsx",
    "content": "import { forwardRef, InputHTMLAttributes } from 'react'\nimport { styled } from 'styled-components'\n\nimport { visuallyHiddenCss } from '../visually-hidden'\n\nconst ConfirmSelectorBaseWrapper = styled.div`\n  display: inline-block;\n`\n\nconst ConfirmSelectorBaseInput = styled.input({}, visuallyHiddenCss)\n\nconst ConfirmSelectorBaseControl = styled.div`\n  display: inline-block;\n  position: relative;\n  width: 22px;\n  height: 22px;\n  border-radius: 11px;\n  background-color: var(--color-gray100);\n\n  ${ConfirmSelectorBaseInput}:checked + & {\n    background-color: var(--color-blue);\n  }\n\n  ${ConfirmSelectorBaseInput}:focus-visible + & {\n    outline: -webkit-focus-ring-color auto 1px;\n    outline-offset: 2px;\n  }\n`\n\nconst ConfirmSelectorBaseSvg = styled.svg`\n  position: absolute;\n  top: 7px;\n  left: 5px;\n`\n\nexport type ConfirmSelectorBaseProps = InputHTMLAttributes<HTMLInputElement>\n\nexport const ConfirmSelectorBase = forwardRef<\n  HTMLInputElement,\n  ConfirmSelectorBaseProps\n>(function ConfirmSelectorBase(props, ref) {\n  return (\n    <ConfirmSelectorBaseWrapper>\n      <ConfirmSelectorBaseInput ref={ref} type=\"checkbox\" {...props} />\n      <ConfirmSelectorBaseControl>\n        <ConfirmSelectorBaseSvg\n          width=\"12\"\n          height=\"9\"\n          viewBox=\"0 0 12 9\"\n          fill=\"none\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          focusable={false}\n          aria-hidden\n        >\n          <path\n            d=\"M1 3.5L5 7.5L11 1\"\n            stroke=\"white\"\n            strokeWidth=\"1.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </ConfirmSelectorBaseSvg>\n      </ConfirmSelectorBaseControl>\n    </ConfirmSelectorBaseWrapper>\n  )\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/confirm-selector/confirm-selector.stories.tsx",
    "content": "import type { StoryObj, Meta } from '@storybook/react'\n\nimport { ConfirmSelector } from './confirm-selector'\n\nconst meta: Meta<typeof ConfirmSelector> = {\n  title: 'tds-ui (Form) / ConfirmSelector',\n  component: ConfirmSelector,\n}\n\nexport default meta\n\nexport const Default: StoryObj<typeof ConfirmSelector> = {\n  args: {\n    children: '이용약관 동의',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/confirm-selector/confirm-selector.tsx",
    "content": "import { forwardRef, PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\n\nimport { Text } from '../text'\n\nimport {\n  ConfirmSelectorBase,\n  ConfirmSelectorBaseProps,\n} from './confirm-selector-base'\n\nconst ConfirmSelectorLabel = styled.label`\n  display: flex;\n  align-items: center;\n  margin-bottom: 20px;\n\n  &:last-child {\n    margin-bottom: 0;\n  }\n`\n\nconst ConfirmSelectorText = styled(Text)`\n  flex: 1;\n`\n\nexport interface ConfirmSelectorProps\n  extends ConfirmSelectorBaseProps,\n    PropsWithChildren {}\n\nexport const ConfirmSelector = forwardRef<\n  HTMLInputElement,\n  ConfirmSelectorProps\n>(function ConfirmSelector({ children, ...props }, ref) {\n  return (\n    <ConfirmSelectorLabel>\n      <ConfirmSelectorText>{children}</ConfirmSelectorText>\n      <ConfirmSelectorBase {...props} ref={ref} />\n    </ConfirmSelectorLabel>\n  )\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/confirm-selector/index.ts",
    "content": "export * from './confirm-selector'\n"
  },
  {
    "path": "packages/tds-ui/src/components/container/container.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Container } from './container'\n\nconst meta: Meta<typeof Container> = {\n  title: 'tds-ui (Layout) / Container',\n  component: Container,\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '레이아웃 구성 시 컴포넌트를 묶거나 스타일을 추가할 때 사용하는 뷰 컴포는넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Container>\n\nexport const Default: Story = {\n  args: { children: 'Basic Container' },\n}\n\nexport const Custom: Story = {\n  args: {\n    borderRadius: 10,\n  },\n  render: (args) => {\n    return (\n      <Container\n        css={{ padding: 50, backgroundColor: 'gray', color: 'white' }}\n        {...args}\n      >\n        Custom CSS Container\n      </Container>\n    )\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/container/container.test.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { render, screen } from '@testing-library/react'\nimport { ThemeProvider } from 'styled-components'\nimport { defaultTheme } from '@titicaca/tds-theme'\n\nimport { Container } from './container'\n\nfunction ThemeWrapper({ children }: PropsWithChildren<unknown>) {\n  return <ThemeProvider theme={defaultTheme}>{children}</ThemeProvider>\n}\n\ntest('should accept style shortcut props', () => {\n  render(\n    <Container\n      position=\"absolute\"\n      display=\"inline-block\"\n      floated=\"none\"\n      backgroundColor=\"white\"\n      css={{\n        textAlign: 'center',\n        whiteSpace: 'pre',\n        userSelect: 'none',\n        cursor: 'pointer',\n      }}\n    >\n      container\n    </Container>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const element = screen.getByText('container')\n\n  expect(element).toHaveStyleRule('position', 'absolute')\n  expect(element).toHaveStyleRule('text-align', 'center')\n  expect(element).toHaveStyleRule('white-space', 'pre')\n  expect(element).toHaveStyleRule('user-select', 'none')\n  expect(element).toHaveStyleRule('display', 'inline-block')\n  expect(element).toHaveStyleRule('cursor', 'pointer')\n  expect(element).toHaveStyleRule('float', 'none')\n  expect(element).toHaveStyleRule('background-color', 'rgba(255,255,255,1)')\n})\n\ntest('should accept spacing props', () => {\n  render(\n    <Container\n      css={{\n        margin: '10px 20px 30px 40px',\n        padding: '50px 60px 70px 80px',\n      }}\n    >\n      container\n    </Container>,\n  )\n\n  const element = screen.getByText('container')\n\n  expect(element).toHaveStyleRule('margin', '10px 20px 30px 40px')\n  expect(element).toHaveStyleRule('padding', '50px 60px 70px 80px')\n})\n\ntest('should accept sizing props', () => {\n  render(\n    <Container\n      css={{\n        width: 10,\n        height: 20,\n        minWidth: 30,\n        minHeight: 40,\n        maxWidth: 50,\n        maxHeight: 60,\n      }}\n    >\n      container\n    </Container>,\n  )\n\n  const element = screen.getByText('container')\n\n  expect(element).toHaveStyleRule('width', '10px')\n  expect(element).toHaveStyleRule('height', '20px')\n  expect(element).toHaveStyleRule('min-width', '30px')\n  expect(element).toHaveStyleRule('min-height', '40px')\n  expect(element).toHaveStyleRule('max-width', '50px')\n  expect(element).toHaveStyleRule('max-height', '60px')\n})\n\ntest('should accept centered mixin', () => {\n  render(<Container centered>container</Container>)\n\n  const element = screen.getByText('container')\n\n  expect(element).toHaveStyleRule('margin-left', 'auto')\n  expect(element).toHaveStyleRule('margin-right', 'auto')\n})\n\ntest('should accept borderRadius mixin', () => {\n  render(<Container borderRadius={10}>container</Container>)\n\n  const element = screen.getByText('container')\n\n  expect(element).toHaveStyleRule('border-radius', '10px')\n})\n\ntest('should accept clearing mixin', () => {\n  render(\n    <Container data-testid=\"test\" clearing>\n      container\n    </Container>,\n  )\n\n  const element = screen.getByText('container')\n\n  expect(element).toHaveStyleRule('content', \"''\", { modifier: '::after' })\n  expect(element).toHaveStyleRule('display', 'block', { modifier: '::after' })\n  expect(element).toHaveStyleRule('clear', 'both', { modifier: '::after' })\n})\n\ntest('should accept horizontalScroll mixin', () => {\n  render(<Container horizontalScroll>container</Container>)\n\n  const element = screen.getByText('container')\n\n  expect(element).toHaveStyleRule('white-space', 'nowrap')\n  expect(element).toHaveStyleRule('overflow', 'auto hidden')\n})\n\ntest('should accept shadow mixin', () => {\n  render(<Container shadow=\"large\">container</Container>)\n\n  const element = screen.getByText('container')\n\n  expect(element).toHaveStyleRule('box-shadow', '0 0 30px 0 rgba(0,0,0,0.1)')\n})\n\ntest('should override style with css prop', () => {\n  render(\n    <Container position=\"absolute\" css={{ position: 'fixed' }}>\n      container\n    </Container>,\n  )\n\n  const element = screen.getByText('container')\n\n  expect(element).toHaveStyleRule('position', 'fixed')\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/container/container.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\nimport { Property } from 'csstype'\nimport type { Theme } from '@titicaca/tds-theme'\n\nimport { BaseSizes } from '../../commons'\nimport {\n  borderRadiusMixin,\n  shadowMixin,\n  centeredMixin,\n  clearingMixin,\n  horizontalScrollMixin,\n} from '../../mixins'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nexport type ContainerProps = PropsWithChildren<{\n  position?: Property.Position\n  display?: Property.Display\n\n  centered?: boolean\n  borderRadius?: number\n  clearing?: boolean\n  floated?: Property.Float\n  horizontalScroll?: boolean\n  shadow?: BaseSizes\n  backgroundColor?: keyof Theme['colors']\n}>\n\n/**\n * 레이아웃 구성 시 컴포넌트를 묶고 스타일을 추가할 때 사용합니다.\n *\n * - 제공된 prop 외의 스타일은 css prop을 사용합니다.\n */\nexport const Container = styled.div.withConfig({\n  shouldForwardProp,\n})<ContainerProps>(\n  (props) => ({\n    boxSizing: 'border-box',\n    position: props.position,\n    display: props.display,\n    float: props.floated ?? 'none',\n    backgroundColor: props.backgroundColor\n      ? props.theme.colors[props.backgroundColor]\n      : undefined,\n  }),\n  centeredMixin,\n  clearingMixin,\n  horizontalScrollMixin,\n  shadowMixin,\n  borderRadiusMixin,\n  (props) => props.css,\n)\n"
  },
  {
    "path": "packages/tds-ui/src/components/container/index.ts",
    "content": "export * from './container'\n"
  },
  {
    "path": "packages/tds-ui/src/components/content-elements/content-elements.tsx",
    "content": "import { styled, css } from 'styled-components'\nimport * as CSS from 'csstype'\n\nimport { List } from '../list'\n\nexport const ImageCarouselElementContainer = styled.div`\n  display: inline-block;\n  vertical-align: top;\n  width: calc(100% - 60px);\n  margin-left: 15px;\n\n  &:first-child {\n    margin-left: 30px;\n  }\n\n  &:last-child {\n    margin-right: 30px;\n  }\n`\n\nexport const ImageBlockElementContainer = styled.div`\n  margin: 0 30px;\n\n  &:not(:first-child) {\n    margin-top: 10px;\n  }\n`\n\nexport const ImageCaption = styled.div`\n  margin-top: 8px;\n  font-size: 13px;\n  font-weight: 500;\n  text-align: center;\n  color: rgba(58, 58, 58, 0.7);\n  white-space: pre-wrap;\n`\n\nexport const ResourceListItem = styled(List.Item)`\n  height: 40px;\n  margin: 20px 0;\n  cursor: pointer;\n`\n\nexport const SquareImage = styled.img<{\n  size?: 'small' | 'medium'\n  borderRadius?: number\n  floated?: CSS.Property.Float\n}>`\n  width: ${({ size = 'medium' }) => ({ small: 40, medium: 140 })[size]}px;\n  height: ${({ size = 'medium' }) => ({ small: 40, medium: 140 })[size]}px;\n  border-radius: ${({ size = 'medium' }) => ({ small: 2, medium: 6 })[size]}px;\n  background-color: #efefef;\n  object-fit: cover;\n\n  ${({ borderRadius }) =>\n    borderRadius &&\n    css`\n      border-radius: ${borderRadius}px;\n    `};\n\n  ${({ floated }) =>\n    floated &&\n    css`\n      float: ${floated};\n    `};\n`\n\nexport const FluidSquareImage = styled.div<{ src?: string }>`\n  position: relative;\n  width: 100%;\n  background-color: #efefef;\n  padding-bottom: 100%;\n  height: 0;\n  background-image: url(${({ src }) => src});\n  background-size: cover;\n  background-position: center center;\n`\n\nexport const SimpleLink = styled.a`\n  font-size: 15px;\n  font-weight: bold;\n  color: #2987f0;\n\n  /* HACK: global-style의 underline 설정보다 우선하도록 수정 */\n  && {\n    text-decoration: underline;\n  }\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/content-elements/index.ts",
    "content": "export * from './content-elements'\n"
  },
  {
    "path": "packages/tds-ui/src/components/drawer/drawer.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Button } from '../button'\nimport { Container } from '../container'\nimport { Text } from '../text'\n\nimport { Drawer } from './drawer'\n\nconst meta: Meta<typeof Drawer> = {\n  title: 'tds-ui (Overlay) / Drawer',\n  component: Drawer,\n  args: {\n    duration: 300,\n    overflow: 'hidden',\n  },\n  argTypes: {\n    active: { type: 'boolean' },\n    duration: { type: 'number' },\n    overflow: {\n      control: 'radio',\n      options: ['visible', 'clip', 'scroll', 'auto', 'hidden'],\n    },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: '하단에서 올라오는 버튼 형식의 뷰 컴포넌트입니다.',\n      },\n      story: {\n        inline: false,\n        iframeHeight: 100,\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Drawer>\n\nexport const Default: Story = {\n  args: {\n    active: true,\n    children: (\n      <Button fluid borderRadius={0}>\n        결제하기\n      </Button>\n    ),\n  },\n}\n\nexport const Custom: Story = {\n  args: {\n    active: true,\n    children: (\n      <Container clearing>\n        <Container floated=\"left\">\n          <Text color=\"blue\" size=\"mini\" margin={{ top: 7, bottom: 4 }}>\n            트리플 클럽가\n          </Text>\n          <Text size=\"large\" bold>\n            50,000원\n          </Text>\n        </Container>\n        <Container floated=\"right\">\n          <Button borderRadius={4}>객실예약</Button>\n        </Container>\n      </Container>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/drawer/drawer.tsx",
    "content": "import { styled } from 'styled-components'\nimport { PropsWithChildren, useEffect } from 'react'\nimport {\n  FloatingPortal,\n  useFloating,\n  useTransitionStatus,\n} from '@floating-ui/react'\n\nimport { FlexBox } from '../flex-box'\n\nconst TRANSITION_DURATION = 300\n\ninterface DrawerContainerProps {\n  overflow?: string\n  duration: number\n}\n\nconst DrawerContainer = styled.div<DrawerContainerProps>`\n  position: fixed;\n  max-width: 768px;\n  width: 100%;\n  bottom: 0;\n  overflow: ${({ overflow }) => overflow || 'hidden'};\n  z-index: 9999;\n  transition: transform ${({ duration }) => duration}ms ease-in-out;\n  transform: translateY(100%);\n\n  &[data-transition='open'] {\n    transform: translateY(0);\n  }\n`\n\nexport interface DrawerProps extends PropsWithChildren {\n  active?: boolean\n  duration?: number\n  overflow?: string\n  onEnter?: () => void\n  onEntered?: () => void\n  onExit?: () => void\n  onExited?: () => void\n}\n\nexport function Drawer({\n  active,\n  duration = TRANSITION_DURATION,\n  overflow,\n  children,\n  onEnter,\n  onEntered,\n  onExit,\n  onExited,\n  ...props\n}: DrawerProps) {\n  const { context, refs } = useFloating({\n    open: active,\n  })\n\n  const { isMounted, status } = useTransitionStatus(context, {\n    duration,\n  })\n\n  useEffect(() => {\n    if (status === 'open') {\n      onEnter?.()\n      const timeout = setTimeout(() => onEntered?.(), TRANSITION_DURATION)\n      return () => clearTimeout(timeout)\n    } else if (status === 'close') {\n      onExit?.()\n      const timeout = setTimeout(() => onExited?.(), TRANSITION_DURATION)\n      return () => clearTimeout(timeout)\n    }\n  }, [onEnter, onEntered, onExit, onExited, status])\n\n  if (!isMounted) {\n    return null\n  }\n\n  return (\n    <FloatingPortal preserveTabOrder={false}>\n      <FlexBox flex justifyContent=\"center\">\n        <DrawerContainer\n          ref={refs.setFloating}\n          duration={duration}\n          overflow={overflow}\n          data-transition={status}\n          {...props}\n        >\n          {children}\n        </DrawerContainer>\n      </FlexBox>\n    </FloatingPortal>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/drawer/index.ts",
    "content": "export * from './drawer'\n"
  },
  {
    "path": "packages/tds-ui/src/components/drawer-button/drawer-button.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { DrawerButton } from './drawer-button'\n\nconst meta: Meta<typeof DrawerButton> = {\n  title: 'tds-ui (Overlay) / DrawerButton',\n  component: DrawerButton,\n  args: {\n    active: false,\n    duration: 300,\n    children: '선택 완료',\n  },\n  argTypes: {\n    active: { type: 'boolean' },\n    disabled: { type: 'boolean' },\n    duration: { type: 'number' },\n  },\n  parameters: {\n    docs: {\n      story: {\n        inline: false,\n        iframeHeight: 100,\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof DrawerButton>\n\nexport const Default: Story = {\n  args: {\n    active: true,\n  },\n}\n\nexport const Disabled: Story = {\n  args: {\n    active: true,\n    disabled: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/drawer-button/drawer-button.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { Drawer, DrawerProps } from '../drawer/drawer'\nimport { Container } from '../container'\nimport { Button, ButtonProps } from '../button'\nimport {\n  paddingMixin,\n  PaddingMixinProps,\n  safeAreaInsetMixin,\n} from '../../mixins'\n\nconst ButtonWithSafeAreaInset = styled(Button)<PaddingMixinProps>`\n  ${paddingMixin}\n  ${safeAreaInsetMixin}\n`\n\nexport type DrawerButtonProps = Omit<DrawerProps, 'overflow'> & ButtonProps\n\nexport function DrawerButton({\n  children,\n  active = false,\n  duration,\n  onEnter,\n  onEntered,\n  onExit,\n  onExited,\n  ...props\n}: DrawerButtonProps) {\n  return (\n    <Drawer\n      active={active}\n      duration={duration}\n      overflow=\"hidden\"\n      onEnter={onEnter}\n      onEntered={onEntered}\n      onExit={onExit}\n      onExited={onExited}\n      {...props}\n    >\n      <Container backgroundColor=\"white\">\n        <ButtonWithSafeAreaInset\n          size=\"large\"\n          borderRadius={0}\n          fluid\n          padding={{ top: 16, right: 25, bottom: 18, left: 25 }}\n        >\n          {children}\n        </ButtonWithSafeAreaInset>\n      </Container>\n    </Drawer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/drawer-button/index.ts",
    "content": "export * from './drawer-button'\n"
  },
  {
    "path": "packages/tds-ui/src/components/fieldset/fieldset-context.tsx",
    "content": "import { createContext } from 'react'\n\nexport interface FieldsetContextValue {\n  isDisabled: boolean\n  isRequired: boolean\n}\n\nexport const FieldsetContext = createContext<FieldsetContextValue | undefined>(\n  undefined,\n)\n"
  },
  {
    "path": "packages/tds-ui/src/components/fieldset/fieldset-legend.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled, css } from 'styled-components'\n\nimport { Text } from '../text'\n\nimport { useFieldset } from './use-fieldset'\n\ninterface LegendProps {\n  isRequired: boolean\n}\n\nconst Legend = styled(Text)<LegendProps>`\n  font-weight: 700;\n  margin-bottom: 16px;\n\n  ${({ isRequired }) =>\n    isRequired &&\n    css`\n      &::after {\n        content: ${isRequired ? \"'*'\" : undefined};\n        display: inline;\n        color: var(--color-mediumRed);\n        font-weight: 500;\n        margin-left: 4px;\n      }\n    `}\n`\n\nexport type FieldsetLegendProps = PropsWithChildren\n\nexport const FieldsetLegend = ({ children }: FieldsetLegendProps) => {\n  const fieldset = useFieldset()\n\n  return (\n    <Legend as=\"legend\" size=\"large\" isRequired={fieldset.isRequired}>\n      {children}\n    </Legend>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/fieldset/fieldset.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Fieldset } from './fieldset'\nimport { FieldsetLegend } from './fieldset-legend'\nimport { useFieldset } from './use-fieldset'\n\nconst CustomInputGroup = () => {\n  const { isRequired } = useFieldset()\n\n  return (\n    <div>\n      <label style={{ display: 'block' }}>\n        Input 1\n        <input required={isRequired} />\n      </label>\n      <label style={{ display: 'block' }}>\n        Input 2\n        <input required={isRequired} />\n      </label>\n    </div>\n  )\n}\n\nconst meta: Meta<typeof Fieldset> = {\n  title: 'tds-ui (Form) / Fieldset',\n  component: Fieldset,\n  args: {\n    isDisabled: false,\n    isRequired: false,\n    children: (\n      <>\n        <FieldsetLegend>Label</FieldsetLegend>\n        <CustomInputGroup />\n      </>\n    ),\n  },\n  argTypes: {\n    isDisabled: { type: 'boolean' },\n    isRequired: { type: 'boolean' },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Fieldset>\n\nexport const Default: Story = {}\n\nexport const Disabled: Story = {\n  args: {\n    isDisabled: true,\n  },\n}\n\nexport const Required: Story = {\n  args: {\n    isRequired: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/fieldset/fieldset.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { FieldsetContext } from './fieldset-context'\n\nexport interface FieldsetProps extends PropsWithChildren {\n  isDisabled?: boolean\n  isRequired?: boolean\n}\n\nexport const Fieldset = ({\n  children,\n  isDisabled = false,\n  isRequired = false,\n}: FieldsetProps) => {\n  return (\n    <FieldsetContext.Provider\n      value={{\n        isDisabled,\n        isRequired,\n      }}\n    >\n      <fieldset disabled={isDisabled}>{children}</fieldset>\n    </FieldsetContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/fieldset/index.ts",
    "content": "export * from './fieldset-context'\nexport * from './fieldset-legend'\nexport * from './fieldset'\nexport * from './use-fieldset'\n"
  },
  {
    "path": "packages/tds-ui/src/components/fieldset/use-fieldset.tsx",
    "content": "import { useContext } from 'react'\n\nimport { FieldsetContext } from './fieldset-context'\n\nexport function useFieldset() {\n  const context = useContext(FieldsetContext)\n  if (!context) {\n    throw new Error('FieldsetContext가 없습니다.')\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/flex-box/flex-box.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { styled } from 'styled-components'\n\nimport { FlexBox, FlexBoxItem } from '../flex-box'\n\nconst meta: Meta<typeof FlexBox> = {\n  title: 'tds-ui (Layout) / FlexBox',\n  component: FlexBox,\n  argTypes: {\n    flex: { type: 'boolean' },\n    flexDirection: {\n      control: 'select',\n      options: ['column', 'column-reverse', 'row', 'row-reverse'],\n    },\n    flexWrap: {\n      control: 'select',\n      options: ['nowrap', 'wrap', 'wrap-reverse'],\n    },\n    justifyContent: {\n      control: 'select',\n      options: [\n        'start',\n        'center',\n        'end',\n        'flex-start',\n        'flex-end',\n        'left',\n        'right',\n        'normal',\n        'space-between',\n        'space-around',\n        'space-evenly',\n        'stretch',\n        'inherit',\n        'initial',\n        'revert',\n        'revert-layer',\n        'unset',\n      ],\n    },\n    alignItems: {\n      control: 'select',\n      options: [\n        'inherit',\n        'initial',\n        'revert',\n        'revert-layer',\n        'unset',\n        'center',\n        'end',\n        'flex-end',\n        'flex-start',\n        'self-end',\n        'self-start',\n        'start',\n        'baseline',\n        'normal',\n        'stretch',\n      ],\n    },\n    gap: { type: 'string' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          'Flex로 레이아웃 구성이 필요할 때 사용하는 뷰 컴포넌트입니다.\\n * FlexBox 는 Container 를 상속받아 구성되어있기 때문에 Container 의 Prop을 그대로 이용 할 수 있습니다.\\n * flex children 요소가 사용 가능한 flex, flexGrow, flexShrink, flexBasis, alignSelf, order는 중첩된 구조의 flex 사용 시에만 사용 권장합니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\nconst Item = styled(FlexBoxItem)`\n  border: 2px solid #e91e63;\n  padding: 10px;\n  border-radius: 10px;\n`\n\ntype Story = StoryObj<typeof FlexBox>\n\nexport const Flex: Story = {\n  args: {\n    flex: true,\n    children: (\n      <>\n        <FlexBoxItem>Item1</FlexBoxItem>\n        <FlexBoxItem>Item2</FlexBoxItem>\n        <FlexBoxItem>Item3</FlexBoxItem>\n      </>\n    ),\n  },\n}\n\nexport const FlexItem: Story = {\n  args: {\n    flex: true,\n    flexDirection: 'row',\n    flexWrap: 'wrap',\n    justifyContent: 'center',\n    alignItems: 'center',\n    alignContent: 'baseline',\n    gap: 'normal',\n    columnGap: 'normal',\n    rowGap: 'normal',\n    children: (\n      <>\n        <FlexBoxItem>\n          <Item>Item1</Item>\n          <Item>Item2</Item>\n          <Item>Item3</Item>\n        </FlexBoxItem>\n        <FlexBoxItem>\n          <Item>Item4</Item>\n          <Item>Item5</Item>\n          <Item>Item6</Item>\n        </FlexBoxItem>\n      </>\n    ),\n  },\n}\n\nexport const Grow: Story = {\n  args: {\n    flex: true,\n    children: (\n      <>\n        <Item flexGrow={1}>Item1</Item>\n        <Item flexGrow={1}>Item2</Item>\n        <Item flexGrow={1}>Item3</Item>\n      </>\n    ),\n  },\n}\n\nexport const Order: Story = {\n  args: {\n    flex: true,\n    children: (\n      <>\n        <Item order={3}>Item1</Item>\n        <Item order={2}>Item2</Item>\n        <Item order={1}>Item3</Item>\n      </>\n    ),\n  },\n}\n\nexport const Shrink: Story = {\n  args: {\n    flex: true,\n    children: (\n      <>\n        <Item flexBasis=\"500px\" flexShrink={1}>\n          Item1\n        </Item>\n        <Item>Item2</Item>\n        <Item>Item3</Item>\n      </>\n    ),\n  },\n}\n\nexport const Direction: Story = {\n  args: {\n    flex: true,\n    flexDirection: 'column',\n    children: (\n      <>\n        <Item>Item1</Item>\n        <Item>Item2</Item>\n        <Item>Item3</Item>\n      </>\n    ),\n  },\n}\n\nexport const Wrap: Story = {\n  args: {\n    flex: true,\n    flexWrap: 'wrap',\n    children: (\n      <>\n        <Item>Item1</Item>\n        <Item>Item2</Item>\n        <Item>Item3</Item>\n      </>\n    ),\n  },\n}\n\nexport const JustifyContent: Story = {\n  args: {\n    flex: true,\n    flexDirection: 'row',\n    justifyContent: 'space-between',\n    children: (\n      <>\n        <Item>Item1</Item>\n        <Item>Item2</Item>\n        <Item>Item3</Item>\n      </>\n    ),\n  },\n}\n\nexport const AlignItems: Story = {\n  args: {\n    flex: true,\n    flexDirection: 'column',\n    alignItems: 'center',\n    children: (\n      <>\n        <Item>Item1</Item>\n        <Item>Item2</Item>\n        <Item>Item3</Item>\n      </>\n    ),\n  },\n}\n\nexport const Gap: Story = {\n  args: {\n    flex: true,\n    gap: '10px',\n    children: (\n      <>\n        <Item>Item1</Item>\n        <Item>Item2</Item>\n        <Item>Item3</Item>\n      </>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/flex-box/flex-box.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { FlexBox } from './flex-box'\n\nit('should accept style shortcut props', () => {\n  render(\n    <FlexBox\n      flex\n      flexGrow={1}\n      flexShrink={1}\n      flexBasis=\"auto\"\n      flexDirection=\"column\"\n      flexWrap=\"wrap\"\n      justifyContent=\"center\"\n      alignItems=\"center\"\n      alignContent=\"center\"\n      alignSelf=\"center\"\n      order={1}\n      gap=\"10px\"\n    >\n      flexbox\n    </FlexBox>,\n  )\n\n  const element = screen.getByText('flexbox')\n\n  expect(element).toHaveStyleRule('display', 'flex')\n  expect(element).toHaveStyleRule('flex-grow', '1')\n  expect(element).toHaveStyleRule('flex-shrink', '1')\n  expect(element).toHaveStyleRule('flex-basis', 'auto')\n  expect(element).toHaveStyleRule('flex-direction', 'column')\n  expect(element).toHaveStyleRule('flex-wrap', 'wrap')\n  expect(element).toHaveStyleRule('justify-content', 'center')\n  expect(element).toHaveStyleRule('align-items', 'center')\n  expect(element).toHaveStyleRule('align-content', 'center')\n  expect(element).toHaveStyleRule('align-self', 'center')\n  expect(element).toHaveStyleRule('order', '1')\n  expect(element).toHaveStyleRule('gap', '10px')\n})\n\nit('should override style with css prop', () => {\n  render(\n    <FlexBox justifyContent=\"center\" css={{ justifyContent: 'flex-end' }}>\n      flexbox\n    </FlexBox>,\n  )\n\n  const element = screen.getByText('flexbox')\n\n  expect(element).toHaveStyleRule('justify-content', 'flex-end')\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/flex-box/flex-box.tsx",
    "content": "import { styled } from 'styled-components'\nimport { Property } from 'csstype'\nimport { HTMLAttributes } from 'react'\n\nimport { Container, ContainerProps } from '../container'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nexport interface FlexItemOwnProps extends ContainerProps {\n  /**\n   * boolean\n   */\n  flex?: Property.Flex\n  /**\n   * flexGrow 는 컨테이너 요소 내부에서 할당 가능한 공간의 정도를 선언합니다.\n   */\n  flexGrow?: Property.FlexGrow\n  /**\n  * 컨테이너에 속한 아이템 크기가 컨테이너 보다 클 때 flexShrink 를 이용하면\n        값에 따라 컨테이너에 맞게 축소됩니다.\n  * */\n  flexShrink?: Property.FlexShrink\n  flexBasis?: Property.FlexBasis\n  alignSelf?: Property.AlignSelf\n  /**\n   * order를 이용하여 컴포넌트 순서를 조절 할 수 있습니다.\n   * */\n  order?: Property.Order\n}\n\nexport type FlexItemProps = FlexItemOwnProps & HTMLAttributes<Element>\n\nexport const FlexBoxItem = styled(Container).withConfig({\n  shouldForwardProp,\n})<FlexItemProps>((props) => ({\n  flex: props.flex,\n  flexGrow: props.flexGrow,\n  flexShrink: props.flexShrink,\n  flexBasis: props.flexBasis,\n  alignSelf: props.alignSelf,\n  order: props.order,\n}))\n\nexport interface FlexBoxProps\n  extends Omit<FlexItemOwnProps, 'flex'>,\n    HTMLAttributes<Element> {\n  /**\n   * boolean\n   */\n  flex?: boolean\n  /**\n   * 아이템을 배치할 때 사용할 주축 및 방향(정방향, 역방향)을 지정합니다.\n   * */\n  flexDirection?: Property.FlexDirection\n  /**\n  * 컨테이너 내부의 아이템들을 강제로 한줄에 배치되게 할 것인지, 또는 가능한\n        영역 내에서 벗어나지 않고 여러행으로 나누어 표현 할 것인지 결정하는\n        속성입니다.\n  * */\n  flexWrap?: Property.FlexWrap\n  /**\n   * JustifyContent 는 주축 정렬을 제어합니다.\n   * */\n  justifyContent?: Property.JustifyContent\n  /**\n   * align-items 는 교차축 정렬을 제어합니다.\n   * */\n  alignItems?: Property.AlignItems\n  alignContent?: Property.AlignContent\n  /**\n   * Gap은 행과 열 사이의 간격(거터)을 설정합니다.\n   * */\n  gap?: Property.Gap\n  columnGap?: Property.ColumnGap\n  rowGap?: Property.RowGap\n}\n\n/**\n * flex 속성을 추가하면 display: flex 가 적용됩니다.\n * FlexBox 는 Container 를 상속받아 구성되어있기 때문에 Container 의 Prop\n * 을 그대로 이용 할 수 있습니다.\n *\n * flex children 요소가 사용 가능한 flex, flexGrow, flexShrink, flexBasis,\n * alignSelf, order는 중첩된 구조의 flex 사용 시에만 사용 권장합니다.\n */\nexport const FlexBox = styled(Container).withConfig({\n  shouldForwardProp,\n})<FlexBoxProps>((props) => ({\n  display: props.flex ? 'flex' : undefined,\n  flexDirection: props.flexDirection,\n  flexWrap: props.flexWrap,\n  justifyContent: props.justifyContent,\n  alignItems: props.alignItems,\n  alignContent: props.alignContent,\n  gap: props.gap,\n  columnGap: props.columnGap,\n  rowGap: props.rowGap,\n  flexGrow: props.flexGrow,\n  flexShrink: props.flexShrink,\n  flexBasis: props.flexBasis,\n  alignSelf: props.alignSelf,\n  order: props.order,\n}))\n"
  },
  {
    "path": "packages/tds-ui/src/components/flex-box/index.ts",
    "content": "export * from './flex-box'\n"
  },
  {
    "path": "packages/tds-ui/src/components/form-field/form-field-context.tsx",
    "content": "import { createContext, FocusEventHandler, useContext } from 'react'\n\nexport interface FormFieldContextValue {\n  inputId: string\n  labelId: string\n  descriptionId: string\n  errorId: string\n  isError: boolean\n  isDisabled: boolean\n  isRequired: boolean\n  isFocused: boolean\n  handleBlur: FocusEventHandler\n  handleFocus: FocusEventHandler\n}\n\nexport const FormFieldContext = createContext<\n  FormFieldContextValue | undefined\n>(undefined)\n\nexport function useFormField() {\n  const context = useContext(FormFieldContext)\n  if (!context) {\n    throw new Error('FormFieldContext가 없습니다.')\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/form-field/form-field-error.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { Container } from '../container'\nimport { Text } from '../text'\n\nimport { useFormField } from './form-field-context'\n\nexport type FormFieldErrorProps = PropsWithChildren\n\nexport const FormFieldError = ({ children }: FormFieldErrorProps) => {\n  const formField = useFormField()\n\n  return (\n    <Container css={{ padding: '6px 0 0' }}>\n      <Text color=\"mediumRed\" size=\"tiny\" id={formField.errorId}>\n        {children}\n      </Text>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/form-field/form-field-help.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { Container } from '../container'\nimport { Text } from '../text'\n\nimport { useFormField } from './form-field-context'\n\nexport type FormFieldHelpProps = PropsWithChildren\n\nexport const FormFieldHelp = ({ children }: FormFieldHelpProps) => {\n  const formField = useFormField()\n\n  return (\n    <Container\n      css={{\n        padding: '6px 0 0',\n      }}\n    >\n      <Text alpha={0.5} size=\"tiny\" id={formField.descriptionId}>\n        {children}\n      </Text>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/form-field/form-field-label.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled, css } from 'styled-components'\n\nimport { Container } from '../container'\nimport { Text } from '../text'\n\nimport { useFormField } from './form-field-context'\n\ninterface LabelProps {\n  isError: boolean\n  isRequired: boolean\n  isFocused: boolean\n}\n\nconst Label = styled(Text)<LabelProps>`\n  display: inline-block;\n  margin-bottom: 6px;\n\n  ${({ isFocused }) =>\n    isFocused &&\n    css`\n      color: var(--color-blue);\n    `}\n\n  ${({ isError }) =>\n    isError &&\n    css`\n      color: var(--color-mediumRed);\n    `}\n\n  ${({ isRequired }) =>\n    isRequired &&\n    css`\n      &::after {\n        content: ${isRequired ? \"'*'\" : undefined};\n        display: inline;\n        color: var(--color-mediumRed);\n        font-weight: normal;\n        margin-left: 4px;\n      }\n    `}\n`\n\nexport type FormFieldLabelProps = PropsWithChildren\n\nexport const FormFieldLabel = ({ children }: FormFieldLabelProps) => {\n  const formField = useFormField()\n\n  return (\n    <Container>\n      <Label\n        as=\"label\"\n        size=\"tiny\"\n        htmlFor={formField.inputId}\n        isError={formField.isError}\n        isRequired={formField.isRequired}\n        isFocused={formField.isFocused}\n      >\n        {children}\n      </Label>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/form-field/form-field.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { FormField } from './form-field'\nimport { useFormField } from './form-field-context'\nimport { FormFieldError } from './form-field-error'\nimport { FormFieldHelp } from './form-field-help'\nimport { FormFieldLabel } from './form-field-label'\n\nconst meta: Meta<typeof FormField> = {\n  title: 'tds-ui (Form) / FormField',\n  component: FormField,\n  args: {\n    isError: false,\n    isDisabled: false,\n    isRequired: false,\n  },\n  argTypes: {\n    isError: { type: 'boolean' },\n    isDisabled: { type: 'boolean' },\n    isRequired: { type: 'boolean' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: 'Form을 구성할 때 사용되는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\nconst CustomInput = () => {\n  const {\n    inputId,\n    descriptionId,\n    errorId,\n    handleBlur,\n    handleFocus,\n    isDisabled,\n    isError,\n    isRequired,\n  } = useFormField()\n\n  return (\n    <input\n      id={inputId}\n      value=\"FormField 테스트\"\n      disabled={isDisabled}\n      required={isRequired}\n      aria-invalid={isError}\n      aria-describedby={descriptionId}\n      aria-errormessage={errorId}\n      onBlur={handleBlur}\n      onFocus={handleFocus}\n    />\n  )\n}\n\ntype Story = StoryObj<typeof FormField>\n\nexport const Default: Story = {\n  args: {\n    children: (\n      <>\n        <FormFieldLabel>Label</FormFieldLabel>\n        <CustomInput />\n      </>\n    ),\n  },\n}\n\nexport const Required: Story = {\n  args: {\n    isRequired: true,\n    children: (\n      <>\n        <FormFieldLabel>Label</FormFieldLabel>\n        <CustomInput />\n      </>\n    ),\n  },\n}\n\nexport const WithHelpMessage: Story = {\n  args: {\n    children: (\n      <>\n        <FormFieldLabel>Label</FormFieldLabel>\n        <CustomInput />\n        <FormFieldHelp>Helper text.</FormFieldHelp>\n      </>\n    ),\n  },\n}\n\nexport const WithErrorMessage: Story = {\n  args: {\n    isError: true,\n    children: (\n      <>\n        <FormFieldLabel>Label</FormFieldLabel>\n        <CustomInput />\n        <FormFieldError>Helper text.</FormFieldError>\n      </>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/form-field/form-field.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { FormFieldContext } from './form-field-context'\nimport {\n  useFormFieldState,\n  UseFormFieldStateParams,\n} from './use-form-field-state'\n\nexport interface FormFieldProps\n  extends UseFormFieldStateParams,\n    PropsWithChildren {\n  isError?: boolean\n  isDisabled?: boolean\n  isRequired?: boolean\n}\n\nexport const FormField = ({\n  children,\n  isError = false,\n  isDisabled = false,\n  isRequired = false,\n  onBlur,\n  onFocus,\n}: FormFieldProps) => {\n  const state = useFormFieldState({ onBlur, onFocus })\n\n  return (\n    <FormFieldContext.Provider\n      value={{\n        ...state,\n        isError,\n        isDisabled,\n        isRequired,\n      }}\n    >\n      {children}\n    </FormFieldContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/form-field/index.ts",
    "content": "export * from './form-field-context'\nexport * from './form-field-error'\nexport * from './form-field-help'\nexport * from './form-field-label'\nexport * from './form-field'\nexport * from './use-form-field-state'\n"
  },
  {
    "path": "packages/tds-ui/src/components/form-field/use-form-field-state.ts",
    "content": "import { FocusEventHandler, useId, useState } from 'react'\n\nexport interface UseFormFieldStateParams {\n  onBlur?: FocusEventHandler\n  onFocus?: FocusEventHandler\n}\n\nexport function useFormFieldState(params: UseFormFieldStateParams) {\n  const inputId = useId()\n  const labelId = useId()\n  const descriptionId = useId()\n  const errorId = useId()\n  const [isFocused, setIsFocused] = useState(false)\n\n  const handleBlur: FocusEventHandler = (event) => {\n    setIsFocused(false)\n    params.onBlur?.(event)\n  }\n\n  const handleFocus: FocusEventHandler = (event) => {\n    setIsFocused(true)\n    params.onFocus?.(event)\n  }\n\n  return {\n    inputId,\n    labelId,\n    descriptionId,\n    errorId,\n    isFocused,\n    handleBlur,\n    handleFocus,\n  }\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/gender-selector/gender-selector-item.tsx",
    "content": "import { ChangeEventHandler, PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\n\nimport { visuallyHiddenCss } from '../visually-hidden'\nimport { useRadioGroup } from '../radio-group'\n\nconst Label = styled.label<{ checked: boolean }>`\n  display: inline-block;\n  position: relative;\n  width: 50%;\n  padding: 15px 0;\n  border: ${({ checked }) =>\n    checked\n      ? `1px solid var(--color-blue) `\n      : `1px solid var(--color-gray100) `};\n  border-radius: 2px;\n  text-align: center;\n  font-size: 16px;\n  color: ${({ checked }) =>\n    checked ? `var(--color-blue) ` : `var(--color-gray300) `};\n\n  &:last-child {\n    border-left: none;\n    border: ${({ checked }) => checked && `1px solid var(--color-blue) `};\n  }\n`\n\nconst Input = styled.input({}, visuallyHiddenCss)\n\nexport interface GenderSelectorItemProps extends PropsWithChildren {\n  value?: string\n  disabled?: boolean\n}\n\nexport const GenderSelectorItem = ({\n  children,\n  value,\n  disabled,\n}: GenderSelectorItemProps) => {\n  const group = useRadioGroup()\n\n  const checked = group.value === value\n\n  const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {\n    group.onChange?.(event.target.value)\n  }\n\n  return (\n    <Label checked={checked}>\n      <Input\n        type=\"radio\"\n        name={group.name}\n        checked={checked}\n        disabled={disabled}\n        value={value}\n        onChange={handleChange}\n      />\n      {children}\n    </Label>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/gender-selector/gender-selector.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { GenderSelector } from './gender-selector'\n\nconst meta: Meta<typeof GenderSelector> = {\n  title: 'tds-ui (Form) / GenderSelector',\n  component: GenderSelector,\n  args: {\n    disabled: false,\n    required: false,\n  },\n  argTypes: {\n    name: { type: 'string' },\n    value: { control: 'radio', options: ['MALE', 'FEMALE'] },\n    disabled: { type: 'boolean' },\n    required: { type: 'boolean' },\n    label: { type: 'string' },\n    help: { type: 'string' },\n    error: { type: 'string' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: '성별을 선택할 때 사용되는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof GenderSelector>\n\nexport const Default: Story = {\n  args: { name: 'gender', value: 'MALE' },\n}\n\nexport const Disabled: Story = {\n  args: {\n    name: 'gender',\n    value: 'MALE',\n    disabled: true,\n  },\n}\n\nexport const Required: Story = {\n  args: { name: 'gender', value: 'MALE', required: true },\n}\n\nexport const WithLabel: Story = {\n  args: {\n    name: 'gender',\n    value: 'MALE',\n    label: '성별 선택',\n  },\n}\n\nexport const WithHelpMessage: Story = {\n  args: {\n    name: 'gender',\n    value: 'MALE',\n    help: '가이드 메시지',\n  },\n}\n\nexport const WithErrorMessage: Story = {\n  args: {\n    name: 'gender',\n    value: 'MALE',\n    error: '에러 메시지',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/gender-selector/gender-selector.tsx",
    "content": "import { RadioGroup, RadioGroupProps } from '../radio-group'\n\nimport { GenderSelectorItem } from './gender-selector-item'\n\nexport type GenderSelectorProps = Omit<RadioGroupProps, 'children'>\n\nexport const GenderSelector = ({ ...props }: GenderSelectorProps) => {\n  return (\n    <RadioGroup {...props}>\n      <GenderSelectorItem disabled={props.disabled} value=\"MALE\">\n        남자\n      </GenderSelectorItem>\n      <GenderSelectorItem disabled={props.disabled} value=\"FEMALE\">\n        여자\n      </GenderSelectorItem>\n    </RadioGroup>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/gender-selector/index.ts",
    "content": "export * from './gender-selector'\n"
  },
  {
    "path": "packages/tds-ui/src/components/hr/hr.ts",
    "content": "import { styled, css } from 'styled-components'\n\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nexport interface HrProps {\n  compact?: boolean\n  color?: string\n}\n\nexport const HR1 = styled.div.withConfig({ shouldForwardProp })<HrProps>`\n  margin: 50px 30px;\n  height: 1px;\n  background-color: ${({ color }) => color || '#efefef'};\n\n  ${({ compact }) =>\n    compact &&\n    css`\n      margin: 0;\n    `};\n`\n\nexport const HR2 = styled.div.withConfig({ shouldForwardProp })<HrProps>`\n  margin: 50px 0;\n  height: 10px;\n  background-color: #efefef;\n\n  ${({ compact }) =>\n    compact &&\n    css`\n      margin: 0;\n    `};\n`\n\nexport const HR3 = styled.div.withConfig({\n  shouldForwardProp,\n})<{ height?: number }>`\n  height: ${({ height }) => height || 10}px;\n  background-color: transparent;\n`\n\nexport const HR4 = styled.div`\n  margin: 40px auto;\n  width: 130px;\n  height: 37px;\n  background-repeat: no-repeat;\n  background-size: 130px 37px;\n  background-image: url('https://assets.triple.guide/images/img-line1@2x.png');\n`\n\nexport const HR5 = styled.div`\n  margin: 40px auto;\n  width: 130px;\n  height: 37px;\n  background-repeat: no-repeat;\n  background-size: 130px 37px;\n  background-image: url('https://assets.triple.guide/images/img-line2@2x.png');\n`\n\nexport const HR6 = styled.div`\n  margin: 40px auto;\n  width: 130px;\n  height: 37px;\n  background-repeat: no-repeat;\n  background-size: 130px 37px;\n  background-image: url('https://assets.triple.guide/images/img-line3@2x.png');\n`\n\nexport const HR7 = styled.div.withConfig({ shouldForwardProp })<HrProps>`\n  margin: 30px auto;\n  ${({ compact }) =>\n    compact &&\n    css`\n      margin: 0;\n    `};\n  width: 100%;\n  border-bottom: dashed 1px #efefef;\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/hr/index.ts",
    "content": "export * from './hr'\n"
  },
  {
    "path": "packages/tds-ui/src/components/icon/icon.ts",
    "content": "import { styled } from 'styled-components'\n\nimport { GlobalSizes, MarginPadding } from '../../commons'\nimport { marginMixin, paddingMixin } from '../../mixins'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\ntype Icons = 'save' | 'web' | 'call' | 'map' | 'arrowRight'\n\nconst URL_BY_NAMES: { [key in Icons]: string } = {\n  save: 'https://assets.triple.guide/images/ico-save@4x.png',\n  web: 'https://assets.triple.guide/images/ico-end-web@4x.png',\n  call: 'https://assets.triple.guide/images/ico-end-call@4x.png',\n  map: 'https://assets.triple.guide/images/ico-end-poi@4x.png',\n  arrowRight: 'https://assets.triple.guide/images/ico-arrow@4x.png',\n}\n\nconst SIZES: Partial<Record<GlobalSizes, string>> = {\n  tiny: '16px',\n  small: '18px',\n  medium: '20px',\n  large: '22px',\n  big: '24px',\n}\n\nexport const Icon = styled.div.withConfig({\n  shouldForwardProp,\n})<{\n  size?: GlobalSizes\n  src?: string\n  name?: Icons\n  padding?: MarginPadding\n  margin?: MarginPadding\n}>`\n  display: inline-block;\n  width: ${({ size }) => SIZES[size || 'small']};\n  height: ${({ size }) => SIZES[size || 'small']};\n  background-image: ${({ src, name }) =>\n    `url(${src || (name ? URL_BY_NAMES[name] : '')}) `};\n  background-size: ${({ size }) =>\n    `${SIZES[size || 'small']} ${SIZES[size || 'small']}`};\n  background-repeat: no-repeat;\n  vertical-align: text-bottom;\n\n  ${marginMixin}\n  ${paddingMixin}\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/icon/index.ts",
    "content": "export * from './icon'\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/README.md",
    "content": "# Core Elements / Image 컴포넌트\n\n> ℹ️이 내용은 triple-frontend-docs에서 mdx로 확인하실 수도 있습니다.\n\n## 개요\n\n이미지를 표시하는 컴포넌트입니다.\n[compound-component 패턴](https://kentcdodds.com/blog/compound-components-with-react-hooks)으로 만들어져 있기 때문에 용도에 맞게 컴포넌트를 조합하여 사용합니다.\ncontext를 만들어주는 컴포넌트, 프레임 관련 컴포넌트, 실제 이미지 태그를 만드는 컴포넌트,\n그리고 각종 추가 정보를 표시하는 컴포넌트로 이루어져있습니다.\n\n## 컴포넌트\n\n### Image\n\n| prop 이름    | 설명                           |\n| ------------ | ------------------------------ |\n| borderRadius | 이미지의 경계 곡률을 정합니다. |\n\n이미지의 컴포넌트 경계를 설정합니다. context provider로 자식을 감쌉니다.\n\n### Image.FixedRatioFrame\n\n| prop 이름 | 설명                                                     |\n| --------- | -------------------------------------------------------- |\n| frame     | (optional) 이미지 비율 preset. `FrameRatioAndSizes` 참조 |\n| floated   | (optional) 이미지 float 값                               |\n| margin    | (optional)                                               |\n| onClick   | (optional)                                               |\n\n이미지를 미리 정해진 프레임 안에 넣는 컴포넌트 중 하나입니다. width는 무조건 100%고 주어진 `frame` 값에 따라 높이가 정해집니다.\n\n### Image.FixedDimensionsFrame\n\n| prop 이름 | 설명                              |\n| --------- | --------------------------------- |\n| size      | (optional) 이미지 세로 preset.    |\n| width     | (optional) px 단위의 이미지 가로. |\n| height    | (optional) px 단위의 이미지 세로. |\n| floated   | (optional) 이미지 float 값        |\n| margin    | (optional)                        |\n| onClick   | (optional)                        |\n\n이미지를 미리 정해진 프레임 안에 넣는 컴포넌트 중 하나입니다.\n`width`를 명시하지 않으면 100%로 설정됩니다.\nheight는 `height` prop으로 직접 입력하거나 `size`에 맞게 미리 정해진 height 값을 사용할 수 있습니다. `height`이 `size`보다 우선시됩니다. 아무 값도 주어지지 않으면 height는 지정되지 않습니다.\n\n### Image.Img\n\n| prop 이름 | 설명                                        |\n| --------- | ------------------------------------------- |\n| src       | 이미지 URL                                  |\n| alt       | (optional) 이미지 대체 문구                 |\n| 기타      | img 태그에 들어가는 모든 prop이 가능합니다. |\n\n실제 `img` 태그를 그리는 컴포넌트입니다. Frame 관련 컴포넌트의 자식으로 들어갑니다.\n\n### Image.SourceUrl\n\n이미지 출처를 표시할 때 사용하는 컴포넌트입니다.\n이미지 우측 하단에 위치시켜주고 기본적인 텍스트 스타일을 지정해줍니다.\nFrame 관련 컴포넌트의 자식으로 들어갑니다.\n\n### Image.Overlay\n\n| prop 이름   | 설명                                |\n| ----------- | ----------------------------------- |\n| overlayType | 오버레이 색상 타입 (dark, gradient) |\n| padding     | 오버레이의 패딩                     |\n\n이미지 위에 검은색 오버레이를 덧씌워주는 컴포넌트입니다.\nFrame 관련 컴포넌트의 자식으로 들어갑니다.\n\n### Image.LinkIndicator\n\n이미지가 링크임을 표시해주는 오른쪽 화살표를 띄워주는 컴포넌트입니다.\nFrame 관련 컴포넌트의 자식으로 들어갑니다.\n\n### Image.Placeholder\n\n| prop 이름 | 설명       |\n| --------- | ---------- |\n| src       | 이미지 URL |\n\n이미지 값이 없을 때 placeholder로 사용하는 컴포넌트입니다.\n주어진 url의 이미지를 회색 배경과 함께 가운데 40\\*40 크기로 표시합니다.\nFrame 관련 컴포넌트의 자식으로 들어갑니다.\n\n### Image.Circular\n\n| prop 이름 | 설명                                       |\n| --------- | ------------------------------------------ |\n| size      | (optional) 원 크기 preset. (small, medium) |\n| width     | (optional) 원 크기 직접 입력할 수 있는 값  |\n| floated   | (optional) 이미지 float 값                 |\n\n원형 이미지를 표시하는 컴포넌트입니다. `Image` 컴포넌트로 감싸지 않고 독립적으로 사용할 수 있습니다.\n`size`가 `width`보다 우선시됩니다. 아무 값도 주어지지 않으면 `size=\"small\"`로 간주합니다.\n\n## 개발할 때\n\n만약 이미지의 여러 부분이 관련된 기능을 추가한다면,\n기능을 구현할 때 관련된 컴포넌트 중 트리 가장 위쪽 컴포넌트에\nprop을 넣고 context를 통해 트리 아래로 공급하면 좋습니다.\n\n특정 부분만 사용하는 기능이라면 각 컴포넌트에 prop으로 추가하세요.\n\n이미지 위 레이어 형태의 컴포넌트를 추가하게 된다면\n독립적으로 만들고 `Image.Img`와 같은 계층에 마운트하면 됩니다.\n새로운 컴포넌트도 Image의 static property로 제공하여\n사용할 때 편하게 접근할 수 있도록 해주세요.\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/circular.ts",
    "content": "import { styled } from 'styled-components'\nimport * as CSS from 'csstype'\n\nimport { GlobalSizes } from '../../commons'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nconst ROUND_SIZES: Partial<Record<GlobalSizes, number>> = {\n  small: 40,\n  medium: 60,\n}\n\nexport const ImageCircular = styled.img.withConfig({\n  shouldForwardProp,\n})<{\n  size?: GlobalSizes\n  floated?: CSS.Property.Float\n  width?: number\n}>`\n  width: ${({ size, width }) =>\n    (size && ROUND_SIZES[size]) || width || ROUND_SIZES.small}px;\n  height: ${({ size, width }) =>\n    (size && ROUND_SIZES[size]) || width || ROUND_SIZES.small}px;\n  border-radius: ${({ size, width }) =>\n    ((size && ROUND_SIZES[size]) || width || (ROUND_SIZES.small as number)) /\n    2}px;\n  background-color: var(--color-brightGray);\n  object-fit: cover;\n  float: ${({ floated }) => floated || 'none'};\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/context.tsx",
    "content": "import {\n  createContext,\n  PropsWithChildren,\n  useMemo,\n  useContext,\n  useState,\n} from 'react'\n\ninterface ImageStateContextValue {\n  borderRadius: number\n  overlayMounted: boolean\n  setOverlayMounted: (mounted: boolean) => void\n}\n\nconst ImageStateContext = createContext<ImageStateContextValue | null>(null)\n\nexport function ImageStateContextProvider({\n  borderRadius,\n  children,\n}: PropsWithChildren<{\n  borderRadius?: number\n}>) {\n  const [overlayMounted, setOverlayMounted] = useState(false)\n\n  const value = useMemo(\n    () => ({\n      borderRadius: borderRadius ?? 6,\n      overlayMounted,\n      setOverlayMounted,\n    }),\n    [borderRadius, overlayMounted],\n  )\n\n  return (\n    <ImageStateContext.Provider value={value}>\n      {children}\n    </ImageStateContext.Provider>\n  )\n}\n\nexport function useImageState() {\n  const context = useContext(ImageStateContext)\n\n  if (!context) {\n    throw new Error('Cannot use image state outside of provider')\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/fixed-dimensions-frame.tsx",
    "content": "import { PropsWithChildren, MouseEventHandler } from 'react'\nimport { styled } from 'styled-components'\nimport * as CSS from 'csstype'\n\nimport { GlobalSizes, MarginPadding } from '../../commons'\nimport { marginMixin, borderRadiusMixin } from '../../mixins'\n\nimport { useImageState } from './context'\n\nconst IMAGE_HEIGHT_OPTIONS: Partial<Record<GlobalSizes, string>> = {\n  mini: '80px',\n  small: '110px',\n  medium: '200px',\n  large: '400px',\n}\n\nconst FixedDimensionsFrameContainer = styled.div<{\n  size?: GlobalSizes\n  width?: number\n  height?: number\n  borderRadius: number\n  floated?: CSS.Property.Float\n  margin?: MarginPadding\n}>`\n  background-color: #f5f5f5;\n  font-size: 0;\n  position: relative;\n  width: ${({ width }) => (width && `${width}px`) || '100%'};\n  height: ${({ height, size }) =>\n    (height && `${height}px`) || (size ? IMAGE_HEIGHT_OPTIONS[size] : '')};\n  float: ${({ floated }) => floated || 'none'};\n\n  ${marginMixin}\n  ${borderRadiusMixin}\n`\n\nexport function ImageFixedDimensionsFrame({\n  size,\n  width,\n  height,\n  floated,\n  margin,\n  onClick,\n  children,\n}: PropsWithChildren<{\n  size?: GlobalSizes\n  width?: number\n  height?: number\n  floated?: CSS.Property.Float\n  margin?: MarginPadding\n  onClick?: MouseEventHandler\n}>) {\n  const { borderRadius } = useImageState()\n\n  return (\n    <FixedDimensionsFrameContainer\n      size={size}\n      width={width}\n      height={height}\n      floated={floated}\n      margin={margin}\n      borderRadius={borderRadius}\n      onClick={onClick}\n    >\n      {children}\n    </FixedDimensionsFrameContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/fixed-ratio-frame.tsx",
    "content": "import {\n  PropsWithChildren,\n  MouseEventHandler,\n  createContext,\n  useContext,\n} from 'react'\nimport { styled } from 'styled-components'\nimport * as CSS from 'csstype'\n\nimport {\n  FrameRatioAndSizes,\n  MEDIA_FRAME_OPTIONS,\n  MarginPadding,\n} from '../../commons'\nimport { formatMarginPadding, marginMixin } from '../../mixins'\n\nimport { useImageState } from './context'\n\nconst ContentAbsoluteContext = createContext(true)\n\nexport function useContentAbsolute() {\n  return useContext(ContentAbsoluteContext)\n}\n\nconst FixedRatioFrameContainer = styled.div<{\n  frame: FrameRatioAndSizes\n  borderRadius: number\n  overflowHidden?: boolean\n  floated?: CSS.Property.Float\n  margin?: MarginPadding\n}>`\n  font-size: 0;\n  position: relative;\n  width: 100%;\n  background-color: #f5f5f5;\n  float: ${({ floated }) => floated || 'none'};\n\n  ${({ borderRadius }) => `border-radius: ${borderRadius}px;`}\n\n  ${({ frame }) =>\n    frame !== 'original' &&\n    formatMarginPadding({ top: MEDIA_FRAME_OPTIONS[frame] }, 'padding')}\n\n  ${({ overflowHidden }) => overflowHidden && 'overflow: hidden;'}\n\n  ${marginMixin}\n`\n\nexport function ImageFixedRatioFrame({\n  frame = 'small',\n  floated,\n  margin,\n  onClick,\n  children,\n}: PropsWithChildren<{\n  frame?: FrameRatioAndSizes\n  floated?: CSS.Property.Float\n  margin?: MarginPadding\n  onClick?: MouseEventHandler\n}>) {\n  const { borderRadius } = useImageState()\n\n  const originalFrame = frame === 'original'\n\n  return (\n    <FixedRatioFrameContainer\n      frame={frame}\n      overflowHidden={!originalFrame}\n      floated={floated}\n      borderRadius={borderRadius}\n      margin={margin}\n      onClick={onClick}\n    >\n      <ContentAbsoluteContext.Provider value={!originalFrame}>\n        {children}\n      </ContentAbsoluteContext.Provider>\n    </FixedRatioFrameContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/image.stories.tsx",
    "content": "import { Meta, StoryObj } from '@storybook/react'\n\nimport { Image } from './image'\n\nconst meta = {\n  title: 'tds-ui (Media) / Image',\n  component: Image,\n} satisfies Meta<typeof Image>\n\nexport default meta\ntype Story = StoryObj<typeof Image>\n\nexport const FixedRatioFrame: Story = {\n  render: () => (\n    <Image>\n      <Image.FixedRatioFrame frame=\"mini\">\n        <Image.Img src=\"https://triple-corp.com/static/images/img-bg-0.jpg\" />\n      </Image.FixedRatioFrame>\n    </Image>\n  ),\n}\n\nexport const FixedDimensionsFrame: Story = {\n  render: () => (\n    <Image>\n      <Image.FixedDimensionsFrame size=\"medium\" width={320} height={180}>\n        <Image.Img src=\"https://triple-corp.com/static/images/img-bg-0.jpg\" />\n      </Image.FixedDimensionsFrame>\n    </Image>\n  ),\n}\n\nexport const Overlay: Story = {\n  render: () => (\n    <Image>\n      <Image.FixedRatioFrame frame=\"mini\">\n        <Image.Img src=\"https://triple-corp.com/static/images/img-bg-0.jpg\" />\n        <Image.Overlay overlayType=\"dark\" padding={{ top: 20, bottom: 20 }}>\n          <div style={{ fontSize: 14, color: 'white' }}>오버레이입니다.</div>\n        </Image.Overlay>\n      </Image.FixedRatioFrame>\n    </Image>\n  ),\n}\n\nexport const LinkIndicator: Story = {\n  render: () => (\n    <Image>\n      <Image.FixedRatioFrame frame=\"mini\">\n        <Image.Img src=\"https://triple-corp.com/static/images/img-bg-0.jpg\" />\n        <Image.LinkIndicator />\n      </Image.FixedRatioFrame>\n    </Image>\n  ),\n}\n\nexport const Placeholder: Story = {\n  render: () => (\n    <Image>\n      <Image.FixedRatioFrame frame=\"mini\">\n        <Image.Placeholder src=\"https://assets.triple.guide/images/ico-blank-hotel@2x.png\" />\n      </Image.FixedRatioFrame>\n    </Image>\n  ),\n}\n\nexport const Circular: Story = {\n  render: () => (\n    <Image.Circular\n      src=\"https://triple-corp.com/static/images/img-bg-0.jpg\"\n      size=\"medium\"\n    />\n  ),\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/image.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { ImageStateContextProvider } from './context'\nimport { ImageImg } from './img'\nimport { ImageSourceUrl } from './source-url'\nimport { ImageOverlay } from './overlay'\nimport { ImageLinkIndicator } from './link-indicator'\nimport { ImageFixedRatioFrame } from './fixed-ratio-frame'\nimport { ImageFixedDimensionsFrame } from './fixed-dimensions-frame'\nimport { ImageCircular } from './circular'\nimport { ImagePlaceholder } from './placeholder'\nimport { ImageOptimizedImg } from './optimized-img'\n\nexport function Image({\n  borderRadius,\n  children,\n}: PropsWithChildren<{\n  borderRadius?: number\n}>) {\n  return (\n    <ImageStateContextProvider borderRadius={borderRadius}>\n      {children}\n    </ImageStateContextProvider>\n  )\n}\n\nImage.FixedRatioFrame = ImageFixedRatioFrame\nImage.FixedDimensionsFrame = ImageFixedDimensionsFrame\n\nImage.Img = ImageImg\nImage.OptimizedImg = ImageOptimizedImg\nImage.Placeholder = ImagePlaceholder\n\nImage.SourceUrl = ImageSourceUrl\nImage.Overlay = ImageOverlay\nImage.LinkIndicator = ImageLinkIndicator\n\nImage.Circular = ImageCircular\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/img.tsx",
    "content": "import { styled } from 'styled-components'\nimport * as CSS from 'csstype'\nimport { ComponentPropsWithoutRef } from 'react'\n\nimport { useImageState } from './context'\nimport { useContentAbsolute } from './fixed-ratio-frame'\n\nconst Img = styled.img<{\n  borderRadius: number\n  dimmed?: boolean\n  absolute: boolean\n  cursor?: CSS.Property.Cursor\n}>`\n  width: 100%;\n  height: 100%;\n  border-radius: ${({ borderRadius }) => borderRadius}px;\n  object-fit: cover;\n  opacity: ${({ dimmed }) => (dimmed ? 80 : 100)}%;\n  ${({ absolute }) =>\n    absolute &&\n    `\n    position: absolute;\n    top: 0;\n  `}\n  ${({ cursor }) =>\n    cursor &&\n    `\n    cursor: ${cursor};\n  `}\n  z-index: 0;\n`\n\nexport function ImageImg(props: ComponentPropsWithoutRef<'img'>) {\n  const { borderRadius, overlayMounted } = useImageState()\n  const absolute = useContentAbsolute()\n\n  return (\n    <Img\n      {...props}\n      borderRadius={borderRadius}\n      dimmed={overlayMounted}\n      absolute={absolute}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/index.ts",
    "content": "export * from './circular'\nexport * from './fixed-dimensions-frame'\nexport * from './fixed-ratio-frame'\nexport * from './image'\nexport * from './img'\nexport * from './link-indicator'\nexport * from './optimized-img'\nexport * from './overlay'\nexport * from './placeholder'\nexport * from './source-url'\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/link-indicator.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { Icon } from '../icon'\n\nconst IconContainer = styled.div`\n  position: absolute;\n  top: 30px;\n  right: 10px;\n  width: 20px;\n  height: 20px;\n`\n\nexport function ImageLinkIndicator() {\n  return (\n    <IconContainer>\n      <Icon size=\"medium\" name=\"arrowRight\" />\n    </IconContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/optimized-img.tsx",
    "content": "import { useCallback, useState } from 'react'\nimport { styled } from 'styled-components'\nimport { StaticIntersectionObserver } from '@titicaca/intersection-observer'\nimport { generateImageUrl, Version, Quality } from '@titicaca/content-utilities'\n\nimport { useImageState } from './context'\nimport { useContentAbsolute } from './fixed-ratio-frame'\nimport { Placeholder } from './placeholder'\n\nexport interface OptimizedImgProps {\n  mediaUrlBase?: string\n  cloudinaryBucket?: string\n  cloudinaryId: string\n  version?: Version\n  quality?: Quality\n  format?: string\n  deviceSizes?: number[]\n  width?: number\n  height?: number\n  progressiveMode?: 'semi' | 'steep'\n}\n\nconst Img = styled.img<{\n  borderRadius: number\n  dimmed?: boolean\n  absolute: boolean\n}>`\n  width: 100%;\n  height: 100%;\n  border-radius: ${({ borderRadius }) => borderRadius}px;\n  object-fit: cover;\n  opacity: ${({ dimmed }) => (dimmed ? 80 : 100)}%;\n  ${({ absolute }) =>\n    absolute &&\n    `\n    position: absolute;\n    top: 0;\n  `}\n  z-index: 0;\n`\n\nexport function ImageOptimizedImg({\n  mediaUrlBase = 'https://media.triple.guide',\n  cloudinaryBucket = 'triple-cms',\n  cloudinaryId,\n  version = 'full',\n  quality = 'original',\n  format = 'jpeg',\n  deviceSizes = [640, 768, 1024, 1080, 1280],\n  width = 1024,\n  height = 1024,\n  progressiveMode,\n}: Omit<Parameters<typeof Img>[0], 'borderRadius' | 'dimmed' | 'absolute'> &\n  OptimizedImgProps) {\n  const { borderRadius, overlayMounted } = useImageState()\n\n  const [isLoad, setIsLoad] = useState(false)\n  const [imgAttributes, setImgAttributes] = useState<{\n    src?: string\n    srcSet?: string\n    sizes?: string\n  }>({\n    src: generateImageUrl({\n      mediaUrlBase,\n      cloudinaryBucket,\n      cloudinaryId,\n      version,\n      quality,\n      format,\n      width,\n      height,\n      ...(progressiveMode && { progressiveMode }),\n    }),\n  })\n\n  const absolute = useContentAbsolute()\n\n  const handleLazyLoad = useCallback(\n    (event: IntersectionObserverEntry, unobserve: () => void) => {\n      if (event.isIntersecting) {\n        unobserve()\n\n        const srcSet = deviceSizes\n          .sort((a, b) => a - b)\n          .map(\n            (deviceWidth) =>\n              `${generateImageUrl({\n                mediaUrlBase,\n                cloudinaryBucket,\n                cloudinaryId,\n                version,\n                quality,\n                format,\n                width: deviceWidth,\n                height: deviceWidth,\n              })} ${deviceWidth}w`,\n          )\n          .join(', ')\n\n        setIsLoad(event.isIntersecting)\n\n        setImgAttributes((prev) => ({\n          ...prev,\n          srcSet,\n          sizes: '100vw',\n        }))\n      }\n    },\n    [\n      cloudinaryBucket,\n      cloudinaryId,\n      deviceSizes,\n      format,\n      mediaUrlBase,\n      quality,\n      version,\n    ],\n  )\n\n  return (\n    <StaticIntersectionObserver rootMargin=\"200px\" onChange={handleLazyLoad}>\n      {!isLoad ? (\n        <Placeholder absolute={absolute} />\n      ) : (\n        <Img\n          {...imgAttributes}\n          borderRadius={borderRadius}\n          dimmed={overlayMounted}\n          absolute={absolute}\n        />\n      )}\n    </StaticIntersectionObserver>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/overlay.tsx",
    "content": "import { PropsWithChildren, useEffect } from 'react'\nimport { styled, css } from 'styled-components'\n\nimport { MarginPadding } from '../../commons'\nimport { paddingMixin, layeringMixin, LayeringMixinProps } from '../../mixins'\n\nimport { useImageState } from './context'\n\ntype OverlayType = 'gradient' | 'dark'\n\nconst OverlayStyle: { [key in OverlayType]: ReturnType<typeof css> } = {\n  dark: css`\n    background-color: rgba(0, 0, 0, 0.8);\n  `,\n  gradient: css`\n    background-image: linear-gradient(\n      to bottom,\n      rgba(0, 0, 0, 0.6),\n      rgba(0, 0, 0, 0)\n    );\n  `,\n}\n\nconst OverlayContainer = styled.div<\n  {\n    borderRadius: number\n    padding?: MarginPadding\n    overlayType?: OverlayType\n  } & LayeringMixinProps\n>`\n  position: absolute;\n  top: 0;\n  width: 100%;\n  height: 100%;\n  border-radius: ${({ borderRadius }) => borderRadius}px;\n\n  ${({ overlayType = 'gradient' }) => OverlayStyle[overlayType]}\n\n  ${paddingMixin}\n  ${layeringMixin(0)}\n`\n\nexport function ImageOverlay({\n  overlayType = 'gradient',\n  padding,\n  zTier,\n  zIndex,\n  children,\n}: PropsWithChildren<\n  {\n    overlayType?: OverlayType\n    padding?: MarginPadding\n  } & LayeringMixinProps\n>) {\n  const { borderRadius, setOverlayMounted } = useImageState()\n\n  useEffect(() => {\n    setOverlayMounted(true)\n\n    return () => {\n      setOverlayMounted(false)\n    }\n  }, [setOverlayMounted])\n\n  return (\n    <OverlayContainer\n      overlayType={overlayType}\n      padding={padding}\n      borderRadius={borderRadius}\n      zTier={zTier}\n      zIndex={zIndex}\n    >\n      {children}\n    </OverlayContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/placeholder.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { useContentAbsolute } from './fixed-ratio-frame'\n\nexport const Placeholder = styled.div<{\n  src?: string\n  absolute: boolean\n}>`\n  width: 100%;\n  height: 100%;\n  background-color: var(--color-brightGray);\n\n  ${({ src }) =>\n    src &&\n    `\n      background-repeat: no-repeat;\n      background-position: center;\n      background-size: 40px 40px;\n      background-image: url(${src});\n    `};\n\n  ${({ absolute }) =>\n    absolute\n      ? `\n          position: absolute;\n          top: 0;\n          left: 0;\n        `\n      : ''}\n`\n\nexport function ImagePlaceholder({ src }: { src?: string }) {\n  const absolute = useContentAbsolute()\n\n  return <Placeholder src={src} absolute={absolute} />\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/image/source-url.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\n\nconst SourceUrlContainer = styled.div`\n  position: absolute;\n  right: 10px;\n  bottom: 10px;\n  max-width: 150px;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  font-size: 9px;\n  line-height: 1.2;\n  color: rgba(255, 255, 255, 0.9);\n  z-index: 1;\n`\n\nexport function ImageSourceUrl({ children }: PropsWithChildren<unknown>) {\n  return <SourceUrlContainer>{children}</SourceUrlContainer>\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/index.ts",
    "content": "'use client'\n\nexport * from './accordion'\nexport * from './action-sheet'\nexport * from './action-sheet-select'\nexport * from './alert'\nexport * from './button'\nexport * from './carousel'\nexport * from './checkbox'\nexport * from './checkbox-group'\nexport * from './confirm'\nexport * from './confirm-selector'\nexport * from './container'\nexport * from './content-elements'\nexport * from './drawer'\nexport * from './drawer-button'\nexport * from './fieldset'\nexport * from './flex-box'\nexport * from './form-field'\nexport * from './gender-selector'\nexport * from './hr'\nexport * from './icon'\nexport * from './image'\nexport * from './input'\nexport * from './label'\nexport * from './list'\nexport * from './long-clickable'\nexport * from './modal'\nexport * from './navbar'\nexport * from './numeric-spinner'\nexport * from './popup'\nexport * from './radio'\nexport * from './radio-group'\nexport * from './rating'\nexport * from './responsive'\nexport * from './section'\nexport * from './segment'\nexport * from './select'\nexport * from './skeleton'\nexport * from './slider'\nexport * from './spinner'\nexport * from './stack'\nexport * from './sticky-header'\nexport * from './table'\nexport * from './tabs'\nexport * from './tag'\nexport * from './text'\nexport * from './textarea'\nexport * from './tooltip'\nexport * from './video'\nexport * from './visually-hidden'\n"
  },
  {
    "path": "packages/tds-ui/src/components/input/index.ts",
    "content": "export * from './input'\n"
  },
  {
    "path": "packages/tds-ui/src/components/input/input.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { List } from '../list'\n\nimport { Input } from './input'\n\nconst meta: Meta<typeof Input> = {\n  title: 'tds-ui (Form) / Input',\n  component: Input,\n  argTypes: {\n    label: { type: 'string' },\n    placeholder: { type: 'string' },\n    disabled: { type: 'boolean' },\n    required: { type: 'boolean' },\n    error: { type: 'string' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: '사용자에게 입력값을 받는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const Default: StoryObj<typeof Input> = {\n  args: {\n    label: '이름',\n    placeholder: '이름을 입력해주세요',\n    help: '고객님의 요청사항은 해당 호텔에 전달됩니다만 호텔 사정에 따라 필요하신 내용이 이루어지지 않을 수 있으니 많은 양해 바랍니다.',\n  },\n}\n\nexport const Required: StoryObj<typeof Input> = {\n  args: {\n    ...Default.args,\n    required: true,\n  },\n}\n\nexport const Error: StoryObj<typeof Input> = {\n  args: {\n    ...Default.args,\n    error: '이름은 필수 입력 사항입니다.',\n  },\n}\n\nexport const Mask: StoryObj<typeof Input> = {\n  args: {\n    ...Default.args,\n    mask: '99/99',\n    value: 1230,\n  },\n}\n\nexport const MultilineHelp: StoryObj<typeof Input> = {\n  args: {\n    ...Default.args,\n    help: (\n      <List marker verticalGap={4}>\n        <List.Item>연락 가능한 전화번호를 입력해주세요.</List.Item>\n        <List.Item>\n          예약 변경, 이슈가 있는 경우 입력한 번호로 연락드립니다.\n        </List.Item>\n        <List.Item>\n          {`카카오톡 ID는 카카오 프로필 > 설정 > 프로필 관리에서 확인가능합니다.`}\n        </List.Item>\n      </List>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/input/input.tsx",
    "content": "import { InputHTMLAttributes, forwardRef, ReactNode } from 'react'\nimport { styled } from 'styled-components'\nimport InputMask, { Props as InputMaskProps } from 'react-input-mask'\n\nimport {\n  FormFieldContext,\n  FormFieldError,\n  FormFieldHelp,\n  FormFieldLabel,\n  useFormFieldState,\n} from '../form-field'\n\nconst BaseInput = styled(InputMask)`\n  padding: 0 16px;\n  font-size: 16px;\n  height: 48px;\n  font-weight: 500;\n  border: 1px solid var(--color-gray100);\n  border-radius: 4px;\n  width: 100%;\n\n  &:focus {\n    outline: none;\n    border-color: var(--color-blue);\n  }\n\n  &[aria-invalid='true'] {\n    border-color: var(--color-mediumRed);\n  }\n\n  &::placeholder {\n    color: var(--color-gray300);\n  }\n`\n\nexport interface InputProps\n  extends InputHTMLAttributes<HTMLInputElement>,\n    Partial<InputMaskProps> {\n  label?: string\n  error?: string | boolean\n  help?: ReactNode\n}\n\nexport const Input = forwardRef<HTMLInputElement, InputProps>(function Input(\n  { mask = '', label, error, help, onBlur, onFocus, ...props },\n  ref,\n) {\n  const formFieldState = useFormFieldState({ onBlur, onFocus })\n\n  const hasHelp = !!help\n  const isError = !!error\n\n  return (\n    <FormFieldContext.Provider\n      value={{\n        ...formFieldState,\n        isError,\n        isDisabled: !!props.disabled,\n        isRequired: !!props.required,\n      }}\n    >\n      {label ? <FormFieldLabel>{label}</FormFieldLabel> : null}\n      <BaseInput\n        inputRef={ref}\n        id={formFieldState.inputId}\n        mask={mask}\n        aria-describedby={\n          hasHelp && !isError ? formFieldState.descriptionId : undefined\n        }\n        aria-errormessage={isError ? formFieldState.errorId : undefined}\n        aria-invalid={isError}\n        onBlur={formFieldState.handleBlur}\n        onFocus={formFieldState.handleFocus}\n        {...props}\n      />\n      {error ? (\n        <FormFieldError>{error}</FormFieldError>\n      ) : help ? (\n        <FormFieldHelp>{help}</FormFieldHelp>\n      ) : null}\n    </FormFieldContext.Provider>\n  )\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/label/index.ts",
    "content": "export * from './label'\n"
  },
  {
    "path": "packages/tds-ui/src/components/label/label.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Label } from './label'\n\nconst meta: Meta<typeof Label> = {\n  title: 'tds-ui (Data display) / Label',\n  component: Label,\n  argTypes: {\n    radio: { type: 'boolean' },\n    selected: {\n      if: { arg: 'radio' },\n      type: 'boolean',\n    },\n    promo: { type: 'boolean' },\n    color: {\n      if: { arg: 'promo' },\n      control: 'select',\n      options: [\n        'blue',\n        'red',\n        'purple',\n        'orange',\n        'gray',\n        'green',\n        'white',\n        'skyblue',\n        'lightpurple',\n        'black',\n      ],\n    },\n    size: {\n      if: { arg: 'promo' },\n      control: 'select',\n      options: ['tiny', 'small', 'medium', 'large'],\n    },\n    emphasized: { if: { arg: 'promo' }, type: 'boolean' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 다음 행동을 결정하도록 도와주는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Label>\n\nexport const Default: Story = {\n  args: {\n    children: '최신순',\n  },\n}\n\nexport const Radio: Story = {\n  args: {\n    radio: true,\n    children: '최신순',\n  },\n}\n\nexport const Promo: Story = {\n  args: {\n    promo: true,\n    size: 'large',\n    color: 'blue',\n    children: '프로모션',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/label/label.tsx",
    "content": "import { styled, css } from 'styled-components'\nimport CSS from 'csstype'\nimport { ReactNode, PureComponent, HTMLAttributes } from 'react'\n\nimport { MarginPadding } from '../../commons'\nimport { marginMixin } from '../../mixins'\nimport { Container } from '../container'\n\nexport type LabelColor =\n  | 'blue'\n  | 'red'\n  | 'purple'\n  | 'gray'\n  | 'green'\n  | 'white'\n  | 'orange'\n  | 'skyblue'\n  | 'lightpurple'\n  | 'black'\n\nconst LABEL_COLORS: {\n  [key in LabelColor]: {\n    [key: string]: string\n  }\n} = {\n  blue: {\n    background: 'var(--color-blue100)',\n    color: 'var(--color-blue)',\n    emphasizedColor: 'var(--color-white)',\n    emphasizedBackground: 'var(--color-blue)',\n  },\n  red: {\n    background: 'var(--color-red100)',\n    color: 'var(--color-red)',\n    emphasizedColor: 'var(--color-white)',\n    emphasizedBackground: 'var(--color-red)',\n  },\n  purple: {\n    background: 'var(--color-purple100)',\n    color: 'var(--color-purple)',\n    emphasizedColor: 'var(--color-white)',\n    emphasizedBackground: 'var(--color-purple)',\n  },\n  gray: {\n    background: 'var(--color-gray50)',\n    color: 'var(--color-gray700)',\n    emphasizedColor: 'var(--color-white)',\n    emphasizedBackground: 'var(--color-gray700)',\n  },\n  green: {\n    background: 'var(--color-mint100)',\n    color: 'var(--color-mint)',\n    emphasizedColor: 'var(--color-white)',\n    emphasizedBackground: 'var(--color-mint)',\n  },\n  /**\n   * white, orange 의 경우 강조 타입만 정의된 상태\n   */\n  white: {\n    background: 'var(--color-white)',\n    color: 'var(--color-gray)',\n    emphasizedColor: 'var(--color-gray)',\n    emphasizedBackground: 'var(--color-white)',\n    borderColor: 'var(--color-gray200)',\n  },\n  orange: {\n    background: 'var(--color-white)',\n    color: 'var(--color-orange)',\n    emphasizedColor: 'var(--color-white)',\n    emphasizedBackground: 'var(--color-orange)',\n  },\n  skyblue: {\n    background: 'var(--color-skyblue)',\n    color: 'var(--color-white)',\n    emphasizedColor: 'var(--color-white)',\n    emphasizedBackground: 'var(--color-skyblue)',\n  },\n  lightpurple: {\n    background: 'var(--color-lightpurple)',\n    color: 'var(--color-white)',\n    emphasizedColor: 'var(--color-white)',\n    emphasizedBackground: 'var(--color-lightpurple)',\n  },\n  black: {\n    background: 'var(--color-black)',\n    color: 'var(--color-white)',\n    emphasizedColor: 'var(--color-white)',\n    emphasizedBackground: 'var(--color-black)',\n  },\n}\n\ninterface RadioLabelProps {\n  selected?: boolean\n  margin?: MarginPadding\n}\n\nconst backgroundImage = ({ selected }: RadioLabelProps) =>\n  `https://assets.triple.guide/images/img-search-select-${\n    selected ? 'on' : 'off'\n  }@4x.png`\nconst RadioLabel = styled.div<RadioLabelProps>`\n  display: inline-block;\n  padding-left: 9px;\n  font-size: 14px;\n  line-height: 17px;\n  color: ${({ selected }) => (selected ? '#3a3a3a' : 'rgba(58, 58, 58, 0.3)')};\n  background-image: url('${backgroundImage}');\n  background-size: 5px 5px;\n  background-position: left center;\n  background-repeat: no-repeat;\n  cursor: pointer;\n\n  ${marginMixin}\n`\n\nconst PROMO_SIZES = {\n  tiny: {\n    fontSize: 10,\n    borderRadius: 1,\n    height: 15,\n    padding: '0 4px',\n  },\n  small: {\n    fontSize: 11,\n    borderRadius: 2,\n    height: 20,\n    padding: '0 6px',\n  },\n  medium: {\n    fontSize: 12,\n    borderRadius: 2,\n    height: 26,\n    padding: '0 10px',\n  },\n  large: {\n    fontSize: 13,\n    borderRadius: 4,\n    height: 30,\n    padding: '0 13px',\n  },\n}\n\ninterface PromoLabelProps {\n  size?: keyof typeof PROMO_SIZES\n  emphasized?: boolean\n  color?: LabelColor\n  margin?: MarginPadding\n  verticalAlign?: CSS.Property.VerticalAlign<string>\n}\n\nconst PromoLabel = styled.div<PromoLabelProps>`\n  display: inline-block;\n  ${marginMixin}\n\n  ${({ size = 'small' }) => {\n    const { padding, borderRadius, height, fontSize } =\n      PROMO_SIZES[size || 'small']\n    return `\n      padding: ${padding};\n      border-radius: ${borderRadius}px;\n      line-height: ${height}px;\n      height: ${height}px;\n      font-size: ${fontSize}px;\n   `\n  }}\n\n  ${({ verticalAlign }) =>\n    verticalAlign &&\n    `\n    vertical-align: ${verticalAlign};\n  `}\n\n  ${({ emphasized }) =>\n    emphasized\n      ? css<Pick<PromoLabelProps, 'color'>>`\n          font-weight: bold;\n          ${({ color = 'purple' }) => {\n            const { emphasizedColor, emphasizedBackground, borderColor } =\n              LABEL_COLORS[color]\n\n            return css`\n              background-color: ${emphasizedBackground};\n              color: ${emphasizedColor};\n              ${borderColor && `border: 1px solid ${borderColor}`};\n            `\n          }}\n        `\n      : css<Pick<PromoLabelProps, 'color'>>`\n          font-weight: normal;\n          ${({ color = 'purple' }) => {\n            const { color: textColor, background } = LABEL_COLORS[color]\n            return css`\n              background-color: ${background};\n              color: ${textColor};\n            `\n          }}\n        `};\n`\n\ninterface LabelProps extends PromoLabelProps, RadioLabelProps {\n  radio?: boolean\n  promo?: boolean\n  children?: ReactNode\n}\n\nconst LabelGroup = styled(Container)<{ horizontalGap?: number }>`\n  div:not(:first-child) {\n    ${({ horizontalGap }) => css`\n      margin-left: ${horizontalGap || 0}px;\n    `};\n  }\n`\nexport class Label extends PureComponent<\n  LabelProps,\n  HTMLAttributes<HTMLElement>\n> {\n  public static Group = LabelGroup\n\n  public render() {\n    const {\n      props: {\n        radio,\n        selected,\n        margin,\n        children,\n        promo,\n        size,\n        emphasized,\n        color,\n        ...props\n      },\n    } = this\n\n    if (radio) {\n      return (\n        <RadioLabel {...props} selected={selected} margin={margin}>\n          {children}\n        </RadioLabel>\n      )\n    } else if (promo) {\n      return (\n        <PromoLabel\n          {...props}\n          size={size}\n          emphasized={emphasized}\n          color={color}\n          margin={margin}\n        >\n          {children}\n        </PromoLabel>\n      )\n    } else {\n      return children\n    }\n  }\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/list/index.ts",
    "content": "export * from './list'\n"
  },
  {
    "path": "packages/tds-ui/src/components/list/list.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { List } from './list'\n\nconst meta: Meta<typeof List> = {\n  title: 'tds-ui (Data display) / List',\n  component: List,\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 목록 형식으로 정보를 전닿할 때 사용되는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof List>\n\nexport const Default: Story = {\n  args: {\n    children: (\n      <>\n        <List.Item>1</List.Item>\n        <List.Item>2</List.Item>\n        <List.Item>3</List.Item>\n      </>\n    ),\n  },\n}\n\nexport const Marker: Story = {\n  args: {\n    marker: true,\n    children: (\n      <>\n        <List.Item>1</List.Item>\n        <List.Item>2</List.Item>\n        <List.Item>3</List.Item>\n      </>\n    ),\n  },\n}\n\nexport const Divided: Story = {\n  args: {\n    divided: true,\n    dividerWeight: 10,\n    children: (\n      <>\n        <List.Item>1</List.Item>\n        <List.Item>2</List.Item>\n        <List.Item>3</List.Item>\n      </>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/list/list.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { List } from './list'\n\nit('should accept weight divided prop', () => {\n  render(\n    <List divided dividerWeight={10}>\n      <List.Item />\n    </List>,\n  )\n\n  const element = screen.getByRole('list')\n\n  expect(element).toHaveStyleRule('border-bottom', 'solid 10px #efefef', {\n    modifier: '> li:not(:last-child)::after',\n  })\n  expect(element).toHaveStyleRule('content', \"''\", {\n    modifier: '> li:not(:last-child)::after',\n  })\n})\n\nit('should accept marker prop', () => {\n  render(\n    <List marker>\n      <List.Item />\n    </List>,\n  )\n\n  const element = screen.getByRole('list')\n\n  expect(element).toHaveStyleRule('content', \"'·'\", {\n    modifier: '> li::before',\n  })\n})\n\nit('should override style with css prop', () => {\n  render(\n    <List css={{ color: 'red' }}>\n      <List.Item />\n    </List>,\n  )\n\n  const element = screen.getByRole('list')\n\n  expect(element).toHaveStyleRule('color', 'red')\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/list/list.tsx",
    "content": "import { PureComponent, PropsWithChildren } from 'react'\nimport { styled, css } from 'styled-components'\n\nimport { MarginPadding } from '../../commons'\nimport { marginMixin } from '../../mixins'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\ninterface ListBaseProp {\n  margin?: MarginPadding\n  verticalGap?: number\n  clearing?: boolean\n  marker?: boolean\n}\n\ninterface DividerOptions {\n  divided?: boolean\n  dividerColor?: string\n  dividerWeight?: number\n}\n\nexport interface ListItemProps {\n  margin?: MarginPadding\n  noDivider?: boolean\n  minHeight?: number\n}\n\nconst ListBase = styled.ul.withConfig({ shouldForwardProp })<\n  ListBaseProp & DividerOptions\n>`\n  ${marginMixin}\n\n  & > li:not(:first-child) {\n    ${({ divided, verticalGap = 0 }) => css`\n      margin-top: ${divided ? verticalGap / 2 : verticalGap}px;\n    `};\n  }\n\n  ${({ marker }) =>\n    marker &&\n    css`\n      & {\n        padding-left: 0.6em;\n      }\n\n      & > li {\n        &::before {\n          content: '·';\n          position: absolute;\n          top: 0;\n          left: -0.6em;\n        }\n      }\n    `}\n\n  ${({ clearing }) =>\n    clearing\n      ? css`\n          & > li {\n            &::after {\n              content: '';\n              display: block;\n              clear: both;\n            }\n          }\n        `\n      : ''}\n\n  ${({\n    divided,\n    dividerWeight = 1,\n    dividerColor = '#efefef',\n    verticalGap = 0,\n  }) =>\n    divided\n      ? css`\n          & > li:not(:last-child) {\n            &::after {\n              content: '';\n              display: block;\n              height: 0;\n              overflow: hidden;\n              border-bottom: solid ${dividerWeight}px ${dividerColor};\n              margin: ${verticalGap / 2}px 0 ${verticalGap / 2}px 0;\n            }\n          }\n        `\n      : ''}\n`\n\nconst ListItem = styled.li.withConfig({\n  shouldForwardProp,\n})<ListItemProps>`\n  clear: both;\n  position: relative;\n  list-style-type: none;\n\n  ${marginMixin}\n\n  ${({ minHeight }) =>\n    minHeight &&\n    css`\n      min-height: ${minHeight}px;\n    `};\n\n  ${({\n    noDivider = false,\n    margin: { top: marginTop = 0, bottom: marginBottom = 0 } = {},\n  }) =>\n    noDivider &&\n    css`\n      &:not(:last-child) {\n        &::after {\n          border-bottom: 0 none !important;\n          ${marginTop ? `margin-top: ${marginTop}px !important;` : ''}\n          ${marginBottom ? `margin-bottom: ${marginBottom}px !important;` : ''}\n        }\n      }\n    `}\n`\n\nexport class List extends PureComponent<\n  PropsWithChildren<ListItemProps & ListBaseProp & DividerOptions>\n> {\n  public static Item = ListItem\n\n  public render() {\n    const {\n      props: { children, ...props },\n    } = this\n\n    return <ListBase {...props}>{children}</ListBase>\n  }\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/long-clickable/index.ts",
    "content": "export * from './long-clickable'\n"
  },
  {
    "path": "packages/tds-ui/src/components/long-clickable/long-clickable.stories.tsx",
    "content": "import { StoryObj } from '@storybook/react'\n\nimport { Container } from '../container'\nimport { HR1 } from '../hr'\n\nimport { longClickable } from './long-clickable'\n\nconst meta = {\n  title: 'tds-ui (Other) / longClickable',\n  component: longClickable,\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '특정 컴포넌트에 롱탭 이벤트를 적용해야할 경우 사용하는 HOC입니다.\\n * 터치 기능을 갖춘 모바일 기기에서 사용가능합니다. \\n * 관련 PR: [TF #597](https://github.com/titicacadev/triple-frontend/pull/597)\\n * 관련 이슈: [LongClick을 지원하는 HoC를 추가합니다.](https://github.com/titicacadev/triple-frontend/issues/596)',\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const LongClickable: StoryObj<typeof longClickable> = {\n  render: () => {\n    const LongClickableContainer = longClickable(Container)\n\n    const handleLongClick = () => {}\n\n    return (\n      <>\n        <LongClickableContainer onLongClick={handleLongClick}>\n          롱탭이 적용된 텍스트\n        </LongClickableContainer>\n\n        <HR1 />\n\n        <LongClickableContainer>\n          롱탭이 적용되지 않은 텍스트\n        </LongClickableContainer>\n      </>\n    )\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/long-clickable/long-clickable.tsx",
    "content": "import {\n  MouseEventHandler,\n  TouchEventHandler,\n  ComponentType,\n  useCallback,\n  MouseEvent,\n  ReactNode,\n} from 'react'\n\nexport interface LongClickableComponentProps<T = Element> {\n  children?: ReactNode\n  onTouchStart?: TouchEventHandler<T> | null\n  onTouchMove?: TouchEventHandler<T> | null\n  onTouchEnd?: TouchEventHandler<T> | null\n  onClick?: MouseEventHandler<T> | null\n}\n\nexport function longClickable<T extends LongClickableComponentProps>(\n  Component: ComponentType<T>,\n  duration = 500,\n): ComponentType<T & { onLongClick?: () => void }> {\n  let timeoutId: ReturnType<typeof setTimeout> | undefined\n  let isScrolled = false\n\n  return function LongClickComponent({ onLongClick, onClick, ...props }) {\n    const onTouchStart: TouchEventHandler = useCallback(() => {\n      if (onLongClick) {\n        isScrolled = false\n        timeoutId = setTimeout(() => {\n          timeoutId = undefined\n          !isScrolled && onLongClick()\n        }, duration)\n      }\n    }, [onLongClick])\n\n    const onTouchMove: TouchEventHandler = useCallback(() => {\n      if (onLongClick) {\n        isScrolled = true\n      }\n    }, [onLongClick])\n\n    const onTouchEnd: TouchEventHandler = useCallback(\n      (e) => {\n        if (onLongClick) {\n          if (timeoutId && !isScrolled) {\n            clearTimeout(timeoutId)\n            onClick && onClick(e as unknown as MouseEvent) // TODO\n          }\n        }\n      },\n      [onLongClick, onClick],\n    )\n\n    return (\n      <Component\n        {...(props as T)}\n        onClick={onLongClick ? null : onClick}\n        onTouchStart={onTouchStart}\n        onTouchMove={onTouchMove}\n        onTouchEnd={onTouchEnd}\n      />\n    )\n  }\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/modal/index.ts",
    "content": "export * from './modal-action'\nexport * from './modal-actions'\nexport * from './modal-body'\nexport * from './modal-title'\nexport * from './modal'\n"
  },
  {
    "path": "packages/tds-ui/src/components/modal/modal-action.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { GlobalColors } from '../../commons'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nconst ACTION_COLORS: Partial<Record<GlobalColors, string>> = {\n  gray: 'rgba(58, 58, 58, 0.5)',\n  blue: '#368fff',\n}\n\nexport const ModalAction = styled.a.withConfig({\n  shouldForwardProp,\n})<{ color?: GlobalColors | string; disabled?: boolean }>`\n  display: inline-block;\n  white-space: nowrap;\n  height: 50px;\n  line-height: 50px;\n  font-size: 14px;\n  font-weight: bold;\n  text-align: center;\n  color: ${({ color = 'gray', disabled }) =>\n    disabled\n      ? '#E9EAEF'\n      : color in ACTION_COLORS\n        ? ACTION_COLORS[color as GlobalColors]\n        : color};\n  cursor: pointer;\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/modal/modal-actions.tsx",
    "content": "import { Children, ReactNode } from 'react'\nimport { styled, css } from 'styled-components'\n\nexport const ModalActions = styled.div<{ children?: ReactNode }>`\n  display: block;\n  width: 100%;\n  border-top-style: solid;\n  border-width: 1px;\n  border-color: #f5f5f5;\n\n  a {\n    ${({ children }) => {\n      const childrenCount = Children.toArray(children).length\n      return css`\n        width: calc((100% - ${childrenCount - 1}px) / ${childrenCount});\n      `\n    }};\n    padding-left: 0;\n    padding-right: 0;\n  }\n\n  a:not(:first-child) {\n    border-width: 1px;\n    border-left-style: solid;\n    border-color: #f5f5f5;\n  }\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/modal/modal-body.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { css } from 'styled-components'\n\nimport { Container } from '../container'\n\nexport const ModalBody = ({ children, ...props }: PropsWithChildren) => {\n  return (\n    <Container\n      css={css`\n        padding: 40px 30px;\n      `}\n      {...props}\n    >\n      {children}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/modal/modal-context.tsx",
    "content": "import { createContext, useContext } from 'react'\n\nexport interface ModalContextValue {\n  open: boolean\n  labelId: string\n  descriptionId: string\n  onClose?: () => void\n}\n\nexport const ModalContext = createContext<ModalContextValue | undefined>(\n  undefined,\n)\n\nexport function useModal() {\n  const context = useContext(ModalContext)\n  if (!context) {\n    throw new Error()\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/modal/modal-description.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { Text } from '../text'\n\nimport { useModal } from './modal-context'\n\nexport type ModalDescriptionProps = PropsWithChildren\n\nexport const ModalDescription = ({ children }: ModalDescriptionProps) => {\n  const { descriptionId } = useModal()\n\n  return (\n    <Text id={descriptionId} center size=\"large\" lineHeight={1.38} color=\"gray\">\n      {children}\n    </Text>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/modal/modal-title.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\n\nimport { Text } from '../text'\n\nimport { useModal } from './modal-context'\n\nconst StyledText = styled(Text)`\n  margin-bottom: 10px;\n`\n\nexport type ModalTitleProps = PropsWithChildren\n\nexport const ModalTitle = ({ children }: ModalTitleProps) => {\n  const { labelId } = useModal()\n\n  return (\n    <StyledText id={labelId} bold center size=\"big\" color=\"gray\">\n      {children}\n    </StyledText>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/modal/modal.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Modal } from './modal'\n\nconst meta: Meta<typeof Modal> = {\n  title: 'tds-ui (Overlay) / Modal',\n  component: Modal,\n  args: {\n    open: false,\n    flexible: false,\n  },\n  argTypes: {\n    open: { type: 'boolean' },\n    flexible: { type: 'boolean' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '같은 브라우저 내부에서 상위 레이어를 띄우는 뷰 컴포넌트입니다.',\n      },\n      story: {\n        inline: false,\n        iframeHeight: 500,\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Modal>\n\nexport const Default: Story = {\n  args: {\n    open: true,\n    children: (\n      <>\n        <Modal.Body>\n          <Modal.Title>제목</Modal.Title>\n          <Modal.Description>부연설명 영역입니다.</Modal.Description>\n        </Modal.Body>\n        <Modal.Actions>\n          <Modal.Action color=\"blue\">닫기</Modal.Action>\n        </Modal.Actions>\n      </>\n    ),\n  },\n}\n\nexport const Flexible: Story = {\n  args: {\n    open: true,\n    flexible: true,\n    children: (\n      <>\n        <Modal.Body>\n          <Modal.Title>제목</Modal.Title>\n          <Modal.Description>부연설명 영역입니다.</Modal.Description>\n        </Modal.Body>\n        <Modal.Actions>\n          <Modal.Action color=\"blue\">닫기</Modal.Action>\n        </Modal.Actions>\n      </>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/modal/modal.test.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { ThemeProvider } from 'styled-components'\nimport { render, screen, waitFor } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\nimport { defaultTheme } from '@titicaca/tds-theme'\n\nimport { Modal } from './modal'\n\nfunction ThemeWrapper({ children }: PropsWithChildren<unknown>) {\n  return <ThemeProvider theme={defaultTheme}>{children}</ThemeProvider>\n}\n\ntest('올바른 aria attributes를 가집니다.', () => {\n  const onClose = jest.fn()\n\n  render(\n    <Modal open onClose={onClose}>\n      <Modal.Body>\n        <Modal.Title>Title</Modal.Title>\n        <Modal.Description>Description</Modal.Description>\n      </Modal.Body>\n      <Modal.Actions>\n        <Modal.Action>Close</Modal.Action>\n      </Modal.Actions>\n    </Modal>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const modal = screen.getByRole('dialog')\n\n  expect(modal).toHaveAttribute('role', 'dialog')\n  expect(modal).toHaveAttribute('aria-modal', 'true')\n  expect(modal).toHaveAttribute('aria-labelledby', screen.getByText('Title').id)\n  expect(modal).toHaveAttribute(\n    'aria-describedby',\n    screen.getByText('Description').id,\n  )\n})\n\ntest('외부를 클릭하면 닫습니다.', async () => {\n  const user = userEvent.setup()\n\n  const onClose = jest.fn()\n\n  render(\n    <>\n      <button>outside</button>\n      <Modal open onClose={onClose}>\n        <Modal.Body>\n          <Modal.Title>Title</Modal.Title>\n          <Modal.Description>Description</Modal.Description>\n        </Modal.Body>\n        <Modal.Actions>\n          <Modal.Action>Close</Modal.Action>\n        </Modal.Actions>\n      </Modal>\n    </>,\n    { wrapper: ThemeWrapper },\n  )\n\n  await user.click(screen.getByText('outside'))\n\n  expect(onClose).toHaveBeenCalledTimes(1)\n})\n\ntest('ESC 키를 누르면 닫습니다.', async () => {\n  const user = userEvent.setup()\n\n  const onClose = jest.fn()\n\n  render(\n    <Modal open onClose={onClose}>\n      <Modal.Body>\n        <Modal.Title>Title</Modal.Title>\n        <Modal.Description>Description</Modal.Description>\n      </Modal.Body>\n      <Modal.Actions>\n        <Modal.Action>Close</Modal.Action>\n      </Modal.Actions>\n    </Modal>,\n    { wrapper: ThemeWrapper },\n  )\n\n  await user.keyboard('{Escape}')\n\n  expect(onClose).toHaveBeenCalledTimes(1)\n})\n\ntest('focus trap을 사용합니다.', async () => {\n  const user = userEvent.setup()\n\n  const onClose = jest.fn()\n\n  render(\n    <Modal open onClose={onClose}>\n      <Modal.Body>\n        <button>Button 1</button>\n        <button>Button 2</button>\n      </Modal.Body>\n      <Modal.Actions>\n        <Modal.Action>Close</Modal.Action>\n      </Modal.Actions>\n    </Modal>,\n  )\n\n  await waitFor(() => expect(screen.getByRole('dialog')).toHaveFocus())\n\n  await user.tab()\n\n  await waitFor(() => expect(screen.getByText('Button 1')).toHaveFocus())\n\n  await user.tab()\n\n  await waitFor(() => expect(screen.getByText('Button 2')).toHaveFocus())\n\n  await user.tab()\n\n  await waitFor(() => expect(screen.getByText('Button 1')).toHaveFocus())\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/modal/modal.tsx",
    "content": "import { PropsWithChildren, useId } from 'react'\nimport { styled, css } from 'styled-components'\nimport {\n  FloatingFocusManager,\n  FloatingOverlay,\n  FloatingPortal,\n  useDismiss,\n  useFloating,\n  useInteractions,\n  useRole,\n} from '@floating-ui/react'\n\nimport { Container } from '../container'\nimport { FlexBox } from '../flex-box'\n\nimport { ModalAction } from './modal-action'\nimport { ModalActions } from './modal-actions'\nimport { ModalBody } from './modal-body'\nimport { ModalContext } from './modal-context'\nimport { ModalDescription } from './modal-description'\nimport { ModalTitle } from './modal-title'\n\nconst ModalPanel = styled(Container)<{ $flexible: boolean }>`\n  max-height: 100%;\n  background-color: #fff;\n  outline: none;\n  border-radius: 6px;\n  overflow: auto;\n  overscroll-behavior-y: none;\n\n  ${({ $flexible }) =>\n    $flexible\n      ? css`\n          min-width: 295px;\n        `\n      : css`\n          width: 295px;\n        `}\n`\n\nexport interface ModalProps extends PropsWithChildren {\n  open?: boolean\n  flexible?: boolean\n  onClose?: () => void\n}\n\nexport const Modal = ({\n  children,\n  open = false,\n  flexible = false,\n  onClose,\n}: ModalProps) => {\n  const labelId = useId()\n  const descriptionId = useId()\n\n  const { context, refs } = useFloating({\n    open,\n    onOpenChange: (open) => (open ? undefined : onClose?.()),\n  })\n\n  const dismiss = useDismiss(context)\n  const role = useRole(context, { role: 'dialog' })\n\n  const { getFloatingProps } = useInteractions([dismiss, role])\n\n  return (\n    <ModalContext.Provider\n      value={{\n        open: context.open,\n        labelId,\n        descriptionId,\n        onClose,\n      }}\n    >\n      {context.open ? (\n        <FloatingPortal>\n          <FloatingOverlay\n            lockScroll\n            css={css`\n              position: fixed;\n              top: 0;\n              bottom: 0;\n              left: 0;\n              right: 0;\n              background-color: rgba(58, 58, 58, 0.5);\n              z-index: 9999;\n            `}\n          />\n          <FlexBox\n            flex\n            alignItems=\"center\"\n            justifyContent=\"center\"\n            css={css`\n              width: 100vw;\n              height: 100vh;\n              height: 100dvh;\n              position: fixed;\n              top: 0;\n              bottom: 0;\n              left: 0;\n              right: 0;\n              z-index: 9999;\n            `}\n          >\n            <FloatingFocusManager\n              context={context}\n              initialFocus={refs.floating}\n            >\n              <ModalPanel\n                ref={refs.setFloating}\n                aria-labelledby={labelId}\n                aria-describedby={descriptionId}\n                aria-modal\n                $flexible={flexible}\n                {...getFloatingProps()}\n              >\n                {children}\n              </ModalPanel>\n            </FloatingFocusManager>\n          </FlexBox>\n        </FloatingPortal>\n      ) : null}\n    </ModalContext.Provider>\n  )\n}\n\nModal.Action = ModalAction\nModal.Actions = ModalActions\nModal.Body = ModalBody\nModal.Title = ModalTitle\nModal.Description = ModalDescription\n"
  },
  {
    "path": "packages/tds-ui/src/components/navbar/index.ts",
    "content": "export * from './navbar'\nexport * from './search-navbar'\n"
  },
  {
    "path": "packages/tds-ui/src/components/navbar/navbar.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { styled } from 'styled-components'\nimport { defaultTheme } from '@titicaca/tds-theme'\n\nimport { Text } from '../text'\n\nimport { Navbar, NavbarWrapper } from './navbar'\n\nconst Toc = styled.div`\n  position: absolute;\n  left: 52px;\n`\n\nconst meta: Meta<typeof Navbar> = {\n  title: 'tds-ui (Navigation) / Navbar',\n  component: Navbar,\n  args: {\n    zIndex: 2,\n    borderless: false,\n    backgroundColor: 'white',\n    position: 'fixed',\n  },\n  argTypes: {\n    zIndex: { type: 'number' },\n    title: { type: 'string' },\n    borderless: { type: 'boolean' },\n    maxWidth: { type: 'number' },\n    backgroundColor: {\n      control: 'select',\n      options: Object.keys(defaultTheme.colors),\n    },\n    position: {\n      control: 'select',\n      options: ['absolute', 'fixed', 'relative', 'static', 'sticky'],\n    },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '최상단에 Navigation 기능을 제공해야할 때 사용되는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Navbar>\n\nexport const TwoButtons: Story = {\n  args: {\n    title: '도쿄 관광지',\n    children: (\n      <>\n        <Navbar.Item icon=\"back\" />\n        <Navbar.Item icon=\"more\" floated=\"right\" />\n      </>\n    ),\n  },\n}\n\nexport const ThreeButtons: Story = {\n  args: {\n    title: '도쿄 관광지',\n    children: (\n      <>\n        <Navbar.Item icon=\"back\" />\n        <Navbar.Item icon=\"more\" floated=\"right\" />\n        <Navbar.Item icon=\"route\" floated=\"right\" />\n      </>\n    ),\n  },\n}\n\nexport const FourButtons: Story = {\n  args: {\n    title: '도쿄 관광지',\n    children: (\n      <>\n        <Navbar.Item icon=\"back\" />\n        <Navbar.Item icon=\"more\" floated=\"right\" />\n        <Navbar.Item icon=\"route\" floated=\"right\" />\n        <Navbar.Item icon=\"list\" floated=\"right\" />\n      </>\n    ),\n  },\n}\n\nexport const SecondaryNavbar: Story = {\n  args: {\n    title: '도쿄 관광지',\n    borderless: true,\n  },\n  render: (args) => {\n    return (\n      <>\n        <Navbar {...args}>\n          <Navbar.Item icon=\"back\" />\n          <Navbar.Item floated=\"right\" icon=\"more\" />\n        </Navbar>\n        <Navbar.Secondary>\n          <div>test</div>\n        </Navbar.Secondary>\n      </>\n    )\n  },\n}\n\nexport const WrappedNavbar: Story = {\n  args: {\n    title: '도쿄 관광지',\n    borderless: true,\n  },\n  render: (args) => {\n    return (\n      <NavbarWrapper>\n        <Navbar {...args}>\n          <Navbar.Item icon=\"back\" />\n          <Navbar.Item icon=\"more\" floated=\"right\" />\n        </Navbar>\n      </NavbarWrapper>\n    )\n  },\n}\n\nexport const RenderTitle: Story = {\n  args: {\n    renderTitle: () => (\n      <Toc>\n        <Text size=\"small\" bold alpha={1}>\n          도쿄에서 반드시 먹어봐야 할 음식\n        </Text>\n\n        <Text size=\"mini\" alpha={0.5} margin={{ top: 1 }}>\n          라멘\n        </Text>\n      </Toc>\n    ),\n    children: (\n      <>\n        <Navbar.Item icon=\"back\" />\n        <Navbar.Item icon=\"more\" floated=\"right\" />\n      </>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/navbar/navbar.tsx",
    "content": "import * as CSS from 'csstype'\nimport { styled, css } from 'styled-components'\nimport {\n  PropsWithChildren,\n  Children,\n  cloneElement,\n  ReactElement,\n  ReactNode,\n  HTMLAttributes,\n} from 'react'\nimport { TRIPLE_FALLBACK_ACTION_CLASS_NAME } from '@titicaca/triple-fallback-action'\nimport type { Theme } from '@titicaca/tds-theme'\n\nimport { MarginPadding } from '../../commons'\nimport { layeringMixin, LayeringMixinProps, paddingMixin } from '../../mixins'\nimport { unit } from '../../utils/unit'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\ninterface NavbarProps {\n  maxWidth?: number\n  borderless?: boolean\n  backgroundColor?: keyof Theme['colors']\n  position?: CSS.Property.Position\n  padding?: MarginPadding\n}\n\nconst WrapperContainer = styled.div<\n  {\n    position?: CSS.Property.Position\n    top?: number | string\n    height?: number | string\n  } & LayeringMixinProps\n>`\n  position: ${({ position = 'fixed' }) => position};\n  top: ${({ top = 0 }) => unit(top)};\n  left: 0;\n  right: 0;\n  ${layeringMixin(0)}\n  background: var(--color-white);\n  ${({ height }) =>\n    height &&\n    `\n      height: ${unit(height)};\n    `};\n`\n\nconst NavbarFrame = styled.div<NavbarProps & LayeringMixinProps>`\n  background-color: ${({ backgroundColor = 'white', theme }) =>\n    theme.colors[backgroundColor]};\n  position: ${({ position = 'sticky' }) => position};\n  top: 0;\n  left: 0;\n  right: 0;\n  height: 52px;\n  ${layeringMixin(0)}\n  ${({ borderless }) =>\n    borderless\n      ? ''\n      : css`\n          box-shadow: 0 1px 0 0 var(--color-brightGray);\n        `};\n  padding: 9px 12px;\n  margin: 0 auto;\n  max-width: ${({ maxWidth = '100%' }) => unit(maxWidth)};\n`\n\nconst TitleContainer = styled.div<{ childrenCount?: number }>`\n  position: absolute;\n  top: 50%;\n  left: 52px;\n  right: ${({ childrenCount }) => {\n    const count = childrenCount || 0\n    return 26 + 40 * Math.max(count - 1, 0)\n  }}px;\n  transform: translateY(-50%);\n  font-size: 18px;\n  color: #1e1e1e;\n  white-space: nowrap;\n  overflow-x: hidden;\n  text-overflow: ellipsis;\n  line-height: 52px;\n`\n\ntype IconNames =\n  | 'back'\n  | 'close'\n  | 'more'\n  | 'map'\n  | 'write'\n  | 'scraped'\n  | 'unscraped'\n  | 'share'\n  | 'route'\n  | 'search'\n  | 'cs'\n  | 'delete'\n  | 'list'\n  | 'hamburger'\n  | 'message'\n  | 'unreadMessage'\n  | 'support'\n  | 'viewAll'\n\nconst ICON_URL_BY_NAMES: { [key in IconNames]: string } = {\n  back: 'https://assets.triple.guide/images/btn-com-back@4x.png',\n  close: 'https://assets.triple.guide/images/btn-com-close@3x.png',\n  more: 'https://assets.triple.guide/images/btn-com-more@4x.png',\n  map: 'https://assets.triple.guide/images/ico-search-place@4x.png',\n  write: 'https://assets.triple.guide/images/btn-com-write@3x.png',\n  scraped: 'https://assets.triple.guide/images/btn-com-bookmark-on@4x.png',\n  unscraped: 'https://assets.triple.guide/images/btn-com-bookmark-off@4x.png',\n  share: 'https://assets.triple.guide/images/btn-com-share@4x.png',\n  route: 'https://assets.triple.guide/images/btn-com-route@4x.png',\n  search: 'https://assets.triple.guide/images/btn-com-search@3x.png',\n  cs: 'https://assets.triple.guide/images/btn-com-cs@3x.png',\n  delete: 'https://assets.triple.guide/images/btn-search-close@3x.png',\n  list: 'https://assets.triple.guide/images/ico-hotel-list@3x.png',\n  hamburger: 'https://assets.triple.guide/images/btn-my-profile@3x.png',\n  support: 'https://assets.triple.guide/images/btn-com-support@3x.png',\n  message: 'https://assets.triple.guide/images/btn-com-message@3x.png',\n  unreadMessage:\n    'https://assets.triple.guide/images/btn-com-message-noti@3x.png',\n  viewAll: 'https://assets.triple.guide/images/btn-end-view-all@3x.png',\n}\n\ninterface NavbarItemProps {\n  floated?: CSS.Property.Float\n  icon?: IconNames\n  position?: CSS.Property.Position\n  hasTitle?: boolean\n}\n\nconst NavbarItem = styled.div\n  .attrs<NavbarItemProps>(({ icon }) => ({\n    className: ['back', 'close'].includes(icon || '')\n      ? TRIPLE_FALLBACK_ACTION_CLASS_NAME\n      : '',\n  }))\n  .withConfig({\n    shouldForwardProp,\n  })<NavbarItemProps>`\n  ${({ position }) => position && `position: ${position};`}\n  float: ${({ floated }) => floated || 'left'};\n  background-image: url(${({ icon }) => (icon ? ICON_URL_BY_NAMES[icon] : '')});\n  background-size: cover;\n  height: 34px;\n  width: 34px;\n  margin-left: ${({ floated }) => (!floated || floated === 'left' ? 0 : '6px')};\n  margin-right: ${({ floated }) => (floated === 'right' ? 0 : '6px')};\n  cursor: pointer;\n  ${({ hasTitle }) =>\n    hasTitle &&\n    css`\n      line-height: 34px;\n      margin: 0;\n      width: auto;\n      white-space: nowrap;\n      word-break: break-word;\n      text-overflow: ellipsis;\n      overflow-x: hidden;\n    `}\n`\n\nconst SecondaryNavbar = styled.div.withConfig({\n  shouldForwardProp,\n})<NavbarProps & LayeringMixinProps>`\n  background-color: ${({ backgroundColor = 'white', theme }) =>\n    theme.colors[backgroundColor]};\n  ${({ position = 'sticky' }) => `\n      position: ${position};\n      top: ${position === 'sticky' ? '52px' : 0};\n  `}\n  left: 0;\n  right: 0;\n  ${({ padding }) => !padding && 'padding: 0 0 5px 0;'}\n  ${paddingMixin}\n  overflow: hidden;\n  ${layeringMixin(0)}\n  margin: 0 auto;\n  max-width: ${({ maxWidth }) => unit(maxWidth || 768)};\n`\n\nexport function NavbarWrapper({\n  position,\n  top,\n  height,\n  zTier,\n  zIndex,\n  children,\n}: PropsWithChildren<\n  {\n    position?: CSS.Property.Position\n    top?: number | string\n    height?: number | string\n  } & LayeringMixinProps\n>) {\n  return (\n    <WrapperContainer\n      position={position}\n      top={top}\n      height={height}\n      zTier={zTier}\n      zIndex={zIndex}\n    >\n      {Children.map(children, (child) => {\n        return cloneElement(\n          child as ReactElement<{\n            position: 'relative'\n          }>,\n          {\n            position: 'relative',\n          },\n        )\n      })}\n    </WrapperContainer>\n  )\n}\n\nexport function Navbar({\n  title,\n  renderTitle,\n  children,\n  zTier,\n  zIndex = 2,\n  ...props\n}: {\n  renderTitle?: (props?: unknown) => JSX.Element\n  children?: ReactNode\n} & NavbarProps &\n  LayeringMixinProps &\n  HTMLAttributes<HTMLDivElement>) {\n  return (\n    <NavbarFrame zTier={zTier} zIndex={zIndex} {...props}>\n      {renderTitle && renderTitle()}\n      {children}\n      {title && (\n        <NavbarItem floated=\"none\" hasTitle>\n          {title}\n        </NavbarItem>\n      )}\n    </NavbarFrame>\n  )\n}\n\nNavbar.Item = NavbarItem\nNavbar.Secondary = SecondaryNavbar\nNavbar.NavbarFrame = NavbarFrame\nNavbar.TitleContainer = TitleContainer\n"
  },
  {
    "path": "packages/tds-ui/src/components/navbar/search-navbar.stories.tsx",
    "content": "import { SyntheticEvent } from 'react'\nimport type { Meta, StoryObj } from '@storybook/react'\nimport { useArgs } from '@storybook/preview-api'\n\nimport { SearchNavbar } from './search-navbar'\n\nconst meta: Meta<typeof SearchNavbar> = {\n  title: 'tds-ui (Navigation) / SearchNavbar',\n  component: SearchNavbar,\n  args: {\n    backIconType: 'back',\n    borderless: false,\n  },\n  argTypes: {\n    backIconType: { control: 'select', options: ['back', 'close'] },\n    placeholder: { type: 'string' },\n    borderless: { type: 'boolean' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '최상단에 검색 기능을 제공해야할 때 사용되는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof SearchNavbar>\n\nexport const Back: Story = {\n  args: {\n    placeholder: '키워드를 입력해보세요',\n  },\n  render: function Render(args) {\n    const [{ value }, updateArgs] = useArgs()\n\n    const handleChange = (e: SyntheticEvent, value: string) => {\n      updateArgs({ value })\n    }\n\n    return (\n      <>\n        <SearchNavbar {...args} onInputChange={handleChange} />\n        <div>{value}</div>\n      </>\n    )\n  },\n}\n\nexport const Close: Story = {\n  args: {\n    placeholder: '키워드를 입력해보세요',\n    backIconType: 'close',\n  },\n  render: function Render(args) {\n    const [{ value }, updateArgs] = useArgs()\n\n    const handleChange = (e: SyntheticEvent, value: string) => {\n      updateArgs({ value })\n    }\n\n    return (\n      <>\n        <SearchNavbar\n          {...args}\n          onBackClick={() => {}}\n          onDeleteClick={() => {}}\n          onInputChange={handleChange}\n          onKeyUp={() => {}}\n          onBlur={() => {}}\n          onFocus={() => {}}\n          onSearch={() => {}}\n        />\n        <div>{value}</div>\n      </>\n    )\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/navbar/search-navbar.tsx",
    "content": "import { styled } from 'styled-components'\nimport {\n  forwardRef,\n  KeyboardEvent,\n  MouseEventHandler,\n  Ref,\n  SyntheticEvent,\n} from 'react'\n\nimport { LayeringMixinProps, layeringMixin } from '../../mixins'\n\nimport { Navbar } from './navbar'\n\nconst InputText = styled.input`\n  border-style: none;\n  font-size: 18px;\n  text-overflow: ellipsis;\n  padding: 0 35px 0 40px;\n  white-space: nowrap;\n  width: 100%;\n  height: 34px;\n  outline: none;\n\n  ${({ value }) =>\n    value\n      ? `padding: 0 95px 0 40px;`\n      : `\n      background: url(https://assets.triple.guide/images/btn-com-search@3x.png) no-repeat;\n      background-size: 34px 34px;\n      background-position: 100% 0;\n  `}\n`\n\nconst MainNavbarFrame = styled(Navbar.NavbarFrame)<\n  { noBorder?: boolean } & LayeringMixinProps\n>`\n  ${({ noBorder }) =>\n    noBorder ? '' : ` border-bottom: 1px solid var(--color-gray50);`}\n  ${layeringMixin(0)}\n`\n\nconst Icon = styled(Navbar.Item)<{ visible: boolean }>`\n  position: absolute;\n  top: 50%;\n  transform: translateY(-50%);\n  margin-right: 0;\n  float: none;\n  display: ${({ visible }) => (visible ? 'block' : 'none')};\n`\n\nconst SearchIcon = styled(Icon)`\n  right: 12px;\n`\n\nconst DeleteIcon = styled(Icon)`\n  right: 52px;\n`\n\ninterface InputProps {\n  placeholder?: string\n  onInputChange?: (e: SyntheticEvent, value: string) => void\n  onBlur?: (e: SyntheticEvent) => void\n  onFocus?: (e: SyntheticEvent) => void\n  onKeyUp?: (e: KeyboardEvent) => void\n  onClick?: MouseEventHandler<HTMLInputElement>\n  value?: string\n}\n\nconst Input = forwardRef(\n  (\n    {\n      placeholder,\n      onInputChange,\n      onBlur,\n      onFocus,\n      onKeyUp,\n      value,\n      onClick,\n    }: InputProps,\n    ref?: Ref<HTMLInputElement>,\n  ) => (\n    <InputText\n      placeholder={placeholder}\n      onChange={(e) => onInputChange && onInputChange(e, e.target.value)}\n      onBlur={(e) => onBlur && onBlur(e)}\n      onFocus={(e) => onFocus && onFocus(e)}\n      onKeyUp={(e) => onKeyUp && onKeyUp(e)}\n      onClick={onClick}\n      value={value}\n      ref={ref}\n    />\n  ),\n)\n\nInput.displayName = 'InputText'\n\nexport function SearchNavbar({\n  placeholder,\n  onBackClick,\n  onDeleteClick,\n  onInputClick,\n  onInputChange,\n  onKeyUp,\n  onBlur,\n  onFocus,\n  onSearch,\n  value,\n  inputRef,\n  borderless: noBorder,\n  backIconType = 'back',\n  ...rest\n}: {\n  onSearch: () => void\n  onInputClick?: MouseEventHandler<HTMLInputElement>\n  onBackClick: (event: SyntheticEvent) => void\n  onDeleteClick?: (event: SyntheticEvent) => void\n  inputRef?: Ref<HTMLInputElement>\n  borderless?: boolean\n  backIconType?: 'back' | 'close'\n} & InputProps &\n  LayeringMixinProps) {\n  return (\n    // borderless는 NavbarFrame의 기본 border(box-shadow)를 비활성화 시킴.\n    // noBorder는 MainNavbarFrame의 border(border-bottom)를 비활성화 시킴.\n    <MainNavbarFrame borderless noBorder={noBorder} {...rest}>\n      <Icon icon={backIconType} onClick={onBackClick} visible />\n      <Input\n        placeholder={placeholder}\n        onInputChange={onInputChange}\n        onBlur={onBlur}\n        onKeyUp={onKeyUp}\n        onFocus={onFocus}\n        value={value}\n        ref={inputRef}\n        onClick={onInputClick}\n      />\n      <DeleteIcon icon=\"delete\" onClick={onDeleteClick} visible={!!value} />\n      <SearchIcon icon=\"search\" onClick={onSearch} visible={!!value} />\n    </MainNavbarFrame>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/numeric-spinner/index.ts",
    "content": "export * from './numeric-spinner-base'\nexport * from './numeric-spinner'\n"
  },
  {
    "path": "packages/tds-ui/src/components/numeric-spinner/numeric-spinner-base.tsx",
    "content": "import { KeyboardEventHandler } from 'react'\nimport { styled } from 'styled-components'\n\nimport { Text } from '../text'\nimport { GlobalSizes } from '../../commons'\n\nfunction clamp(value: number, min: number, max: number) {\n  if (Math.min(value, min) === value) {\n    return min\n  } else if (Math.max(value, max) === value) {\n    return max\n  } else {\n    return value\n  }\n}\n\nconst Counter = styled.div`\n  display: flex;\n  align-items: center;\n`\n\nconst Button = styled.button`\n  width: 36px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: #222;\n  cursor: pointer;\n\n  &:disabled {\n    cursor: auto;\n    opacity: 0.2;\n  }\n`\n\nexport interface NumericSpinnerBaseProps {\n  disabled?: boolean\n  min?: number\n  max?: number\n  step?: number\n  size?: GlobalSizes\n  value?: number\n  onChange?: (value: number) => void\n}\n\nexport const NumericSpinnerBase = ({\n  disabled,\n  min = 0,\n  max = Infinity,\n  step = 1,\n  size = 'medium',\n  value = 0,\n  onChange,\n}: NumericSpinnerBaseProps) => {\n  const increment = () => {\n    onChange?.(clamp(value + step, min, max))\n  }\n\n  const decrement = () => {\n    onChange?.(clamp(value - step, min, max))\n  }\n\n  const toMax = () => {\n    if (isFinite(max)) {\n      onChange?.(max)\n    }\n  }\n\n  const toMin = () => {\n    if (isFinite(min)) {\n      onChange?.(min)\n    }\n  }\n\n  const handleKeyDown: KeyboardEventHandler = (event) => {\n    switch (event.key) {\n      case 'ArrowUp':\n        event.stopPropagation()\n        increment()\n        break\n      case 'ArrowDown':\n        event.stopPropagation()\n        decrement()\n        break\n      case 'Home':\n        event.stopPropagation()\n        toMin()\n        break\n      case 'End':\n        event.stopPropagation()\n        toMax()\n        break\n    }\n  }\n\n  return (\n    <Counter role=\"group\" tabIndex={0} onKeyDown={handleKeyDown}>\n      <Button\n        disabled={disabled || value <= min}\n        aria-label=\"감소\"\n        type=\"button\"\n        tabIndex={-1}\n        onClick={decrement}\n      >\n        <svg\n          width=\"14\"\n          height=\"3\"\n          viewBox=\"0 0 14 3\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          focusable={false}\n          aria-hidden\n        >\n          <path\n            d=\"M13 0.5H1C0.447715 0.5 0 0.947715 0 1.5C0 2.05228 0.447715 2.5 1 2.5H13C13.5523 2.5 14 2.05228 14 1.5C14 0.947715 13.5523 0.5 13 0.5Z\"\n            fill=\"currentColor\"\n          />\n        </svg>\n      </Button>\n      <Text\n        aria-valuenow={value}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        role=\"spinbutton\"\n        size={size || 'medium'}\n        padding={{ left: 10, right: 10 }}\n      >\n        {value}\n      </Text>\n      <Button\n        disabled={disabled || value >= max}\n        aria-label=\"증가\"\n        type=\"button\"\n        tabIndex={-1}\n        onClick={increment}\n      >\n        <svg\n          width=\"14\"\n          height=\"15\"\n          viewBox=\"0 0 14 15\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          focusable={false}\n          aria-hidden\n        >\n          <path\n            fillRule=\"evenodd\"\n            clipRule=\"evenodd\"\n            d=\"M7 0.5C7.55228 0.5 8 0.947715 8 1.5V6.5H13C13.5523 6.5 14 6.94772 14 7.5C14 8.05228 13.5523 8.5 13 8.5H8V13.5C8 14.0523 7.55228 14.5 7 14.5C6.44772 14.5 6 14.0523 6 13.5V8.5H1C0.447715 8.5 0 8.05228 0 7.5C0 6.94772 0.447715 6.5 1 6.5H6V1.5C6 0.947715 6.44772 0.5 7 0.5Z\"\n            fill=\"currentColor\"\n          />\n        </svg>\n      </Button>\n    </Counter>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/numeric-spinner/numeric-spinner.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { NumericSpinner } from './numeric-spinner'\n\nconst meta: Meta<typeof NumericSpinner> = {\n  title: 'tds-ui (Form) / NumericSpinner',\n  component: NumericSpinner,\n  args: {\n    size: 'small',\n    value: 0,\n    min: 0,\n    // Infinity를 못읽어서 9999로 표현\n    max: 9999,\n    step: 1,\n  },\n  argTypes: {\n    label: { type: 'string' },\n    sublabel: { type: 'string' },\n    strikeLabel: { type: 'string' },\n    value: { type: 'number' },\n    disabled: { type: 'boolean' },\n    min: { type: 'number' },\n    max: { type: 'number' },\n    step: { type: 'number' },\n    size: {\n      control: 'select',\n      options: [\n        'mini',\n        'tiny',\n        'big',\n        'huge',\n        'massive',\n        'small',\n        'medium',\n        'large',\n      ],\n    },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자가 버튼을 통해 숫자를 조절할 수 있는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof NumericSpinner>\n\nexport const Default: Story = {\n  args: {\n    label: '성인',\n  },\n}\n\nexport const SubLabel: Story = {\n  args: {\n    label: '성인',\n    sublabel: '탑승객 수를 의미합니다.',\n  },\n}\nexport const StrikeLabel: Story = {\n  args: {\n    label: '성인',\n    strikeLabel: '중앙에 줄이 그어집니다.',\n  },\n}\n\nexport const Disabled: Story = {\n  args: {\n    label: '성인',\n    disabled: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/numeric-spinner/numeric-spinner.tsx",
    "content": "import { useId } from 'react'\n\nimport { FlexBox, FlexBoxItem } from '../flex-box'\nimport { Text } from '../text'\n\nimport {\n  NumericSpinnerBase,\n  NumericSpinnerBaseProps,\n} from './numeric-spinner-base'\n\nexport interface NumericSpinnerProps extends NumericSpinnerBaseProps {\n  label?: string\n  sublabel?: string\n  strikeLabel?: string\n}\n\nexport const NumericSpinner = ({\n  label,\n  sublabel,\n  strikeLabel,\n  disabled,\n  max,\n  min,\n  step,\n  size,\n  value,\n  onChange,\n  ...props\n}: NumericSpinnerProps) => {\n  const labelId = useId()\n\n  return (\n    <FlexBox flex alignItems=\"center\" {...props}>\n      <FlexBoxItem flex=\"1\">\n        <Text id={labelId} size={size || 'small'}>\n          {label}\n        </Text>\n\n        {sublabel ? (\n          <Text size=\"mini\" color=\"blue\" inline>\n            {sublabel}\n          </Text>\n        ) : null}\n\n        {strikeLabel ? (\n          <Text\n            size=\"mini\"\n            color=\"gray\"\n            alpha={0.3}\n            inline\n            strikethrough\n            margin={{ left: 2 }}\n          >\n            {strikeLabel}\n          </Text>\n        ) : null}\n      </FlexBoxItem>\n\n      <NumericSpinnerBase\n        disabled={disabled}\n        max={max}\n        min={min}\n        step={step}\n        size={size}\n        value={value}\n        onChange={onChange}\n      />\n    </FlexBox>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/popup/index.ts",
    "content": "export * from './popup'\n"
  },
  {
    "path": "packages/tds-ui/src/components/popup/popup.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { styled } from 'styled-components'\n\nimport { Popup } from './popup'\n\nconst meta: Meta<typeof Popup> = {\n  title: 'tds-ui (Overlay) / Popup',\n  component: Popup,\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 내용을 표시하기 위해 갑자기 생성되는 뷰 컴포넌트입니다.',\n      },\n      story: {\n        inline: false,\n        iframeHeight: 500,\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Popup>\n\nconst EmptyScroll = styled.div`\n  height: 200vh;\n`\n\nexport const Default: Story = {\n  args: {\n    open: true,\n    title: '제목',\n    children: <EmptyScroll>팝업 내용입니다</EmptyScroll>,\n  },\n}\n\nexport const Borderless: Story = {\n  args: {\n    open: true,\n    borderless: true,\n    title: '제목',\n    children: <EmptyScroll>팝업 내용입니다</EmptyScroll>,\n  },\n}\n\nexport const NoNavbar: Story = {\n  args: {\n    open: true,\n    noNavbar: true,\n    title: '제목',\n    children: <EmptyScroll>팝업 내용입니다</EmptyScroll>,\n  },\n}\n\n// TODO\n// export const AfterActionSheet = () => {\n//   return (\n//     <>\n//       <Popup title=\"팝업입니다\" open onClose={() => {}}>\n//         <EmptyScroll>Scroll........</EmptyScroll>\n//       </Popup>\n\n//       <ActionSheet open={false} title=\"샘플 액션시트\">\n//         <ActionSheet.Item>메뉴 1</ActionSheet.Item>\n//         <ActionSheet.Item>메뉴 2</ActionSheet.Item>\n//       </ActionSheet>\n//     </>\n//   )\n// }\n// AfterActionSheet.storyName = '팝업과 액션시트가 같은 계층에 있는 경우'\n\n// export const WithInActionSheet = () => {\n//   return (\n//     <Popup title=\"팝업입니다\" open onClose={() => {}}>\n//       <EmptyScroll>Scroll........</EmptyScroll>\n//       <ActionSheet open={false} title=\"샘플 액션시트\">\n//         <ActionSheet.Item>메뉴 1</ActionSheet.Item>\n//         <ActionSheet.Item>메뉴 2</ActionSheet.Item>\n//       </ActionSheet>\n//     </Popup>\n//   )\n// }\n// WithInActionSheet.storyName = '팝업 안에 액션시트가 있는 경우'\n"
  },
  {
    "path": "packages/tds-ui/src/components/popup/popup.test.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { ThemeProvider } from 'styled-components'\nimport { render, screen, waitFor } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\nimport { defaultTheme } from '@titicaca/tds-theme'\n\nimport { Popup } from './popup'\n\nfunction ThemeWrapper({ children }: PropsWithChildren<unknown>) {\n  return <ThemeProvider theme={defaultTheme}>{children}</ThemeProvider>\n}\n\ntest('올바른 aria attributes를 가집니다.', () => {\n  const onClose = jest.fn()\n\n  render(\n    <Popup open onClose={onClose}>\n      contents\n    </Popup>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const modal = screen.getByRole('dialog')\n\n  expect(modal).toHaveAttribute('role', 'dialog')\n  expect(modal).toHaveAttribute('aria-modal', 'true')\n})\n\ntest('ESC 키를 누르면 닫습니다.', async () => {\n  const user = userEvent.setup()\n\n  const onClose = jest.fn()\n\n  render(\n    <Popup open onClose={onClose}>\n      contents\n    </Popup>,\n    { wrapper: ThemeWrapper },\n  )\n\n  await user.keyboard('{Escape}')\n\n  expect(onClose).toHaveBeenCalledTimes(1)\n})\n\ntest('focus trap을 사용합니다.', async () => {\n  const user = userEvent.setup()\n\n  const onClose = jest.fn()\n\n  render(\n    <Popup open onClose={onClose}>\n      <button>Button 1</button>\n      <button>Button 2</button>\n    </Popup>,\n    { wrapper: ThemeWrapper },\n  )\n\n  await waitFor(() => expect(screen.getByRole('dialog')).toHaveFocus())\n\n  await user.tab()\n\n  await waitFor(() => expect(screen.getByText('Button 1')).toHaveFocus())\n\n  await user.tab()\n\n  await waitFor(() => expect(screen.getByText('Button 2')).toHaveFocus())\n\n  await user.tab()\n\n  await waitFor(() => expect(screen.getByText('Button 1')).toHaveFocus())\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/popup/popup.tsx",
    "content": "import { PropsWithChildren, useEffect } from 'react'\nimport { styled } from 'styled-components'\nimport {\n  FloatingFocusManager,\n  FloatingOverlay,\n  FloatingPortal,\n  useDismiss,\n  useFloating,\n  useInteractions,\n  useRole,\n  useTransitionStatus,\n} from '@floating-ui/react'\n\nimport { Navbar } from '../navbar'\n\ntype NavbarIcon = 'close' | 'back'\n\nconst TRANSITION_DURATION = 300\n\nconst PopupContainer = styled.div`\n  position: fixed;\n  top: 0;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  background-color: #fff;\n  user-select: none;\n  z-index: 9999;\n  outline: none;\n  overflow-y: auto;\n  -webkit-overflow-scrolling: touch;\n\n  @supports (padding: env(safe-area-inset-bottom)) {\n    padding-bottom: env(safe-area-inset-bottom);\n  }\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n\n  transition: transform ${TRANSITION_DURATION}ms ease-out;\n  transform: translateY(100%);\n\n  &[data-transition='open'] {\n    transform: translateY(0);\n  }\n`\n\nexport interface PopupProps extends PropsWithChildren {\n  id?: string\n  /**\n   * 팝업을 열지 결정합니다.\n   */\n  open: boolean\n  /**\n   * Navbar의 border를 그릴지 결정합니다.\n   */\n  borderless?: boolean\n  /** Navbar의 제목입니다. */\n  title?: string\n  icon?: NavbarIcon\n  /**\n   * Navbar의 렌더링을 생략할 수 있도록 합니다.\n   */\n  noNavbar?: boolean\n  /**\n   * 닫기 버튼을 눌렀을 때의 이벤트 입니다.\n   */\n  onClose: () => void\n  onEnter?: () => void\n  onEntered?: () => void\n  onExit?: () => void\n  onExited?: () => void\n}\n\n/**\n * 밑에서 올라오는 팝업입니다.\n */\nexport function Popup({\n  id,\n  open = false,\n  borderless = false,\n  icon = 'close',\n  title,\n  noNavbar,\n  children,\n  onClose,\n  onEnter,\n  onEntered,\n  onExit,\n  onExited,\n  ...props\n}: PopupProps) {\n  const { context, refs } = useFloating({\n    open,\n    onOpenChange: (open) => (open ? undefined : onClose?.()),\n  })\n\n  const dismiss = useDismiss(context)\n  const role = useRole(context, { role: 'dialog' })\n\n  const { getFloatingProps } = useInteractions([dismiss, role])\n\n  const { isMounted, status } = useTransitionStatus(context, {\n    duration: TRANSITION_DURATION,\n  })\n\n  useEffect(() => {\n    if (status === 'open') {\n      onEnter?.()\n      const timeout = setTimeout(() => onEntered?.(), TRANSITION_DURATION)\n      return () => clearTimeout(timeout)\n    } else if (status === 'close') {\n      onExit?.()\n      const timeout = setTimeout(() => onExited?.(), TRANSITION_DURATION)\n      return () => clearTimeout(timeout)\n    }\n  }, [onEnter, onEntered, onExit, onExited, status])\n\n  if (!isMounted) {\n    return null\n  }\n\n  return (\n    <FloatingPortal id={id}>\n      <FloatingOverlay lockScroll />\n      <FloatingFocusManager context={context} initialFocus={refs.floating}>\n        <PopupContainer\n          ref={refs.setFloating}\n          data-transition={status}\n          aria-modal\n          {...getFloatingProps(props)}\n        >\n          {noNavbar ? null : (\n            <Navbar borderless={borderless} title={title}>\n              <Navbar.Item floated=\"left\" icon={icon} onClick={onClose} />\n            </Navbar>\n          )}\n          {children}\n        </PopupContainer>\n      </FloatingFocusManager>\n    </FloatingPortal>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio/index.ts",
    "content": "export * from './radio-base'\nexport * from './radio'\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio/radio-base.tsx",
    "content": "import { forwardRef, InputHTMLAttributes } from 'react'\nimport { styled } from 'styled-components'\n\nconst RadioInput = styled.input`\n  appearance: none;\n  position: relative;\n  width: 26px;\n  height: 26px;\n  border: 1px solid var(--color-gray200);\n  border-radius: 50%;\n\n  &::after {\n    content: '';\n    display: block;\n    position: absolute;\n    top: 7px;\n    left: 7px;\n    width: 10px;\n    height: 10px;\n    border-radius: 50%;\n    background-color: var(--color-blue);\n    opacity: 0;\n    transition: opacity 0.3s ease;\n  }\n\n  &:checked::after {\n    opacity: 1;\n  }\n`\n\nexport type RadioBaseProps = InputHTMLAttributes<HTMLInputElement>\n\nexport const RadioBase = forwardRef<HTMLInputElement, RadioBaseProps>(\n  function RadioBase(props, ref) {\n    return <RadioInput ref={ref} type=\"radio\" {...props} />\n  },\n)\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio/radio.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Radio as RadioComponent } from './radio'\n\nconst meta: Meta<typeof RadioComponent> = {\n  title: 'tds-ui (Form) / Radio',\n  component: RadioComponent,\n  argTypes: {\n    name: { type: 'string' },\n    checked: { type: 'boolean' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: '라디오 버튼 기능을 제공하는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const Radio: StoryObj<typeof RadioComponent> = {\n  args: {\n    value: 'radio',\n    children: '라디오 버튼',\n    checked: false,\n  },\n}\n\nexport const RadioChecked: StoryObj<typeof RadioComponent> = {\n  args: {\n    value: 'radio',\n    children: '라디오 버튼',\n    checked: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio/radio.tsx",
    "content": "import {\n  ChangeEventHandler,\n  forwardRef,\n  PropsWithChildren,\n  useContext,\n} from 'react'\nimport { styled } from 'styled-components'\n\nimport { Text } from '../text'\nimport { RadioGroupContext } from '../radio-group'\n\nimport { RadioBase, RadioBaseProps } from './radio-base'\n\nconst RadioLabel = styled.label`\n  display: flex;\n  align-items: center;\n  margin-bottom: 20px;\n\n  &:last-child {\n    margin-bottom: 0;\n  }\n`\n\nconst RadioText = styled(Text)`\n  flex: 1;\n`\n\nexport type RadioProps = RadioBaseProps & PropsWithChildren\n\nexport const Radio = forwardRef<HTMLInputElement, RadioProps>(function Radio(\n  { children, name, checked, value, onChange, ...props },\n  ref,\n) {\n  const group = useContext(RadioGroupContext)\n\n  const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {\n    if (group) {\n      group.onChange?.(event.target.value)\n    } else {\n      onChange?.(event)\n    }\n  }\n\n  return (\n    <RadioLabel>\n      <RadioText size=\"large\">{children}</RadioText>\n      <RadioBase\n        {...props}\n        ref={ref}\n        name={name ?? group?.name}\n        checked={checked ?? (value ? group?.value === value : undefined)}\n        value={value}\n        onChange={handleChange}\n      />\n    </RadioLabel>\n  )\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio-group/index.ts",
    "content": "export * from './radio-group-context'\nexport * from './radio-group'\nexport * from './use-radio-group'\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio-group/radio-group-context.tsx",
    "content": "import { createContext } from 'react'\n\nexport interface RadioGroupContextValue {\n  descriptionId: string\n  errorId: string\n  isDisabled: boolean\n  isError: boolean\n  isFocused: boolean\n  isRequired: boolean\n  name?: string\n  value?: string\n  onChange?: (value: string) => void\n}\n\nexport const RadioGroupContext = createContext<\n  RadioGroupContextValue | undefined\n>(undefined)\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio-group/radio-group-error.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { Container } from '../container'\nimport { Text } from '../text'\n\nimport { useRadioGroup } from './use-radio-group'\n\nexport type RadioGroupErrorProps = PropsWithChildren\n\nexport const RadioGroupError = ({ children }: RadioGroupErrorProps) => {\n  const radioGroup = useRadioGroup()\n\n  return (\n    <Container css={{ padding: '6px 0 0' }}>\n      <Text color=\"red\" size=\"tiny\" id={radioGroup.errorId}>\n        {children}\n      </Text>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio-group/radio-group-help.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { Container } from '../container'\nimport { Text } from '../text'\n\nimport { useRadioGroup } from './use-radio-group'\n\nexport type RadioGroupHelpProps = PropsWithChildren\n\nexport const RadioGroupHelp = ({ children }: RadioGroupHelpProps) => {\n  const radioGroup = useRadioGroup()\n\n  return (\n    <Container\n      css={{\n        padding: '6px 0 0',\n      }}\n    >\n      <Text alpha={0.5} size=\"tiny\" id={radioGroup.descriptionId}>\n        {children}\n      </Text>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio-group/radio-group-label.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled, css } from 'styled-components'\n\nimport { Text } from '../text'\n\nimport { useRadioGroup } from './use-radio-group'\n\ninterface LabelProps {\n  isError: boolean\n  isRequired: boolean\n  isFocused: boolean\n}\n\nconst Label = styled(Text)<LabelProps>`\n  margin-bottom: 6px;\n\n  ${({ isFocused }) =>\n    isFocused &&\n    css`\n      color: var(--color-blue);\n    `}\n\n  ${({ isError }) =>\n    isError &&\n    css`\n      color: var(--color-red);\n    `}\n\n  ${({ isRequired }) =>\n    isRequired &&\n    css`\n      &::after {\n        content: ${isRequired ? \"'*'\" : undefined};\n        display: inline;\n        color: var(--color-mediumRed);\n        font-weight: normal;\n        margin-left: 4px;\n      }\n    `}\n`\n\nexport type RadioGroupLabelProps = PropsWithChildren\n\nexport const RadioGroupLabel = ({ children }: RadioGroupLabelProps) => {\n  const radioGroup = useRadioGroup()\n\n  return (\n    <Label\n      as=\"legend\"\n      size=\"tiny\"\n      isError={radioGroup.isError}\n      isRequired={radioGroup.isRequired}\n      isFocused={radioGroup.isFocused}\n    >\n      {children}\n    </Label>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio-group/radio-group.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Radio } from '../radio/radio'\n\nimport { RadioGroup } from './radio-group'\n\nconst meta: Meta<typeof RadioGroup> = {\n  title: 'tds-ui (Form) / RadioGroup',\n  component: RadioGroup,\n  args: {\n    disabled: false,\n    required: false,\n  },\n  argTypes: {\n    name: { type: 'string' },\n    value: { type: 'string' },\n    disabled: { type: 'boolean' },\n    required: { type: 'boolean' },\n    label: { type: 'string' },\n    error: { type: 'string' },\n    help: { type: 'string' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: '개별 라디오를 묶어줄 때 사용되는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof RadioGroup>\n\nexport const Default: Story = {\n  args: {\n    name: 'option',\n    value: 'a',\n    children: (\n      <>\n        <Radio value=\"a\">Option A</Radio>\n        <Radio value=\"b\">Option B</Radio>\n        <Radio value=\"c\">Option C</Radio>\n      </>\n    ),\n  },\n}\n\nexport const Disabled: Story = {\n  args: {\n    name: 'option',\n    value: 'a',\n    disabled: true,\n    children: (\n      <>\n        <Radio value=\"a\">Option A</Radio>\n        <Radio value=\"b\">Option B</Radio>\n        <Radio value=\"c\">Option C</Radio>\n      </>\n    ),\n  },\n}\n\nexport const Required: Story = {\n  args: {\n    name: 'option',\n    value: 'a',\n    required: true,\n    children: (\n      <>\n        <Radio value=\"a\">Option A</Radio>\n        <Radio value=\"b\">Option B</Radio>\n        <Radio value=\"c\">Option C</Radio>\n      </>\n    ),\n  },\n}\n\nexport const WithLabel: Story = {\n  args: {\n    name: 'option',\n    value: 'a',\n    label: '라디오',\n    children: (\n      <>\n        <Radio value=\"a\">Option A</Radio>\n        <Radio value=\"b\">Option B</Radio>\n        <Radio value=\"c\">Option C</Radio>\n      </>\n    ),\n  },\n}\n\nexport const WithHelpMessage: Story = {\n  args: {\n    name: 'option',\n    value: 'a',\n    label: '라디오',\n    help: 'Help text',\n    children: (\n      <>\n        <Radio value=\"a\">Option A</Radio>\n        <Radio value=\"b\">Option B</Radio>\n        <Radio value=\"c\">Option C</Radio>\n      </>\n    ),\n  },\n}\n\nexport const WithErrorMessage: Story = {\n  args: {\n    name: 'option',\n    value: 'a',\n    label: '라디오',\n    error: 'Error text',\n    children: (\n      <>\n        <Radio value=\"a\">Option A</Radio>\n        <Radio value=\"b\">Option B</Radio>\n        <Radio value=\"c\">Option C</Radio>\n      </>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio-group/radio-group.tsx",
    "content": "import { FocusEventHandler, HTMLAttributes, useId, useState } from 'react'\n\nimport { RadioGroupContext } from './radio-group-context'\nimport { RadioGroupLabel } from './radio-group-label'\nimport { RadioGroupError } from './radio-group-error'\nimport { RadioGroupHelp } from './radio-group-help'\n\nexport interface RadioGroupProps\n  extends Omit<HTMLAttributes<HTMLFieldSetElement>, 'onChange'> {\n  name?: string\n  value?: string\n  disabled?: boolean\n  required?: boolean\n  label?: string\n  error?: string\n  help?: string\n  onBlur?: FocusEventHandler\n  onChange?: (value: string) => void\n  onFocus?: FocusEventHandler\n}\n\nexport const RadioGroup = ({\n  children,\n  name,\n  value,\n  disabled = false,\n  required = false,\n  label,\n  error,\n  help,\n  onBlur,\n  onChange,\n  onFocus,\n  ...props\n}: RadioGroupProps) => {\n  const descriptionId = useId()\n  const errorId = useId()\n  const [isFocused, setIsFocused] = useState(false)\n\n  const handleBlur: FocusEventHandler = (event) => {\n    setIsFocused(false)\n    onBlur?.(event)\n  }\n\n  const handleFocus: FocusEventHandler = (event) => {\n    setIsFocused(true)\n    onFocus?.(event)\n  }\n\n  const isError = !!error\n\n  return (\n    <RadioGroupContext.Provider\n      value={{\n        descriptionId,\n        errorId,\n        isDisabled: disabled,\n        isError,\n        isFocused,\n        isRequired: required,\n        name,\n        value,\n        onChange,\n      }}\n    >\n      <fieldset\n        role=\"radiogroup\"\n        aria-describedby={descriptionId}\n        aria-errormessage={errorId}\n        aria-invalid={isError}\n        aria-required={required}\n        onBlur={handleBlur}\n        onFocus={handleFocus}\n        {...props}\n      >\n        {label ? <RadioGroupLabel>{label}</RadioGroupLabel> : null}\n        <div>{children}</div>\n        {error ? (\n          <RadioGroupError>{error}</RadioGroupError>\n        ) : help ? (\n          <RadioGroupHelp>{help}</RadioGroupHelp>\n        ) : null}\n      </fieldset>\n    </RadioGroupContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/radio-group/use-radio-group.tsx",
    "content": "import { useContext } from 'react'\n\nimport { RadioGroupContext } from './radio-group-context'\n\nexport function useRadioGroup() {\n  const context = useContext(RadioGroupContext)\n  if (!context) {\n    throw new Error('RadioGroupContext가 없습니다.')\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/rating/index.ts",
    "content": "export * from './rating'\n"
  },
  {
    "path": "packages/tds-ui/src/components/rating/rating.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Rating } from './rating'\n\nconst meta: Meta<typeof Rating> = {\n  title: 'tds-ui (Data display) / Rating',\n  component: Rating,\n  args: {\n    size: 'tiny',\n    score: 0,\n  },\n  argTypes: {\n    score: { type: 'number' },\n    size: {\n      control: 'select',\n      options: ['tiny', 'small', 'medium'],\n    },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 리뷰 평점을 제공하는 뷰 컴포넌트입니다.\\n * 최솟값 0, 최댓값 5**로 설정됩니다.\\n * score에 최소, 최대보다 작거나 큰 값을 넣어도 동작합니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Rating>\n\nexport const Default: Story = {\n  args: {},\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/rating/rating.tsx",
    "content": "import { SyntheticEvent, useMemo } from 'react'\nimport { styled, css } from 'styled-components'\nimport * as CSS from 'csstype'\n\nimport { GlobalSizes } from '../../commons'\n\nconst SIZES: Partial<Record<GlobalSizes, string>> = {\n  tiny: '14px',\n  small: '16px',\n  medium: '30px',\n}\n\nconst MARGINS: Partial<Record<GlobalSizes, string>> = {\n  small: '0',\n  medium: '2px',\n}\n\nconst IMAGE_PREFIXES: Partial<Record<GlobalSizes, string>> = {\n  tiny: 'https://assets.triple.guide/images/img-review-star',\n  small: 'https://assets.triple.guide/images/img-review-star',\n  medium: 'https://assets.triple.guide/images/img-review-star-medium',\n}\n\nconst RatingStar = styled.span<{\n  verticalAlign?: CSS.Property.VerticalAlign<string>\n  size?: GlobalSizes\n  full?: boolean\n  half?: boolean\n}>`\n  display: inline-block;\n  vertical-align: ${({ verticalAlign }) => verticalAlign || 'text-bottom'};\n\n  ${({ size = 'small', full, half }) => css`\n    width: ${SIZES[size]};\n    height: ${SIZES[size]};\n    margin: 0 ${MARGINS[size]};\n    background-size: ${SIZES[size]} ${SIZES[size]};\n    background-image: url(${IMAGE_PREFIXES[size]}-${full\n      ? 'full'\n      : half\n        ? 'half'\n        : 'empty'}@4x.png);\n  `};\n`\n\nexport function Rating({\n  size,\n  score: initScore = 0,\n  verticalAlign,\n  onClick,\n}: {\n  size?: GlobalSizes\n  score?: number\n  verticalAlign?: CSS.Property.VerticalAlign<string>\n  onClick?: (event: SyntheticEvent, rating: number) => unknown\n}) {\n  const score = useMemo(() => Math.max(Math.min(initScore, 5), 0), [initScore])\n  const full = Math.floor(score)\n  const half = Math.floor((score - full) * 2)\n  const empty = 5 - full - half\n\n  return (\n    <>\n      {[...Array(full)].map((_, i: number) => (\n        <RatingStar\n          key={`full-${i}`}\n          size={size}\n          verticalAlign={verticalAlign}\n          full\n          onClick={onClick ? (e) => onClick(e, i + 1) : undefined}\n        />\n      ))}\n      {[...Array(half)].map((_, i) => (\n        <RatingStar\n          key={`half-${i}`}\n          size={size}\n          verticalAlign={verticalAlign}\n          half\n          onClick={onClick ? (e) => onClick(e, full + i + 1) : undefined}\n        />\n      ))}\n      {[...Array(empty)].map((_, i) => (\n        <RatingStar\n          key={`empty-${i}`}\n          size={size}\n          verticalAlign={verticalAlign}\n          onClick={onClick ? (e) => onClick(e, full + half + i + 1) : undefined}\n        />\n      ))}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/responsive/index.ts",
    "content": "export * from './responsive'\n"
  },
  {
    "path": "packages/tds-ui/src/components/responsive/responsive.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { Responsive } from './responsive'\n\nit('should have media query for maxWidth is passed', () => {\n  render(<Responsive maxWidth={1000}>responsive</Responsive>)\n\n  const element = screen.getByText('responsive')\n\n  expect(element).toHaveStyleRule('display', 'none', {\n    media: '(min-width:1001px)',\n  })\n})\n\nit('should have media query when minWidth is passed', () => {\n  render(<Responsive minWidth={1000}>responsive</Responsive>)\n\n  const element = screen.getByText('responsive')\n\n  expect(element).toHaveStyleRule('display', 'none', {\n    media: '(max-width:999px)',\n  })\n})\n\nit('should accept inline prop', () => {\n  render(<Responsive inline>responsive</Responsive>)\n\n  const element = screen.getByText('responsive')\n\n  expect(element).toHaveStyleRule('display', 'inline')\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/responsive/responsive.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled, css } from 'styled-components'\n\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nexport type ResponsiveProps = PropsWithChildren<{\n  inline?: boolean\n  maxWidth?: number\n  minWidth?: number\n}>\n\nexport const Responsive = styled.div.withConfig({\n  shouldForwardProp,\n})<ResponsiveProps>`\n  display: ${({ inline }) => (inline ? 'inline' : 'block')};\n\n  ${({ minWidth }) =>\n    minWidth &&\n    css`\n      @media (max-width: ${minWidth - 1}px) {\n        display: none;\n      }\n    `}\n\n  ${({ maxWidth }) =>\n    maxWidth &&\n    css`\n      @media (min-width: ${maxWidth + 1}px) {\n        display: none;\n      }\n    `}\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/section/index.ts",
    "content": "export * from './section'\n"
  },
  {
    "path": "packages/tds-ui/src/components/section/section.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Section } from './section'\n\nconst meta: Meta<typeof Section> = {\n  title: 'tds-ui (Layout) / Section',\n  component: Section,\n  argTypes: {\n    divider: {\n      control: 'select',\n      options: ['none', 'top', 'bottom'],\n    },\n    anchor: {\n      type: 'string',\n    },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '영역을 구분할 때 사용되는 뷰 컴포넌트입니다. \\n * `padding-left`, `padding-right`가 `30px`로 고정되어 있습니다. \\n * `min-width : 320px`, `max-width: 768px`로 고정되어 있습니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const Default: StoryObj<typeof Section> = {\n  args: {\n    children:\n      '기타노 이진칸 거리에 위치한 풍향계의 집은 1900년대에 독일인이 살았던 저택이다. 기타노 지역에서 유일하게 벽돌로 만들어진 건물로, 지붕 꼭대기에 있는 닭 모양의 풍향계가 유명하다. 풍향계의 집 내관은 독일의 전통 양식과 20세기 전후의 아르누보 디자인으로 꾸며져 있다. 실내에는 각종 서양 가구 및 생활용품들과 원래 주인의 가족 사진들이 전시되어 있다.',\n    divider: 'none',\n  },\n}\n\nexport const TopDivider: StoryObj<typeof Section> = {\n  args: {\n    children:\n      '기타노 이진칸 거리에 위치한 풍향계의 집은 1900년대에 독일인이 살았던 저택이다. 기타노 지역에서 유일하게 벽돌로 만들어진 건물로, 지붕 꼭대기에 있는 닭 모양의 풍향계가 유명하다. 풍향계의 집 내관은 독일의 전통 양식과 20세기 전후의 아르누보 디자인으로 꾸며져 있다. 실내에는 각종 서양 가구 및 생활용품들과 원래 주인의 가족 사진들이 전시되어 있다.',\n    divider: 'top',\n  },\n}\n\nexport const BottomDivider: StoryObj<typeof Section> = {\n  args: {\n    children:\n      '기타노 이진칸 거리에 위치한 풍향계의 집은 1900년대에 독일인이 살았던 저택이다. 기타노 지역에서 유일하게 벽돌로 만들어진 건물로, 지붕 꼭대기에 있는 닭 모양의 풍향계가 유명하다. 풍향계의 집 내관은 독일의 전통 양식과 20세기 전후의 아르누보 디자인으로 꾸며져 있다. 실내에는 각종 서양 가구 및 생활용품들과 원래 주인의 가족 사진들이 전시되어 있다.',\n    divider: 'bottom',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/section/section.test.tsx",
    "content": "/* eslint-disable testing-library/no-node-access */\nimport { render, screen } from '@testing-library/react'\n\nimport { Section } from './section'\n\nit('should render null if children is empty', () => {\n  render(<Section data-testid=\"id\" />)\n\n  const element = screen.queryByText('id')\n\n  expect(element).not.toBeInTheDocument()\n})\n\nit('should accept anchor prop', () => {\n  render(<Section anchor=\"anchorValue\">section</Section>)\n\n  const element = screen.getByText('section')\n\n  expect(element).toHaveAttribute('id', 'anchorValue')\n})\n\nit('should show top divider', () => {\n  render(<Section divider=\"top\">section</Section>)\n\n  const element = screen.getByText('section')\n\n  expect(element.previousSibling).toBeInTheDocument()\n  expect(element.nextSibling).not.toBeInTheDocument()\n})\n\nit('should show bottom divider', () => {\n  render(<Section divider=\"bottom\">section</Section>)\n\n  const element = screen.getByText('section')\n\n  expect(element.previousSibling).not.toBeInTheDocument()\n  expect(element.nextSibling).toBeInTheDocument()\n})\n\nit('should override style with css prop', () => {\n  render(\n    <Section position=\"absolute\" css={{ position: 'fixed' }}>\n      section\n    </Section>,\n  )\n\n  const element = screen.getByText('section')\n\n  expect(element).toHaveStyleRule('position', 'fixed')\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/section/section.tsx",
    "content": "import { css } from 'styled-components'\n\nimport { Container, ContainerProps } from '../container'\nimport { HR2 } from '../hr'\n\nexport interface SectionProps extends ContainerProps {\n  divider?: string\n  anchor?: string\n}\n\nexport function Section({ children, divider, anchor, ...props }: SectionProps) {\n  if (!children) {\n    return null\n  }\n\n  return (\n    <>\n      {divider === 'top' && <HR2 compact />}\n      <Container\n        id={anchor}\n        centered\n        clearing\n        css={css`\n          position: relative;\n          min-width: 320px;\n          max-width: 768px;\n          padding-left: 30px;\n          padding-right: 30px;\n        `}\n        {...props}\n      >\n        {children}\n      </Container>\n      {divider === 'bottom' && <HR2 compact />}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/segment/index.ts",
    "content": "export * from './segment'\n"
  },
  {
    "path": "packages/tds-ui/src/components/segment/segment.stories.tsx",
    "content": "import { Meta, StoryObj } from '@storybook/react'\n\nimport { Card } from './segment'\n\nconst meta = {\n  title: 'tds-ui (Layout) / Card',\n  component: Card,\n} satisfies Meta<typeof Card>\n\nexport default meta\ntype Story = StoryObj<typeof Card>\n\nexport const Default: Story = {\n  args: { children: 'Card' },\n}\n\nexport const ShadowSmall: Story = {\n  args: { ...Default.args, shadow: 'small' },\n}\n\nexport const ShadowMedium: Story = {\n  args: { ...Default.args, shadow: 'medium' },\n}\n\nexport const ShadowLarge: Story = {\n  args: { ...Default.args, shadow: 'large' },\n}\n\nexport const Radius: Story = {\n  args: { ...Default.args, radius: 20 },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/segment/segment.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { css, styled } from 'styled-components'\n\nimport { shadowMixin, ShadowMixinProps } from '../../mixins'\n\nexport const Segment = styled.div`\n  padding: 20px;\n  border-radius: 6px;\n  background-color: #fafafa;\n\n  &::after {\n    content: '';\n    display: block;\n    clear: both;\n  }\n`\n\nexport interface BoxProps {\n  radius?: number\n}\n\nconst borderRadius = ({ radius = 0 }) => css`\n  border-radius: ${radius}px;\n`\n\nconst shadowMixinWithDefault = (props: ShadowMixinProps) =>\n  shadowMixin({ shadow: 'medium', ...props })\n\nexport const CardFrame = styled.div<BoxProps & ShadowMixinProps>`\n  ${borderRadius}\n  ${shadowMixinWithDefault}\n`\n\nexport interface CardProps\n  extends PropsWithChildren<BoxProps & ShadowMixinProps> {}\n\n/**\n * Card Component\n *\n * Props\n *  - radius: number\n *  - shadow: ShadowType\n *  - shadowValue: string\n */\nexport function Card({\n  children,\n  radius,\n  shadow,\n  shadowValue,\n  ...props\n}: CardProps) {\n  return (\n    <CardFrame\n      {...props}\n      radius={radius}\n      shadow={shadow}\n      shadowValue={shadowValue}\n    >\n      {children}\n    </CardFrame>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/select/index.ts",
    "content": "export * from './select'\n"
  },
  {
    "path": "packages/tds-ui/src/components/select/select.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Select } from './select'\n\nconst meta: Meta<typeof Select> = {\n  title: 'tds-ui (Form) / Select',\n  component: Select,\n  argTypes: {\n    placeholder: { type: 'string' },\n    label: { type: 'string' },\n    error: { if: { arg: 'help', truthy: false }, type: 'string' },\n    help: { if: { arg: 'error', truthy: false }, type: 'string' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 옵션 선택을 제공할 때 사용되는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\nexport default meta\n\ntype Story = StoryObj<typeof Select>\n\nconst STORY_OPTIONS = [\n  {\n    label: '12:00',\n    value: '12:00',\n  },\n  {\n    label: '12:10',\n    value: '12:10',\n  },\n  {\n    label: '12:20',\n    value: '12:20',\n  },\n]\n\nexport const Default: Story = {\n  args: {\n    value: '',\n    placeholder: '시간을 선택해주세요',\n    options: STORY_OPTIONS,\n  },\n}\n\nexport const Selected: Story = {\n  args: {\n    value: '12:00',\n    placeholder: '시간을 선택해주세요',\n    options: STORY_OPTIONS,\n  },\n}\n\nexport const WithLabel: Story = {\n  args: {\n    value: '',\n    label: '투어티켓 시간',\n    placeholder: '시간을 선택해주세요',\n    options: STORY_OPTIONS,\n  },\n}\n\nexport const WithHelpMessage: Story = {\n  args: {\n    value: '',\n    label: '투어티켓 시간',\n    placeholder: '시간을 선택해주세요',\n    help: 'Help text',\n    options: STORY_OPTIONS,\n  },\n}\n\nexport const WithErrorMessage: Story = {\n  args: {\n    value: '',\n    label: '투어티켓 시간',\n    placeholder: '시간을 선택해주세요',\n    error: 'Error text',\n    options: STORY_OPTIONS,\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/select/select.tsx",
    "content": "import { forwardRef, SelectHTMLAttributes } from 'react'\nimport { styled } from 'styled-components'\n\nimport {\n  FormFieldContext,\n  FormFieldError,\n  FormFieldHelp,\n  FormFieldLabel,\n  useFormFieldState,\n} from '../form-field'\nimport { Container } from '../container'\n\nconst BaseSelect = styled.select`\n  appearance: none;\n  position: relative;\n  width: 100%;\n  height: 48px;\n  text-indent: 16px;\n  font-size: 16px;\n  font-weight: 500;\n  border: 1px solid var(--color-gray100);\n  border-radius: 4px;\n  background-color: var(--color-white);\n  color: var(--color-gray);\n\n  &:disabled {\n    background-color: rgba(235, 235, 235, 1);\n    color: var(--color-gray300);\n  }\n\n  &[aria-invalid='true'] {\n    border-color: var(--color-mediumRed);\n    color: var(--color-mediumRed);\n  }\n`\n\nconst Svg = styled.svg`\n  position: absolute;\n  top: 16px;\n  right: 16px;\n`\n\nexport type OptionValueType = string | number | readonly string[]\n\nexport interface SelectOption {\n  label: string\n  value: OptionValueType\n}\n\nexport interface SelectOwnProps {\n  value?: OptionValueType\n  options?: SelectOption[]\n  placeholder?: string\n  label?: string\n  error?: string | boolean\n  help?: string\n}\n\nexport type SelectProps = SelectOwnProps &\n  SelectHTMLAttributes<HTMLSelectElement>\n\nexport const Select = forwardRef<HTMLSelectElement, SelectProps>(\n  function Select(\n    {\n      value,\n      placeholder,\n      options,\n      label,\n      error,\n      help,\n      onBlur,\n      onFocus,\n      ...props\n    },\n    ref,\n  ) {\n    const formFieldState = useFormFieldState({ onBlur, onFocus })\n\n    const hasHelp = !!help\n    const isError = !!error\n\n    return (\n      <FormFieldContext.Provider\n        value={{\n          ...formFieldState,\n          isDisabled: !!props.disabled,\n          isError,\n          isRequired: !!props.required,\n        }}\n      >\n        {label ? <FormFieldLabel>{label}</FormFieldLabel> : null}\n        <Container position=\"relative\">\n          <BaseSelect\n            ref={ref}\n            id={formFieldState.inputId}\n            value={value}\n            aria-describedby={\n              hasHelp ? formFieldState.descriptionId : undefined\n            }\n            aria-errormessage={isError ? formFieldState.errorId : undefined}\n            aria-invalid={isError}\n            {...props}\n          >\n            {placeholder ? <option value=\"\">{placeholder}</option> : null}\n            {options?.map(({ label, value }, idx) => (\n              <option key={idx} value={value}>\n                {label}\n              </option>\n            ))}\n          </BaseSelect>\n          <Svg\n            width=\"10\"\n            height=\"20\"\n            viewBox=\"0 0 10 20\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path\n              d=\"M9 8.5L5 12.5L1 8.5\"\n              stroke=\"#3A3A3A\"\n              strokeOpacity=\"0.3\"\n              strokeLinejoin=\"round\"\n            />\n          </Svg>\n        </Container>\n        {error ? (\n          <FormFieldError>{error}</FormFieldError>\n        ) : help ? (\n          <FormFieldHelp>{help}</FormFieldHelp>\n        ) : null}\n      </FormFieldContext.Provider>\n    )\n  },\n)\n"
  },
  {
    "path": "packages/tds-ui/src/components/skeleton/index.ts",
    "content": "export * from './skeleton'\n"
  },
  {
    "path": "packages/tds-ui/src/components/skeleton/skeleton.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport {\n  Skeleton,\n  SkeletonButton,\n  SkeletonCircle,\n  SkeletonText,\n} from './skeleton'\n\nconst meta: Meta<typeof Skeleton> = {\n  title: 'tds-ui (Feedback) / Skeleton',\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 실제 데이터가 렌더링 되기 전에 보이게 될 화면의 윤곽을 먼저 그려주는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const Box: StoryObj<typeof Skeleton> = {\n  render: () => {\n    return (\n      <Skeleton\n        borderRadius={4}\n        css={{\n          height: '150px',\n          margin: '0 0 15px',\n        }}\n      />\n    )\n  },\n}\n\nexport const Text: StoryObj<typeof SkeletonText> = {\n  render: () => {\n    return (\n      <SkeletonText\n        borderRadius={4}\n        css={{\n          margin: '0 0 15px',\n        }}\n      />\n    )\n  },\n}\n\nexport const Button: StoryObj<typeof SkeletonButton> = {\n  render: () => {\n    return <SkeletonButton borderRadius={4} />\n  },\n}\n\nexport const Circle: StoryObj<typeof SkeletonButton> = {\n  render: () => {\n    return (\n      <SkeletonCircle\n        css={{\n          margin: '0 0 15px',\n        }}\n      />\n    )\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/skeleton/skeleton.tsx",
    "content": "import { styled, css, keyframes } from 'styled-components'\n\nimport { Container, ContainerProps } from '../container'\n\nconst opacityAnimation = keyframes`\n  0% {\n    opacity: 1;\n  }\n\n  50% {\n    opacity: 0.4;\n  }\n\n  100% {\n    opacity: 1;\n  }\n`\n\nconst waveAnimation = keyframes`\n  0% {\n    transform: translateX(-100%);\n  }\n\n  50% {\n    transform: translateX(0);\n  }\n\n  100% {\n    transform: translateX(100%);\n  }\n`\n\ntype SkeletonProps = ContainerProps & { height?: number | string }\n\nexport const Skeleton = styled(Container)`\n  position: relative;\n  overflow: hidden;\n  background: var(--color-gray100);\n  animation: ${opacityAnimation} 1.5s ease-in-out 0.5s infinite;\n\n  &::after {\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    content: '';\n    position: absolute;\n    animation: ${waveAnimation} 1.6s linear 0.5s infinite;\n    transform: translateX(-100%);\n    background: linear-gradient(\n      90deg,\n      transparent,\n      var(--color-gray20),\n      transparent\n    );\n  }\n`\n\nexport function SkeletonText({ height = '16px', ...props }: SkeletonProps) {\n  return (\n    <Skeleton\n      css={css`\n        height: ${height};\n      `}\n      {...props}\n    />\n  )\n}\n\nexport function SkeletonCircle({\n  size = 50,\n  ...props\n}: { size?: number } & Omit<\n  SkeletonProps,\n  'width' | 'height' | 'borderRadius'\n>) {\n  return (\n    <Skeleton\n      borderRadius={size}\n      css={css`\n        width: ${size}px;\n        height: ${size}px;\n      `}\n      {...props}\n    />\n  )\n}\n\nexport function SkeletonButton({\n  height = '45px',\n  borderRadius = 4,\n  ...props\n}: SkeletonProps) {\n  return (\n    <Skeleton\n      borderRadius={borderRadius}\n      css={css`\n        height: ${height};\n      `}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/slider/handle.tsx",
    "content": "import { GetHandleProps } from 'react-compound-slider'\nimport { styled } from 'styled-components'\n\nconst HandleContainer = styled.div`\n  position: absolute;\n  width: 70px;\n  height: 90px;\n  transform: translate(-50%, -50%);\n  z-index: 1;\n`\n\nconst HandlePeg = styled.div`\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  width: 18px;\n  height: 18px;\n  border-radius: 18px;\n  border: solid 3px #368fff;\n  background-color: #fff;\n  transform: translate(-50%, -50%);\n  z-index: 1;\n`\n\ninterface Props {\n  id: string\n  percent: number\n  value: number\n  max: number\n  min: number\n  getHandleProps: GetHandleProps\n}\n\nexport function Handle({\n  id,\n  percent,\n  value,\n  max,\n  min,\n  getHandleProps,\n}: Props) {\n  return (\n    <HandleContainer\n      tabIndex={0}\n      role=\"slider\"\n      aria-valuemax={max}\n      aria-valuemin={min}\n      aria-valuenow={value}\n      aria-orientation=\"horizontal\"\n      style={{\n        left: `${percent}%`,\n      }}\n      {...getHandleProps(id)}\n    >\n      <HandlePeg />\n    </HandleContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/slider/index.ts",
    "content": "export * from './single-slider'\nexport * from './range-slider'\n"
  },
  {
    "path": "packages/tds-ui/src/components/slider/range-slider.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { RangeSlider } from './range-slider'\n\nconst meta: Meta<typeof RangeSlider> = {\n  title: 'tds-ui (Form) / RangeSlider',\n  component: RangeSlider,\n}\n\nexport default meta\n\nexport const Basic: StoryObj<typeof RangeSlider> = {\n  args: {\n    min: 0,\n    max: 500000,\n    debounceTime: 800,\n    nonLinear: false,\n    initialValues: [0, 500000],\n    disabled: false,\n  },\n}\n\nexport const Adjusted: StoryObj<typeof RangeSlider> = {\n  args: {\n    min: 1,\n    max: 31,\n    step: 3,\n    adjustInitValues: true,\n    debounceTime: 800,\n    nonLinear: false,\n    initialValues: [1, 31],\n    disabled: false,\n  },\n}\n\nexport const Disabled: StoryObj<typeof RangeSlider> = {\n  args: {\n    min: 0,\n    max: 500000,\n    debounceTime: 800,\n    nonLinear: false,\n    initialValues: [0, 500000],\n    disabled: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/slider/range-slider.tsx",
    "content": "import { ComponentType } from 'react'\nimport { Tracks } from 'react-compound-slider'\n\nimport { Track } from './track'\nimport { SliderBaseProps, SliderBase } from './slider-base'\nimport { SliderValue } from './types'\n\ninterface RangeSliderProps extends Omit<SliderBaseProps, 'labelComponent'> {\n  labelComponent?: ComponentType<{\n    fromValue: SliderValue[0]\n    toValue: SliderValue[1]\n  }>\n}\n\nexport function RangeSlider({\n  labelComponent: LabelComponent,\n  ...restProps\n}: RangeSliderProps) {\n  return (\n    <SliderBase\n      {...restProps}\n      labelComponent={\n        LabelComponent\n          ? ({ values }) => (\n              <LabelComponent fromValue={values[0]} toValue={values[1]} />\n            )\n          : undefined\n      }\n    >\n      <Tracks>\n        {({ tracks, getTrackProps }) => (\n          <>\n            {tracks.map(({ id, source, target }) => (\n              <Track\n                key={id}\n                active={source.id !== '$' && target.id !== '$'}\n                left={source.percent}\n                right={target.percent}\n                getTrackProps={getTrackProps}\n              />\n            ))}\n          </>\n        )}\n      </Tracks>\n    </SliderBase>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/slider/single-slider.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { SingleSlider } from './single-slider'\n\nconst meta: Meta<typeof SingleSlider> = {\n  title: 'tds-ui (Form) / SingleSlider',\n  component: SingleSlider,\n}\n\nexport default meta\n\nexport const Baisc: StoryObj<typeof SingleSlider> = {\n  args: {\n    min: 0,\n    max: 500000,\n    debounceTime: 800,\n    nonLinear: false,\n    initialValue: 0,\n    disabled: false,\n  },\n}\n\nexport const Disabled: StoryObj<typeof SingleSlider> = {\n  args: {\n    min: 0,\n    max: 500000,\n    debounceTime: 800,\n    nonLinear: false,\n    initialValue: 0,\n    disabled: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/slider/single-slider.tsx",
    "content": "import { ComponentType } from 'react'\nimport { Tracks } from 'react-compound-slider'\n\nimport { Track } from './track'\nimport { SliderBaseProps, SliderBase } from './slider-base'\n\ninterface SingleSliderProps\n  extends Omit<\n    SliderBaseProps,\n    'initialValues' | 'labelComponent' | 'onChange'\n  > {\n  /**\n   * 초기값.\n   */\n  initialValue?: number\n  /**\n   * 슬라이더 상단 라벨 영역을 표시하는 컴포넌트.\n   */\n  labelComponent?: ComponentType<{ value: number }>\n  onChange: (value: number) => void\n}\n\nexport function SingleSlider({\n  initialValue,\n  labelComponent: LabelComponent,\n  onChange,\n  ...restProps\n}: SingleSliderProps) {\n  return (\n    <SliderBase\n      {...restProps}\n      initialValues={initialValue ? [initialValue] : undefined}\n      onChange={(values) => onChange(values[0])}\n      labelComponent={\n        LabelComponent\n          ? ({ values }) => <LabelComponent value={values[0]} />\n          : undefined\n      }\n    >\n      <Tracks>\n        {({ tracks, getTrackProps }) => (\n          <>\n            {tracks.map(({ id, source, target }) => (\n              <Track\n                key={id}\n                active={target.id !== '$'}\n                left={source.percent}\n                right={target.percent}\n                getTrackProps={getTrackProps}\n              />\n            ))}\n          </>\n        )}\n      </Tracks>\n    </SliderBase>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/slider/slider-base.tsx",
    "content": "import {\n  useState,\n  useEffect,\n  useMemo,\n  useCallback,\n  ComponentType,\n  PropsWithChildren,\n} from 'react'\nimport { styled } from 'styled-components'\nimport { Rail, Slider as OriginalSlider, Handles } from 'react-compound-slider'\nimport { debounce } from '@titicaca/view-utilities'\n\nimport { Container } from '../container'\n\nimport { Handle } from './handle'\nimport { ValueTransformer, SliderValue } from './types'\n\nexport interface SliderBaseProps {\n  initialValues?: SliderValue\n  /**\n   * 증감량의 단위 정확하게 작동하지 않으니 상대적인 값으로 생각해주세요.\n   */\n  step?: number\n  /**\n   * 범위의 최소값.\n   */\n  min?: number\n  /**\n   * 범위의 최대값.\n   */\n  max?: number\n  labelComponent?: ComponentType<{\n    values: SliderValue\n  }>\n  onChange: (values: SliderValue) => void\n  /**\n   * 비선형적으로 증감하게 만듧니다.\n   */\n  nonLinear?: boolean\n  /**\n   * onChange 함수에 걸리는 debounce 시간 (ms).\n   */\n  debounceTime?: number\n  /**\n   * initialValues가 step의 배수가 아닐 경우 가까운 숫자로 조정\n   */\n  adjustInitValues?: boolean\n  /**\n   * 비활성화 (Handler를 감추고 배경색을 변경합니다)\n   */\n  disabled?: boolean\n}\n\nconst IDENTICAL_SCALE: ValueTransformer = (x) => x\n\nconst LINEAR_FN_SET: ValueTransformer[] = [IDENTICAL_SCALE, IDENTICAL_SCALE]\n\nconst EXPONENT = 1 / Math.E\nconst NON_LINEAR_FN_SET: ValueTransformer[] = [\n  (x) => Math.round(Math.pow(x, EXPONENT)),\n  (x) => Math.round(Math.pow(x, 1 / EXPONENT)),\n]\n\nconst SliderContainer = styled.div`\n  position: relative;\n  height: 18px;\n  touch-action: pan-x;\n`\n\nconst RailBase = styled.div<Pick<SliderBaseProps, 'disabled'>>`\n  position: absolute;\n  width: 100%;\n  border-radius: 4px;\n  background-color: ${({ disabled }) => (disabled ? '#368FFF4D' : '#efefef')};\n  height: 3px;\n  transform: translate(0, -50%);\n`\nconst adjustMax = (maxVal: number, step: number) =>\n  maxVal % step ? Math.ceil(maxVal / step) * step : maxVal\nconst adjustMin = (minVal: number, step: number) =>\n  minVal % step ? Math.floor(minVal / step) * step : minVal\n\nexport function SliderBase({\n  step = 1,\n  initialValues,\n  min = 0,\n  max = 100,\n  onChange,\n  labelComponent: LabelComponent,\n  nonLinear,\n  debounceTime = 500,\n  adjustInitValues,\n  disabled = false,\n  children,\n}: PropsWithChildren<SliderBaseProps>) {\n  const [values, setValues] = useState<SliderValue>(\n    adjustInitValues && initialValues\n      ? [adjustMin(initialValues[0], step), adjustMax(initialValues[1], step)]\n      : initialValues || [0],\n  )\n\n  const adjustedMin = adjustMin(min, step)\n  const adjustedMax = adjustMax(max, step)\n\n  const [scaleFn, scaleFnInverse] = nonLinear\n    ? NON_LINEAR_FN_SET\n    : LINEAR_FN_SET\n\n  const limiter: ValueTransformer = (value) => {\n    if (value < adjustedMin) {\n      return adjustedMin\n    }\n    if (value > adjustedMax) {\n      return adjustedMax\n    }\n    return value\n  }\n\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const debouncedChangeHandler = useCallback(debounce(onChange, debounceTime), [\n    onChange,\n    debounceTime,\n  ])\n\n  const adjustedValues = useMemo(() => {\n    if (values.length === 1) {\n      return [Math.max(min, values[0])]\n    } else {\n      return [Math.max(min, values[0]), Math.min(max, values[1])]\n    }\n  }, [max, min, values])\n\n  useEffect(() => {\n    debouncedChangeHandler(adjustedValues)\n  }, [adjustedValues]) // eslint-disable-line react-hooks/exhaustive-deps\n\n  return (\n    <Container>\n      {LabelComponent ? <LabelComponent values={adjustedValues} /> : null}\n\n      <SliderContainer>\n        <OriginalSlider\n          values={values.map(scaleFn)}\n          mode={2}\n          step={scaleFn(step)}\n          domain={[adjustedMin, adjustedMax].map(scaleFn)}\n          rootStyle={{\n            position: 'absolute',\n            top: '50%',\n            left: 0,\n            right: 0,\n          }}\n          onUpdate={(newValues) =>\n            setValues(newValues.map(scaleFnInverse).map(limiter))\n          }\n          disabled={disabled}\n        >\n          <Rail>{() => <RailBase disabled={disabled} />}</Rail>\n          {!disabled && (\n            <Handles>\n              {({ handles, getHandleProps }) => (\n                <>\n                  {handles.map(({ id, percent }, i) => (\n                    <Handle\n                      key={id}\n                      id={id}\n                      percent={percent}\n                      max={\n                        i === 0 && adjustedValues.length > 1\n                          ? adjustedValues[1] - step\n                          : max\n                      }\n                      min={i === 0 ? min : adjustedValues[0] + step}\n                      value={adjustedValues[i]}\n                      getHandleProps={getHandleProps}\n                    />\n                  ))}\n                </>\n              )}\n            </Handles>\n          )}\n          {!disabled && children}\n        </OriginalSlider>\n      </SliderContainer>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/slider/track.tsx",
    "content": "import { GetTrackProps } from 'react-compound-slider'\nimport { styled } from 'styled-components'\n\nexport const TrackContainer = styled.div<{ left: number; right: number }>`\n  position: absolute;\n  padding: 20px 0;\n  margin-top: -20px;\n`\n\nexport const ActiveTrack = styled.div`\n  height: 3px;\n  border-radius: 4px;\n  background-color: #368fff;\n  transform: translate(0, -50%);\n`\n\ninterface Props {\n  active: boolean\n  left: number\n  right: number\n  getTrackProps: GetTrackProps\n}\n\nexport function Track({ active, left, right, getTrackProps }: Props) {\n  return (\n    <TrackContainer\n      style={{\n        left: `${left}%`,\n        right: `${100 - right}%`,\n      }}\n      {...getTrackProps()}\n    >\n      {active ? <ActiveTrack /> : null}\n    </TrackContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/slider/types.ts",
    "content": "export type SliderValue = readonly number[]\n\nexport type ValueTransformer = (x: number) => number\n"
  },
  {
    "path": "packages/tds-ui/src/components/spinner/index.ts",
    "content": "export * from './rolling-spinner'\nexport * from './spinner'\n"
  },
  {
    "path": "packages/tds-ui/src/components/spinner/rolling-spinner.stories.tsx",
    "content": "import type { StoryObj } from '@storybook/react'\n\nimport { RollingSpinner } from './rolling-spinner'\n\nconst meta = {\n  title: 'tds-ui (Feedback) / RollingSpinner',\n  component: RollingSpinner,\n  args: {\n    size: 36,\n    duration: 50,\n  },\n  argTypes: {\n    size: { type: 'number' },\n    duration: { type: 'number' },\n    zTier: { type: 'number' },\n    zIndex: { type: 'number' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 데이터를 불러오고 있음을 알려주는 뷰 컴포넌트입니다.',\n      },\n      story: {\n        inline: false,\n        iframeHeight: 300,\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const Default: StoryObj<typeof RollingSpinner> = {\n  args: {\n    imageUrls: [\n      'https://triple-dev.titicaca-corp.com/air/static/images/airline-logos/7C.png',\n      'https://triple-dev.titicaca-corp.com/air/static/images/airline-logos/TW.png',\n      'https://triple-dev.titicaca-corp.com/air/static/images/airline-logos/AC.png',\n    ],\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/spinner/rolling-spinner.tsx",
    "content": "import { TRIPLE_FALLBACK_ACTION_CLASS_NAME } from '@titicaca/triple-fallback-action'\nimport { useMemo, PropsWithChildren } from 'react'\nimport { styled, keyframes } from 'styled-components'\n\nimport { layeringMixin, LayeringMixinProps } from '../../mixins'\nimport { Container } from '../container'\n\nconst marquee = keyframes`\n  0% {\n    transform: translateX(0);\n }\n\n 100% {\n    transform: translateX(-100%);\n }\n`\n\nconst RollingSpinnerFrame = styled.div<LayeringMixinProps>`\n  position: fixed;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  left: 0;\n\n  ${layeringMixin(10)}\n`\n\nconst RollingSpinnerContainer = styled.div<{ size: number }>`\n  position: absolute;\n  width: 100%;\n  top: 50%;\n  overflow: visible;\n  text-align: center;\n\n  ${({ size }) => `\n    transform: translateY(calc(-50% - ${size / 2}px));\n  `}\n`\n\nconst TrackContainer = styled.div`\n  position: relative;\n  white-space: nowrap;\n\n  &::before,\n  &::after {\n    content: '';\n    position: absolute;\n    top: -3px;\n    width: 55px;\n    height: 40px;\n    z-index: 1;\n  }\n\n  &::before {\n    left: 0;\n    background: linear-gradient(\n      to right,\n      rgba(255, 255, 255, 0.99) 0%,\n      rgba(255, 255, 255, 0) 100%\n    );\n  }\n\n  &::after {\n    right: 0;\n    background: linear-gradient(\n      to right,\n      rgba(255, 255, 255, 0) 0%,\n      rgba(255, 255, 255, 0.99) 100%\n    );\n  }\n`\n\nconst Track = styled.div<{ duration: number }>`\n  position: relative;\n  display: inline-block;\n  animation: ${marquee} linear infinite;\n  ${({ duration }) => `animation-duration: ${duration}s;`}\n`\n\nconst ImageContainer = styled.div<{\n  duration: number\n  offset: number\n}>`\n  position: relative;\n  display: inline-block;\n  vertical-align: top;\n  font-size: 0;\n\n  ${({ offset }) => {\n    const keyframeName = `swap-${offset}`\n    const snap = (offset + 1) * 20\n\n    return `\n      @keyframes ${keyframeName} {\n        0%, ${snap}% {\n          left: 0%;\n        }\n\n        ${snap + 0.01}%, 100% {\n          left: 100%;\n        }\n      }\n\n      animation: ${keyframeName} linear infinite;\n  `\n  }}\n\n  ${({ duration }) => `animation-duration: ${duration}s;`}\n`\n\nconst Image = styled.img<{ size: number }>`\n  display: inline-block;\n  vertical-align: top;\n  text-align: center;\n  margin: 0 8px;\n\n  ${({ size }) => `\n    height: ${size}px;\n  `}\n`\n\nexport function RollingSpinner({\n  imageUrls,\n  size = 36,\n  duration = 50,\n  zTier,\n  zIndex,\n  children,\n}: PropsWithChildren<\n  {\n    size?: number\n    duration?: number\n    imageUrls: string[]\n  } & LayeringMixinProps\n>) {\n  const images = useMemo(\n    () =>\n      Array.from({ length: 5 }).map((_, idx) => {\n        return (\n          <ImageContainer key={idx} duration={duration} offset={idx}>\n            {imageUrls.map((url: string, index: number) => (\n              <Image src={url} size={size} key={index} alt=\"rolling_image\" />\n            ))}\n          </ImageContainer>\n        )\n      }),\n    [duration, imageUrls, size],\n  )\n\n  return (\n    <RollingSpinnerFrame\n      className={TRIPLE_FALLBACK_ACTION_CLASS_NAME}\n      zTier={zTier}\n      zIndex={zIndex}\n    >\n      <RollingSpinnerContainer size={size}>\n        {children ? <Container>{children}</Container> : null}\n        <TrackContainer>\n          <Track duration={duration}>{images}</Track>\n        </TrackContainer>\n      </RollingSpinnerContainer>\n    </RollingSpinnerFrame>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/spinner/spinner.stories.tsx",
    "content": "import type { StoryObj } from '@storybook/react'\n\nimport { Spinner } from './spinner'\n\nconst meta = {\n  title: 'tds-ui (Feedback) / Spinner',\n  component: Spinner,\n  argTypes: {\n    full: { type: 'boolean' },\n    zTier: { type: 'number' },\n    zIndex: { type: 'number' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자에게 데이터를 불러오고 있음을 알려주는 뷰 컴포넌트입니다.',\n      },\n      story: {\n        inline: false,\n        iframeHeight: 300,\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const Default: StoryObj<typeof Spinner> = {\n  args: {\n    full: false,\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/spinner/spinner.tsx",
    "content": "import { ReactNode } from 'react'\nimport { TRIPLE_FALLBACK_ACTION_CLASS_NAME } from '@titicaca/triple-fallback-action'\nimport { styled, css, keyframes } from 'styled-components'\n\nimport { layeringMixin, LayeringMixinProps } from '../../mixins'\n\nconst loadingAnimation = keyframes`\n  100% {\n    background-position: -1740px;\n  }\n`\n\nconst Container = styled.div<{ full?: boolean } & LayeringMixinProps>`\n  width: 100%;\n  height: 100%;\n  position: fixed;\n  top: 0;\n  left: 0;\n  display: table;\n\n  ${layeringMixin(10)}\n\n  ${({ full }) =>\n    full &&\n    css`\n      background-color: rgb(255, 255, 255);\n    `};\n`\n\nconst Wrapper = styled.div`\n  display: table-cell;\n  vertical-align: middle;\n  text-align: center;\n`\n\nconst Icon = styled.div`\n  margin: 0 auto;\n  width: 58px;\n  height: 58px;\n  background-image: url('https://assets.triple.guide/images/ico-spinner.png');\n  background-size: 1740px 58px;\n  animation: ${loadingAnimation} 1s steps(30) infinite;\n`\n\nexport function Spinner({\n  full,\n  children,\n  zTier,\n  zIndex,\n}: {\n  full?: boolean\n  children?: ReactNode\n} & LayeringMixinProps) {\n  return (\n    <Container full={full} zTier={zTier} zIndex={zIndex}>\n      <Wrapper className={TRIPLE_FALLBACK_ACTION_CLASS_NAME}>\n        <Icon />\n        {children}\n      </Wrapper>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/stack/index.ts",
    "content": "export * from './stack'\n"
  },
  {
    "path": "packages/tds-ui/src/components/stack/stack.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { styled } from 'styled-components'\n\nimport { Stack } from './stack'\n\nconst meta: Meta<typeof Stack> = {\n  title: 'tds-ui (Layout) / Stack',\n  component: Stack,\n}\n\nexport default meta\n\nconst VerticalContainer = styled.div`\n  border: 1px solid black;\n`\n\nconst HorizontalContainer = styled.div`\n  box-sizing: content-box;\n  border: 1px solid black;\n  width: 200px;\n  overflow-y: auto;\n  white-space: nowrap;\n`\n\nconst Box = styled.div`\n  box-sizing: content-box;\n  border: 1px solid red;\n  width: 100px;\n  height: 50px;\n  margin: 10px;\n  padding: 10px;\n  font-size: 0.825rem;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n`\n\nconst Box2 = styled(Box)`\n  position: relative;\n  display: inline-block;\n`\n\ntype Story = StoryObj<typeof Stack>\n\nexport const Default: Story = {\n  render: () => {\n    return (\n      <>\n        <p>\n          보통 POI 목록이나 상품목록 등과 같이 세로 나열형 아이템을 표시할 때\n          상하 여백(padding, margin) 을 0으로 초기화 하기 위한 컴포넌트 입니다.\n        </p>\n\n        <br />\n\n        <VerticalContainer>\n          <Stack>\n            <Box>margin: 10px</Box>\n            <Box />\n            <Box />\n          </Stack>\n        </VerticalContainer>\n      </>\n    )\n  },\n}\n\nexport const Horizontal: Story = {\n  args: {\n    horizontal: true,\n  },\n  render: (args) => {\n    return (\n      <>\n        <p>\n          가로 나열형 아이템을 표시할 때 좌우 여백(padding, margin) 을 0으로\n          초기화 하기 위한 컴포넌트 입니다.\n          <br />\n          <code>horizontal</code> 속성을 부여하므로써 좌우 여백을 0으로\n          초기화하게 됩니다.\n        </p>\n        <br />\n        <HorizontalContainer>\n          <Stack {...args}>\n            <Box2 />\n            <Box2 />\n            <Box2 />\n            <Box2 />\n            <Box2 />\n            <Box2 />\n          </Stack>\n        </HorizontalContainer>\n      </>\n    )\n  },\n}\n\nexport const Vertical: Story = {\n  args: {\n    vertical: true,\n  },\n  render: (args) => {\n    return (\n      <>\n        <p>\n          세로 나열형 아이템을 표시할 때 상하 여백(padding, margin) 을 0으로\n          초기화 하기 위한 컴포넌트 입니다.\n          <br />\n          <code>vertical</code> 속성을 부여하므로써 상하 여백을 0으로 초기화하게\n          됩니다.\n        </p>\n\n        <br />\n\n        <VerticalContainer>\n          <Stack {...args}>\n            <Box />\n            <Box />\n            <Box />\n          </Stack>\n        </VerticalContainer>\n      </>\n    )\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/stack/stack.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { Stack } from './stack'\n\nit('should override style with css prop', () => {\n  render(\n    <Stack position=\"absolute\" css={{ position: 'fixed' }}>\n      stack\n    </Stack>,\n  )\n\n  const element = screen.getByText('stack')\n\n  expect(element).toHaveStyleRule('position', 'fixed')\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/stack/stack.tsx",
    "content": "import { css } from 'styled-components'\n\nimport { Container, ContainerProps } from '../container'\n\nconst veritcalStyle = css`\n  & > :first-child {\n    margin-top: 0;\n    padding-top: 0;\n  }\n\n  & > :last-child {\n    margin-bottom: 0;\n    padding-top: 0;\n  }\n`\n\nconst horizontalStyle = css`\n  & > :first-child {\n    margin-left: 0;\n    padding-left: 0;\n  }\n\n  & > :last-child {\n    margin-right: 0;\n    padding-right: 0;\n  }\n`\n\nexport interface StackProps extends ContainerProps {\n  vertical?: boolean\n  horizontal?: boolean\n}\n\n/**\n * 나열형 컴포넌트의 상하, 좌우 여백을 리셋하기 위한 컴포넌트\n *\n * - 새로형\n * <Stack>...</Stack>\n * <Stack vertical>...</Stack>\n *\n * - 가로형\n * <Stack horizontal>...</Stack>\n */\nexport function Stack({ children, horizontal, ...props }: StackProps) {\n  return (\n    <Container css={horizontal ? horizontalStyle : veritcalStyle} {...props}>\n      {children}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/sticky-header/index.ts",
    "content": "export * from './sticky-header'\n"
  },
  {
    "path": "packages/tds-ui/src/components/sticky-header/sticky-header.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { StickyHeader } from './sticky-header'\n\nconst meta: Meta<typeof StickyHeader> = {\n  title: 'tds-ui (Layout) / StickyHeader',\n  component: StickyHeader,\n  args: {\n    zIndex: 3,\n  },\n  argTypes: {\n    zTier: { type: 'number' },\n    zIndex: { type: 'number' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: '최상단에 `sticky`하게 고정시켜주는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const Default: StoryObj<typeof StickyHeader> = {\n  args: {\n    children: 'Basic StickyHeader',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/sticky-header/sticky-header.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { StickyHeader } from './sticky-header'\n\nit('should override style with css prop', () => {\n  render(<StickyHeader css={{ top: 10 }} />)\n\n  const element = screen.getByRole('banner')\n\n  expect(element).toHaveStyleRule('top', '10px')\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/sticky-header/sticky-header.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\n\nimport { layeringMixin, LayeringMixinProps } from '../../mixins'\nimport { Container } from '../container'\n\nconst StyledContainer = styled(Container)<LayeringMixinProps>`\n  position: sticky;\n  top: 0;\n  ${layeringMixin(0)}\n`\n\nexport type StickyHeaderProps = PropsWithChildren<LayeringMixinProps>\n\nexport function StickyHeader({\n  zIndex = 3,\n  zTier,\n  children,\n  ...props\n}: StickyHeaderProps) {\n  return (\n    <StyledContainer as=\"header\" zIndex={zIndex} zTier={zTier} {...props}>\n      {children}\n    </StyledContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/table/index.ts",
    "content": "export * from './table'\n"
  },
  {
    "path": "packages/tds-ui/src/components/table/table.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Table } from './table'\n\nconst meta: Meta<typeof Table> = {\n  title: 'tds-ui (Data display) / Table',\n  component: Table,\n  parameters: {\n    docs: {\n      description: {\n        component: '사용자에게 데이터를 표형식으로 보여주는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Table>\n\nexport const Horizontal: Story = {\n  args: {\n    type: 'horizontal',\n    head: [\n      { text: '목적지' },\n      { text: '요금 / 소요시간' },\n      { text: '운행간격' },\n    ],\n    body: [\n      [{ text: '난바 OCAT' }, { text: '1,050엔 / 45분' }, { text: '30분' }],\n      [{ text: '오사카' }, { text: '1,550엔 / 70분' }, { text: '30분' }],\n      [{ text: '신우메다시티' }, { text: '1,550엔 / 75분' }, { text: '30분' }],\n      [{ text: '신사이바시' }, { text: '1,550엔 / 70분' }, { text: '30분' }],\n      [{ text: '난코' }, { text: '1,550엔 / 45분' }, { text: '70분' }],\n    ],\n  },\n}\n\nexport const Vertical: Story = {\n  args: {\n    type: 'vertical',\n    head: [\n      { text: '루트' },\n      { text: '요금' },\n      { text: '소요시간' },\n      { text: '운행간격' },\n    ],\n    body: [\n      [{ text: '간사이 공항 → JR 교토역' }],\n      [{ text: '하루카 편도 3,370엔 이코카 & 하루카 편도 3,600엔' }],\n      [{ text: '1시간 15분' }],\n      [{ text: '시간당 급행 2대 / 일반 3대' }],\n    ],\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/table/table.tsx",
    "content": "import { Children, ReactNode } from 'react'\nimport { styled, css } from 'styled-components'\nimport * as CSS from 'csstype'\n\nimport { Text } from '../text/text'\nimport { MarginPadding } from '../../commons'\nimport { paddingMixin } from '../../mixins'\n\ntype TableType = 'vertical' | 'horizontal'\n\ninterface TableEntity {\n  text: string\n}\ntype TableRow = TableEntity[]\n\ninterface TableBodyProps {\n  head: TableRow\n  body: TableRow[]\n}\n\nexport interface TableProps extends TableBodyProps {\n  type: TableType\n}\n\nconst BACKGROUND_COLORS: { [key: string]: string } = {\n  header: '234, 234, 234',\n  body: '245, 245, 245',\n}\n\nconst Container = styled.div<{ borderRadius?: number; borderLine?: boolean }>`\n  overflow: hidden;\n\n  ${({ borderRadius }) =>\n    borderRadius &&\n    css`\n      border-radius: ${borderRadius}px;\n    `};\n\n  ${({ borderLine }) =>\n    borderLine &&\n    css`\n      & > div:not(:last-child) {\n        border-bottom: 1px solid rgb(${BACKGROUND_COLORS.header});\n      }\n    `};\n`\n\nconst Row = styled.div<{\n  borderRadius?: number\n  verticalGap?: number\n  children?: ReactNode\n}>`\n  width: 100%;\n  display: table;\n\n  ${({ borderRadius }) =>\n    borderRadius &&\n    css`\n      border-radius: ${borderRadius}px;\n      overflow: hidden;\n    `};\n\n  ${({ verticalGap, children = [] }) =>\n    verticalGap\n      ? css`\n          &:not(:last-child) {\n            margin-bottom: ${verticalGap}px;\n          }\n        `\n      : css`\n          & > div {\n            width: ${100 / Children.count(children)}%;\n          }\n        `};\n`\n\nconst Column = styled.div<{\n  /** 퍼센트 width */\n  width?: number\n  textAlign?: CSS.Property.TextAlign\n  type?: 'header' | 'body'\n  padding?: MarginPadding\n}>`\n  width: ${({ width }) => width || '100'}%;\n  display: table-cell;\n  vertical-align: middle;\n  text-align: ${({ textAlign }) => textAlign || 'center'};\n\n  ${({ type }) =>\n    type &&\n    css`\n      background-color: rgb(${BACKGROUND_COLORS[type || 'body']});\n    `};\n\n  ${paddingMixin}\n`\n\nfunction HorizontalTable({ head, body }: TableBodyProps) {\n  return (\n    <Container borderLine borderRadius={6}>\n      <Row>\n        {head.map(({ text }, idx) => (\n          <Column\n            key={idx}\n            type=\"header\"\n            padding={{ top: 12, bottom: 12, left: 15, right: 15 }}\n          >\n            <Text bold size=\"small\">\n              {text}\n            </Text>\n          </Column>\n        ))}\n      </Row>\n\n      {body.map((columns, idx) => (\n        <Row key={idx}>\n          {columns.map(({ text }, idx) => (\n            <Column\n              key={idx}\n              type=\"body\"\n              padding={{ top: 12, bottom: 12, left: 15, right: 15 }}\n            >\n              <Text size=\"small\">{text}</Text>\n            </Column>\n          ))}\n        </Row>\n      ))}\n    </Container>\n  )\n}\n\nfunction VerticalTable({ head, body }: TableBodyProps) {\n  return (\n    <Container>\n      {head.map(({ text }, idx) => (\n        <Row key={idx} verticalGap={10} borderRadius={6}>\n          <Column\n            width={25}\n            type=\"header\"\n            padding={{\n              top: 13,\n              bottom: 13,\n              left: 15,\n              right: 15,\n            }}\n            textAlign=\"left\"\n          >\n            <Text size=\"small\" bold>\n              {text}\n            </Text>\n          </Column>\n          {(body[idx] || []).map(({ text: columnText }, idx) => (\n            <Column\n              key={idx}\n              width={75}\n              type=\"body\"\n              padding={{\n                top: 13,\n                bottom: 13,\n                left: 15,\n                right: 15,\n              }}\n              textAlign=\"left\"\n            >\n              <Text size=\"small\">{columnText}</Text>\n            </Column>\n          ))}\n        </Row>\n      ))}\n    </Container>\n  )\n}\n\nexport function Table({ head, body, type }: TableProps) {\n  const Container = type === 'vertical' ? VerticalTable : HorizontalTable\n  return <Container head={head} body={body} />\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/basic-tab-list.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { TabListBase, TabListBaseProps } from './tab-list-base'\n\nconst StyledTabListBase = styled(TabListBase)`\n  display: flex;\n  background-color: var(--color-brightGray);\n  border-radius: 4px;\n  padding: 2px;\n`\n\nexport const BasicTabList = ({ children, ...props }: TabListBaseProps) => {\n  return <StyledTabListBase {...props}>{children}</StyledTabListBase>\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/basic-tab.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { TabBase, TabBaseProps } from './tab-base'\n\nconst StyledTabBase = styled(TabBase)`\n  flex: 1;\n  color: rgba(46, 46, 46, 0.3);\n  border-radius: 2px;\n  font-size: 14px;\n  font-weight: bold;\n  padding: 11px 0;\n\n  &[aria-selected='true'] {\n    color: var(--color-gray);\n    background-color: var(--color-white);\n  }\n`\n\nexport const BasicTab = <Value extends number | string | symbol>({\n  children,\n  ...props\n}: TabBaseProps<Value>) => {\n  return <StyledTabBase {...props}>{children}</StyledTabBase>\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/index.ts",
    "content": "export * from './tab-base'\nexport * from './tab-list-base'\nexport * from './tab-list'\nexport * from './tab-panel'\nexport * from './tab'\nexport * from './tabs-context'\nexport * from './tabs'\nexport * from './types'\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/pointing-tab-context.tsx",
    "content": "import { createContext, MutableRefObject, useContext } from 'react'\n\nexport interface PointingTabContextValue<\n  Value extends number | string | symbol,\n> {\n  tabsRef: MutableRefObject<Record<Value, HTMLButtonElement | null>>\n  left: number\n  width: number\n}\n\nexport const PointingTabContext = createContext<\n  PointingTabContextValue<string> | undefined\n>(undefined)\n\nexport function usePointingTab<Value extends number | string | symbol>() {\n  const context = useContext(PointingTabContext) as\n    | PointingTabContextValue<Value>\n    | undefined\n  if (!context) {\n    throw new Error('PointingTabContext가 없습니다.')\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/pointing-tab-list.tsx",
    "content": "import { useEffect, useRef, useState } from 'react'\nimport { styled, css } from 'styled-components'\n\nimport { PointingTabContext } from './pointing-tab-context'\nimport { TabListBase, TabListBaseProps } from './tab-list-base'\nimport { useTabs } from './tabs-context'\n\ninterface PointValue {\n  left: number\n  width: number\n}\n\ninterface StyledTabListBaseProps {\n  $left: number\n  $width: number\n  $scroll: boolean\n}\n\nconst StyledTabListBase = styled(TabListBase)<StyledTabListBaseProps>`\n  position: relative;\n  display: flex;\n  border-bottom: 1px solid var(--color-gray50);\n\n  ${({ $scroll }) =>\n    $scroll &&\n    css`\n      overflow-x: scroll;\n      -webkit-overflow-scrolling: touch;\n\n      &::-webkit-scrollbar {\n        display: none;\n      }\n    `}\n\n  &::after {\n    content: '';\n    display: inline-block;\n    position: absolute;\n    bottom: 0;\n    width: ${({ $width }) => `${$width}px`};\n    left: ${({ $left }) => `${$left}px`};\n    height: 2px;\n    background: var(--color-blue);\n    transition: all 0.2s;\n  }\n`\n\nexport const PointingTabList = <Value extends string>({\n  children,\n  ...props\n}: TabListBaseProps) => {\n  const tabs = useTabs<Value>()\n  const tabsRef = useRef<Record<string, HTMLButtonElement | null>>({})\n  const [pointValue, setPointValue] = useState<PointValue>({\n    left: 0,\n    width: 0,\n  })\n\n  useEffect(() => {\n    if (Object.keys(tabsRef.current).length === 0) {\n      return\n    }\n\n    const currentTab = tabsRef.current[tabs.value]\n\n    if (currentTab) {\n      setPointValue({\n        left: currentTab.offsetLeft,\n        width: currentTab.clientWidth,\n      })\n    }\n  }, [tabs.value])\n\n  return (\n    <StyledTabListBase\n      {...props}\n      $scroll={tabs.scroll}\n      $left={pointValue.left}\n      $width={pointValue.width}\n    >\n      <PointingTabContext.Provider\n        value={{\n          tabsRef,\n          left: pointValue.left,\n          width: pointValue.width,\n        }}\n      >\n        {children}\n      </PointingTabContext.Provider>\n    </StyledTabListBase>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/pointing-tab.tsx",
    "content": "import { styled, css } from 'styled-components'\n\nimport { usePointingTab } from './pointing-tab-context'\nimport { TabBase, TabBaseProps } from './tab-base'\nimport { useTabs } from './tabs-context'\n\ninterface StyledTabBaseProps {\n  $scroll: boolean\n}\n\nconst StyledTabBase = styled(TabBase)<StyledTabBaseProps>`\n  flex: 1;\n  font-size: 15px;\n  font-weight: bold;\n  color: var(--color-gray300);\n  padding: 11px 0;\n\n  &[aria-selected='true'] {\n    color: var(--color-gray);\n  }\n\n  ${({ $scroll }) =>\n    $scroll &&\n    css`\n      flex: none;\n      padding: 11px 18px;\n    `}\n`\n\nexport const PointingTab = <Value extends number | string | symbol>({\n  children,\n  ...props\n}: TabBaseProps<Value>) => {\n  const tabs = useTabs<Value>()\n  const { tabsRef } = usePointingTab<Value>()\n\n  return (\n    <StyledTabBase\n      ref={(node) => {\n        tabsRef.current[props.value] = node\n      }}\n      $scroll={tabs.scroll}\n      {...props}\n    >\n      {children}\n    </StyledTabBase>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/rounded-tab-list.tsx",
    "content": "import { styled, css } from 'styled-components'\n\nimport { TabListBase, TabListBaseProps } from './tab-list-base'\nimport { useTabs } from './tabs-context'\n\ninterface StyledTabListBaseProps {\n  $scroll: boolean\n}\n\nconst StyledTabListBase = styled(TabListBase)<StyledTabListBaseProps>`\n  display: flex;\n  gap: 5px;\n  padding: 10px 30px;\n\n  ${({ $scroll }) =>\n    $scroll &&\n    css`\n      overflow-x: scroll;\n      -webkit-overflow-scrolling: touch;\n\n      &::-webkit-scrollbar {\n        display: none;\n      }\n    `}\n`\n\nexport const RoundedTabList = <Value extends number | string | symbol>({\n  children,\n  ...props\n}: TabListBaseProps) => {\n  const tabs = useTabs<Value>()\n\n  return (\n    <StyledTabListBase {...props} $scroll={tabs.scroll}>\n      {children}\n    </StyledTabListBase>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/rounded-tab.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { TabBase, TabBaseProps } from './tab-base'\n\nconst StyledTabBase = styled(TabBase)`\n  flex: none;\n  padding: 8px 14px;\n  border: 1px solid var(--color-gray100);\n  border-radius: 100px;\n  background: var(--color-white);\n  font-size: 13px;\n  font-weight: bold;\n  line-height: 16px;\n  color: var(--color-gray300);\n\n  &[aria-selected='true'] {\n    color: var(--color-white);\n    background: var(--color-blue);\n    border: 1px solid var(--color-blue);\n  }\n`\n\nexport const RoundedTab = <Value extends number | string | symbol>({\n  children,\n  ...props\n}: TabBaseProps<Value>) => {\n  return <StyledTabBase {...props}>{children}</StyledTabBase>\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/tab-base.tsx",
    "content": "import {\n  FocusEventHandler,\n  ForwardedRef,\n  forwardRef,\n  KeyboardEventHandler,\n  MouseEventHandler,\n  PropsWithChildren,\n  useEffect,\n  useRef,\n} from 'react'\nimport { useFocusEffect, useRovingTabIndex } from 'react-roving-tabindex'\n\nimport { mergeRefs } from '../../utils/merge-refs'\n\nimport { useTabs } from './tabs-context'\n\nexport interface TabBaseProps<Value> extends PropsWithChildren {\n  /**\n   * 각 탭마다의 유니크한 값\n   */\n  value: Value\n}\n\nfunction TabBaseComponent<Value extends number | string | symbol>(\n  { children, value, ...props }: TabBaseProps<Value>,\n  ref: ForwardedRef<HTMLButtonElement>,\n) {\n  const tabs = useTabs<Value>()\n  const internalRef = useRef<HTMLButtonElement>(null)\n\n  const [tabIndex, focused, onKeyDown, onClick] = useRovingTabIndex(\n    internalRef,\n    false,\n  )\n\n  const isSelected = tabs.value === value\n\n  const wasSelected = useRef(isSelected)\n\n  const handleClick: MouseEventHandler = () => {\n    onClick()\n  }\n\n  const handleFocus: FocusEventHandler = () => {\n    tabs.handleFocusChanged?.(value)\n  }\n\n  const handleKeyDown: KeyboardEventHandler = (event) => {\n    onKeyDown(event)\n  }\n\n  useEffect(() => {\n    if (isSelected && !wasSelected.current) {\n      internalRef.current?.focus()\n    }\n    wasSelected.current = isSelected\n  }, [isSelected])\n\n  useFocusEffect(focused, internalRef)\n\n  return (\n    <button\n      ref={mergeRefs([ref, internalRef])}\n      id={`${tabs.id}-tab-${value.toString()}`}\n      role=\"tab\"\n      tabIndex={tabIndex}\n      aria-controls={`${tabs.id}-panel-${value.toString()}`}\n      aria-selected={isSelected}\n      onClick={handleClick}\n      onFocus={handleFocus}\n      onKeyDown={handleKeyDown}\n      {...props}\n    >\n      {children}\n    </button>\n  )\n}\n\nexport const TabBase = forwardRef(TabBaseComponent)\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/tab-list-base.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { RovingTabIndexProvider } from 'react-roving-tabindex'\n\nexport type TabListBaseProps = PropsWithChildren\n\nexport const TabListBase = ({ children, ...props }: TabListBaseProps) => {\n  return (\n    <RovingTabIndexProvider\n      options={{\n        direction: 'horizontal',\n        focusOnClick: true,\n        loopAround: true,\n      }}\n    >\n      <div role=\"tablist\" aria-orientation=\"horizontal\" {...props}>\n        {children}\n      </div>\n    </RovingTabIndexProvider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/tab-list.tsx",
    "content": "import { BasicTabList } from './basic-tab-list'\nimport { PointingTabList } from './pointing-tab-list'\nimport { RoundedTabList } from './rounded-tab-list'\nimport { TabListBaseProps } from './tab-list-base'\nimport { useTabs } from './tabs-context'\n\nexport type TabListProps = TabListBaseProps\n\nexport const TabList = <Value extends number | string | symbol>(\n  props: TabListProps,\n) => {\n  const tabs = useTabs<Value>()\n\n  switch (tabs.variant) {\n    case 'basic':\n      return <BasicTabList {...props} />\n    case 'pointing':\n      return <PointingTabList {...props} />\n    case 'rounded':\n      return <RoundedTabList {...props} />\n  }\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/tab-panel.tsx",
    "content": "import React, { PropsWithChildren } from 'react'\n\nimport { useTabs } from './tabs-context'\n\nexport interface TabPanelProps<Value> extends PropsWithChildren {\n  /**\n   * 각 탭마다의 유니크한 값\n   */\n  value: Value\n}\n\nexport const TabPanel = <Value extends number | string | symbol>({\n  children,\n  value,\n}: TabPanelProps<Value>) => {\n  const tabs = useTabs<Value>()\n\n  return (\n    <div\n      id={`${tabs.id}-panel-${value.toString()}`}\n      role=\"tabpanel\"\n      hidden={tabs.value !== value}\n      tabIndex={0}\n      aria-labelledby={`${tabs.id}-tab-${value.toString()}`}\n    >\n      {children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/tab.tsx",
    "content": "import { BasicTab } from './basic-tab'\nimport { PointingTab } from './pointing-tab'\nimport { RoundedTab } from './rounded-tab'\nimport { TabBaseProps } from './tab-base'\nimport { useTabs } from './tabs-context'\n\nexport type TabProps<Value> = TabBaseProps<Value>\n\nexport const Tab = <Value extends number | string | symbol>(\n  props: TabProps<Value>,\n) => {\n  const tabs = useTabs<Value>()\n\n  switch (tabs.variant) {\n    case 'basic':\n      return <BasicTab {...props} />\n    case 'pointing':\n      return <PointingTab<Value> {...props} />\n    case 'rounded':\n      return <RoundedTab {...props} />\n  }\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/tabs-context.tsx",
    "content": "import { createContext, useContext } from 'react'\n\nimport { TabVariant } from './types'\n\nexport interface TabsContextValue<Value extends number | string | symbol> {\n  id: string\n  value: Value\n  variant: TabVariant\n  scroll: boolean\n  handleFocusChanged: (value: Value) => void\n}\n\nexport const TabsContext = createContext<TabsContextValue<string> | undefined>(\n  undefined,\n)\n\nexport function useTabs<Value extends number | string | symbol>() {\n  const context = useContext(TabsContext) as TabsContextValue<Value> | undefined\n  if (!context) {\n    throw new Error('TabsContextContext가 없습니다.')\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/tabs.stories.tsx",
    "content": "import type { Meta, StoryFn, StoryObj } from '@storybook/react'\nimport { useState } from 'react'\n\nimport { Tabs } from './tabs'\nimport { TabList } from './tab-list'\nimport { Tab } from './tab'\nimport { TabPanel } from './tab-panel'\n\nconst meta: Meta<typeof Tabs> = {\n  title: 'tds-ui (Disclosure) / Tabs',\n  component: Tabs,\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '좁은 화면에 많은 수의 컴포넌트를 배치할 필요가 있을 때 사용되는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Tabs>\n\nconst Template: StoryFn<typeof Tabs> = (args) => {\n  const [value, setValue] = useState('a')\n\n  return (\n    <Tabs {...args} value={value} onChange={setValue}>\n      <TabList>\n        <Tab value=\"a\">A</Tab>\n        <Tab value=\"b\">B</Tab>\n        <Tab value=\"c\">C</Tab>\n      </TabList>\n      <TabPanel value=\"a\">This is panel A</TabPanel>\n      <TabPanel value=\"b\">This is panel B</TabPanel>\n      <TabPanel value=\"c\">This is panel C</TabPanel>\n    </Tabs>\n  )\n}\n\nexport const Basic: Story = {\n  args: {\n    variant: 'basic',\n  },\n  render: Template,\n}\n\nexport const Pointing: Story = {\n  args: {\n    variant: 'pointing',\n  },\n  render: Template,\n}\n\nexport const PointingScroll: Story = {\n  args: {\n    variant: 'pointing',\n    scroll: true,\n  },\n  render: Template,\n}\n\nexport const Rounded: Story = {\n  args: {\n    variant: 'rounded',\n  },\n  render: Template,\n}\n\nexport const RoundedScroll: Story = {\n  args: {\n    variant: 'rounded',\n    scroll: true,\n  },\n  render: Template,\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/tabs.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\nimport { useState } from 'react'\n\nimport { Tab } from './tab'\nimport { TabList } from './tab-list'\nimport { TabPanel } from './tab-panel'\nimport { Tabs } from './tabs'\n\ntest('방향키로 roving tabindex를 사용합니다.', async () => {\n  const user = userEvent.setup()\n\n  const Example = () => {\n    const [value, setValue] = useState('a')\n\n    return (\n      <Tabs value={value} onChange={setValue}>\n        <TabList>\n          <Tab value=\"a\">A</Tab>\n          <Tab value=\"b\">B</Tab>\n          <Tab value=\"c\">C</Tab>\n        </TabList>\n        <TabPanel value=\"a\">This is panel A</TabPanel>\n        <TabPanel value=\"b\">This is panel B</TabPanel>\n        <TabPanel value=\"c\">This is panel C</TabPanel>\n      </Tabs>\n    )\n  }\n\n  render(<Example />)\n\n  await user.tab()\n\n  expect(screen.getByText('A')).toHaveFocus()\n  expect(screen.getByText('This is panel A')).toBeVisible()\n\n  await user.keyboard('{ArrowRight}')\n\n  expect(screen.getByText('B')).toHaveFocus()\n  expect(screen.getByText('This is panel B')).toBeVisible()\n\n  await user.keyboard('{ArrowRight}')\n\n  expect(screen.getByText('C')).toHaveFocus()\n  expect(screen.getByText('This is panel C')).toBeVisible()\n\n  // 마지막에서 다음 포커스는 처음으로 돌아 갑니다.\n  await user.keyboard('{ArrowRight}')\n\n  expect(screen.getByText('A')).toHaveFocus()\n  expect(screen.getByText('This is panel A')).toBeVisible()\n\n  // 처음에서 이전 포커스는 마지막으로 돌아 갑니다.\n  await user.keyboard('{ArrowLeft}')\n\n  expect(screen.getByText('C')).toHaveFocus()\n  expect(screen.getByText('This is panel C')).toBeVisible()\n\n  await user.keyboard('{ArrowLeft}')\n\n  expect(screen.getByText('B')).toHaveFocus()\n  expect(screen.getByText('This is panel B')).toBeVisible()\n\n  await user.keyboard('{ArrowLeft}')\n\n  expect(screen.getByText('A')).toHaveFocus()\n  expect(screen.getByText('This is panel A')).toBeVisible()\n})\n\ntest('Home, End키로 roving tabindex를 사용합니다.', async () => {\n  const user = userEvent.setup()\n\n  const Example = () => {\n    const [value, setValue] = useState('a')\n\n    return (\n      <Tabs value={value} onChange={setValue}>\n        <TabList>\n          <Tab value=\"a\">A</Tab>\n          <Tab value=\"b\">B</Tab>\n          <Tab value=\"c\">C</Tab>\n        </TabList>\n        <TabPanel value=\"a\">This is panel A</TabPanel>\n        <TabPanel value=\"b\">This is panel B</TabPanel>\n        <TabPanel value=\"c\">This is panel C</TabPanel>\n      </Tabs>\n    )\n  }\n\n  render(<Example />)\n\n  await user.tab()\n\n  expect(screen.getByText('A')).toHaveFocus()\n  expect(screen.getByText('This is panel A')).toBeVisible()\n\n  await user.keyboard('{End}')\n\n  expect(screen.getByText('C')).toHaveFocus()\n  expect(screen.getByText('This is panel C')).toBeVisible()\n\n  await user.keyboard('{Home}')\n\n  expect(screen.getByText('A')).toHaveFocus()\n  expect(screen.getByText('This is panel A')).toBeVisible()\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/tabs.tsx",
    "content": "import { PropsWithChildren, Provider, useId } from 'react'\n\nimport { TabsContext, TabsContextValue } from './tabs-context'\nimport { TabVariant } from './types'\n\nexport interface TabsProps<Value extends number | string | symbol>\n  extends PropsWithChildren {\n  /**\n   * 현재 탭을 가르키는 값\n   */\n  value: Value\n  /**\n   * 디자인 variant\n   */\n  variant?: TabVariant\n  /**\n   * 스크롤 가능한 탭 사용 여부. `pointing` 또는 `rounded` variant 일 때만 사용 가능\n   */\n  scroll?: boolean\n  /**\n   * 탭 변경 이벤트 핸들러\n   */\n  onChange?: (value: Value) => void\n}\n\nexport const Tabs = <Value extends number | string | symbol>({\n  children,\n  value,\n  variant = 'basic',\n  scroll = false,\n  onChange,\n}: TabsProps<Value>) => {\n  const id = useId()\n  const TabsContextProvider = TabsContext.Provider as Provider<\n    TabsContextValue<Value> | undefined\n  >\n\n  function handleFocusChanged(newValue: Value) {\n    if (value !== newValue) {\n      onChange?.(newValue)\n    }\n  }\n\n  return (\n    <TabsContextProvider\n      value={{ id, value, variant, scroll, handleFocusChanged }}\n    >\n      {children}\n    </TabsContextProvider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tabs/types.ts",
    "content": "export type TabVariant = 'basic' | 'pointing' | 'rounded'\n"
  },
  {
    "path": "packages/tds-ui/src/components/tag/index.ts",
    "content": "export * from './tag'\n"
  },
  {
    "path": "packages/tds-ui/src/components/tag/tag.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Tag } from './tag'\n\nconst meta: Meta<typeof Tag> = {\n  title: 'tds-ui (Data display) / Tag',\n  component: Tag,\n  argTypes: {\n    type: {\n      control: 'select',\n      options: ['special', 'pink', 'purple', 'default'],\n    },\n    size: { control: 'select', options: ['tiny', 'mini', 'small', 'medium'] },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: '이벤트 태그에 사용되는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\nexport const Default: StoryObj<typeof Tag> = {\n  args: {\n    type: 'default',\n    size: 'mini',\n    children: '이벤트~태그',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tag/tag.tsx",
    "content": "import { CSSProperties } from 'react'\nimport { styled, css } from 'styled-components'\n\nimport { GlobalSizes, MarginPadding } from '../../commons'\nimport { marginMixin } from '../../mixins'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nexport type TagColors = 'special' | 'pink' | 'purple' | 'default'\n\nconst COLORS: { [key in TagColors]: string } = {\n  special: '#fd2e69',\n  pink: '#fff',\n  purple: '#fff',\n  default: 'rgba(58, 58, 58, 0.8)',\n}\n\nconst BG_COLORS: { [key in TagColors]: string } = {\n  special: 'rgba(253, 46, 105, 0.1)',\n  pink: '#fd2e69',\n  purple: '#9b71f7',\n  default: '#f5f5f5',\n}\n\nconst PADDING_SIZE: Partial<Record<GlobalSizes, MarginPadding>> = {\n  tiny: { top: 2, right: 8, bottom: 2, left: 8 },\n  mini: { top: 4, right: 6, bottom: 4, left: 6 },\n  small: { top: 4, right: 10, bottom: 4, left: 10 },\n  medium: { top: 6, right: 10, bottom: 6, left: 10 },\n}\n\nexport const Tag = styled.div.withConfig({ shouldForwardProp })<{\n  type?: TagColors\n  margin?: MarginPadding\n  size?: GlobalSizes\n  style?: CSSProperties\n}>`\n  display: inline-block;\n  font-size: 12px;\n  font-weight: 500;\n  color: ${({ type }) => COLORS[type || 'default']};\n  background-color: ${({ type }) => BG_COLORS[type || 'default']};\n  border-radius: 4px;\n\n  ${marginMixin}\n\n  ${({ size = 'mini' }) => {\n    const padding = PADDING_SIZE[size] || {}\n    return css`\n      padding: ${padding.top || 0}px ${padding.right || 0}px\n        ${padding.bottom || 0}px ${padding.left || 0}px;\n    `\n  }};\n\n  ${({ style }) =>\n    style &&\n    css`\n      ${Object.keys(style)\n        .map((key) => `${key}: ${style[key as keyof CSSProperties]};`) // HACK: style: CSSProperties이므로\n        .join('\\n')};\n    `};\n`\n"
  },
  {
    "path": "packages/tds-ui/src/components/text/index.ts",
    "content": "export * from './text'\nexport * from './typography'\n"
  },
  {
    "path": "packages/tds-ui/src/components/text/text.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Text } from './text'\n\nconst meta: Meta<typeof Text> = {\n  title: 'tds-ui (Typography) / Text',\n  component: Text,\n  parameters: {\n    docs: {\n      description: {\n        component: 'Text를 표현할때 사용하는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Text>\n\nexport const Default: Story = {\n  args: {\n    children: '텍스트',\n  },\n}\n\nexport const Custom: Story = {\n  args: {\n    bold: true,\n    color: 'mint',\n    size: 20,\n    children: '텍스트',\n  },\n}\n\nexport const MaxLines: Story = {\n  args: {\n    maxLines: 2,\n    children: (\n      <>\n        각급 선거관리위원회는 선거인명부의 작성등 선거사무와 국민투표사무에\n        관하여 관계 행정기관에 필요한 지시를 할 수 있다. 모든 국민의 재산권은\n        보장된다. 그 내용과 한계는 법률로 정한다. 국회의원은 현행범인인 경우를\n        제외하고는 회기중 국회의 동의없이 체포 또는 구금되지 아니한다. 대법원과\n        각급법원의 조직은 법률로 정한다. 대한민국의 경제질서는 개인과 기업의\n        경제상의 자유와 창의를 존중함을 기본으로 한다.\n      </>\n    ),\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/text/text.test.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { ThemeProvider } from 'styled-components'\nimport { render, screen } from '@testing-library/react'\nimport { defaultTheme } from '@titicaca/tds-theme'\n\nimport { Text } from './text'\n\nfunction ThemeWrapper({ children }: PropsWithChildren<unknown>) {\n  return <ThemeProvider theme={defaultTheme}>{children}</ThemeProvider>\n}\n\ntest('should have default styles', () => {\n  render(<Text>text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('overflow-wrap', 'break-word')\n  expect(element).toHaveStyleRule('float', 'none')\n  expect(element).toHaveStyleRule('font-weight', '500')\n  expect(element).toHaveStyleRule('white-space', 'pre-line')\n})\n\ntest('should accept style shortcut props', () => {\n  render(\n    <Text\n      cursor=\"pointer\"\n      floated=\"left\"\n      textAlign=\"right\"\n      whiteSpace=\"pre\"\n      wordBreak=\"keep-all\"\n    >\n      text\n    </Text>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('cursor', 'pointer')\n  expect(element).toHaveStyleRule('float', 'left')\n  expect(element).toHaveStyleRule('text-align', 'right')\n  expect(element).toHaveStyleRule('white-space', 'pre')\n  expect(element).toHaveStyleRule('word-break', 'keep-all')\n})\n\ntest('should accept inline prop', () => {\n  render(<Text inline>text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('display', 'inline')\n})\n\ntest('should accept inlineBlock prop', () => {\n  render(<Text inlineBlock>text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('display', 'inline-block')\n})\n\ntest('should take on inlineBlock than inline when both are passed', () => {\n  render(\n    <Text inline inlineBlock>\n      text\n    </Text>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('display', 'inline-block')\n})\n\ntest('should accept bold prop', () => {\n  render(<Text bold>text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('font-weight', 'bold')\n})\n\ntest('should take center prop', () => {\n  render(<Text center>text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('text-align', 'center')\n})\n\ntest('should take on textAlign than center when both are passed', () => {\n  render(\n    <Text center textAlign=\"right\">\n      text\n    </Text>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('text-align', 'right')\n})\n\ntest('should accept strikethrough prop', () => {\n  render(<Text strikethrough>text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('text-decoration', 'line-through')\n})\n\ntest('should accept underline prop', () => {\n  render(<Text underline>text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('text-decoration', 'underline')\n})\n\ntest('should take on strikethrough than underline when both are passed', () => {\n  render(\n    <Text strikethrough underline>\n      text\n    </Text>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('text-decoration', 'line-through')\n})\n\ntest('should accept color prop', () => {\n  render(<Text color=\"white\">text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('color', 'rgba(255,255,255,1)')\n})\n\ntest('should accept color prop with alpha', () => {\n  render(\n    <Text alpha={0.5} color=\"white\">\n      text\n    </Text>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('color', 'rgba(255,255,255,0.5)')\n})\n\ntest('should accept spacing props', () => {\n  render(\n    <Text\n      margin={{ top: 10, right: 20, bottom: 30, left: 40 }}\n      padding={{ top: 50, right: 60, bottom: 70, left: 80 }}\n    >\n      text\n    </Text>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('margin', '10px 20px 30px 40px')\n  expect(element).toHaveStyleRule('padding', '50px 60px 70px 80px')\n})\n\ntest('should accept ellipsis mixin', () => {\n  render(<Text ellipsis>text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('white-space', 'nowrap')\n  expect(element).toHaveStyleRule('text-overflow', 'ellipsis')\n  expect(element).toHaveStyleRule('overflow', 'hidden')\n})\n\ntest('should accept maxLines mixin', () => {\n  render(<Text maxLines={2}>text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('display', '-webkit-box')\n  expect(element).toHaveStyleRule('-webkit-box-orient', 'vertical')\n  expect(element).toHaveStyleRule('-webkit-line-clamp', '2')\n  expect(element).toHaveStyleRule('text-overflow', 'ellipsis')\n  expect(element).toHaveStyleRule('overflow', 'hidden')\n})\n\ntest('should accept legacy typography props', () => {\n  render(\n    <Text size=\"large\" lineHeight={1} letterSpacing={0.1}>\n      text\n    </Text>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('font-size', '16px')\n  expect(element).toHaveStyleRule('line-height', '1')\n  expect(element).toHaveStyleRule('letter-spacing', '0.1px')\n})\n\ntest('should accept textStyle mixin', () => {\n  render(<Text textStyle=\"M\">text</Text>, { wrapper: ThemeWrapper })\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('font-size', '20px')\n  expect(element).toHaveStyleRule('line-height', '24px')\n  expect(element).toHaveStyleRule('letter-spacing', '-0.2px')\n})\n\ntest('should ignore legacy typography props when textStyle mixin is passed', () => {\n  render(\n    <Text textStyle=\"M\" size=\"large\" lineHeight={1} letterSpacing={0.1}>\n      text\n    </Text>,\n    { wrapper: ThemeWrapper },\n  )\n\n  const element = screen.getByText('text')\n\n  expect(element).toHaveStyleRule('font-size', '20px')\n  expect(element).toHaveStyleRule('line-height', '24px')\n  expect(element).toHaveStyleRule('letter-spacing', '-0.2px')\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/text/text.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\nimport { Property } from 'csstype'\nimport type { Theme } from '@titicaca/tds-theme'\n\nimport { MarginPadding, GlobalSizes, GetGlobalColor } from '../../commons'\nimport {\n  KeyOfTextStyleMap,\n  ellipsisMixin,\n  marginMixin,\n  maxLinesMixin,\n  paddingMixin,\n  textStyleMixin,\n} from '../../mixins'\nimport { shouldForwardProp } from '../../utils/should-forward-prop'\n\nfunction rgba({ color, alpha }: { color?: string; alpha?: number }) {\n  return `rgba(${GetGlobalColor(color || 'gray')}, ${alpha || 1})`\n}\n\nexport type TextProps = PropsWithChildren<{\n  alpha?: number\n  bold?: boolean\n  center?: boolean\n  color?: keyof Theme['colors'] | string\n  cursor?: Property.Cursor\n  ellipsis?: boolean\n  floated?: Property.Float\n  inline?: boolean\n  inlineBlock?: boolean\n  letterSpacing?: number\n  lineHeight?: number | string\n  margin?: MarginPadding\n  maxLines?: number\n  padding?: MarginPadding\n  size?: GlobalSizes | number\n  strikethrough?: boolean\n  textAlign?: Property.TextAlign\n  textStyle?: KeyOfTextStyleMap\n  underline?: boolean\n  whiteSpace?: Property.WhiteSpace\n  wordBreak?: Property.WordBreak\n}>\n\nexport const Text = styled.div.withConfig({ shouldForwardProp })<TextProps>(\n  (props) => ({\n    boxSizing: 'border-box',\n    overflowWrap: 'break-word',\n    color: props.alpha\n      ? rgba({ color: props.color, alpha: props.alpha })\n      : props.theme.colors?.[(props.color as keyof Theme['colors']) ?? 'gray'],\n    cursor: props.cursor,\n    display: props.inlineBlock\n      ? 'inline-block'\n      : props.inline\n        ? 'inline'\n        : undefined,\n    float: props.floated ?? 'none',\n    fontWeight: props.bold ? 'bold' : 500,\n    textAlign: props.textAlign\n      ? props.textAlign\n      : props.center\n        ? 'center'\n        : undefined,\n    textDecoration: props.strikethrough\n      ? 'line-through'\n      : props.underline\n        ? 'underline'\n        : undefined,\n    whiteSpace: props.whiteSpace ?? 'pre-line',\n    wordBreak: props.wordBreak,\n  }),\n  marginMixin,\n  paddingMixin,\n  textStyleMixin,\n  ellipsisMixin,\n  maxLinesMixin,\n)\n\ninterface TextTitleBaseProps {\n  margin?: MarginPadding\n}\n\nconst TextTitleBase = styled(Text)<TextTitleBaseProps>`\n  line-height: 1.2;\n  font-size: 24px;\n  font-weight: bold;\n  color: #3a3a3a;\n  ${marginMixin}\n`\n\nexport type TextTitleProps = PropsWithChildren<TextTitleBaseProps>\n\nexport function TextTitle({ children, margin, ...props }: TextTitleProps) {\n  return (\n    <TextTitleBase as=\"h1\" margin={margin} {...props}>\n      {children}\n    </TextTitleBase>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/text/typography.tsx",
    "content": "import * as CSS from 'csstype'\n\nimport { Container } from '../container'\n\nimport { Text, TextProps } from './text'\n\nexport type H1Props = TextProps & {\n  href?: string\n  headline?: string\n  emphasize?: boolean\n  textAlign?: CSS.Property.TextAlign\n}\n\nexport type H2Props = TextProps\nexport type H3Props = TextProps\nexport type H4Props = TextProps\nexport type ParagraphProps = TextProps\n\nexport function H1({\n  href,\n  headline,\n  emphasize,\n  textAlign,\n  children,\n  ...props\n}: H1Props) {\n  return (\n    <Container\n      id={href}\n      css={{\n        textAlign,\n      }}\n      {...props}\n    >\n      {headline && (\n        <Text bold size=\"tiny\" color=\"blue\" margin={{ bottom: 3 }}>\n          {headline}\n        </Text>\n      )}\n      <Text bold size=\"huge\" color={emphasize ? 'blue' : 'gray'}>\n        {children}\n      </Text>\n    </Container>\n  )\n}\n\nexport function H2({ children, ...props }: H2Props) {\n  return (\n    <Text size=\"big\" color=\"gray\" {...props}>\n      {children}\n    </Text>\n  )\n}\n\nexport function H3({ children, ...props }: H3Props) {\n  return (\n    <Text bold size=\"large\" color=\"gray\" {...props}>\n      {children}\n    </Text>\n  )\n}\n\nexport function H4({ children, ...props }: H4Props) {\n  return (\n    <Text bold size=\"large\" color=\"blue\" {...props}>\n      {children}\n    </Text>\n  )\n}\n\nexport function Paragraph({ children, ...props }: ParagraphProps) {\n  return (\n    <Text lineHeight={1.63} alpha={0.9} {...props}>\n      {children}\n    </Text>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/textarea/index.ts",
    "content": "export * from './textarea'\n"
  },
  {
    "path": "packages/tds-ui/src/components/textarea/textarea.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Textarea } from './textarea'\n\nconst meta: Meta<typeof Textarea> = {\n  title: 'tds-ui (Form) / Textarea',\n  component: Textarea,\n  args: { required: false },\n  argTypes: {\n    required: { type: 'boolean' },\n    label: { type: 'string' },\n    error: { if: { arg: 'help', truthy: false }, type: 'string' },\n    help: { if: { arg: 'error', truthy: false }, type: 'string' },\n  },\n  parameters: {\n    docs: {\n      description: {\n        component:\n          '사용자가 여러 줄의 긴 문장을 입력할 수 있도록 제공하는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Textarea>\n\nexport const Default: Story = {\n  args: {\n    placeholder: '요청사항을 입력해주세요.',\n  },\n}\n\nexport const Required: Story = {\n  args: {\n    label: '요청사항',\n    required: true,\n    placeholder: '요청사항을 입력해주세요.',\n  },\n}\n\nexport const WithLabel: Story = {\n  args: {\n    label: '요청사항',\n    placeholder: '요청사항을 입력해주세요.',\n  },\n}\n\nexport const WithHelpMessage: Story = {\n  args: {\n    label: '요청사항',\n    placeholder: '요청사항을 입력해주세요.',\n    help: '고객님의 요청사항은 해당 호텔에 전달됩니다만 호텔 사정에 따라 필요하신 내용이 이루어지지 않을 수 있으니 많은 양해 바랍니다.',\n  },\n}\n\nexport const WithErrorMessage: Story = {\n  args: {\n    label: '요청사항',\n    placeholder: '요청사항을 입력해주세요.',\n    error: '요청사항은 필수 입력 사항입니다.',\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/textarea/textarea.tsx",
    "content": "import { forwardRef, TextareaHTMLAttributes } from 'react'\nimport { styled } from 'styled-components'\n\nimport { GlobalColors } from '../../commons'\nimport {\n  FormFieldContext,\n  FormFieldError,\n  FormFieldHelp,\n  FormFieldLabel,\n  useFormFieldState,\n} from '../form-field'\n\nconst COLORS: Partial<Record<GlobalColors, string>> = {\n  blue: '54, 143, 255',\n  red: '255, 33, 60',\n  gray: '58, 58, 58',\n}\n\nconst BaseTextarea = styled.textarea`\n  overflow: hidden;\n  outline: none;\n  padding: 14px 16px;\n  font-size: 16px;\n  font-weight: 500;\n  border: 1px solid #efefef;\n  border-radius: 4px;\n  width: 100%;\n  resize: none;\n  min-height: 100px;\n\n  &::placeholder {\n    color: rgba(${COLORS.gray}, 0.3);\n  }\n\n  &:focus {\n    border-color: rgb(${COLORS.blue});\n  }\n\n  &[aria-invalid='true'] {\n    border-color: rgb(${COLORS.red});\n  }\n`\n\nexport interface TextareaProps\n  extends TextareaHTMLAttributes<HTMLTextAreaElement> {\n  required?: boolean\n  label?: string\n  error?: string | boolean\n  help?: string\n}\n\nexport const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(\n  function Textarea(\n    { required = false, label, error, help, onBlur, onFocus, ...props },\n    ref,\n  ) {\n    const formFieldState = useFormFieldState({ onBlur, onFocus })\n\n    const hasLabel = !!label\n    const hasHelp = !!help\n    const isError = !!error\n\n    return (\n      <FormFieldContext.Provider\n        value={{\n          ...formFieldState,\n          isError,\n          isDisabled: false,\n          isRequired: required,\n        }}\n      >\n        {label ? <FormFieldLabel>{label}</FormFieldLabel> : null}\n        <BaseTextarea\n          ref={ref}\n          id={hasLabel ? formFieldState.inputId : undefined}\n          aria-describedby={\n            hasHelp && !isError ? formFieldState.descriptionId : undefined\n          }\n          aria-errormessage={isError ? formFieldState.errorId : undefined}\n          aria-invalid={isError}\n          aria-multiline\n          onBlur={formFieldState.handleBlur}\n          onFocus={formFieldState.handleFocus}\n          {...props}\n        />\n        {error ? (\n          <FormFieldError>{error}</FormFieldError>\n        ) : help ? (\n          <FormFieldHelp>{help}</FormFieldHelp>\n        ) : null}\n      </FormFieldContext.Provider>\n    )\n  },\n)\n"
  },
  {
    "path": "packages/tds-ui/src/components/tooltip/index.ts",
    "content": "export * from './tooltip'\n"
  },
  {
    "path": "packages/tds-ui/src/components/tooltip/tooltip.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { styled } from 'styled-components'\n\nimport { Tooltip } from './tooltip'\n\nconst meta: Meta<typeof Tooltip> = {\n  title: 'tds-ui (Overlay) / Tooltip',\n  component: Tooltip,\n  args: {\n    onClick: () => {},\n  },\n  parameters: {\n    docs: {\n      description: {\n        component: '말풍선을 노출하는 뷰 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof Tooltip>\n\nconst Base = styled.div`\n  position: relative;\n  margin: 50px;\n  border: solid 1px black;\n  padding: 10px;\n`\n\nexport const ArrowTop: Story = {\n  args: {\n    label: '모든 호텔 보기',\n    pointing: {\n      vertical: 'top',\n      horizontal: 'left',\n      horizontalOffset: 26,\n    },\n    nowrap: false,\n    backgroundColor: 'rgba(13, 208, 175, 1)',\n  },\n  render: (args) => {\n    return (\n      <Base>\n        툴팁 표시 대상\n        <Tooltip {...args} />\n      </Base>\n    )\n  },\n}\n\nexport const ArrowBottom: Story = {\n  args: {\n    label: '쿠폰사용시 -15,000원 더 할인!',\n    positioning: { top: -25 },\n    borderRadius: '30',\n  },\n  render: (args) => {\n    return (\n      <Base>\n        툴팁 표시 대상\n        <Tooltip {...args} />\n      </Base>\n    )\n  },\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/tooltip/tooltip.tsx",
    "content": "import { MouseEventHandler } from 'react'\nimport { styled, css } from 'styled-components'\n\ninterface PointingOptions {\n  vertical: 'top' | 'bottom'\n  horizontal: 'left' | 'right'\n  horizontalOffset?: number\n}\n\ninterface TooltipFrameProps {\n  positioning?: Partial<\n    Record<'top' | 'right' | 'bottom' | 'left', number | string>\n  >\n  borderRadius?: string\n  hasShadow?: boolean\n  backgroundColor: string\n  pointing: PointingOptions\n}\n\ninterface TooltipProps extends Partial<TooltipFrameProps> {\n  label: string\n  onClick?: MouseEventHandler<HTMLDivElement>\n  nowrap?: boolean\n}\n\nconst DEFAULT_POINTING_OPTION = {\n  vertical: 'bottom',\n  horizontal: 'left',\n  horizontalOffset: 26,\n} as const\n\nconst DEFAULT_BACKGROUND_COLOR = 'rgba(13, 208, 175, 1)'\n\nconst POINTING_BASE_STYLE = css`\n  position: absolute;\n  content: '';\n  width: 11px;\n  height: 11px;\n  border-right: 5px solid transparent;\n  border-left: 5px solid transparent;\n`\n\nconst TooltipFrame = styled.div<TooltipFrameProps>`\n  position: relative;\n  color: var(--color-white);\n  padding: 6px 11px;\n\n  ${({ backgroundColor }) => `background-color: ${backgroundColor}`};\n\n  ${({ pointing, backgroundColor }) => {\n    switch (pointing.vertical) {\n      case 'top':\n        return `\n          &::before {\n            ${POINTING_BASE_STYLE}\n\n            top: -11px;\n            ${pointing.horizontal}: ${pointing.horizontalOffset}px;\n            border-bottom: 5px solid ${backgroundColor};\n          }\n        `\n      case 'bottom':\n        return `\n          &::after {\n            ${POINTING_BASE_STYLE}\n\n            bottom: -11px;\n            ${pointing.horizontal}: ${pointing.horizontalOffset}px;\n            border-top: 5px solid ${backgroundColor};\n          }\n         `\n    }\n  }}\n\n  ${({ borderRadius }) =>\n    borderRadius &&\n    css`\n      border-radius: ${borderRadius}px;\n    `}\n\n  ${({ positioning }) =>\n    positioning &&\n    css`\n      position: absolute;\n      ${typeof positioning.top === 'number' ? `top: ${positioning.top}px;` : ''}\n      ${typeof positioning.right === 'number'\n        ? `right: ${positioning.right}px;`\n        : ''}\n      ${typeof positioning.bottom === 'number'\n        ? `bottom: ${positioning.bottom}px;`\n        : ''}\n      ${typeof positioning.left === 'number'\n        ? `left: ${positioning.left}px;`\n        : ''}\n    `}\n\n  ${({ hasShadow }) => hasShadow && 'box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.1);'}\n`\n\nconst TooltipContainer = styled.div<{ paddingRight?: number; nowrap: boolean }>`\n  position: relative;\n  font-size: 12px;\n  font-weight: bold;\n\n  ${({ paddingRight }) =>\n    paddingRight &&\n    css`\n      padding-right: ${paddingRight}px;\n    `}\n\n  ${({ nowrap }) => nowrap && 'white-space: nowrap;'}\n`\n\nconst ArrowRight = styled.span`\n  width: 9px;\n  height: 13px;\n  top: 50%;\n  position: absolute;\n  right: 0;\n  bottom: 0;\n  transform: translateY(-50%);\n  background-repeat: no-repeat;\n  background-size: 9px 13px;\n  background-image: url('https://assets.triple.guide/images/ico-arrow-right-w@3x.png');\n`\n\nexport function Tooltip({\n  label,\n  onClick,\n  nowrap,\n  ...frameProps\n}: TooltipProps) {\n  return (\n    <TooltipFrame\n      {...{\n        ...frameProps,\n        backgroundColor: frameProps.backgroundColor || DEFAULT_BACKGROUND_COLOR,\n        pointing: frameProps.pointing || DEFAULT_POINTING_OPTION,\n      }}\n      role=\"tooltip\"\n      onClick={onClick}\n    >\n      <TooltipContainer paddingRight={onClick && 12} nowrap={!!nowrap}>\n        {label}\n        {onClick && <ArrowRight />}\n      </TooltipContainer>\n    </TooltipFrame>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/context.tsx",
    "content": "import { createContext, PropsWithChildren, useContext, useMemo } from 'react'\n\nimport { FrameRatioAndSizes } from '../../commons'\n\ninterface VideoStateContextValue {\n  frame: FrameRatioAndSizes\n  fallbackImageUrl: string\n}\n\nconst VideoStateContext = createContext<VideoStateContextValue | null>(null)\n\nfunction VideoStateContextProvider({\n  frame,\n  fallbackImageUrl,\n  children,\n}: PropsWithChildren<VideoStateContextValue>) {\n  const value = useMemo(\n    () => ({\n      frame: frame ?? 'large',\n      fallbackImageUrl: fallbackImageUrl ?? '',\n    }),\n    [frame, fallbackImageUrl],\n  )\n\n  return (\n    <VideoStateContext.Provider value={value}>\n      {children}\n    </VideoStateContext.Provider>\n  )\n}\n\nexport function VideoWrapper({\n  frame,\n  fallbackImageUrl,\n  children,\n}: PropsWithChildren<VideoStateContextValue>) {\n  return (\n    <VideoStateContextProvider\n      frame={frame}\n      fallbackImageUrl={fallbackImageUrl}\n    >\n      {children}\n    </VideoStateContextProvider>\n  )\n}\n\nexport function useVideoState() {\n  const context = useContext(VideoStateContext)\n\n  if (!context) {\n    throw new Error('Cannot use video state outside of provider')\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/controls.tsx",
    "content": "import {\n  useState,\n  useCallback,\n  RefObject,\n  useEffect,\n  ChangeEventHandler,\n  MouseEventHandler,\n} from 'react'\nimport { styled } from 'styled-components'\nimport { debounce } from '@titicaca/view-utilities'\n\nimport { Seeker } from './seeker'\nimport { PlayPauseButton } from './play-pause-button'\nimport { MuteUnmuteButton } from './mute-unmute-button'\nimport { formatTime } from './utils'\nimport { useVideoControl } from './use-video-control'\n\nconst ControlsContainer = styled.div<{ visible: boolean }>`\n  position: absolute;\n  top: 0;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  opacity: ${({ visible }) => (visible ? '1' : '0')};\n  background-color: rgba(0, 0, 0, 0.4);\n  transition: opacity 0.3s;\n`\n\nconst CurrentTime = styled.div`\n  position: absolute;\n  color: #fff;\n  font-size: 10px;\n  left: 0;\n  bottom: 12px;\n  width: 45px;\n  text-align: nter;\n`\n\nconst Duration = styled.div`\n  position: absolute;\n  font-size: 10px;\n  color: #fff;\n  right: 0;\n  bottom: 12px;\n  width: 45px;\n  text-align: center;\n`\n\nconst Progress = styled.progress`\n  position: absolute;\n  left: 45px;\n  right: 45px;\n  bottom: 10px;\n  width: calc(100% - 90px);\n  appearance: none;\n\n  &::-webkit-progress-bar {\n    position: absolute;\n    bottom: 5px;\n    height: 3px;\n    border-radius: 2.5px;\n    background-color: rgba(255, 255, 255, 0.5);\n  }\n\n  &::-webkit-progress-value {\n    border-radius: 2.5px;\n    background-color: var(--color-blue);\n  }\n`\n\ninterface Props {\n  hideControls?: boolean\n  initialHidden?: boolean\n  initialMuted?: boolean\n  videoRef: RefObject<HTMLVideoElement>\n}\n\nexport function Controls({\n  hideControls,\n  initialHidden,\n  initialMuted,\n  videoRef,\n}: Props) {\n  const { duration, currentTime, progress, seek, playing, muted } =\n    useVideoControl({\n      videoRef,\n      initialMuted,\n    })\n  const [oncePlayed, setOncePlayed] = useState(false)\n\n  const [visible, setVisible] = useState(false)\n\n  // TODO: useDebouncedState 사용하기\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const handleFadeOut = useCallback(\n    debounce(() => setVisible(false), 2500),\n    [],\n  )\n\n  const handleSeekerChange: ChangeEventHandler<HTMLInputElement> = useCallback(\n    (e) => {\n      if (videoRef.current) {\n        videoRef.current.currentTime = parseFloat(e.target.value)\n      }\n\n      handleFadeOut()\n    },\n    [videoRef, handleFadeOut],\n  )\n\n  const handleSeekerClick: MouseEventHandler<HTMLInputElement> = useCallback(\n    (e) => {\n      e.stopPropagation()\n      handleFadeOut()\n    },\n    [handleFadeOut],\n  )\n\n  const handleControls = useCallback(() => {\n    if (visible) {\n      setVisible(false)\n    } else {\n      setVisible(true)\n      handleFadeOut()\n    }\n  }, [visible, handleFadeOut])\n\n  useEffect(() => {\n    if (playing) {\n      handleFadeOut()\n    }\n  }, [handleFadeOut, playing])\n\n  useEffect(() => {\n    if (playing && !oncePlayed) {\n      setOncePlayed(true)\n    }\n  }, [oncePlayed, playing])\n\n  const playPauseVisible = oncePlayed ? visible : !initialHidden\n\n  return (\n    <>\n      <ControlsContainer visible={visible} onClick={handleControls}>\n        {!hideControls && (\n          <>\n            <CurrentTime>{currentTime || '00:00'}</CurrentTime>\n            {duration ? <Duration>{formatTime(duration)}</Duration> : null}\n            {duration ? <Progress max={duration} value={progress} /> : null}\n            <Seeker\n              visible={visible}\n              seek={seek}\n              duration={duration}\n              onClick={handleSeekerClick}\n              onChange={handleSeekerChange}\n            />\n          </>\n        )}\n      </ControlsContainer>\n      <PlayPauseButton\n        videoRef={videoRef}\n        playing={playing}\n        visible={playPauseVisible}\n        onPlayPause={handleFadeOut}\n      />\n      <MuteUnmuteButton\n        videoRef={videoRef}\n        muted={muted}\n        visible={visible}\n        onMuteUnmute={handleFadeOut}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/index.ts",
    "content": "export * from './video-element'\nexport * from './video-frame'\nexport * from './video'\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/mute-unmute-button.tsx",
    "content": "import { useCallback, RefObject, SyntheticEvent } from 'react'\nimport { styled } from 'styled-components'\n\nconst MUTE_BUTTON_IMAGE_URL =\n  'https://assets.triple.guide/images/btn-video-volume-mute@3x.png'\nconst UNMUTE_BUTTON_IMAGE_URL =\n  'https://assets.triple.guide/images/btn-video-volume-up@3x.png'\n\ninterface MuteUnmutButtonBaseProps {\n  muted: boolean\n  visible: boolean\n}\n\nconst backgroundImage = ({ muted }: MuteUnmutButtonBaseProps) =>\n  muted ? MUTE_BUTTON_IMAGE_URL : UNMUTE_BUTTON_IMAGE_URL\nconst MuteUnmuteButtonBase = styled.button<MuteUnmutButtonBaseProps>`\n  position: absolute;\n  width: 40px;\n  height: 36px;\n  top: 3px;\n  right: 3px;\n  background-image: url(${backgroundImage});\n  background-size: cover;\n\n  &:focus {\n    outline: none;\n  }\n\n  opacity: ${({ visible }) => (visible ? '1' : '0')};\n  transition: opacity 0.3s;\n`\n\ninterface Props {\n  muted: boolean\n  visible: boolean\n  videoRef: RefObject<HTMLVideoElement>\n  onMuteUnmute: (e: SyntheticEvent) => void\n}\n\nexport function MuteUnmuteButton({\n  muted,\n  visible,\n  videoRef,\n  onMuteUnmute,\n}: Props) {\n  const handleMuteUnmute = useCallback(\n    (e: SyntheticEvent) => {\n      if (videoRef.current) {\n        videoRef.current.muted = !muted\n        onMuteUnmute(e)\n        return\n      }\n\n      return true\n    },\n    [muted, videoRef, onMuteUnmute],\n  )\n\n  return (\n    <MuteUnmuteButtonBase\n      muted={muted}\n      visible={visible}\n      onClick={handleMuteUnmute}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/play-pause-button.tsx",
    "content": "import { useCallback, RefObject, SyntheticEvent } from 'react'\nimport { styled } from 'styled-components'\n\nconst PLAY_BUTTON_IMAGE_URL =\n  'https://assets.triple.guide/images/btn-video-play@3x.png'\nconst PAUSE_BUTTON_IMAGE_URL =\n  'https://assets.triple.guide/images/btn-video-stop@3x.png'\n\ninterface BaseProps {\n  playing: boolean\n  visible: boolean\n}\n\nconst PlayPauseButtonBase = styled.button<BaseProps>`\n  position: absolute;\n  background: none;\n  width: 60px;\n  height: 60px;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  background-image: ${({ playing }) =>\n    playing\n      ? `url(${PAUSE_BUTTON_IMAGE_URL}) `\n      : `url(${PLAY_BUTTON_IMAGE_URL}) `};\n  background-size: cover;\n\n  &:focus {\n    outline: none;\n  }\n\n  opacity: ${({ visible }) => (visible ? '1' : '0')};\n  pointer-events: ${({ visible }) => (visible ? 'auto' : 'none')};\n  transition: opacity 0.3s;\n`\n\ninterface Props {\n  playing: boolean\n  visible: boolean\n  videoRef: RefObject<HTMLVideoElement>\n  onPlayPause: (e?: SyntheticEvent) => void\n}\n\nexport function PlayPauseButton({\n  playing,\n  visible,\n  videoRef,\n  onPlayPause,\n}: Props) {\n  const handlePlayPause = useCallback(\n    (e: SyntheticEvent) => {\n      try {\n        if (videoRef.current && visible) {\n          playing ? videoRef.current.pause() : videoRef.current.play()\n          e.stopPropagation()\n          onPlayPause()\n        }\n      } catch {}\n    },\n    [videoRef, playing, visible, onPlayPause],\n  )\n  return (\n    <PlayPauseButtonBase\n      visible={visible}\n      playing={playing}\n      onClick={handlePlayPause}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/seeker.tsx",
    "content": "import {\n  useState,\n  useCallback,\n  ChangeEventHandler,\n  MouseEventHandler,\n} from 'react'\nimport { styled } from 'styled-components'\nimport { debounce } from '@titicaca/view-utilities'\n\nconst SeekerBase = styled.input<{ handleVisible: boolean }>`\n  background: transparent;\n  color: transparent;\n  position: absolute;\n  left: 45px;\n  right: 45px;\n  width: calc(100% - 90px);\n  bottom: 10px;\n\n  &:focus {\n    outline: none;\n  }\n\n  &::-webkit-slider-thumb {\n    appearance: none;\n    width: 13px;\n    height: 13px;\n    border-radius: 10px;\n    background-color: var(--color-blue);\n    cursor: pointer;\n    opacity: ${({ handleVisible }) => (handleVisible ? '1' : '0')};\n    transition: opacity 0.3s;\n  }\n\n  &::-moz-range-thumb {\n    width: 13px;\n    height: 13px;\n    border-radius: 10px;\n    background-color: var(--color-blue);\n    cursor: pointer;\n    opacity: ${({ handleVisible }) => (handleVisible ? '1' : '0')};\n    transition: opacity 0.3s;\n  }\n\n  &::-ms-thumb {\n    width: 13px;\n    height: 13px;\n    border-radius: 10px;\n    background-color: var(--color-blue);\n    cursor: pointer;\n    opacity: ${({ handleVisible }) => (handleVisible ? '1' : '0')};\n    transition: opacity 0.3s;\n  }\n\n  &:hover {\n    &::-webkit-slider-thumb {\n      opacity: 1;\n    }\n  }\n`\n\nexport function Seeker({\n  seek,\n  duration,\n  visible,\n  onClick,\n  onChange,\n}: {\n  seek: string\n  duration: number\n  visible: boolean\n  onClick: MouseEventHandler<HTMLInputElement>\n  onChange: ChangeEventHandler<HTMLInputElement>\n}) {\n  const [handleVisible, setHandleVisible] = useState(false)\n  // TODO: useDebouncedState 사용하기\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const handleHandleFadeOut = useCallback(\n    debounce(() => setHandleVisible(false), 500),\n    [setHandleVisible],\n  )\n\n  const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(\n    (e) => {\n      if (visible) {\n        setHandleVisible(true)\n        onChange(e)\n        handleHandleFadeOut()\n      }\n    },\n    [visible, onChange, setHandleVisible, handleHandleFadeOut],\n  )\n\n  return (\n    <SeekerBase\n      handleVisible={handleVisible}\n      value={seek}\n      type=\"range\"\n      max={duration}\n      min={0}\n      step={0.01}\n      onClick={visible ? onClick : undefined}\n      onChange={handleChange}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/sources.tsx",
    "content": "import { MEDIA_FRAME_OPTIONS, FrameRatioAndSizes } from '../../commons'\n\nconst MEDIA_CDN_URL_BASE = 'https://media.triple.guide'\nconst FORMATS = ['webm', 'mp4', 'ogv']\n\nexport function Sources({\n  src,\n  srcType,\n  cloudinaryBucket,\n  cloudinaryId,\n  frame,\n}: {\n  src?: string\n  srcType?: string\n  cloudinaryBucket?: string\n  cloudinaryId?: string\n  frame: FrameRatioAndSizes\n}) {\n  const matchData = (MEDIA_FRAME_OPTIONS[frame] || '').match(/^(\\d+)%$/)\n\n  if (!matchData) {\n    return null\n  }\n\n  const [, heightOverWidthPercent] = matchData\n  const widthOverHeight = 100 / parseInt(heightOverWidthPercent, 10)\n  const manipulationParams = `c_fill,ar_${widthOverHeight},f_auto`\n\n  if (!cloudinaryBucket || !cloudinaryId) {\n    return <source src={src} type={srcType} />\n  }\n\n  return (\n    <>\n      {FORMATS.map((format) => (\n        <source\n          key={format}\n          src={`${MEDIA_CDN_URL_BASE}/${cloudinaryBucket}/video/upload/${manipulationParams}/${cloudinaryId}.${format}`}\n        />\n      ))}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/use-video-control.ts",
    "content": "import { useState, useEffect, RefObject } from 'react'\n\nimport { formatTime } from './utils'\n\nexport function useVideoControl({\n  videoRef,\n  initialMuted = false,\n}: {\n  videoRef: RefObject<HTMLVideoElement>\n  initialMuted?: boolean\n}) {\n  const [duration, setDuartion] = useState<number>(0)\n  const [progress, setProgress] = useState<number>(0)\n  const [currentTime, setCurrentTime] = useState<string>('')\n  const [seek, setSeek] = useState<string>('')\n  const [playing, setPlaying] = useState(false)\n  const [muted, setMuted] = useState(initialMuted)\n\n  useEffect(() => {\n    const currentRef = videoRef.current\n\n    const handlePlay = () => setPlaying(true)\n    const handlePause = () => setPlaying(false)\n\n    if (currentRef) {\n      const handleDuartionChange = () => {\n        const duration = currentRef.duration\n        !isNaN(duration) && setDuartion(Math.floor(duration))\n      }\n\n      const handleTimeUpdate = () => {\n        const currentTime = formatTime(Math.floor(currentRef.currentTime))\n\n        setCurrentTime(currentTime)\n        setProgress(currentRef.currentTime)\n        setSeek(String(currentRef.currentTime))\n      }\n\n      const handleSync = () => {\n        setMuted(currentRef.muted)\n      }\n\n      currentRef.addEventListener('durationchange', handleDuartionChange)\n      currentRef.addEventListener('progress', handleDuartionChange)\n      currentRef.addEventListener('timeupdate', handleTimeUpdate)\n      currentRef.addEventListener('play', handlePlay)\n      currentRef.addEventListener('pause', handlePause)\n      currentRef.addEventListener('volumechange', handleSync)\n\n      return () => {\n        currentRef.removeEventListener('durationchange', handleDuartionChange)\n        currentRef.removeEventListener('progress', handleDuartionChange)\n        currentRef.removeEventListener('timeupdate', handleTimeUpdate)\n        currentRef.removeEventListener('play', handlePlay)\n        currentRef.removeEventListener('pause', handlePause)\n        currentRef.removeEventListener('volumechange', handleSync)\n      }\n    } else {\n      throw new Error('Cannot use Vidoe Control State')\n    }\n  }, [videoRef])\n\n  return {\n    duration,\n    currentTime,\n    progress,\n    seek,\n    playing,\n    muted,\n  }\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/use-video-ref.ts",
    "content": "import { useRef, useState, useEffect } from 'react'\n\nexport function useVideoRef() {\n  const [pending, setPending] = useState(false)\n\n  const videoRef = useRef<HTMLVideoElement>(null)\n\n  useEffect(() => {\n    const currentRef = videoRef.current\n\n    const handlePending = () => setPending(true)\n    const handleReady = () => setPending(false)\n\n    if (currentRef) {\n      currentRef.addEventListener('loadstart', handlePending)\n      currentRef.addEventListener('loadedmetadata', handleReady)\n      currentRef.addEventListener('canplay', handleReady)\n      currentRef.addEventListener('play', handleReady)\n      currentRef.addEventListener('waiting', handlePending)\n\n      return () => {\n        currentRef.removeEventListener('loadstart', handlePending)\n        currentRef.removeEventListener('loadedmetadata', handleReady)\n        currentRef.removeEventListener('canplay', handleReady)\n        currentRef.removeEventListener('play', handleReady)\n        currentRef.removeEventListener('waiting', handlePending)\n      }\n    } else {\n      throw new Error('Cannot use Video State')\n    }\n  }, [])\n\n  return {\n    videoRef,\n    pending,\n  }\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/utils.tsx",
    "content": "export function formatTime(totalSeconds: number) {\n  const minutes = pad2(String(Math.floor(totalSeconds / 60)))\n  const seconds = pad2(String(totalSeconds % 60))\n\n  return `${minutes}:${seconds}`\n}\n\nfunction pad2(original: string) {\n  if (original.length === 0) {\n    return '00'\n  } else if (original.length === 1) {\n    return `0${original}`\n  }\n\n  return original\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/video-element.tsx",
    "content": "import { styled } from 'styled-components'\nimport { forwardRef } from 'react'\n\nimport { mergeRefs } from '../../utils/merge-refs'\n\nimport { Sources } from './sources'\nimport { Controls } from './controls'\nimport { useVideoState } from './context'\nimport { useVideoRef } from './use-video-ref'\n\nconst Pending = styled.div`\n  position: absolute;\n  border: none;\n  background: none;\n  width: 60px;\n  height: 60px;\n  border-radius: 30px;\n  top: 50%;\n  left: 50%;\n  background-image: url('https://assets.triple.guide/images/img-video-loading@3x.png');\n  background-size: cover;\n  animation: rotation 2s infinite linear;\n\n  @keyframes rotation {\n    from {\n      transform: translate(-50%, -50%) rotate(0deg);\n    }\n\n    to {\n      transform: translate(-50%, -50%) rotate(359deg);\n    }\n  }\n`\n\nconst Video = styled.video`\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  top: 0;\n  overflow: hidden;\n  object-fit: cover;\n`\n\ninterface Props {\n  src?: string\n  srcType?: string\n  cloudinaryBucket?: string\n  cloudinaryId?: string\n  autoPlay?: boolean\n  muted?: boolean\n  loop?: boolean\n  showNativeControls?: boolean\n  hideControls?: boolean\n  initialControlsHidden?: boolean\n}\n\nexport const VideoElement = forwardRef<HTMLVideoElement, Props>(\n  (\n    {\n      src,\n      srcType,\n      cloudinaryBucket,\n      cloudinaryId,\n      autoPlay,\n      muted,\n      loop,\n      showNativeControls,\n      hideControls,\n      initialControlsHidden,\n    },\n    ref,\n  ) => {\n    const { frame, fallbackImageUrl } = useVideoState()\n    const { videoRef, pending } = useVideoRef()\n\n    return (\n      <>\n        <Video\n          loop={loop}\n          playsInline\n          preload=\"metadata\"\n          controls={!!showNativeControls}\n          autoPlay={autoPlay}\n          muted={muted ?? autoPlay}\n          ref={mergeRefs([videoRef, ref])}\n          poster={fallbackImageUrl}\n        >\n          <Sources\n            src={src}\n            srcType={srcType}\n            cloudinaryBucket={cloudinaryBucket}\n            cloudinaryId={cloudinaryId}\n            frame={frame}\n          />\n        </Video>\n        {pending && <Pending />}\n        <Controls\n          hideControls={hideControls}\n          initialHidden={autoPlay || initialControlsHidden}\n          initialMuted={muted ?? autoPlay}\n          videoRef={videoRef}\n        />\n      </>\n    )\n  },\n)\n\nVideoElement.displayName = 'VideoElement'\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/video-frame.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\n\nimport { MEDIA_FRAME_OPTIONS, FrameRatioAndSizes } from '../../commons'\nimport { formatMarginPadding } from '../../mixins'\n\nimport { VideoWrapper } from './context'\n\nconst VideoContainer = styled.div<{\n  frame: FrameRatioAndSizes\n  fallbackImageUrl: string\n  borderRadius?: number\n}>`\n  width: 100%;\n  overflow: hidden;\n  height: 0;\n  position: relative;\n  background-image: url(${({ fallbackImageUrl }) => fallbackImageUrl});\n  background-size: cover;\n  border-radius: ${({ borderRadius }) =>\n    borderRadius === 0 ? 0 : borderRadius || 6}px;\n\n  ${({ frame }) =>\n    frame !== 'original' &&\n    formatMarginPadding(\n      { top: MEDIA_FRAME_OPTIONS[frame || 'small'] },\n      'padding',\n    )}\n`\n\nexport function VideoFrame({\n  borderRadius,\n  children,\n  frame,\n  fallbackImageUrl,\n  removeFrame,\n}: PropsWithChildren<{\n  borderRadius?: number\n  frame: FrameRatioAndSizes\n  fallbackImageUrl: string\n  removeFrame?: boolean\n}>) {\n  if (removeFrame) {\n    return (\n      <VideoWrapper frame={frame} fallbackImageUrl={fallbackImageUrl}>\n        {children}\n      </VideoWrapper>\n    )\n  }\n\n  return (\n    <VideoWrapper frame={frame} fallbackImageUrl={fallbackImageUrl}>\n      <VideoContainer\n        borderRadius={borderRadius}\n        frame={frame}\n        fallbackImageUrl={fallbackImageUrl}\n      >\n        {children}\n      </VideoContainer>\n    </VideoWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/video.stories.tsx",
    "content": "import { Meta, StoryObj } from '@storybook/react'\n\nimport { Video } from './video'\n\nconst meta = {\n  title: 'tds-ui (Media) / Video',\n  component: Video,\n} satisfies Meta<typeof Video>\n\nexport default meta\ntype Story = StoryObj<typeof Video>\n\nexport const Default: Story = {\n  render: () => (\n    <Video\n      frame=\"medium\"\n      src=\"https://media.triple.guide/triple-dev/video/upload/c_fill,h_256,w_256,f_auto/580e50be-d1d5-4ad8-a477-ad13f4faec9d.mp4\"\n      fallbackImageUrl=\"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_1024,w_1024/580e50be-d1d5-4ad8-a477-ad13f4faec9d.jpeg\"\n      cloudinaryId=\"580e50be-d1d5-4ad8-a477-ad13f4faec9d\"\n      cloudinaryBucket=\"triple-dev\"\n    />\n  ),\n}\n"
  },
  {
    "path": "packages/tds-ui/src/components/video/video.tsx",
    "content": "import { forwardRef } from 'react'\n\nimport { FrameRatioAndSizes } from '../../commons'\n\nimport { VideoFrame } from './video-frame'\nimport { VideoElement } from './video-element'\n\ninterface Props {\n  src?: string\n  srcType?: string\n  cloudinaryBucket?: string\n  cloudinaryId?: string\n  fallbackImageUrl: string\n  frame: FrameRatioAndSizes\n  autoPlay?: boolean\n  muted?: boolean\n  loop?: boolean\n  borderRadius?: number\n  hideControls?: boolean\n  initialControlsHidden?: boolean\n  showNativeControls?: boolean\n  removeFrame?: boolean\n}\n\nexport const Video = forwardRef<HTMLVideoElement, Props>(\n  (\n    {\n      src,\n      srcType = 'video/mp4',\n      cloudinaryBucket,\n      cloudinaryId,\n      autoPlay,\n      muted,\n      loop = true,\n      hideControls,\n      initialControlsHidden,\n      showNativeControls,\n      frame,\n      fallbackImageUrl,\n      removeFrame,\n    },\n    ref,\n  ) => {\n    return (\n      <VideoFrame\n        removeFrame={removeFrame}\n        frame={frame}\n        fallbackImageUrl={fallbackImageUrl}\n      >\n        <VideoElement\n          src={src}\n          srcType={srcType}\n          cloudinaryBucket={cloudinaryBucket}\n          cloudinaryId={cloudinaryId}\n          autoPlay={autoPlay}\n          muted={muted}\n          loop={loop}\n          showNativeControls={showNativeControls}\n          hideControls={hideControls}\n          initialControlsHidden={initialControlsHidden}\n          ref={ref}\n        />\n      </VideoFrame>\n    )\n  },\n)\n\nVideo.displayName = 'Video'\n"
  },
  {
    "path": "packages/tds-ui/src/components/visually-hidden/index.ts",
    "content": "export * from './visually-hidden'\n"
  },
  {
    "path": "packages/tds-ui/src/components/visually-hidden/visually-hidden.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { VisuallyHidden } from './visually-hidden'\n\ntest('스크린 리더가 읽을 수 있습니다.', () => {\n  render(<VisuallyHidden>Visually Hidden</VisuallyHidden>)\n\n  expect(screen.getByText('Visually Hidden')).toBeInTheDocument()\n})\n"
  },
  {
    "path": "packages/tds-ui/src/components/visually-hidden/visually-hidden.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled, css } from 'styled-components'\n\nexport const visuallyHiddenCss = css`\n  border: 0;\n  clip: rect(0, 0, 0, 0);\n  height: 1px;\n  width: 1px;\n  margin: -1px;\n  padding: 0;\n  overflow: hidden;\n  white-space: nowrap;\n  position: absolute;\n`\n\nconst StyledDiv = styled.div({}, visuallyHiddenCss)\n\nexport type VisuallyHiddenProps = PropsWithChildren\n\nexport const VisuallyHidden = ({ children, ...props }: VisuallyHiddenProps) => {\n  return <StyledDiv {...props}>{children}</StyledDiv>\n}\n"
  },
  {
    "path": "packages/tds-ui/src/index.ts",
    "content": "export * from './components'\nexport * from './mixins'\nexport * from './commons'\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/border-radius.ts",
    "content": "import { css } from 'styled-components'\n\nexport interface BorderRadiusMixinProps {\n  borderRadius?: number\n}\n\n/**\n * border-radius를 지정하는 mixin\n * 자식 엘리먼트가 border-radius를 무시하지 않도록 overflow: hidden이 추가됩니다.\n * 또한, 관련한 safari 버그를 우회하는 workaround도 포함합니다.\n * @param  borderRadiusMixin\n */\nexport const borderRadiusMixin = ({ borderRadius }: BorderRadiusMixinProps) =>\n  borderRadius &&\n  css`\n    overflow: hidden;\n    mask-image: radial-gradient(white, black);\n    border-radius: ${borderRadius}px;\n  `\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/box.ts",
    "content": "import { css } from 'styled-components'\n\nimport { BaseSizes } from '../commons'\n\nconst ShadowSizeMap: { [key in BaseSizes | 'none']: string } = {\n  small: '0 0 10px 0 rgba(0, 0, 0, 0.07)',\n  medium: '0 0 20px 0 rgba(0, 0, 0, 0.07)',\n  large: '0 0 30px 0 rgba(0, 0, 0, 0.1)',\n  none: '',\n}\n\nexport interface ShadowMixinProps {\n  shadow?: KeyOfShadowSize\n  shadowValue?: string\n}\n\n/**\n * Usage\n *\n * import { shadowMixin } from '../mixins/box'\n *\n * const ShadowComponent = styled.div`\n *  ${shadowMixin}\n * `\n *\n * <BoxShadowedComponent shadow=\"small | medium | large\" />\n *\n * const ShadowContainer = styled(Container)`\n *   ${shadowMixin}\n * `\n *\n * <ShadowContainer shadow=\"small\"></ShadowContainer>\n *\n */\nexport const shadowMixin = ({ shadow, shadowValue }: ShadowMixinProps) => {\n  const value = shadow ? ShadowSizeMap[shadow] : shadowValue\n\n  return value\n    ? css`\n        box-shadow: ${value};\n      `\n    : undefined\n}\n\nexport type KeyOfShadowSize = keyof typeof ShadowSizeMap\nexport type ShadowSizeMapType = { [key in KeyOfShadowSize]: string }\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/centered.ts",
    "content": "import { css } from 'styled-components'\n\nexport interface CenteredMixinProps {\n  centered?: boolean\n}\n\nexport const centeredMixin = ({ centered }: CenteredMixinProps) =>\n  centered\n    ? css`\n        margin-left: auto;\n        margin-right: auto;\n      `\n    : undefined\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/clearing.ts",
    "content": "import { css } from 'styled-components'\n\nexport interface ClearingMixinProps {\n  clearing?: boolean\n}\n\nexport const clearingMixin = ({ clearing }: ClearingMixinProps) =>\n  clearing\n    ? css`\n        &::after {\n          content: '';\n          display: block;\n          clear: both;\n        }\n      `\n    : undefined\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/ellipsis.ts",
    "content": "import { css } from 'styled-components'\n\nexport interface EllipsisMixinProps {\n  ellipsis?: boolean\n}\n\nexport const ellipsisMixin = ({ ellipsis }: EllipsisMixinProps) =>\n  ellipsis\n    ? css`\n        white-space: nowrap;\n        text-overflow: ellipsis;\n        overflow: hidden;\n      `\n    : undefined\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/horizontal-scroll.ts",
    "content": "import { css } from 'styled-components'\n\nexport interface HorizontalScrollMixinProps {\n  horizontalScroll?: boolean\n}\n\nexport const horizontalScrollMixin = ({\n  horizontalScroll,\n}: HorizontalScrollMixinProps) =>\n  horizontalScroll\n    ? css`\n        white-space: nowrap;\n        overflow: auto hidden;\n      `\n    : undefined\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/index.ts",
    "content": "export * from './border-radius'\nexport * from './box'\nexport * from './centered'\nexport * from './clearing'\nexport * from './ellipsis'\nexport * from './horizontal-scroll'\nexport * from './layering'\nexport * from './margin-padding'\nexport * from './max-lines'\nexport * from './positioning'\nexport * from './safe-area'\nexport * from './text-style'\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/layering.ts",
    "content": "import { css } from 'styled-components'\n\nexport interface LayeringMixinProps {\n  zTier?: number\n  zIndex?: number\n}\n\nexport const layeringMixin =\n  (defaultTier: number) =>\n  ({ zIndex = defaultTier, zTier = 0 }: LayeringMixinProps) => css`\n    z-index: ${zTier * 100 + zIndex};\n  `\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/margin-padding.ts",
    "content": "import { css } from 'styled-components'\n\nimport { MarginPadding } from '../commons'\nimport { unit } from '../utils/unit'\n\nexport function formatMarginPadding(\n  marginPadding: MarginPadding | undefined,\n  key: 'margin' | 'padding',\n) {\n  if (!marginPadding) {\n    return undefined\n  }\n\n  if (key === 'margin') {\n    return css`\n      /* stylelint-disable declaration-block-no-redundant-longhand-properties */\n      margin-top: ${unit(marginPadding.top || 0)};\n      margin-left: ${unit(marginPadding.left || 0)};\n      margin-right: ${unit(marginPadding.right || 0)};\n      margin-bottom: ${unit(marginPadding.bottom || 0)};\n      /* stylelint-enable declaration-block-no-redundant-longhand-properties */\n    `\n  } else {\n    return css`\n      /* stylelint-disable declaration-block-no-redundant-longhand-properties */\n      padding-top: ${unit(marginPadding.top || 0)};\n      padding-left: ${unit(marginPadding.left || 0)};\n      padding-right: ${unit(marginPadding.right || 0)};\n      padding-bottom: ${unit(marginPadding.bottom || 0)};\n      /* stylelint-enable declaration-block-no-redundant-longhand-properties */\n    `\n  }\n}\n\nexport interface MarginMixinProps {\n  margin?: MarginPadding\n}\n\nexport const marginMixin = ({ margin }: MarginMixinProps) =>\n  formatMarginPadding(margin, 'margin')\n\nexport interface PaddingMixinProps {\n  padding?: MarginPadding\n}\n\nexport const paddingMixin = ({ padding }: PaddingMixinProps) =>\n  formatMarginPadding(padding, 'padding')\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/max-lines.ts",
    "content": "import { css } from 'styled-components'\n\nexport interface MaxLinesMixinProps {\n  maxLines?: number\n}\n\nexport const maxLinesMixin = ({ maxLines }: MaxLinesMixinProps) =>\n  maxLines\n    ? css`\n        /* stylelint-disable-next-line value-no-vendor-prefix */\n        display: -webkit-box;\n        -webkit-box-orient: vertical;\n        -webkit-line-clamp: ${maxLines};\n        text-overflow: ellipsis;\n        overflow: hidden;\n        white-space: pre-line;\n      `\n    : undefined\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/positioning.ts",
    "content": "import { css } from 'styled-components'\n\nimport { MarginPadding } from '../commons'\nimport { unit } from '../utils/unit'\n\nexport interface PositioningMixinProps {\n  positioning?: MarginPadding\n}\n\nexport const positioningMixin = ({ positioning }: PositioningMixinProps) =>\n  positioning\n    ? css`\n        top: ${positioning.top !== undefined\n          ? unit(positioning.top)\n          : undefined};\n        right: ${positioning.right !== undefined\n          ? unit(positioning.right)\n          : undefined};\n        bottom: ${positioning.bottom !== undefined\n          ? unit(positioning.bottom)\n          : undefined};\n        left: ${positioning.left !== undefined\n          ? unit(positioning.left)\n          : undefined};\n      `\n    : undefined\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/safe-area.ts",
    "content": "import { css } from 'styled-components'\n\nimport { MarginPadding } from '../commons'\nimport { unit } from '../utils/unit'\n\nexport interface SafeAreaInsetMixinProps {\n  padding?: MarginPadding\n}\n\nexport const safeAreaInsetMixin = ({ padding }: SafeAreaInsetMixinProps) => {\n  const paddingBottom = unit(padding?.bottom || '0px')\n\n  return css`\n    @supports (padding: env(safe-area-inset-bottom)) {\n      padding-bottom: calc(env(safe-area-inset-bottom) + ${paddingBottom});\n    }\n  `\n}\n"
  },
  {
    "path": "packages/tds-ui/src/mixins/text-style.ts",
    "content": "import { css } from 'styled-components'\n\nimport { GlobalSizes } from '../commons'\n\n/**\n * Create text style css\n * @param fontSize font-size\n * @param lineHeight line-height\n * @param letterSpacing letter-spacing\n */\nconst textStyle = (\n  fontSize: number,\n  lineHeight: number,\n  letterSpacing: number,\n) => css`\n  font-size: ${fontSize}px;\n  line-height: ${lineHeight}px;\n  letter-spacing: ${letterSpacing}px;\n`\n\n/**\n * 기존 스펙 그대로 text style 을 생성하는 함수\n * @deprecated\n * @param fontSize\n * @param lineHeight\n * @param letterSpacing\n */\nconst _unsafeTextStyle = (\n  fontSize: GlobalSizes | number = 'large',\n  lineHeight: number | string = 1.2,\n  letterSpacing = 0,\n) => {\n  const sizes: { [key in GlobalSizes]: string } = {\n    mini: '12px',\n    tiny: '13px',\n    small: '14px',\n    medium: '15px',\n    large: '16px',\n    big: '19px',\n    huge: '21px',\n    massive: '24px',\n  }\n  const size = typeof fontSize === 'string' ? sizes[fontSize] : `${fontSize}px`\n\n  return css`\n    font-size: ${size};\n    line-height: ${lineHeight};\n    letter-spacing: ${letterSpacing}px;\n  `\n}\n\nconst textStyleMap = {\n  /* 가계부 금액 */\n  L6: textStyle(36, 47, -0.3),\n  /* 서비스메인, 도시메인 타이틀 */\n  M8: textStyle(28, 37, -0.3),\n  /* 쿠폰 금액 */\n  M6: textStyle(28, 35, -0.3),\n  /* 타이틀 */\n  M4: textStyle(24, 33, -0.3),\n  /* 금액 가격 등 숫자 */\n  M2: textStyle(22, 31, -0.2),\n  /* 서비스메인 카드 타이틀 */\n  M1: textStyle(21, 29, -0.2),\n  /* 목록, 타이틀 */\n  M: textStyle(20, 24, -0.2),\n  /* 서비스메인 타이틀 */\n  S9: textStyle(19, 23, -0.2),\n  /* 서비스 메인 타이틀 */\n  S8: textStyle(18, 21, -0.2),\n  /* 상단 네비게이션바 타이틀 */\n  S7: textStyle(17, 20, -0.1),\n  /* 본문, 도시메인 카드 타이틀, POI 타이틀, 최근검색어 */\n  S6: textStyle(16, 19, -0.1),\n  /* 본문 */\n  S5: textStyle(15, 19, -0.1),\n  /* 본문, 날짜 */\n  S4: textStyle(14, 24, -0.1),\n  /* 도시메인카드 본문, 서브 설명 */\n  S3: textStyle(13, 23, 0),\n  /* 부가설명, 일정판 요일 */\n  S2: textStyle(12, 22, 0),\n}\n\nexport function getTextStyle(type: KeyOfTextStyleMap) {\n  return textStyleMap[type]\n}\n\nexport type KeyOfTextStyleMap = keyof typeof textStyleMap\nexport type TextStyleMapType = { [key in KeyOfTextStyleMap]: string }\n\nexport interface TextStyleMixinProps {\n  textStyle?: KeyOfTextStyleMap\n  size?: GlobalSizes | number\n  lineHeight?: number | string\n  letterSpacing?: number\n}\n\nexport const textStyleMixin = ({\n  textStyle,\n  size,\n  lineHeight,\n  letterSpacing,\n}: TextStyleMixinProps) => {\n  if (textStyle && (size || lineHeight || letterSpacing)) {\n    // TODO: development 환경에서만 기록하는 logger 만들기\n    // eslint-disable-next-line no-console\n    console.warn(\n      \"🙅🏻‍♂️\\n[Warn] Please don't use `size`, `lineHeight` and `letterSpacing` with `textStyle` together. \\nIf they are used together, `size` and `lineHeight` will be omit. See \\nhttps://github.com/titicacadev/triple-frontend/issues/401\",\n    )\n  }\n\n  return textStyle\n    ? getTextStyle(textStyle)\n    : _unsafeTextStyle(size, lineHeight, letterSpacing)\n}\n"
  },
  {
    "path": "packages/tds-ui/src/utils/merge-refs.ts",
    "content": "import type * as React from 'react'\n\nexport function mergeRefs<T = unknown>(\n  refs: Array<React.MutableRefObject<T> | React.LegacyRef<T> | undefined>,\n): React.RefCallback<T> {\n  return (value) => {\n    refs.forEach((ref) => {\n      if (typeof ref === 'function') {\n        ref(value)\n      } else if (ref != null) {\n        ;(ref as React.MutableRefObject<T | null>).current = value\n      }\n    })\n  }\n}\n"
  },
  {
    "path": "packages/tds-ui/src/utils/should-forward-prop.ts",
    "content": "import isPropValid from '@emotion/is-prop-valid'\nimport { ShouldForwardProp } from 'styled-components'\n\n// styled-components v5의 기본 동작을 구현합니다.\nexport const shouldForwardProp: ShouldForwardProp<'web'> = (\n  propName,\n  target,\n) => {\n  if (typeof target === 'string') {\n    // HTML 요소인 경우, 유효한 HTML 속성이면 prop을 전달합니다.\n    return isPropValid(propName)\n  }\n  // 다른 요소들에 대해서는 모든 props를 전달합니다.\n  return true\n}\n"
  },
  {
    "path": "packages/tds-ui/src/utils/unit.ts",
    "content": "export const unit = (value: number | string, suffix = 'px') =>\n  typeof value === 'string' ? value : value !== 0 ? `${value}${suffix}` : value\n"
  },
  {
    "path": "packages/tds-ui/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/tds-ui/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/tds-ui/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/tds-widget/.eslintignore",
    "content": "**/generated.ts\n"
  },
  {
    "path": "packages/tds-widget/.prettierignore",
    "content": "**/generated.ts\n"
  },
  {
    "path": "packages/tds-widget/.stylelintignore",
    "content": "**/generated.ts\n"
  },
  {
    "path": "packages/tds-widget/package.json",
    "content": "{\n  \"name\": \"@titicaca/tds-widget\",\n  \"version\": \"14.2.3\",\n  \"description\": \"TDS widget\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/tds-widget\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@egjs/flicking\": \"^3.9.3\",\n    \"@egjs/react-flicking\": \"^3.8.3\",\n    \"@floating-ui/react\": \"^0.27.4\",\n    \"@react-google-maps/api\": \"^2.20.6\",\n    \"@titicaca/fetcher\": \"workspace:*\",\n    \"@titicaca/intersection-observer\": \"workspace:*\",\n    \"@titicaca/react-hooks\": \"workspace:*\",\n    \"@titicaca/router\": \"workspace:*\",\n    \"@titicaca/tds-ui\": \"workspace:*\",\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"@titicaca/triple-web-to-native-interfaces\": \"1.11.0\",\n    \"@titicaca/type-definitions\": \"workspace:*\",\n    \"@titicaca/view-utilities\": \"workspace:*\",\n    \"autolinker\": \"^4.1.0\",\n    \"date-fns\": \"^3.6.0\",\n    \"deepmerge\": \"^4.3.1\",\n    \"dompurify\": \"^3.1.7\",\n    \"graphql\": \"^16.10.0\",\n    \"graphql-request\": \"^6.1.0\",\n    \"moment\": \"^2.30.1\",\n    \"qs\": \"^6.14.0\",\n    \"react-day-picker\": \"^7.4.10\",\n    \"react-intersection-observer\": \"^9.13.1\",\n    \"react-transition-group\": \"^4.4.5\",\n    \"react-zoom-pan-pinch\": \"^3.6.1\",\n    \"scroll-to-element\": \"^2.0.3\",\n    \"use-long-press\": \"^3.2.0\"\n  },\n  \"devDependencies\": {\n    \"@graphql-codegen/cli\": \"5.0.5\",\n    \"@graphql-codegen/typescript\": \"4.1.5\",\n    \"@graphql-codegen/typescript-generic-sdk\": \"^4.0.1\",\n    \"@graphql-codegen/typescript-operations\": \"4.5.1\",\n    \"@tanstack/react-query\": \"^5.67.1\",\n    \"@titicaca/tds-theme\": \"workspace:*\",\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"@titicaca/triple-web-test-utils\": \"workspace:*\",\n    \"@types/dompurify\": \"^3.0.5\",\n    \"@types/qs\": \"^6.9.18\",\n    \"@types/react-transition-group\": \"^4.4.12\",\n    \"csstype\": \"^3.1.3\",\n    \"react\": \"^18.3.1\",\n    \"styled-components\": \"^6.1.15\",\n    \"utility-types\": \"^3.11.0\"\n  },\n  \"peerDependencies\": {\n    \"@tanstack/react-query\": \"^5\",\n    \"@titicaca/tds-theme\": \"*\",\n    \"@titicaca/triple-web\": \"*\",\n    \"@titicaca/triple-web-to-native-interfaces\": \"1.11.0\",\n    \"react\": \"^18.0\",\n    \"styled-components\": \"^6.0\"\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/ad-banners/api.ts",
    "content": "import qs from 'qs'\nimport { authGuardedFetchers, NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\n\nimport { Banner } from './typing'\n\nexport type ContentType =\n  | 'article'\n  | 'attraction'\n  | 'hotel'\n  | 'restaurant'\n  | 'air'\n\nexport enum BannerTypes {\n  ListTopBanner = 'menu_top_banner_ad_v0',\n  ContentDetailsBanner = 'content_details_ad_v0',\n}\n\ninterface UserLocation {\n  latitude?: number | null\n  longitude?: number | null\n}\n\ninterface AdBannersFetchingParams {\n  contentType: ContentType\n  regionId: string\n  contentId?: string\n  contentRegionId?: string\n  userLocation: UserLocation\n  bannerType: BannerTypes\n}\n\n/**\n * 트리플 광고 시스템에 등록된 광고 목록을 요청합니다.\n *\n * @param contentType\n * @param regionId\n * @param contentId\n * @param userLocation\n * @param bannerType\n */\n\nexport async function getAdBanners({\n  contentType,\n  regionId,\n  contentId,\n  userLocation,\n  bannerType,\n}: AdBannersFetchingParams) {\n  const search = getSearchQuery(bannerType, {\n    contentType,\n    regionId,\n    contentId,\n    userLocation,\n  })\n\n  const response = await authGuardedFetchers.get<{ items: Banner[] }>(\n    `/api/inventories/${bannerType}/items?${search}`,\n    {\n      credentials: 'same-origin',\n    },\n  )\n  if (response === NEED_LOGIN_IDENTIFIER || !response.ok) {\n    return []\n  }\n  const {\n    parsedBody: { items },\n  } = response\n  return items\n}\n\n/**\n * 광고 노출, 광고 클릭 이벤트를 기록합니다.\n *\n * @param itemId 광고 ID\n * @param eventType\n * @param regionId\n * @param bannerType\n * @param userLocation\n * @param contentId\n * @param contentType\n */\nexport async function postAdBannerEvent({\n  itemId,\n  eventType,\n  regionId,\n  bannerType,\n  userLocation,\n  contentId,\n  contentType,\n}: {\n  itemId: string\n  eventType: string\n  bannerType: BannerTypes\n  userLocation?: UserLocation\n  regionId: string\n  contentId?: string\n  contentType?: ContentType\n}) {\n  const body = getRequestBody(bannerType, {\n    eventType,\n    regionId,\n    contentId,\n    contentType,\n    userLocation,\n  })\n\n  return authGuardedFetchers.post(\n    `/api/inventories/${bannerType}/items/${itemId}/events`,\n    {\n      body,\n      headers: {\n        'content-type': 'application/json',\n      },\n      credentials: 'same-origin',\n    },\n  )\n}\n\nfunction getSearchQuery(\n  bannerType: BannerTypes,\n  options: {\n    contentType: string\n    regionId: string\n    contentId?: string\n    userLocation?: UserLocation\n  },\n) {\n  const { contentId, regionId, contentType, userLocation } = options\n\n  switch (bannerType) {\n    case BannerTypes.ContentDetailsBanner:\n      return qs.stringify({\n        content_id: contentId,\n        content_region_id: regionId,\n        content_type: contentType,\n        user_location:\n          userLocation && userLocation.longitude && userLocation.latitude\n            ? `${userLocation.longitude},${userLocation.latitude}`\n            : undefined,\n      })\n    case BannerTypes.ListTopBanner:\n      return qs.stringify({\n        content_type: contentType,\n        region_id: regionId,\n      })\n  }\n}\n\nfunction getRequestBody(\n  bannerType: BannerTypes,\n  options: {\n    eventType: string\n    contentId?: string\n    regionId: string\n    contentType?: string\n    userLocation?: UserLocation\n  },\n) {\n  const { eventType, contentId, regionId, contentType, userLocation } = options\n\n  switch (bannerType) {\n    case BannerTypes.ContentDetailsBanner:\n      return {\n        content: {\n          id: contentId,\n          regionId,\n          type: contentType,\n        },\n        eventType,\n        userLocation:\n          userLocation && userLocation.longitude && userLocation.latitude\n            ? [userLocation.longitude, userLocation.latitude]\n            : undefined,\n      }\n    case BannerTypes.ListTopBanner:\n      return {\n        eventType,\n        regionId,\n      }\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/ad-banners/content-details-banner.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport { MarginPadding } from '@titicaca/tds-ui'\nimport { useTrackEvent } from '@titicaca/triple-web'\nimport { useNavigate } from '@titicaca/router'\n\nimport {\n  ContentType,\n  postAdBannerEvent,\n  getAdBanners,\n  BannerTypes,\n} from './api'\nimport { Banner } from './typing'\nimport { VerticalListView } from './vertical-list-view'\n\ninterface EventAttributes {\n  title?: string\n}\n\n/**\n * 트리플 광고 API를 사용하는 배너의 Props\n */\ninterface AdSystemBannerProps {\n  contentType: ContentType\n  contentRegionId: string\n  contentId?: string\n  latitude: number | null\n  longitude: number | null\n  eventAttributes?: EventAttributes\n}\n\n/**\n * Inventory API를 사용하는 배너의 Props\n */\ninterface InventoryBannerProps {\n  onBannersFetch: () => Promise<Banner[]>\n  onBannerIntersect?: (\n    isIntersecting: boolean,\n    banner: Banner,\n    index: number,\n  ) => void\n  onBannerClick?: (banner: Banner, index: number) => void\n}\n\ntype AdBannersProps = {\n  margin?: MarginPadding\n  padding?: MarginPadding\n} & (AdSystemBannerProps | InventoryBannerProps)\n\nconst NOOP = () => {}\n\nfunction isPropsForInventoryApi(\n  props: AdBannersProps,\n): props is InventoryBannerProps {\n  return 'onBannersFetch' in props\n}\n\nfunction useAdBannerProps(props: AdBannersProps) {\n  const trackEvent = useTrackEvent()\n  const { navigate } = useNavigate()\n\n  if (isPropsForInventoryApi(props)) {\n    const { onBannersFetch, onBannerIntersect, onBannerClick } = props\n\n    return {\n      getBannersApi: onBannersFetch,\n      handleBannerIntersecting: onBannerIntersect || NOOP,\n      handleBannerClick: (banner: Banner, index: number) => {\n        if (onBannerClick) {\n          onBannerClick(banner, index)\n        }\n\n        navigate(banner.target)\n      },\n    }\n  } else {\n    const {\n      contentType,\n      contentId,\n      contentRegionId,\n      latitude,\n      longitude,\n      eventAttributes: { title } = { title: undefined },\n    } = props\n\n    return {\n      getBannersApi: () =>\n        getAdBanners({\n          contentType,\n          regionId: contentRegionId,\n          contentId,\n          userLocation: { longitude, latitude },\n          bannerType: BannerTypes.ContentDetailsBanner,\n        }),\n      handleBannerIntersecting: (\n        isIntersecting: boolean,\n        banner: Banner,\n        index: number,\n      ) => {\n        if (!isIntersecting) {\n          return\n        }\n\n        postAdBannerEvent({\n          itemId: banner.id,\n          eventType: 'impression',\n          bannerType: BannerTypes.ContentDetailsBanner,\n          contentId,\n          regionId: contentRegionId,\n          contentType,\n          userLocation: { longitude, latitude },\n        })\n\n        trackEvent({\n          fa: {\n            action: 'V0_배너노출',\n            banner_id: banner.id,\n            banner_position: index,\n            url: banner.target,\n            poi_id: contentId,\n          },\n        })\n      },\n      handleBannerClick: (banner: Banner, index: number) => {\n        postAdBannerEvent({\n          itemId: banner.id,\n          eventType: 'click',\n          bannerType: BannerTypes.ContentDetailsBanner,\n          contentId,\n          regionId: contentRegionId,\n          contentType,\n          userLocation: { longitude, latitude },\n        })\n\n        trackEvent({\n          fa: {\n            action: 'V0_배너선택',\n            banner_id: banner.id,\n            banner_position: index,\n            url: banner.target,\n            poi_id: contentId,\n          },\n          ga: [\n            'V0_배너선택',\n            `${title}_${contentId}_${banner.id}_${banner.desc}_${banner.target}`,\n          ],\n        })\n\n        navigate(banner.target)\n      },\n    }\n  }\n}\n\nexport function ContentDetailsBanner(props: AdBannersProps) {\n  const { margin, padding } = props\n  const { getBannersApi, handleBannerIntersecting, handleBannerClick } =\n    useAdBannerProps(props)\n  const [banners, setBanners] = useState<Banner[]>([])\n\n  useEffect(() => {\n    let handle: number | undefined\n\n    async function fetchBanners() {\n      const response = await getBannersApi()\n\n      setBanners(response || [])\n    }\n\n    if (window.requestIdleCallback) {\n      handle = window.requestIdleCallback(() => {\n        fetchBanners()\n      })\n    } else {\n      fetchBanners()\n    }\n\n    return () => {\n      if (handle && window.cancelIdleCallback) {\n        window.cancelIdleCallback(handle)\n      }\n    }\n    // HACK: 최초 한 번만 실행\n  }, []) // eslint-disable-line react-hooks/exhaustive-deps\n\n  return (\n    <VerticalListView\n      banners={banners}\n      padding={padding}\n      margin={margin}\n      onBannerClick={handleBannerClick}\n      onBannerIntersect={handleBannerIntersecting}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/ad-banners/horizontal-entity.tsx",
    "content": "import { FC } from 'react'\nimport { styled } from 'styled-components'\n\nimport { Banner } from './typing'\n\ninterface HorizontalEntityProps {\n  banner: Banner\n  onClick: (banner: Banner) => void\n  onLoad: () => void\n  widthOffset: number\n}\n\nconst BannerItem = styled.a<{ $widthOffset: number }>`\n  position: absolute;\n  left: -10000px;\n  display: block;\n  width: ${({ $widthOffset }) => `calc(100% - ${$widthOffset}px) `};\n  border-radius: 4px;\n\n  > img {\n    width: 100%;\n    height: 100%;\n  }\n`\n\nexport const HorizontalEntity: FC<HorizontalEntityProps> = ({\n  banner,\n  onLoad,\n  onClick,\n  widthOffset,\n}) => {\n  return (\n    <BannerItem\n      onClick={(e) => {\n        e.preventDefault()\n\n        onClick(banner)\n      }}\n      $widthOffset={widthOffset}\n    >\n      <img src={banner.image} alt={banner.desc} onLoad={onLoad} />\n    </BannerItem>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/ad-banners/horizontal-list-view.tsx",
    "content": "import { FC, useRef, useEffect, useState } from 'react'\nimport { formatMarginPadding, MarginPadding } from '@titicaca/tds-ui'\nimport { StaticIntersectionObserver as IntersectionObserver } from '@titicaca/intersection-observer'\nimport { FlickingOptions } from '@egjs/flicking'\nimport Flicking from '@egjs/react-flicking'\nimport { css } from 'styled-components'\n\nimport { Banner } from './typing'\nimport { HorizontalEntity } from './horizontal-entity'\nimport ListSection from './list-section'\n\ninterface HorizontalListViewProps {\n  banners: Banner[]\n  padding?: MarginPadding\n  margin?: MarginPadding\n  onBannerClick: (banner: Banner, index: number) => void\n  onBannerIntersect: (\n    isIntersecting: boolean,\n    banner: Banner,\n    index: number,\n  ) => void\n}\n\nconst FLICKING_DEFAULT_INDEX = 0\nconst FLICKING_CONFIG: Partial<FlickingOptions> = {\n  collectStatistics: false,\n  zIndex: 1,\n  defaultIndex: FLICKING_DEFAULT_INDEX,\n  autoResize: false,\n  horizontal: true,\n  bounce: [10, 10],\n  duration: 100,\n  gap: 10,\n}\n\nexport const HorizontalListView: FC<HorizontalListViewProps> = ({\n  banners,\n  padding = {},\n  margin,\n  onBannerIntersect,\n  onBannerClick,\n}) => {\n  const [visibleIndex, setVisibleIndex] = useState(FLICKING_DEFAULT_INDEX)\n  const flickingRef = useRef<Flicking | null>(null)\n\n  const resizeFlicking = () => {\n    if (!flickingRef.current) {\n      return\n    }\n\n    flickingRef.current.resize()\n  }\n\n  const makeBannerClickHandler = (index: number) => {\n    return (banner: Banner) => {\n      if (!flickingRef.current?.isPlaying()) {\n        onBannerClick(banner, index)\n      }\n    }\n  }\n\n  useEffect(() => {\n    window.addEventListener('orientationchange', resizeFlicking)\n    window.addEventListener('resize', resizeFlicking)\n\n    return () => {\n      window.removeEventListener('orientationchange', resizeFlicking)\n      window.removeEventListener('resize', resizeFlicking)\n    }\n  }, [])\n\n  useEffect(() => {\n    resizeFlicking()\n  }, [padding.left, padding.right])\n\n  if (banners.length === 0) {\n    return null\n  }\n\n  return (\n    <IntersectionObserver\n      onChange={({ isIntersecting }: { isIntersecting: boolean }) => {\n        if (isIntersecting && banners.length > 0) {\n          onBannerIntersect(true, banners[visibleIndex], visibleIndex)\n        }\n      }}\n    >\n      <div>\n        <ListSection\n          css={css(\n            {\n              minWidth: 0,\n              paddingTop: padding.top,\n              paddingBottom: padding.bottom,\n            },\n            formatMarginPadding(margin, 'margin'),\n          )}\n        >\n          <Flicking\n            {...FLICKING_CONFIG}\n            ref={flickingRef}\n            onMoveEnd={(e) => {\n              const newIndex = e.index\n\n              onBannerIntersect(false, banners[visibleIndex], visibleIndex)\n              onBannerIntersect(true, banners[newIndex], newIndex)\n              setVisibleIndex(newIndex)\n            }}\n          >\n            {banners.map((banner, index) => {\n              return (\n                <HorizontalEntity\n                  key={banner.id}\n                  banner={banner}\n                  onClick={makeBannerClickHandler(index)}\n                  onLoad={resizeFlicking}\n                  widthOffset={Number(padding.left || padding.right || 25) * 2}\n                />\n              )\n            })}\n          </Flicking>\n        </ListSection>\n      </div>\n    </IntersectionObserver>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/ad-banners/index.ts",
    "content": "export * from './list-top-banners'\nexport * from './content-details-banner'\nexport { ListDirection } from './typing'\n"
  },
  {
    "path": "packages/tds-widget/src/ad-banners/list-section.ts",
    "content": "import { styled } from 'styled-components'\nimport { Section } from '@titicaca/tds-ui'\n\nconst ListSection = styled(Section)`\n  @media (min-width: 600px) {\n    display: none;\n  }\n`\n\nexport default ListSection\n"
  },
  {
    "path": "packages/tds-widget/src/ad-banners/list-top-banners.tsx",
    "content": "import { FC, useState, useEffect } from 'react'\nimport { MarginPadding } from '@titicaca/tds-ui'\nimport { useTrackEvent } from '@titicaca/triple-web'\nimport { useNavigate } from '@titicaca/router'\n\nimport {\n  ContentType,\n  postAdBannerEvent,\n  getAdBanners,\n  BannerTypes,\n} from './api'\nimport { Banner, ListDirection } from './typing'\nimport { HorizontalListView } from './horizontal-list-view'\nimport { VerticalListView } from './vertical-list-view'\n\ninterface EventAttributes {\n  title?: string\n}\n\n/**\n * 트리플 광고 API를 사용하는 배너의 Props\n */\ninterface AdSystemBannerProps {\n  contentType: ContentType\n  contentId?: string\n  regionId: string\n  latitude: number | null\n  longitude: number | null\n  eventAttributes?: EventAttributes\n}\n\n/**\n * Inventory API를 사용하는 배너의 Props\n */\ninterface InventoryBannerProps {\n  onBannersFetch: () => Promise<Banner[]>\n  onBannerIntersect?: (\n    isIntersecting: boolean,\n    banner: Banner,\n    index: number,\n  ) => void\n  onBannerClick?: (banner: Banner, index: number) => void\n}\n\ntype AdBannersProps = {\n  margin?: MarginPadding\n  padding?: MarginPadding\n  direction?: ListDirection\n} & (AdSystemBannerProps | InventoryBannerProps)\n\nconst NOOP = () => {}\n\nconst COMPONENT_SET = {\n  [ListDirection.Vertical]: VerticalListView,\n  [ListDirection.Horizontal]: HorizontalListView,\n}\n\nfunction isPropsForInventoryApi(\n  props: AdBannersProps,\n): props is InventoryBannerProps {\n  return 'onBannersFetch' in props\n}\n\nfunction useAdBannerProps(props: AdBannersProps) {\n  const trackEvent = useTrackEvent()\n  const { navigate } = useNavigate()\n\n  if (isPropsForInventoryApi(props)) {\n    const { onBannersFetch, onBannerIntersect, onBannerClick } = props\n\n    return {\n      getBannersApi: onBannersFetch,\n      handleBannerIntersecting: onBannerIntersect || NOOP,\n      handleBannerClick: (banner: Banner, index: number) => {\n        if (onBannerClick) {\n          onBannerClick(banner, index)\n        }\n\n        navigate(banner.target)\n      },\n    }\n  } else {\n    const {\n      contentType,\n      contentId,\n      regionId,\n      latitude,\n      longitude,\n      eventAttributes: { title } = { title: undefined },\n    } = props\n\n    return {\n      getBannersApi: () =>\n        getAdBanners({\n          contentType,\n          regionId,\n          contentId,\n          userLocation: { longitude, latitude },\n          bannerType: BannerTypes.ListTopBanner,\n        }),\n      handleBannerIntersecting: (\n        isIntersecting: boolean,\n        banner: Banner,\n        index: number,\n      ) => {\n        if (!isIntersecting) {\n          return\n        }\n\n        postAdBannerEvent({\n          itemId: banner.id,\n          eventType: 'impression',\n          regionId,\n          bannerType: BannerTypes.ListTopBanner,\n        })\n\n        trackEvent({\n          fa: {\n            action: 'V0_배너노출',\n            banner_id: banner.id,\n            banner_position: index,\n            url: banner.target,\n            poi_id: contentId,\n          },\n        })\n      },\n      handleBannerClick: (banner: Banner, index: number) => {\n        postAdBannerEvent({\n          itemId: banner.id,\n          eventType: 'click',\n          regionId,\n          bannerType: BannerTypes.ListTopBanner,\n        })\n\n        trackEvent({\n          fa: {\n            action: 'V0_배너선택',\n            banner_id: banner.id,\n            banner_position: index,\n            url: banner.target,\n            poi_id: contentId,\n          },\n          ga: [\n            'V0_배너선택',\n            `${title}_${contentId}_${banner.id}_${banner.desc}_${banner.target}`,\n          ],\n        })\n\n        navigate(banner.target)\n      },\n    }\n  }\n}\n\nexport const ListTopBanners: FC<AdBannersProps> = (props) => {\n  const { margin, padding, direction = ListDirection.Vertical } = props\n  const { getBannersApi, handleBannerIntersecting, handleBannerClick } =\n    useAdBannerProps(props)\n  const [banners, setBanners] = useState<Banner[]>([])\n\n  const Component = COMPONENT_SET[direction]\n\n  useEffect(() => {\n    let handle: number | undefined\n\n    async function fetchBanners() {\n      const response = await getBannersApi()\n\n      setBanners(response || [])\n    }\n\n    if (window.requestIdleCallback) {\n      handle = window.requestIdleCallback(() => {\n        fetchBanners()\n      })\n    } else {\n      fetchBanners()\n    }\n\n    return () => {\n      if (handle && window.cancelIdleCallback) {\n        window.cancelIdleCallback(handle)\n      }\n    }\n    // HACK: 최초 한 번만 실행\n  }, []) // eslint-disable-line react-hooks/exhaustive-deps\n\n  return (\n    <Component\n      banners={banners}\n      padding={padding}\n      margin={margin}\n      onBannerClick={handleBannerClick}\n      onBannerIntersect={handleBannerIntersecting}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/ad-banners/typing.ts",
    "content": "export interface Banner {\n  id: string\n  desc: string\n  image: string\n  target: string\n}\n\nexport enum ListDirection {\n  Vertical = 'vertical',\n  Horizontal = 'horizontal',\n}\n"
  },
  {
    "path": "packages/tds-widget/src/ad-banners/vertical-entity.tsx",
    "content": "import { FC } from 'react'\nimport { styled } from 'styled-components'\nimport { Container } from '@titicaca/tds-ui'\nimport { StaticIntersectionObserver as IntersectionObserver } from '@titicaca/intersection-observer'\n\nimport { Banner } from './typing'\n\ninterface VerticalEntityProps {\n  banner: Banner\n  onClick: (banner: Banner) => void\n  onIntersect: (isIntersecting: boolean, banner: Banner) => void\n}\n\nconst BannerImage = styled.img`\n  width: 100%;\n  vertical-align: top;\n`\n\nexport const VerticalEntity: FC<VerticalEntityProps> = ({\n  banner,\n  onClick,\n  onIntersect,\n}) => {\n  const handleIntersectionChange = ({\n    isIntersecting,\n  }: IntersectionObserverEntry) => {\n    onIntersect(isIntersecting, banner)\n  }\n  const handleBannerClick = () => {\n    onClick(banner)\n  }\n\n  return (\n    <IntersectionObserver threshold={0.5} onChange={handleIntersectionChange}>\n      <Container\n        borderRadius={6}\n        css={{\n          margin: '10px 0 0',\n        }}\n      >\n        <BannerImage src={banner.image} onClick={handleBannerClick} />\n      </Container>\n    </IntersectionObserver>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/ad-banners/vertical-list-view.tsx",
    "content": "import { FC } from 'react'\nimport { css } from 'styled-components'\nimport { formatMarginPadding, MarginPadding } from '@titicaca/tds-ui'\n\nimport { VerticalEntity } from './vertical-entity'\nimport { Banner } from './typing'\nimport ListSection from './list-section'\n\ninterface VerticalListViewProps {\n  banners: Banner[]\n  padding?: MarginPadding\n  margin?: MarginPadding\n  onBannerClick: (banner: Banner, index: number) => void\n  onBannerIntersect: (\n    isIntersecting: boolean,\n    banner: Banner,\n    index: number,\n  ) => void\n}\n\nexport const VerticalListView: FC<VerticalListViewProps> = ({\n  banners,\n  padding,\n  margin,\n  onBannerIntersect,\n  onBannerClick,\n}) => {\n  const makeBannerClickHandler = (index: number) => {\n    return (banner: Banner) => {\n      onBannerClick(banner, index)\n    }\n  }\n  const makeBannerIntersectingHandler = (index: number) => {\n    return (isIntersecting: boolean, banner: Banner) => {\n      onBannerIntersect(isIntersecting, banner, index)\n    }\n  }\n\n  if (banners.length === 0) {\n    return null\n  }\n\n  return (\n    <ListSection\n      css={css(\n        {\n          minWidth: 0,\n        },\n        formatMarginPadding(margin, 'margin'),\n        formatMarginPadding(padding, 'padding'),\n      )}\n    >\n      {banners.map((banner, index) => (\n        <VerticalEntity\n          key={banner.id}\n          banner={banner}\n          onClick={makeBannerClickHandler(index)}\n          onIntersect={makeBannerIntersectingHandler(index)}\n        />\n      ))}\n    </ListSection>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-banner/app-banner.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { AppBanner } from './app-banner'\n\nexport default {\n  title: 'tds-widget / app-banner / AppBanner',\n  component: AppBanner,\n} as Meta<typeof AppBanner>\n\nexport const Basic: StoryObj<typeof AppBanner> = {\n  args: {\n    title: '트리플 - 해외여행 가이드',\n    description: '가이드북, 일정짜기, 길찾기, 맛집',\n    cta: '앱에서 보기',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-banner/app-banner.tsx",
    "content": "import { SyntheticEvent } from 'react'\nimport { styled, css } from 'styled-components'\nimport { Text, layeringMixin, LayeringMixinProps } from '@titicaca/tds-ui'\nimport { useTranslation } from '@titicaca/triple-web'\n\nconst AppBannerFrame = styled.header<\n  { $fixed?: boolean; $maxWidth?: number } & LayeringMixinProps\n>`\n  background-color: #fff;\n  border-bottom: 1px solid #efefef;\n  height: 60px;\n  position: sticky;\n\n  ${layeringMixin(0)}\n\n  ${({ $fixed }) =>\n    $fixed &&\n    css`\n      top: 0;\n    `};\n  ${({ $maxWidth }) =>\n    $maxWidth &&\n    css`\n      @media (min-width: ${$maxWidth + 1}px) {\n        display: none;\n      }\n    `};\n`\n\nconst Logo = styled.h1`\n  background-repeat: no-repeat;\n  background-size: 34px 34px;\n  background-image: url('https://assets.triple.guide/images/app-download@2x.png');\n  width: 34px;\n  height: 34px;\n  top: 50%;\n  left: 20px;\n  margin-top: -17px;\n  position: absolute;\n`\n\nconst ContentContainer = styled.div`\n  top: 50%;\n  left: 64px;\n  margin-top: -15.5px;\n  position: absolute;\n  height: 31px;\n`\n\nconst CallToAction = styled.a`\n  position: absolute;\n  right: 20px;\n  top: 50%;\n  margin-top: -15px;\n  padding: 9px 15px 8px;\n  height: 30px;\n  border-radius: 15px;\n  line-height: 13px;\n  font-size: 11px;\n  font-weight: bold;\n  color: #fff;\n  background-color: #0bd0af;\n`\n\ntype Props = {\n  title?: string\n  description?: string\n  cta?: string\n  href?: string\n  onCtaClick?: (e?: SyntheticEvent) => void\n} & LayeringMixinProps\n\nexport function AppBanner({\n  title,\n  description,\n  cta,\n  href,\n  onCtaClick,\n  zTier,\n  zIndex = 1,\n  ...props\n}: Props) {\n  const t = useTranslation()\n\n  return (\n    <AppBannerFrame {...props} zTier={zTier} zIndex={zIndex}>\n      <Logo />\n      <ContentContainer>\n        <Text\n          bold\n          size=\"mini\"\n          lineHeight=\"15px\"\n          color=\"gray\"\n          margin={{ bottom: 1 }}\n        >\n          {title}\n        </Text>\n        <Text size=\"mini\" lineHeight=\"15px\" color=\"gray\" alpha={0.7}>\n          {description}\n        </Text>\n      </ContentContainer>\n      <CallToAction href={href} onClick={onCtaClick}>\n        {cta || t('앱에서 보기')}\n      </CallToAction>\n    </AppBannerFrame>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-banner/index.ts",
    "content": "export * from './app-banner'\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/article-card-cta.tsx",
    "content": "import { useCallback } from 'react'\nimport { Image } from '@titicaca/tds-ui'\nimport { useTrackEvent } from '@titicaca/triple-web'\nimport { StaticIntersectionObserver } from '@titicaca/intersection-observer'\nimport { InventoryItemMeta } from '@titicaca/type-definitions'\n\nexport function ArticleCardCta({\n  href,\n  cta,\n  onClick,\n}: {\n  href?: string\n  cta: InventoryItemMeta | null\n  onClick?: () => void\n}) {\n  const trackEvent = useTrackEvent()\n\n  const handleCtaIntersect = useCallback(() => {\n    trackEvent({\n      ga: ['앱설치 유도 구좌_노출', cta?.desc || ''],\n    })\n  }, [cta, trackEvent])\n\n  const handleCtaClick = useCallback(() => {\n    trackEvent({\n      ga: ['앱설치 유도 구좌_선택', cta?.desc || ''],\n    })\n    onClick && onClick()\n  }, [cta, onClick, trackEvent])\n\n  const handleIntersectionChange = ({\n    isIntersecting,\n  }: {\n    isIntersecting: boolean\n  }) => isIntersecting && handleCtaIntersect()\n\n  return (\n    <StaticIntersectionObserver\n      threshold={0.7}\n      onChange={handleIntersectionChange}\n    >\n      <a href={href}>\n        <Image borderRadius={6}>\n          <Image.FixedRatioFrame frame=\"big\" onClick={handleCtaClick}>\n            <Image.Img src={cta?.image} />\n          </Image.FixedRatioFrame>\n        </Image>\n      </a>\n    </StaticIntersectionObserver>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/banner-cta.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { BannerCta as BannerCTA } from './banner-cta'\n\nexport default {\n  title: 'tds-widget / app-installation-cta / BannerCTA',\n  component: BannerCTA,\n} as Meta<typeof BannerCTA>\n\n// TODO: 서버에 데이터가 없어서 mocking 해야 할 듯\nexport const Basic: StoryObj<typeof BannerCTA> = {\n  args: {\n    inventoryId: 'app-install-cta-tna-v1',\n    installUrl: 'https://triple-dev.titicaca-corp.com',\n    disableTextBanner: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/banner-cta.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport { InventoryItemMeta } from '@titicaca/type-definitions'\n\nimport { BottomFixedContainer } from './elements'\nimport { ImageBanner } from './image-banner'\nimport { TextBanner } from './text-banner'\nimport { CtaProps } from './interfaces'\nimport { fetchInventoryItems } from './service'\n\ninterface BannerCtaProps extends CtaProps {\n  /**\n   * 표시할 이미지의 인벤토리 ID\n   */\n  inventoryId: string\n  /**\n   * 앱 설치 URL\n   */\n  installUrl: string\n  installText?: string\n  dismissText?: string\n  disableTextBanner?: boolean\n}\n\n/**\n * 이미지가 포함된 배너를 띄우고 dismiss 시에는 텍스트 배너로 바뀌는 CTA 컴포넌트\n */\nexport function BannerCta({\n  inventoryId,\n  installUrl,\n  onShow,\n  onClick,\n  onDismiss,\n  installText,\n  dismissText,\n  disableTextBanner,\n}: BannerCtaProps) {\n  const [inventoryItem, setInventoryItem] = useState<InventoryItemMeta>()\n  const [isImageBannerOpen, setIsImageBannerOpen] = useState(true)\n  const { image = '', desc = '' } = inventoryItem || {}\n\n  useEffect(() => {\n    async function fetchCtaImage() {\n      const items = await fetchInventoryItems({ inventoryId })\n\n      if (items) {\n        if (items.length > 0) {\n          const item = items[0]\n\n          setInventoryItem({\n            image: item.image ? item.image.replace(/\\.jpg$/, '.png') : '',\n            desc: item.desc,\n          })\n        } else {\n          onDismiss && onDismiss()\n        }\n      }\n    }\n    fetchCtaImage()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [inventoryId])\n\n  return inventoryItem ? (\n    isImageBannerOpen && image ? (\n      <BottomFixedContainer>\n        <ImageBanner\n          imgUrl={image}\n          installUrl={installUrl}\n          installText={installText}\n          dismissText={dismissText}\n          onShow={onShow}\n          onClick={onClick}\n          onDismiss={() => {\n            setIsImageBannerOpen(false)\n            onDismiss && onDismiss(inventoryItem)\n          }}\n        />\n      </BottomFixedContainer>\n    ) : !disableTextBanner ? (\n      <TextBanner\n        message={desc}\n        installUrl={installUrl}\n        onShow={onShow}\n        onClick={onClick}\n      />\n    ) : null\n  ) : null\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/chatbot-cta.tsx",
    "content": "import { useState, useEffect, useCallback, useRef } from 'react'\nimport { Text, LayeringMixinProps } from '@titicaca/tds-ui'\nimport { CSSTransition } from 'react-transition-group'\nimport { InventoryItemMeta } from '@titicaca/type-definitions'\nimport { useSessionStorage } from '@titicaca/react-hooks'\nimport { useTranslation } from '@titicaca/triple-web'\n\nimport {\n  CHATBOT_CLOSED_STORAGE_KEY,\n  EVENT_CHATBOT_CTA_READY,\n} from './constants'\nimport { CtaProps } from './interfaces'\nimport {\n  ChatbotContainer,\n  ChatBalloon,\n  ChatbotAction,\n  ChatbotCloseButton,\n  ChatbotIcon,\n} from './elements'\n\ninterface ChatbotCtaProps extends CtaProps {\n  available?: boolean\n  inventoryId: string\n  installUrl: string\n  unmountOnExit?: boolean\n}\n\n/**\n * 챗봇 스타일의 하단 배너 CTA를 build/destroy 에니메이션과 함께 띄웁니다.\n *\n * @param available CTA 가 표시되어야하는지의 여부 (기본값 false) (controlled)\n * @param inventoryId 표시할 이미지의 인벤토리 ID\n * @param installUrl 앱 설치 URL\n * @param unmountOnExit 표시되지 않는 상태일 때 컴포넌트 마운트 해제\n */\nexport function ChatbotCta({\n  available = false,\n  inventoryId,\n  installUrl,\n  onShow,\n  onClick,\n  onDismiss,\n  zTier,\n  zIndex,\n  unmountOnExit,\n}: ChatbotCtaProps & LayeringMixinProps) {\n  const t = useTranslation()\n\n  const [inventoryItem, setInventoryItem] = useState<InventoryItemMeta>()\n  const [visibility, setVisibility] = useState(false)\n  const chatbotContainerRef = useRef<HTMLDivElement>(null)\n\n  const { detailedDesc = '', text = '' } = inventoryItem || {}\n\n  const [visited, setVisited] = useSessionStorage(CHATBOT_CLOSED_STORAGE_KEY)\n\n  useEffect(() => {\n    if (visited === 'true') {\n      return\n    }\n    if (available && inventoryItem) {\n      setVisibility(true)\n      window.dispatchEvent(new Event(EVENT_CHATBOT_CTA_READY))\n    }\n  }, [available, inventoryItem, onShow, visited])\n\n  useEffect(() => {\n    async function fetchInventory() {\n      const response = await fetch(`/api/inventories/v1/${inventoryId}/items`, {\n        credentials: 'same-origin',\n      })\n\n      if (response.ok) {\n        const { items } = (await response.json()) as {\n          items: InventoryItemMeta[]\n        }\n        const [item] = items\n\n        if (item) {\n          setInventoryItem({\n            detailedDesc: (item.detailedDesc || '').replace('\\\\n', '\\n'),\n            text: item.text,\n          })\n        }\n      }\n    }\n\n    available && inventoryId && !detailedDesc && fetchInventory()\n  }, [available, detailedDesc, inventoryId, text])\n\n  useEffect(() => {\n    if (inventoryItem?.detailedDesc && visibility) {\n      onShow && onShow(inventoryItem)\n    }\n  }, [inventoryItem, onShow, visibility])\n\n  const handleClick = useCallback(() => {\n    onClick && onClick(inventoryItem)\n  }, [onClick, inventoryItem])\n\n  const handleDismiss = useCallback(() => {\n    setVisibility(false)\n    onDismiss && onDismiss(inventoryItem)\n\n    setVisited('true')\n  }, [onDismiss, inventoryItem, setVisited])\n\n  return (\n    <CSSTransition\n      nodeRef={chatbotContainerRef}\n      in={visibility}\n      appear\n      classNames=\"chatbot-slide\"\n      timeout={500}\n      mountOnEnter={unmountOnExit}\n      unmountOnExit={unmountOnExit}\n    >\n      <ChatbotContainer\n        ref={chatbotContainerRef}\n        $visibility={visibility ? 1 : 0}\n        zTier={zTier}\n        zIndex={zIndex}\n      >\n        <ChatBalloon>\n          <Text size={18} bold lineHeight=\"24px\">\n            {detailedDesc}\n          </Text>\n          <ChatbotAction href={installUrl} onClick={handleClick}>\n            {text}\n          </ChatbotAction>\n          <ChatbotCloseButton onClick={handleDismiss}>\n            {t('닫기')}\n          </ChatbotCloseButton>\n        </ChatBalloon>\n        <ChatbotIcon href={installUrl} onClick={handleClick}>\n          {t('트리플')}\n        </ChatbotIcon>\n      </ChatbotContainer>\n    </CSSTransition>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/constants.ts",
    "content": "export enum BannerExitStrategy {\n  NONE = 'NONE',\n  CHATBOT_READY = 'CHATBOT_READY',\n}\n\nexport const FLOATING_BUTTON_CLOSED_STORAGE_KEY = 'close_install_button'\nexport const CHATBOT_CLOSED_STORAGE_KEY = 'triple_chatbotad_closed'\nexport const EVENT_CHATBOT_CTA_READY = 'triple_chatbot_cta_ready'\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/elements.tsx",
    "content": "import { styled, css } from 'styled-components'\nimport {\n  Text,\n  MarginPadding,\n  layeringMixin,\n  LayeringMixinProps,\n  safeAreaInsetMixin,\n} from '@titicaca/tds-ui'\n\nexport const Overlay = styled.div<LayeringMixinProps>`\n  position: fixed;\n  top: 0;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.07);\n  background-color: var(--color-gray500);\n  ${layeringMixin(1)}\n`\nexport const BottomFixedContainer = styled.div<LayeringMixinProps>`\n  position: absolute;\n  left: 0;\n  bottom: 0;\n  width: 100%;\n\n  ${layeringMixin(0)}\n  > * {\n    margin: 0 auto;\n  }\n`\nconst CONTENT_MIN_WIDTH = 320\nconst CONTENT_MAX_WIDTH = 768\nexport const ImageBannerWrapper = styled.div`\n  box-sizing: border-box;\n  min-width: ${CONTENT_MIN_WIDTH}px;\n  height: 230px;\n  padding: 0 20px 20px;\n  box-shadow: 0 0 20px rgba(0, 0, 0, 0.07);\n  background-color: #0179ff;\n`\nexport const ImageWrapper = styled.div`\n  margin: 0 auto;\n  height: 130px;\n`\nexport const BannerImage = styled.img`\n  position: relative;\n  top: 50%;\n  left: 50%;\n  transform: translate3d(-50%, -50%, 0);\n  display: block;\n  width: 320px;\n  height: 130px;\n`\nexport const InstallLink = styled.a`\n  display: block;\n  box-sizing: border-box;\n  margin: 5px auto 16px;\n  max-width: ${CONTENT_MAX_WIDTH}px;\n  height: 44px;\n  line-height: 23px;\n  border-radius: 25px;\n  padding: 10px 0 11px;\n  background-color: white;\n  color: black;\n  text-align: center;\n  font-size: 14px;\n  font-weight: bold;\n  cursor: pointer;\n  text-decoration: none;\n`\nexport const DismissButton = styled.button`\n  display: block;\n  margin: 0 auto;\n  border: 0;\n  background-color: transparent;\n  opacity: 0.6;\n  font-size: 12px;\n  font-weight: 500;\n  text-align: center;\n  color: white;\n  text-decoration: underline;\n  outline: none;\n  cursor: pointer;\n`\nexport const TextBannerWrapper = styled.a`\n  display: block;\n  box-sizing: border-box;\n  width: 100%;\n  height: 54px;\n  line-height: 17px;\n  padding: 19px 0 18px;\n  box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.07);\n  background-color: #0179ff;\n  color: white;\n  font-size: 14px;\n  font-weight: bold;\n  text-align: center;\n  cursor: pointer;\n  text-decoration: none;\n`\nexport const DownloadIcon = styled.img`\n  margin-left: 6px;\n  width: 12px;\n  height: 13px;\n  vertical-align: middle;\n  transform: translateY(-1px); /* HACK: 아래로 가있는 이미지 위로 끌어 올림 */\n`\nconst MIN_DESKTOP_WIDTH = 1142\nexport const InstallDescription = styled(Text)`\n  height: 21px;\n  font-size: 18px;\n  font-weight: bold;\n  color: var(--color-white);\n`\n\nexport const InstallAnchor = styled.a`\n  box-sizing: border-box;\n  width: 100%;\n  height: 100%;\n  display: block;\n  text-decoration: none;\n  padding: 23px 0 22px 32px;\n\n  &:visited,\n  &:hover,\n  &:active {\n    color: inherit;\n  }\n`\n\nexport const GoAppButton = styled.img`\n  width: 20px;\n  height: 20px;\n`\n\nexport const CloseButton = styled.img`\n  width: 30px;\n  height: 30px;\n  margin: 27px 16px 27px 0;\n`\n\nconst inactiveFloatingButtonStyle = css<{\n  fixed?: 1 | 0\n  margin?: MarginPadding\n}>`\n  /* stylelint-disable unit-no-unknown */\n  transform: translate3d(\n    0,\n    calc(\n      100% +\n        ${({ fixed, margin }) =>\n          margin ? `${margin.right || 0}px` : fixed ? '30px' : '0px'}\n    ),\n    0\n  );\n`\nconst activeFloatingButtonStyle = `\n  transform: translate3d(0, 0, 0);\n`\nconst floatingButtonTransitionConfig = `\n  transition: transform 300ms ease-out;\n`\ninterface FloatingButtonProps {\n  visibility: 1 | 0\n  fixed?: 1 | 0\n  margin?: MarginPadding\n  padding?: MarginPadding\n}\nexport const FloatingButtonContainer = styled.div<\n  FloatingButtonProps & LayeringMixinProps\n>`\n  height: 84px;\n\n  ${layeringMixin(1)}\n  ${safeAreaInsetMixin}\n  @media (min-width: ${MIN_DESKTOP_WIDTH}px) {\n    display: none;\n  }\n\n  ${({ fixed }) =>\n    fixed &&\n    css`\n      position: fixed;\n      bottom: 0;\n      left: 10px;\n      right: 10px;\n      margin-bottom: 30px;\n    `};\n  ${({ margin }) =>\n    margin &&\n    css`\n      margin: ${margin.top || 0}px ${margin.right || 0}px\n        ${margin.bottom || 0}px ${margin.left || 0}px;\n    `};\n  ${({ visibility }) => (visibility ? 'display: block;' : 'display: none;')}\n  &.floating-button-slide-exit {\n    ${activeFloatingButtonStyle}\n  }\n\n  &.floating-button-slide-exit-active {\n    ${inactiveFloatingButtonStyle}\n    ${floatingButtonTransitionConfig}\n  }\n\n  &.floating-button-slide-exit-done {\n    ${inactiveFloatingButtonStyle}\n    display: none;\n  }\n`\n\nexport const FloatingButton = styled.div`\n  display: flex;\n  border-radius: 42px;\n  box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.07);\n  background-color: var(--color-blue980);\n  overflow: hidden;\n  margin-left: auto;\n  margin-right: auto;\n  max-width: 768px;\n`\n// ChatbotCTA\nexport const ChatBalloon = styled.div`\n  position: relative;\n  z-index: 1;\n  background-color: rgba(255, 255, 255, 0.98);\n  border-radius: 26px 26px 0;\n  padding: 30px;\n  margin-right: 50px;\n  box-sizing: border-box;\n  min-height: 132px;\n`\nexport const ChatbotAction = styled.a`\n  display: inline-block;\n  font-size: 13px;\n  font-weight: bold;\n  text-decoration: none;\n  line-height: 16px;\n  color: var(--color-blue980);\n  padding-right: 14px;\n  background-size: 14px 14px;\n  background-image: url('https://assets.triple.guide/images/ico-right-blue-arrow-s@3x.png');\n  background-repeat: no-repeat;\n  background-position: right 1px;\n  margin-top: 10px;\n`\nexport const ChatbotCloseButton = styled.button`\n  position: absolute;\n  top: 10px;\n  right: 10px;\n  margin: 0;\n  padding: 0;\n  width: 24px;\n  height: 24px;\n  overflow: hidden;\n  text-indent: -1000px;\n  border-radius: 12px;\n  box-sizing: border-box;\n  background-color: transparent;\n  background-image: url('https://assets.triple-dev.titicaca-corp.com/images/btn-gray-close-circle-s@3x.png');\n  background-size: 24px 24px;\n  background-position: left top;\n  background-repeat: no-repeat;\n  border: none;\n  cursor: pointer;\n\n  &:focus {\n    outline: none;\n  }\n`\nexport const ChatbotIcon = styled.a`\n  position: absolute;\n  z-index: 1;\n  bottom: 0;\n  right: 0;\n  display: block;\n  width: 40px;\n  height: 40px;\n  overflow: hidden;\n  text-indent: -1000px;\n  background-color: transparent;\n  background-image: url('https://assets.triple-dev.titicaca-corp.com/images/ico-circle-triple-bi@3x.png');\n  background-size: 40px 40px;\n  background-position: left top;\n  background-repeat: no-repeat;\n`\nconst inactiveChatbotContainerStyle = `\n  transform: translate3d(0, calc(100% + 10px), 0);\n  @media (min-width: 768px) {\n    transform: translate3d(0, calc(100% + 30px), 0);\n  }\n`\nconst activeChatbotContainerStyle = `\n  transform: translate3d(0, 0, 0);\n`\nconst chatbotContainerTransitionStyle = `\n  transition: transform 300ms ease-out;\n`\nexport const ChatbotContainer = styled.div<\n  { $visibility: 1 | 0 } & LayeringMixinProps\n>`\n  position: fixed;\n  bottom: 10px;\n  left: 10px;\n  right: 10px;\n  ${layeringMixin(1)}\n  ${ChatBalloon} {\n    ${({ $visibility }) =>\n      $visibility ? 'box-shadow: 0 30px 100px 0 rgba(0, 0, 0, 0.3);' : ''}\n  }\n\n  @media (min-width: 768px) {\n    bottom: 30px;\n    left: 30px;\n    right: 30px;\n  }\n\n  &:not([class*='chatbot-slide-']) {\n    ${inactiveChatbotContainerStyle}\n    display: none;\n  }\n\n  &.chatbot-slide-appear,\n  &.chatbot-slide-enter {\n    ${inactiveChatbotContainerStyle}\n  }\n\n  &.chatbot-slide-appear-active,\n  &.chatbot-slide-enter-active {\n    ${activeChatbotContainerStyle}\n    ${chatbotContainerTransitionStyle}\n  }\n\n  &.chatbot-slide-enter-done {\n    ${activeChatbotContainerStyle}\n  }\n\n  &.chatbot-slide-exit {\n    ${activeChatbotContainerStyle}\n  }\n\n  &.chatbot-slide-exit-active {\n    ${inactiveChatbotContainerStyle}\n    ${chatbotContainerTransitionStyle}\n  }\n\n  &.chatbot-slide-exit-done {\n    ${inactiveChatbotContainerStyle}\n    display: none;\n  }\n`\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/floating-button-cta.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { FloatingButtonCta as FloatingButtonCTA } from './floating-button-cta'\n\nexport default {\n  title: 'tds-widget / app-installation-cta / FloatingButtonCTA',\n  component: FloatingButtonCTA,\n  parameters: {\n    chromatic: {\n      viewports: [375],\n    },\n  },\n} as Meta<typeof FloatingButtonCTA>\n\nexport const Basic: StoryObj<typeof FloatingButtonCTA> = {\n  args: {\n    appInstallLink: 'https://triple.onelink.me/aZP6/21d43a81',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/floating-button-cta.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { useState, useCallback, useEffect, useRef } from 'react'\nimport {\n  Text,\n  MarginPadding,\n  LayeringMixinProps,\n  Container,\n} from '@titicaca/tds-ui'\nimport { CSSTransition } from 'react-transition-group'\nimport { useSessionStorage } from '@titicaca/react-hooks'\n\nimport {\n  BannerExitStrategy,\n  EVENT_CHATBOT_CTA_READY,\n  FLOATING_BUTTON_CLOSED_STORAGE_KEY,\n} from './constants'\nimport { CtaProps } from './interfaces'\nimport {\n  FloatingButtonContainer,\n  InstallAnchor,\n  GoAppButton,\n  CloseButton,\n  FloatingButton,\n} from './elements'\n\ninterface FloatingButtonCtaProps extends CtaProps {\n  exitStrategy?: BannerExitStrategy\n  fixed?: boolean\n  appInstallLink?: string\n  title?: string\n  description?: string\n  margin?: MarginPadding\n  trackEvent?: any\n  trackEventParams?: any\n  unmountOnExit?: boolean\n}\n\n/**\n * '트리플 앱 설치하기' 하단 플로팅 버튼 CTA\n *\n * @param exitStrategy 이 버튼 컴포넌트가 사라져야하는 조건 또는 전략 (기본값 NONE)\n * @param fixed 스크롤 위치와 관계없이 fixed position 인지의 여부\n * @param appInstallLink 앱 설치 URL\n * @param title 앱 설치 안내 문구 제목\n * @param description 앱 설치 안내 문구 설명\n * @param trackEvent 이벤트 트래킹 함수\n * @param margin 버튼 주변 margin 값 (optional)\n * @param trackEventParams GA/FA 수집 파라미터\n * @param unmountOnExit 버튼이 표시되지 않을 때 컴포넌트 마운트 해제 여부\n */\nexport function FloatingButtonCta({\n  exitStrategy = BannerExitStrategy.NONE,\n  fixed,\n  appInstallLink,\n  title = '트리플 앱 설치하기',\n  description = '가이드북, 일정짜기, 길찾기, 맛집',\n  margin,\n  trackEvent,\n  trackEventParams,\n  onShow,\n  onClick,\n  onDismiss,\n  zTier,\n  zIndex,\n  unmountOnExit,\n}: FloatingButtonCtaProps & LayeringMixinProps) {\n  const [buttonVisibility, setButtonVisibility] = useState(false)\n  const [available, setAvailable] = useState(true)\n  const floatingButtonContainerRef = useRef<HTMLDivElement>(null)\n  const sendTrackEventRequest = useCallback(\n    (param: any) => {\n      trackEvent && param && trackEvent(param)\n    },\n    [trackEvent],\n  )\n  const [visitedPages, setVisitedPages] = useSessionStorage(\n    FLOATING_BUTTON_CLOSED_STORAGE_KEY,\n  )\n  useEffect(() => {\n    if (visitedPages === 'true') {\n      return\n    }\n    setButtonVisibility(true)\n  }, [visitedPages])\n  useEffect(() => {\n    if (buttonVisibility) {\n      sendTrackEventRequest(trackEventParams && trackEventParams.onShow)\n      onShow && onShow()\n    }\n  }, [buttonVisibility, onShow, sendTrackEventRequest, trackEventParams])\n  const handleClick = useCallback(() => {\n    sendTrackEventRequest(trackEventParams && trackEventParams.onSelect)\n    onClick && onClick()\n    return true\n  }, [onClick, sendTrackEventRequest, trackEventParams])\n  const handleDismiss = useCallback(() => {\n    setButtonVisibility(false)\n    sendTrackEventRequest(trackEventParams && trackEventParams.onClose)\n    onDismiss && onDismiss()\n    setVisitedPages('true')\n  }, [onDismiss, sendTrackEventRequest, setVisitedPages, trackEventParams])\n  useEffect(() => {\n    if (exitStrategy === BannerExitStrategy.CHATBOT_READY) {\n      const onChatbotReady = () => {\n        setAvailable(false)\n        window.removeEventListener(EVENT_CHATBOT_CTA_READY, onChatbotReady)\n      }\n      window.addEventListener(EVENT_CHATBOT_CTA_READY, onChatbotReady)\n      return () => {\n        window.removeEventListener(EVENT_CHATBOT_CTA_READY, onChatbotReady)\n      }\n    }\n  }, [exitStrategy])\n  return (\n    <CSSTransition\n      nodeRef={floatingButtonContainerRef}\n      in={available}\n      appear\n      classNames=\"floating-button-slide\"\n      timeout={500}\n      mountOnEnter={unmountOnExit}\n      unmountOnExit={unmountOnExit}\n    >\n      <FloatingButtonContainer\n        ref={floatingButtonContainerRef}\n        visibility={buttonVisibility ? 1 : 0}\n        fixed={fixed ? 1 : 0}\n        margin={margin}\n        zTier={zTier}\n        zIndex={zIndex}\n      >\n        <FloatingButton>\n          <Container css={{ width: '100%' }}>\n            <InstallAnchor href={appInstallLink} onClick={handleClick}>\n              <Text size={18} lineHeight=\"21px\" bold color=\"white\">\n                <Text floated=\"left\" color=\"white\">\n                  {title}\n                </Text>\n                <GoAppButton src=\"https://assets.triple.guide/images/ico-arrow@4x.png\" />\n              </Text>\n              <Text\n                size={12}\n                lineHeight=\"15px\"\n                color=\"white600\"\n                margin={{ top: 3 }}\n              >\n                {description}\n              </Text>\n            </InstallAnchor>\n          </Container>\n          <Container css={{ width: 46 }} onClick={handleDismiss}>\n            <CloseButton src=\"https://assets.triple.guide/images/btn-closebanner@3x.png\" />\n          </Container>\n        </FloatingButton>\n      </FloatingButtonContainer>\n    </CSSTransition>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/image-banner.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { ImageBanner } from './image-banner'\n\nexport default {\n  title: 'tds-widget / app-installation-cta / ImageBanner',\n  component: ImageBanner,\n} as Meta\n\nexport const Basic: StoryObj<typeof ImageBanner> = {\n  args: {\n    // TODO: 이미지 추가하면 좋을 것 같은데 어떤 이미지를 써야할지 모르겠음\n    imgUrl: '',\n    installUrl: 'https://triple-dev.titicaca-corp.com',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/image-banner.tsx",
    "content": "import { SyntheticEvent, useCallback, useEffect, useMemo } from 'react'\nimport { useTranslation } from '@titicaca/triple-web'\n\nimport {\n  ImageBannerWrapper,\n  ImageWrapper,\n  BannerImage,\n  InstallLink,\n  DismissButton,\n} from './elements'\nimport { CtaProps } from './interfaces'\n\ninterface ImageBannerProps extends CtaProps {\n  imgUrl?: string\n  installUrl: string\n  installText?: string\n  dismissText?: string\n}\n\nexport function ImageBanner({\n  imgUrl,\n  installUrl,\n  installText,\n  dismissText,\n  onShow,\n  onClick,\n  onDismiss,\n}: ImageBannerProps) {\n  const t = useTranslation()\n\n  const imgSrc =\n    (imgUrl ?? '').trim() ||\n    'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'\n\n  const inventoryItem = useMemo(\n    () => (imgUrl ? { image: imgUrl } : undefined),\n    [imgUrl],\n  )\n\n  useEffect(() => {\n    onShow && onShow(inventoryItem)\n  }, [onShow, inventoryItem])\n\n  const handleClick = useCallback(\n    (e: SyntheticEvent) => {\n      e.stopPropagation()\n      onClick && onClick(inventoryItem)\n    },\n    [onClick, inventoryItem],\n  )\n\n  const handleDismiss = useCallback(\n    (e: SyntheticEvent) => {\n      e.stopPropagation()\n      onDismiss && onDismiss(inventoryItem)\n    },\n    [onDismiss, inventoryItem],\n  )\n\n  return (\n    <ImageBannerWrapper>\n      <ImageWrapper>\n        <BannerImage src={imgSrc} />\n      </ImageWrapper>\n\n      <InstallLink href={installUrl} onClick={handleClick}>\n        <span role=\"img\" aria-label=\"eyes\">\n          👀\n        </span>\n        <span>\n          &nbsp;&nbsp;\n          {installText || t('편하게 앱에서 보기')}\n        </span>\n      </InstallLink>\n\n      <DismissButton onClick={handleDismiss}>\n        {dismissText || t('아깝지만 나중에 받을게요')}\n      </DismissButton>\n    </ImageBannerWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/index.ts",
    "content": "export * from './article-card-cta'\nexport * from './banner-cta'\nexport * from './chatbot-cta'\nexport * from './floating-button-cta'\nexport * from './image-banner'\nexport * from './text-banner'\n\nexport * from './constants'\nexport * from './service'\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/interfaces.ts",
    "content": "import { InventoryItemMeta } from '@titicaca/type-definitions'\n\nexport interface CtaProps {\n  onShow?: (item?: InventoryItemMeta) => void\n  onClick?: (item?: InventoryItemMeta) => void\n  onDismiss?: (item?: InventoryItemMeta) => void\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/service.ts",
    "content": "import { InventoryItemMeta } from '@titicaca/type-definitions'\n\nexport async function fetchInventoryItems({\n  inventoryId,\n}: {\n  inventoryId?: string\n}): Promise<InventoryItemMeta[] | null> {\n  const response = await fetch(`/api/inventories/v1/${inventoryId}/items`, {\n    credentials: 'same-origin',\n  })\n\n  if (response.ok) {\n    const { items = [] }: { items: InventoryItemMeta[] } = await response.json()\n    return items\n  } else {\n    return null\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/text-banner.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { TextBanner } from './text-banner'\n\nexport default {\n  title: 'tds-widget / app-installation-cta / TextBanner',\n  component: TextBanner,\n} as Meta<typeof TextBanner>\n\nexport const Basic: StoryObj<typeof TextBanner> = {\n  args: {\n    message: '앱 다운로드시 가이드북 무료',\n    installUrl: 'https://triple-dev.titicaca-corp.com',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/app-installation-cta/text-banner.tsx",
    "content": "import { useCallback, useEffect, useMemo } from 'react'\n\nimport { TextBannerWrapper, DownloadIcon } from './elements'\nimport { CtaProps } from './interfaces'\n\ninterface TextBannerProps extends CtaProps {\n  message: string\n  installUrl: string\n}\n\nexport function TextBanner({\n  message,\n  installUrl,\n  onShow,\n  onClick,\n}: TextBannerProps) {\n  const inventoryItem = useMemo(\n    () => (message ? { desc: message } : undefined),\n    [message],\n  )\n\n  useEffect(() => {\n    onShow && onShow(inventoryItem)\n  }, [onShow, inventoryItem])\n\n  const handleClick = useCallback(() => {\n    onClick && onClick(inventoryItem)\n  }, [onClick, inventoryItem])\n\n  return (\n    <TextBannerWrapper href={installUrl} onClick={handleClick}>\n      {message}\n      <DownloadIcon src=\"https://assets.triple.guide/images/m-banner-top-dw@3x.png\" />\n    </TextBannerWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/author/author-intro.tsx",
    "content": "import { styled } from 'styled-components'\nimport { Text } from '@titicaca/tds-ui'\n\nconst Html = styled.div`\n  line-height: 1.43;\n  margin: 21px 0 0;\n  color: var(--color-gray500);\n  font-size: 14px;\n  font-weight: 500;\n\n  a {\n    color: var(--color-gray500);\n  }\n`\n\nexport function AuthorIntro({\n  value: { rawHTML, text },\n}: {\n  value: { text?: string; rawHTML?: string }\n}) {\n  if (rawHTML) {\n    return <Html dangerouslySetInnerHTML={{ __html: rawHTML }} />\n  }\n  return (\n    <Text alpha={0.5} size={14} lineHeight={1.43} margin={{ top: 21 }}>\n      {text}\n    </Text>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/author/author.stories.tsx",
    "content": "import type { StoryObj } from '@storybook/react'\n\nimport { Author } from './author'\n\nexport default {\n  title: 'tds-widget / author / Author',\n  component: Author,\n} as StoryObj<typeof Author>\n\nexport const Basic: StoryObj = {\n  args: {\n    source: {\n      name: '에디터가있을때',\n      bio: '여행이 좋아 디지털 노마드로 전세계를 떠돌며\\n일을하고 있습니다. budim@gmail.com',\n      image: {\n        id: '568dea0a-c04a-403a-84c8-ae5171878c6a',\n        sizes: {\n          full: {\n            url: 'https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit,f_auto/568dea0a-c04a-403a-84c8-ae5171878c6a.jpg',\n          },\n          large: {\n            url: 'https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit,f_auto/568dea0a-c04a-403a-84c8-ae5171878c6a.jpg',\n          },\n          small_square: {\n            url: 'https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill,f_auto/568dea0a-c04a-403a-84c8-ae5171878c6a.jpg',\n          },\n        },\n        width: 640,\n        height: 427,\n      },\n      intro: {\n        rawHTML:\n          '<p>자주 여행을 꿈꾸고, 이따금씩 순간을 톺아보려 합니다.<br><a href=\"https://www.naver.com\">www.instagram.com/romi1403</a></p>',\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/author/author.tsx",
    "content": "import { Container, Text, Image } from '@titicaca/tds-ui'\nimport { ImageMeta } from '@titicaca/type-definitions'\n\nimport { AuthorIntro } from './author-intro'\n\nexport function Author({\n  source: { name, bio, image, intro },\n  bioOverride,\n  introOverride,\n  ...props\n}: {\n  source: {\n    name: string\n    bio?: string\n    image?: ImageMeta\n    intro?: {\n      text?: string\n      rawHTML?: string\n    }\n  }\n  bioOverride?: string\n  introOverride?: { text?: string; rawHTML?: string }\n}) {\n  const displayedBio = (bioOverride || bio || '').replace('\\n', '')\n  const displayedIntro = introOverride || intro\n\n  return (\n    <Container {...props}>\n      {image && (\n        <Image.Circular\n          floated=\"right\"\n          width={45}\n          src={image.sizes.large.url}\n        />\n      )}\n      <Container>\n        <Text bold size=\"large\" color=\"gray\" padding={{ top: 4, bottom: 4 }}>\n          {name}\n        </Text>\n        <Text\n          size=\"tiny\"\n          color=\"gray\"\n          alpha={0.3}\n          maxLines={1}\n          padding={{ right: 15 }}\n        >\n          {displayedBio}\n        </Text>\n      </Container>\n\n      {displayedIntro && <AuthorIntro value={displayedIntro} />}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/author/index.ts",
    "content": "export * from './author'\n"
  },
  {
    "path": "packages/tds-widget/src/booking-completion/booking-completion.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { BookingCompletion } from '.'\n\nexport default {\n  title: 'tds-widget / booking-completion / Booking Complete',\n  component: BookingCompletion,\n  args: {\n    onAddToSchedule: () => {},\n    onMoveToBookingDetail: () => {},\n    onMoveToMain: () => {},\n    onMoveToRegion: () => {},\n  },\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta<typeof BookingCompletion>\n\nexport const Basic: StoryObj<typeof BookingCompletion> = {\n  args: {\n    descriptions: [\n      '공급사 확인 후 예약이 확정됩니다.',\n      '예약이 확정되면 이메일로 바우처가 발송됩니다.',\n    ],\n  },\n}\n\nexport const Compact: StoryObj<typeof BookingCompletion> = {\n  args: {\n    ...Basic.args,\n    compact: true,\n  },\n}\n\nexport const WithRegion: StoryObj<typeof BookingCompletion> = {\n  args: {\n    ...Basic.args,\n    region: {\n      id: '',\n      names: { ko: '바르셀로나', en: 'Barcelona', local: null },\n    },\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/booking-completion/index.tsx",
    "content": "import { useCallback } from 'react'\nimport { Container, Text, Button, ButtonGroup } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\nimport { TranslatedProperty } from '@titicaca/type-definitions'\nimport { useClientAppCallback, useTranslation } from '@titicaca/triple-web'\nimport { useNavigate } from '@titicaca/router'\n\ninterface Region {\n  id: string\n  names: TranslatedProperty\n}\n\ninterface BookingCompletionProps {\n  /**\n   * 최상단에 보여지는 제목입니다.\n   */\n  title?: string\n  myBookingButtonTitle?: string\n  compact?: boolean\n  /**\n   * `내 예약에서 확인` 버튼을 눌렀을 때 발생하는 이벤트입니다.\n   */\n  onMoveToBookingDetail: () => void\n  onMoveToMain?: () => void\n  onMoveToRegion?: () => void\n  onAddToSchedule?: () => void\n  /**\n   * 해당 예약에 대한 설명 문구입니다. 없을 경우 표시되지 않습니다.\n   */\n  descriptions?: string[]\n  /**\n   * 해당 예약에 대한 region 정보 있습니다. region 정보가 존재할 경우, 추가로 버튼이 표시되며, 해당 버튼을 클릭시 도시메인으로 이동합니다.\n   *\n   * `names.ko를` 우선으로 표시하며, 없을 경우 `names.en` 정보를 노출합니다.\n   */\n  region?: Region\n}\n\nconst DescriptionText = styled(Text)`\n  &::before {\n    display: inline-block;\n    content: '';\n    width: 10px;\n    height: 10px;\n    background-image: url('https://assets.triple.guide/images/img-bullet-check-b@3x.png');\n    background-size: 10px;\n    background-repeat: no-repeat;\n    margin-right: 5px;\n  }\n`\n\nconst GrayButton = styled(Button)`\n  border-radius: 4px;\n  background-color: #f5f5f5;\n  color: #3a3a3a;\n  font-weight: bold;\n  font-size: 14px;\n  height: 45px;\n  line-height: normal;\n`\n\n/**\n * 항공/호텔/TNA에서 예약이 최종적으로 완료 되었을 때 보여주는 페이지입니다.\n */\nexport function BookingCompletion({\n  title,\n  myBookingButtonTitle,\n  compact = false,\n  onMoveToBookingDetail,\n  onMoveToMain,\n  onMoveToRegion,\n  onAddToSchedule,\n  descriptions,\n  region,\n}: BookingCompletionProps) {\n  const t = useTranslation()\n\n  const regionName = region?.names.ko || region?.names.en\n  const { navigate } = useNavigate()\n\n  const handleMoveToRegion = useClientAppCallback(\n    useCallback(() => {\n      onMoveToRegion?.()\n      navigate(`/regions/${region?.id}`)\n    }, [navigate, onMoveToRegion, region?.id]),\n  )\n\n  return (\n    <>\n      <Container\n        css={{\n          margin: '0 0 12px',\n        }}\n      >\n        <Text size={28} bold>\n          {title || t('예약이 접수되었습니다.')}\n        </Text>\n      </Container>\n      {(descriptions || []).map((description, idx) => (\n        <DescriptionText\n          key={idx}\n          size=\"small\"\n          color=\"blue\"\n          bold\n          margin={{ bottom: 8 }}\n        >\n          {description}\n        </DescriptionText>\n      ))}\n      <Text color=\"gray\" size=\"mini\" alpha={0.5}>\n        {t('자세한 사항은 내 예약에서 확인해주세요.')}\n      </Text>\n      {compact ? (\n        <Button\n          margin={{ top: 30 }}\n          basic\n          inverted\n          color=\"blue\"\n          size=\"small\"\n          onClick={onMoveToBookingDetail}\n          fluid\n        >\n          {myBookingButtonTitle || t('내 예약에서 확인')}\n        </Button>\n      ) : (\n        <>\n          <Container\n            css={{\n              margin: '30px 0 0',\n            }}\n          >\n            <ButtonGroup horizontalGap={7}>\n              <Button\n                basic\n                inverted\n                color=\"blue\"\n                size=\"small\"\n                onClick={onMoveToBookingDetail}\n              >\n                {myBookingButtonTitle || t('내 예약에서 확인')}\n              </Button>\n              <Button\n                basic\n                inverted\n                color=\"gray\"\n                size=\"small\"\n                onClick={() => {\n                  onMoveToMain?.()\n                  navigate('/main')\n                }}\n              >\n                {t('트리플 홈으로 가기')}\n              </Button>\n            </ButtonGroup>\n          </Container>\n          {regionName ? (\n            <GrayButton fluid margin={{ top: 6 }} onClick={handleMoveToRegion}>\n              {t('{{regionName}} 여행 준비하러 가기', { regionName })}\n            </GrayButton>\n          ) : null}\n\n          {onAddToSchedule ? (\n            <GrayButton fluid margin={{ top: 6 }} onClick={onAddToSchedule}>\n              {t('내 일정에 추가하기')}\n            </GrayButton>\n          ) : null}\n        </>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/altered.tsx",
    "content": "import { FlexBox } from '@titicaca/tds-ui'\nimport { styled, css, CSSProp } from 'styled-components'\n\nimport ExclamationMarkIcon from '../icons/ExclamationMarkIcon'\n\nimport Bubble from './bubble'\nimport { BlindedBubbleProp } from './type'\n\nconst AlteredText = styled.span<{ color?: CSSProp }>`\n  ${({ color }) =>\n    color &&\n    css`\n      color: ${color};\n    `}\n`\n\nexport default function AlteredBubble({\n  my,\n  parentMessage,\n  alternativeText,\n  textColor,\n  ...props\n}: BlindedBubbleProp) {\n  return (\n    <Bubble my={my} {...props}>\n      <FlexBox flex alignItems=\"center\" gap=\"4px\">\n        <ExclamationMarkIcon color={textColor} />\n        <AlteredText color={textColor}>\n          {alternativeText ?? '관리자에 의해 삭제되었습니다'}\n        </AlteredText>\n      </FlexBox>\n    </Bubble>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/bubble-ui.tsx",
    "content": "import { CSSProp } from 'styled-components'\n\nimport { TextBubble } from './text'\nimport { ImageBubble } from './image'\nimport { RichBubble } from './rich'\nimport {\n  BubbleProp,\n  ButtonBubbleProp,\n  CouponBubbleProp,\n  ImageBubbleProp,\n  ProductBubbleProp,\n  RichBubbleProp,\n  TextBubbleProp,\n} from './type'\nimport { ProductBubble } from './product'\nimport AlteredBubble from './altered'\nimport { ALTERNATIVE_TEXT_MESSAGE } from './constants'\nimport { ParentMessageUIProp } from './parent'\nimport { ButtonBubble } from './button'\nimport NolBubbleUI, {\n  isNolBubbleType,\n  NolBubbleTypeArray,\n  NolBubbleUIProps,\n} from './nol/bubble-ui'\n\nexport const BubbleTypeArray = [\n  'text',\n  'images',\n  'rich',\n  'product',\n  'button',\n  ...NolBubbleTypeArray,\n] as const\n\nexport const CompositeBubbleTypeArray = ['rich', 'coupon'] as const\n\nexport type BubbleType = (typeof BubbleTypeArray)[number]\nexport type CompositeBubbleType = (typeof CompositeBubbleTypeArray)[number]\n\nexport interface BubbleUIPropBase {\n  type: BubbleType\n  parentMessage?: ParentMessageUIProp | null\n}\n\ntype CompositeBubbleUIPropBase = Omit<BubbleUIPropBase, 'type'> & {\n  type: CompositeBubbleType\n}\n\nexport interface TextBubbleUIProp extends BubbleUIPropBase {\n  type: 'text'\n  value: Pick<TextBubbleProp, 'message'>\n}\n\nexport interface ImageBubbleUIProp extends BubbleUIPropBase {\n  type: 'images'\n  value: Pick<ImageBubbleProp, 'images'>\n}\n\nexport interface RichBubbleUIProp\n  extends BubbleUIPropBase,\n    CompositeBubbleUIPropBase {\n  type: 'rich'\n  value: Pick<RichBubbleProp, 'blocks'>\n}\n\nexport interface ProductBubbleUIProp extends BubbleUIPropBase {\n  type: 'product'\n  value: Pick<ProductBubbleProp, 'product'>\n}\n\nexport interface ButtonBubbleUIProp extends BubbleUIPropBase {\n  type: 'button'\n  value: Pick<ButtonBubbleProp, 'label' | 'action'>\n}\n\nexport interface CouponBubbleUIProp extends CompositeBubbleUIPropBase {\n  type: 'coupon'\n  value: Pick<CouponBubbleProp, 'coupon'>\n}\n\nexport type BubbleUIProps = (\n  | TextBubbleUIProp\n  | ImageBubbleUIProp\n  | RichBubbleUIProp\n  | ProductBubbleUIProp\n  | ButtonBubbleUIProp\n  | NolBubbleUIProps\n) & {\n  id: string\n  my: boolean\n  created?: boolean\n  blinded?: boolean\n  deleted?: boolean\n  unfriended?: boolean\n  alternativeText?: string\n  parentMessage?: ParentMessageUIProp\n  onParentMessageClick?: TextBubbleProp['onParentMessageClick']\n  onBubbleClick?: BubbleProp['onClick']\n  onImageBubbleClick?: ImageBubbleProp['onClick']\n  /**\n   * a 링크 클릭 동작으로, 정의되지 않은 경우 새창으로 열립니다.\n   */\n  onTextBubbleLinkClick?: TextBubbleProp['onLinkClick']\n  onBubbleLongPress?: BubbleProp['onLongPress']\n  onImageBubbleLongPress?: ImageBubbleProp['onLongPress']\n  onRichBubbleBlockClick?: {\n    image?: RichBubbleProp['onImageClick']\n    beforeButtonRouting?: RichBubbleProp['onButtonClickBeforeRouting']\n  }\n  onCouponBubbleClick?: CouponBubbleProp['onClick']\n  richBubbleStyle?: {\n    textItemStyle?: CSSProp\n    imageItemStyle?: CSSProp\n    buttonItemStyle?: CSSProp\n  }\n  maxWidthOffset?: BubbleProp['maxWidthOffset']\n  maxWidth?: BubbleProp['maxWidth']\n  cloudinaryName?: string\n  mediaUrlBase?: string\n  hasArrow?: boolean\n  alteredTextColor?: string\n  arrowRadius?: number\n  borderRadius?: number\n\n  onOpenMenu?: () => void\n} & Pick<\n    TextBubbleProp,\n    | 'fullTextViewAvailable'\n    | 'isFullTextViewOpen'\n    | 'openFullTextView'\n    | 'closeFullTextView'\n    | 'CustomFullTextViewController'\n  >\n\nexport default function BubbleUI({\n  type,\n  value,\n  id,\n  my,\n  created,\n  blinded,\n  deleted,\n  unfriended,\n  alternativeText,\n  onBubbleClick,\n  onImageBubbleClick,\n  onTextBubbleLinkClick,\n  onBubbleLongPress,\n  onImageBubbleLongPress,\n  onRichBubbleBlockClick,\n  onCouponBubbleClick,\n  onParentMessageClick,\n  richBubbleStyle,\n  maxWidthOffset,\n  cloudinaryName,\n  mediaUrlBase,\n  hasArrow,\n  alteredTextColor,\n  fullTextViewAvailable = false,\n  isFullTextViewOpen,\n  openFullTextView,\n  closeFullTextView,\n  onOpenMenu,\n  CustomFullTextViewController,\n  ...props\n}: BubbleUIProps) {\n  if (blinded || deleted || unfriended) {\n    return (\n      <AlteredBubble\n        id={id}\n        my={my}\n        alternativeText={\n          alternativeText ||\n          (unfriended\n            ? ALTERNATIVE_TEXT_MESSAGE.unfriended\n            : blinded\n              ? ALTERNATIVE_TEXT_MESSAGE.blinded\n              : ALTERNATIVE_TEXT_MESSAGE.deleted)\n        }\n        textColor={alteredTextColor}\n        hasArrow={hasArrow}\n        {...props}\n      />\n    )\n  }\n  if (isNolBubbleType(type)) {\n    return (\n      <NolBubbleUI\n        id={id}\n        my={my}\n        type={type}\n        value={value as NolBubbleUIProps['value']}\n        onCouponBubbleClick={onCouponBubbleClick}\n        {...props}\n      />\n    )\n  }\n  switch (type) {\n    case 'button':\n      if (value.action.type !== 'link') {\n        throw new Error(\n          '버튼 액션 타입이 link가 아닙니다. 현재 지원하지 않습니다.',\n        )\n      }\n      return (\n        <ButtonBubble\n          id={id}\n          my={my}\n          label={value.label}\n          action={value.action}\n          onClick={onBubbleClick}\n          onLinkClick={onTextBubbleLinkClick}\n          onLongPress={onBubbleLongPress}\n          hasArrow={hasArrow}\n          maxWidthOffset={maxWidthOffset}\n          {...props}\n        />\n      )\n    case 'text':\n      return (\n        <TextBubble\n          id={id}\n          my={my}\n          message={value.message}\n          created={created}\n          onClick={onBubbleClick}\n          onLinkClick={onTextBubbleLinkClick}\n          onLongPress={onBubbleLongPress}\n          onOpenMenu={onOpenMenu}\n          hasArrow={hasArrow}\n          fullTextViewAvailable={fullTextViewAvailable}\n          isFullTextViewOpen={isFullTextViewOpen}\n          openFullTextView={openFullTextView}\n          closeFullTextView={closeFullTextView}\n          onParentMessageClick={onParentMessageClick}\n          CustomFullTextViewController={CustomFullTextViewController}\n          maxWidthOffset={maxWidthOffset}\n          {...props}\n        />\n      )\n    case 'images':\n      return (\n        <ImageBubble\n          id={id}\n          images={value.images}\n          onClick={onImageBubbleClick}\n          onLongPress={onImageBubbleLongPress}\n          {...props}\n        />\n      )\n    case 'rich':\n      if (!cloudinaryName || !mediaUrlBase) {\n        throw new Error('cloudinaryName 또는 mediaUrlBase가 존재하지 않습니다.')\n      }\n      return (\n        <RichBubble\n          id={id}\n          my={my}\n          blocks={value.blocks}\n          cloudinaryName={cloudinaryName}\n          mediaUrlBase={mediaUrlBase}\n          onClick={onBubbleClick}\n          onLongPress={onBubbleLongPress}\n          onImageClick={onRichBubbleBlockClick?.image}\n          onButtonClickBeforeRouting={\n            onRichBubbleBlockClick?.beforeButtonRouting\n          }\n          buttonItemStyle={richBubbleStyle?.buttonItemStyle}\n          imageItemStyle={richBubbleStyle?.imageItemStyle}\n          textItemStyle={richBubbleStyle?.textItemStyle}\n          hasArrow={hasArrow}\n          maxWidthOffset={maxWidthOffset}\n          {...props}\n        />\n      )\n    case 'product':\n      return (\n        <ProductBubble\n          id={id}\n          my={my}\n          product={value.product}\n          onClick={onBubbleClick}\n          onLongPress={onBubbleLongPress}\n          maxWidthOffset={maxWidthOffset}\n          hasArrow={hasArrow}\n          {...props}\n        />\n      )\n    default:\n      throw new Error('지원하지 않는 메시지 타입입니다.')\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/bubble.stories.tsx",
    "content": "import { ScrollProvider } from '../chat'\nimport { CouponItem } from '../types'\nimport { NOL_COLOR } from '../nol-theme-provider/constants'\nimport { NolThemeProvider } from '../nol-theme-provider'\n\nimport {\n  CouponBubbleProp,\n  ImageBubbleProp,\n  ProductBubbleProp,\n  RichBubbleProp,\n  TextBubbleProp,\n} from './type'\nimport AlteredBubble from './altered'\n\nimport {\n  ImageBubble,\n  ProductBubble,\n  RichBubble,\n  TextBubble,\n  NolCouponContentBubble,\n  NolCouponButtonBubble,\n  NolCouponContentPartnerBubble,\n} from './index'\n\nexport default {\n  title: 'tds-widget / chat / Bubble',\n  decorators: [\n    (Story: () => JSX.Element) => (\n      <ScrollProvider>\n        <Story />\n      </ScrollProvider>\n    ),\n  ],\n}\n\nexport const Text = {\n  render: (args: TextBubbleProp) => <TextBubble {...args} />,\n  argTypes: {\n    message: {\n      type: 'text',\n      required: true,\n    },\n    my: {\n      type: 'boolean',\n      required: true,\n    },\n    id: {\n      type: 'string',\n      required: true,\n    },\n  },\n  args: {\n    message: '안녕하세요',\n    my: true,\n    id: 'text_bubble',\n    parentMessage: {\n      id: 'parent_message',\n      type: 'text',\n      blinded: false,\n      value: { message: '안녕하세요' },\n      sender: {\n        profile: { name: '트리플' },\n        unregistered: false,\n      },\n    },\n  },\n}\n\nexport const FullText = {\n  render: (args: TextBubbleProp) => <TextBubble {...args} />,\n  args: {\n    message: `탄핵결정은 공직으로부터 파면함에 그친다. 그러나, 이에 의하여 민사상이나 형사상의 책임이 면제되지는 아니한다. 일반사면을 명하려면 국회의 동의를 얻어야 한다. 모든 국민은 자기의 행위가 아닌 친족의 행위로 인하여 불이익한 처우를 받지 아니한다. 재판의 전심절차로서 행정심판을 할 수 있다. 행정심판의 절차는 법률로 정하되, 사법절차가 준용되어야 한다. 재의의 요구가 있을 때에는 국회는 재의에 붙이고, 재적의원과반수의 출석과 출석의원 3분의 2 이상의 찬성으로 전과 같은 의결을 하면 그 법률안은 법률로서 확정된다. 모든 국민은 그 보호하는 자녀에게 적어도 초등교육과 법률이 정하는 교육을 받게 할 의무를 진다. 이 헌법시행 당시의 법령과 조약은 이 헌법에 위배되지 아니하는 한 그 효력을 지속한다. 대통령이 제1항의 기간내에 공포나 재의의 요구를 하지 아니한 때에도 그 법률안은 법률로서 확정된다. 대통령은 법률안의 일부에 대하여 또는 법률안을 수정하여 재의를 요구할 수 없다. 모든 국민의 재산권은 보장된다. 그 내용과 한계는 법률로 정한다. 국무총리·국무위원 또는 정부위원은 국회나 그 위원회에 출석하여 국정처리상황을 보고하거나 의견을 진술하고 질문에 응답할 수 있다.`,\n    my: true,\n    id: 'text_bubble',\n    fullTextViewAvailable: true,\n    created: true,\n  },\n}\n\nexport const Product = {\n  render: (args: ProductBubbleProp) => <ProductBubble {...args} />,\n  args: {\n    my: false,\n    product: {\n      customerBookingStatus: 'ONGOING',\n      productName: '상품 이름',\n      productThumbnail:\n        'https://media.triple.guide/triple-cms/c_limit,f_auto,w_1024/3ec44da6-ef5f-4804-bdd8-ab9aebc28e2b.jpeg',\n      itemName: '아이템 이름',\n    },\n    id: 'product_bubble',\n  },\n}\n\nexport const NolCouponContent = {\n  render: (args: CouponBubbleProp) => (\n    <NolThemeProvider theme={NOL_COLOR}>\n      <NolCouponContentBubble {...args} />\n    </NolThemeProvider>\n  ),\n  args: {\n    coupon: {\n      name: '빨리 예약하세요~ 오늘까지만 사용 가능한 쿠폰~',\n      discount: {\n        type: 'AMOUNT',\n        value: 5000,\n        maxDiscountAmount: 5000,\n      },\n      period: {\n        startAt: '2025-05-23T00:00:00+09:00',\n        endAt: '2035-05-24T00:00:00+09:00',\n      },\n      code: 'X9XWCGGM58N9A499',\n      propertyId: '10003136',\n      propertyName: '오즈 모텔',\n      type: 'random',\n    },\n    id: 'coupon_bubble',\n    onClick: (coupon: CouponItem) => {\n      alert(`쿠폰 코드 ${coupon.code} 다운로드`)\n    },\n  },\n}\n\nexport const NolCouponButton = {\n  render: (args: CouponBubbleProp) => (\n    <NolThemeProvider theme={NOL_COLOR}>\n      <NolCouponButtonBubble {...args} />\n    </NolThemeProvider>\n  ),\n  args: {\n    coupon: {\n      name: '빨리 예약하세요~ 오늘까지만 사용 가능한 쿠폰~',\n      discount: {\n        type: 'AMOUNT',\n        value: 5000,\n        maxDiscountAmount: 5000,\n      },\n      period: {\n        startAt: '2025-05-23T00:00:00+09:00',\n        endAt: '2035-05-24T00:00:00+09:00',\n      },\n      code: 'X9XWCGGM58N9A499',\n      propertyId: '10003136',\n      propertyName: '오즈 모텔',\n      type: 'random',\n    },\n    id: 'coupon_bubble',\n    onClick: (coupon: CouponItem) => {\n      alert(`쿠폰 코드 ${coupon.code} 다운로드`)\n    },\n  },\n}\n\nexport const NolCouponPartnerContent = {\n  render: (args: CouponBubbleProp) => (\n    <NolThemeProvider theme={NOL_COLOR}>\n      <NolCouponContentPartnerBubble {...args} />\n    </NolThemeProvider>\n  ),\n  args: {\n    coupon: {\n      name: '신규 고객 쿠폰',\n      discount: {\n        type: 'AMOUNT',\n        value: 5000,\n        maxDiscountAmount: 5000,\n      },\n      period: {\n        startAt: '2025-05-23T00:00:00+09:00',\n        endAt: '2035-05-24T00:00:00+09:00',\n      },\n      code: 'X9XWCGGM58N9A499',\n      propertyId: '10003136',\n      propertyName: 'NOL 호텔',\n      type: 'partner',\n    },\n    id: 'coupon_bubble',\n    onClick: (coupon: CouponItem) => {\n      alert(`쿠폰 코드 ${coupon.code} 다운로드`)\n    },\n  },\n}\n\nexport const Rich = {\n  render: (args: RichBubbleProp) => <RichBubble {...args} />,\n  args: {\n    my: false,\n    blocks: [\n      {\n        type: 'text',\n        message: `안녕하세요.\n    TNA_BPM입니다. 고객님의 투어·티켓 예약이 확정되었습니다.\n\n    • 예약번호: 1111\n    • 예약상품: [멀티리전_테스트][부산/가평/통영] 유효기간 상품 | 차량 이용권\n    • 예약옵션: 성인x1\n\n    예약한 날짜에 TNA_BPM 팀과 만나 투어확정서를 제시해주세요.\n    투어확정서의 상세한 내용은 [트리플 앱 > 내 예약 > 예약번호 1111]에서 확인하실 수 있습니다.\n    궁금한 점이 있으시면 TNA_BPM 문의를 편하게 이용해주세요.\n    `,\n      },\n      {\n        type: 'button',\n        label: '예약상세 바로가기',\n        action: { param: 'link', type: 'link' },\n      },\n    ],\n    cloudinaryName: 'triple-dev',\n    mediaUrlBase: '',\n    id: 'rich_bubble',\n  },\n}\n\nexport const Image = {\n  render: (args: ImageBubbleProp) => <ImageBubble {...args} />,\n  args: {\n    images: [\n      {\n        id: 'test image',\n        sizes: {\n          large: {\n            url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n          },\n        },\n      },\n    ],\n  },\n}\n\nexport const Altered = {\n  render: (args: React.ComponentProps<typeof AlteredBubble>) => (\n    <AlteredBubble {...args} />\n  ),\n  args: {\n    my: true,\n    alternativeText: '관리자에 의해 가려진 메세지입니다.',\n    textColor: 'gray',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/bubble.tsx",
    "content": "import { styled, css } from 'styled-components'\nimport { PropsWithChildren } from 'react'\nimport { useLongPress } from 'use-long-press'\nimport { Text } from '@titicaca/tds-ui'\n\nimport { BubbleCSSProp, BubbleProp } from './type'\nimport ParentMessageUI, { ParentMessageUIProp } from './parent'\n\nconst StyledBubble = styled(Text).attrs({\n  textAlign: 'left',\n  inlineBlock: true,\n})<BubbleCSSProp>`\n  position: relative;\n  margin: 0;\n  padding: 11px;\n\n  > div {\n    word-break: break-word;\n    white-space: pre-wrap;\n  }\n\n  ${({ borderRadius = 20 }) => `border-radius: ${borderRadius}px;`}\n  ${({ maxWidthOffset, maxWidth }) =>\n    `max-width: min(calc(100% - ${maxWidthOffset || 100}px), ${maxWidth ? `${maxWidth}px` : '100%'});`}\n  ${({ my, hasArrow = true, arrowRadius = 4 }) => css`\n    background-color: ${my ? '#00BB92' : '#F6F6F6'};\n    ${my && 'color:  var(--color-white);'}\n    ${hasArrow &&\n    (my\n      ? `border-top-right-radius: ${arrowRadius}px;`\n      : `border-top-left-radius: ${arrowRadius}px;`)};\n  `}\n`\n\nexport function BaseBubble({\n  id,\n  onClick,\n  onLongPress,\n  children,\n  ...props\n}: PropsWithChildren<BubbleProp>) {\n  const bind = useLongPress(\n    (target, context) => {\n      onLongPress?.(id, target, context)\n    },\n    {\n      threshold: 500,\n      cancelOnMovement: true,\n    },\n  )\n\n  return (\n    <StyledBubble onClick={(e) => onClick?.(e, id)} {...props} {...bind()}>\n      {children}\n    </StyledBubble>\n  )\n}\n\nexport default function BubbleWithParentMessage({\n  parentMessage,\n  onParentMessageClick,\n  children,\n  ...props\n}: PropsWithChildren<BubbleProp> & {\n  parentMessage?: ParentMessageUIProp\n  onParentMessageClick?: (id: string) => void\n}) {\n  return (\n    <BaseBubble {...props}>\n      {parentMessage ? (\n        <ParentMessageUI onClick={onParentMessageClick} {...parentMessage} />\n      ) : null}\n      {children}\n    </BaseBubble>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/button.tsx",
    "content": "import { css, styled } from 'styled-components'\n\nimport { useATagNavigator } from '../utils'\n\nimport Bubble from './bubble'\nimport { ButtonBubbleProp } from './type'\nimport { StyledText } from './item/text'\n\nconst ARROW_ICON = ` <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\">\n<path d=\"M3.375 9.16683L7.16667 5.37516L3.375 1.5835\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>`\n\nconst getSVG = (svg: string) => {\n  return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`\n}\n\nconst Text = styled(StyledText)`\n  text-decoration: none !important;\n\n  &::after {\n    content: '';\n    color: inherit;\n    background-color: currentcolor;\n    display: inline-block;\n    width: 10px;\n    height: 10px;\n    margin-left: 4px;\n    mask-image: url(${getSVG(ARROW_ICON)});\n    mask-size: contain;\n    mask-repeat: no-repeat;\n    mask-position: center;\n  }\n`\n\nexport function ButtonBubble({\n  id,\n  label,\n  action,\n  my,\n  disabled,\n  onClick,\n  ...props\n}: ButtonBubbleProp) {\n  const aTagNavigator = useATagNavigator(\n    'onLinkClick' in props ? props.onLinkClick : undefined,\n  )\n\n  return (\n    <Bubble id={id} my={my} {...props}>\n      <button\n        css={css`\n          color: ${my ? '#B5FFFB' : 'var(--color-blue)'};\n        `}\n        disabled={disabled}\n        role=\"link\"\n        type=\"button\"\n        {...('param' in action && { 'data-link': action.param })}\n        onClick={(e) => {\n          if (action.type === 'link') {\n            aTagNavigator(e)\n          } else if ('onButtonClick' in props) {\n            props.onButtonClick?.()\n          }\n          onClick?.(e, id)\n        }}\n      >\n        <Text>{label}</Text>\n      </button>\n    </Bubble>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/constants.ts",
    "content": "export const ALTERNATIVE_TEXT_MESSAGE = {\n  blinded: '관리자에 의해 삭제된 메세지입니다.',\n  deleted: '삭제된 메세지입니다.',\n  unfriended: '차단한 사용자의 메세지입니다.',\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/elements.tsx",
    "content": "import { styled, css } from 'styled-components'\nimport {\n  MarginPadding,\n  formatMarginPadding,\n  Text,\n  HR1,\n  FlexBox,\n  HrProps,\n} from '@titicaca/tds-ui'\nimport { Theme } from '@titicaca/tds-theme'\n\ninterface BadgeProps {\n  backgroundColor?: keyof Theme['colors']\n  margin?: MarginPadding\n}\n\nexport const Badge = styled.span<BadgeProps>`\n  display: inline-block;\n  font-size: 11px;\n  height: 20px;\n  line-height: 20px;\n  padding: 0 5px;\n  color: var(--color-white);\n  font-weight: 700;\n  border-radius: 2px;\n  ${({ margin }) => formatMarginPadding(margin, 'margin')}\n\n  ${({ backgroundColor }) =>\n    backgroundColor && `background-color: var(--color-${backgroundColor});`}\n`\n\nexport const ProductName = styled(Text)`\n  width: 100%;\n  font-weight: 700;\n  text-overflow: ellipsis;\n`\n\nexport const ProductImage = styled.img`\n  display: flex;\n  width: 40px;\n  height: 40px;\n  border-radius: 6px;\n  flex-direction: column;\n  align-items: center;\n`\n\nexport const ProductHr = styled(HR1)<HrProps>`\n  margin: 12px 0;\n`\n\nexport const ProductInfo = ({\n  title,\n  label,\n}: {\n  title: string\n  label?: string\n}) => {\n  return (\n    <>\n      {label && (\n        <FlexBox\n          flex\n          alignItems=\"flex-start\"\n          gap=\"10px\"\n          css={css`\n            & + & {\n              margin-top: 4px;\n            }\n          `}\n        >\n          <Text color=\"gray500\" size={13} css={{ minWidth: '47px' }}>\n            {title}\n          </Text>\n          <Text\n            color=\"gray700\"\n            size={13}\n            css={{ maxWidth: 'calc(100% - 47px)' }}\n          >\n            {label}\n          </Text>\n        </FlexBox>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/full-text-message-view.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport { Popup, Navbar, Container as BaseContainer } from '@titicaca/tds-ui'\nimport styled from 'styled-components'\n\nconst Container = styled(BaseContainer)`\n  padding: 20px;\n  display: block;\n  font-size: 15px;\n  white-space: pre-wrap;\n  word-break: break-word;\n\n  > a {\n    color: var(--color-blue);\n    line-break: anywhere;\n  }\n`\n\nexport function FullTextMessageView({\n  open,\n  onClose,\n  openMenu,\n  disableMenu,\n  children,\n}: PropsWithChildren<{\n  open: boolean\n  onClose: () => void\n  openMenu?: () => void\n  disableMenu: boolean\n}>) {\n  return (\n    <Popup open={open} onClose={onClose} noNavbar>\n      <Navbar css={{ boxShadow: 'none' }}>\n        <Navbar.Item floated=\"left\" icon=\"back\" onClick={onClose} />\n        <Navbar.TitleContainer css={{ right: 52 }}>\n          전체보기\n        </Navbar.TitleContainer>\n        {!disableMenu ? (\n          <Navbar.Item floated=\"right\" icon=\"more\" onClick={openMenu} />\n        ) : null}\n      </Navbar>\n      <Container>{children}</Container>\n    </Popup>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/image.tsx",
    "content": "import { styled } from 'styled-components'\nimport { useLongPress } from 'use-long-press'\nimport { Container } from '@titicaca/tds-ui'\n\nimport { MetaDataInterface } from '../types'\n\nimport { ImageItem } from './item'\nimport { ImageBubbleProp } from './type'\n\nconst DEFAULT_IMAGE_NUM_IN_ROW = 3\n\nconst ImageRow = styled.div`\n  &:not(&:last-child) {\n    margin-bottom: 5px;\n  }\n`\n\nconst MAX_IMAGE_WIDTH = 247\n\nexport function ImageBubble({\n  id,\n  images,\n  onClick,\n  onLongPress,\n}: ImageBubbleProp) {\n  const allocatedImages = allocateImages(images)\n\n  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call\n  const bind = useLongPress(\n    (target, context) => {\n      onLongPress?.(id, target, context)\n    },\n    {\n      threshold: 500,\n      cancelOnMovement: true,\n    },\n  )\n\n  return (\n    <Container\n      display=\"inline-block\"\n      css={{ maxWidth: MAX_IMAGE_WIDTH, verticalAlign: 'bottom' }}\n      {...bind()}\n    >\n      {allocatedImages.map((imagesInRow, index) => (\n        <ImageRow key={index} css={{ display: 'flex', gap: 5 }}>\n          {imagesInRow.map((image) => (\n            <ImageItem\n              key={image.id}\n              src={image.sizes.large.url}\n              onClick={(e) => {\n                if (onClick) {\n                  onClick?.(e, images, image.index)\n                }\n              }}\n              css={\n                images.length === 2 || images.length === 4\n                  ? { height: 121, width: 121 }\n                  : imagesInRow.length > 1\n                    ? { height: 79, width: 79, flexGrow: 1 }\n                    : {\n                        width: Math.min(MAX_IMAGE_WIDTH, image.width),\n                        height: Math.min(\n                          Math.floor(\n                            (MAX_IMAGE_WIDTH * image.height) / image.width,\n                          ),\n                          image.height,\n                        ),\n                      }\n              }\n            />\n          ))}\n        </ImageRow>\n      ))}\n    </Container>\n  )\n}\n\nfunction allocateImages(\n  images: Array<MetaDataInterface>,\n): Array<Array<MetaDataInterface & { index: number }>> {\n  if (images.length === 1) {\n    return [[{ ...images[0], index: 0 }]]\n  }\n  const imagesWithIndex = images.map((image, index) => ({ ...image, index }))\n  const allocatedImages: Array<Array<MetaDataInterface & { index: number }>> =\n    []\n  let i = 0\n  let row = 0\n  while (i < imagesWithIndex.length) {\n    row += 1\n    if (\n      row ===\n        Math.ceil(imagesWithIndex.length / DEFAULT_IMAGE_NUM_IN_ROW) - 1 &&\n      images.length % DEFAULT_IMAGE_NUM_IN_ROW === 1\n    ) {\n      allocatedImages.push(\n        imagesWithIndex.slice(i, i + DEFAULT_IMAGE_NUM_IN_ROW - 1),\n      )\n      i += DEFAULT_IMAGE_NUM_IN_ROW - 1\n      continue\n    }\n    allocatedImages.push(imagesWithIndex.slice(i, i + DEFAULT_IMAGE_NUM_IN_ROW))\n    i += DEFAULT_IMAGE_NUM_IN_ROW\n  }\n  return allocatedImages\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/images.stories.tsx",
    "content": "import { Meta, StoryFn } from '@storybook/react'\nimport { Title, Controls, Primary, Description } from '@storybook/blocks'\nimport { useState } from 'react'\nimport { Select } from '@titicaca/tds-ui'\n\nimport { MetaDataInterface } from '../types'\n\nimport { ImageBubbleProp } from './type'\n\nimport { ImageBubble } from '.'\n\nconst images: MetaDataInterface[] = [...Array(10).keys()].map((_, idx) => ({\n  id: `test image_${idx}`,\n  width: 1125,\n  height: 2436,\n  cloudinaryBucket: 'triple-dev',\n  cloudinaryId: 'cloudinary',\n  type: 'image',\n  sizes: {\n    full: {\n      url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n    },\n    large: {\n      url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n    },\n    smallSquare: {\n      url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n    },\n  },\n}))\n\nfunction RenderImageBubble() {\n  const [imageNumber, setImageNumber] = useState<number>(1)\n\n  return (\n    <>\n      <Select\n        label=\"이미지 개수 선택\"\n        options={[...Array(10).keys()].map((number) => ({\n          label: (number + 1).toString(),\n          value: number + 1,\n        }))}\n        value={imageNumber}\n        onChange={(e) => setImageNumber(e.target.value as unknown as number)}\n        css={{ marginBottom: 5 }}\n      />\n\n      <ImageBubble id=\"test-image-id\" images={images.slice(0, imageNumber)} />\n    </>\n  )\n}\n\nexport default {\n  title: 'tds-widget / chat / Bubble / Images',\n  component: ImageBubble,\n  parameters: {\n    docs: {\n      description: {\n        component: '이미지 개수에 따라 버블의 UI가 달라집니다.',\n      },\n      page: () => (\n        <>\n          <Title />\n          <Description />\n          <RenderImageBubble />\n          <Primary />\n          <Controls />\n        </>\n      ),\n    },\n  },\n} as Meta\n\nconst template: StoryFn<ImageBubbleProp> = (args) => <ImageBubble {...args} />\n\nexport const Image = {\n  render: template,\n  args: {\n    images: [\n      {\n        id: 'test image',\n        sizes: {\n          large: {\n            url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n          },\n        },\n      },\n    ],\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/index.ts",
    "content": "export { ImageBubble } from './image'\nexport { TextBubble } from './text'\nexport { RichBubble } from './rich'\nexport { ProductBubble } from './product'\nexport {\n  NolCouponContentBubble,\n  NolCouponButtonBubble,\n  NolCouponContentPartnerBubble,\n} from './nol/coupon'\nexport { Badge, ProductName, ProductImage, ProductHr } from './elements'\nexport { default as BubbleUI } from './bubble-ui'\nexport { ALTERNATIVE_TEXT_MESSAGE } from './constants'\nexport * from './nol'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/item/image.tsx",
    "content": "import { MouseEventHandler } from 'react'\nimport { styled } from 'styled-components'\n\nconst PreviewImage = styled.img`\n  object-fit: cover;\n  border-radius: 4px;\n`\n\nexport default function ImageItem({\n  src,\n  onClick,\n  ...props\n}: {\n  src: string\n  onClick?: MouseEventHandler\n}) {\n  return <PreviewImage src={src} onClick={onClick} {...props} />\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/item/index.ts",
    "content": "export { default as ImageItem } from './image'\nexport { default as TextItem } from './text'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/item/text.tsx",
    "content": "import { Autolinker } from 'autolinker'\nimport { MouseEventHandler } from 'react'\nimport { styled } from 'styled-components'\n\nexport const StyledText = styled.span`\n  display: -webkit-box;\n  padding-left: 5px;\n  padding-right: 5px;\n  user-select: none;\n  word-break: break-word;\n`\n\nexport default function TextItem({\n  text,\n  href,\n  onClick,\n}: {\n  text: string\n  href?: string\n  onClick?: MouseEventHandler\n}) {\n  return (\n    <StyledText\n      dangerouslySetInnerHTML={{\n        __html: Autolinker.link(href ? `<a href=${href}>${text}</a>` : text, {\n          newWindow: true,\n          stripPrefix: false,\n        }),\n      }}\n      onClick={onClick}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/nol/bubble-ui.tsx",
    "content": "import { CouponBubbleProp } from '../type'\nimport { BubbleUIPropBase, BubbleUIProps } from '../bubble-ui'\n\nimport {\n  NolCouponButtonBubble,\n  NolCouponContentBubble,\n  NolCouponContentPartnerBubble,\n} from './coupon'\n\nexport const NolBubbleTypeArray = [\n  'nol-coupon-content',\n  'nol-coupon-button',\n  'nol-coupon-content-partner',\n] as const\n\nexport type NolBubbleType = (typeof NolBubbleTypeArray)[number]\n\nexport interface NolCouponContentBubbleUIProp extends BubbleUIPropBase {\n  type: 'nol-coupon-content'\n  value: Pick<CouponBubbleProp, 'coupon'>\n}\n\nexport interface NolCouponButtonBubbleUIProp extends BubbleUIPropBase {\n  type: 'nol-coupon-button'\n  value: Pick<CouponBubbleProp, 'coupon'>\n}\n\nexport interface NolCouponContentPartnerBubbleUIProp extends BubbleUIPropBase {\n  type: 'nol-coupon-content-partner'\n  value: Pick<CouponBubbleProp, 'coupon'>\n}\n\nexport type NolBubbleUIProps =\n  | NolCouponContentBubbleUIProp\n  | NolCouponButtonBubbleUIProp\n  | NolCouponContentPartnerBubbleUIProp\n\nexport function isNolBubbleType(type: string): type is NolBubbleType {\n  return NolBubbleTypeArray.includes(type as NolBubbleType)\n}\n\nexport default function NolBubbleUI({\n  type,\n  value,\n  id,\n  my,\n  onCouponBubbleClick,\n  ...props\n}: BubbleUIProps) {\n  switch (type) {\n    case 'nol-coupon-content':\n      return (\n        <NolCouponContentBubble\n          id={id}\n          my={my}\n          coupon={value.coupon}\n          onClick={onCouponBubbleClick}\n          {...props}\n        />\n      )\n    case 'nol-coupon-button':\n      return (\n        <NolCouponButtonBubble\n          id={id}\n          my={my}\n          coupon={value.coupon}\n          onClick={onCouponBubbleClick}\n          {...props}\n        />\n      )\n    case 'nol-coupon-content-partner':\n      return (\n        <NolCouponContentPartnerBubble\n          id={id}\n          my={my}\n          coupon={value.coupon}\n          onClick={onCouponBubbleClick}\n          {...props}\n        />\n      )\n    default:\n      throw new Error('지원하지 않는 메시지 타입입니다.')\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/nol/coupon.tsx",
    "content": "import { styled } from 'styled-components'\nimport { format, isAfter, subMinutes } from 'date-fns'\nimport { formatNumber } from '@titicaca/view-utilities'\nimport { Button, FlexBox, Text } from '@titicaca/tds-ui'\n\nimport { CouponBubbleProp } from '../type'\nimport { ButtonBubble } from '../button'\n\nimport { nolBackgroundColor } from './styles'\n\nconst CouponContainer = styled.div<{ valid: boolean }>`\n  display: inline-flex;\n  border-radius: 14px;\n  border: 1px solid ${({ valid }) => (valid ? '#B2BAFF' : '#E1E2E7')};\n  overflow: hidden;\n`\n\nconst Coupon = styled.div<{ valid: boolean }>`\n  padding: 15px;\n  width: 213px;\n  text-align: left;\n  display: inline-block;\n  color: ${({ valid }) => (valid ? '#1B1C1F' : '#B6B7BB')};\n  background-color: ${({ valid }) => (valid ? 'white' : '#FEFEFF')};\n`\n\nconst StyledDownloadIcon = styled.svg<{ disabled?: boolean }>`\n  stroke: ${({ disabled }) => (disabled ? '#DADBDF' : '#1B1C1F')};\n`\n\nfunction DownloadIcon({ disabled }: { disabled?: boolean }) {\n  return (\n    <StyledDownloadIcon\n      disabled={disabled}\n      width=\"20\"\n      height=\"20\"\n      viewBox=\"0 0 20 20\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path d=\"M10 2.5V11.25\" strokeWidth=\"1.625\" strokeLinecap=\"round\" />\n      <path\n        d=\"M5.625 8.75L9.73483 12.8598C9.88128 13.0063 10.1187 13.0063 10.2652 12.8598L14.375 8.75\"\n        strokeWidth=\"1.625\"\n        strokeLinecap=\"round\"\n      />\n      <path d=\"M4.375 16.75H15.625\" strokeWidth=\"1.625\" strokeLinecap=\"round\" />\n    </StyledDownloadIcon>\n  )\n}\n\nconst DownloadButton = styled(Button)`\n  padding: 0;\n  border-radius: 0;\n  border-left: 1px solid #ecedf7;\n  background-color: #f7f7ff;\n  width: 39px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n\n  &:disabled {\n    background-color: #f7f8fb;\n  }\n`\n\nconst Badge = styled.div<{ valid: boolean }>`\n  margin-bottom: 2px;\n  padding: 0 6px;\n  width: fit-content;\n  border-radius: 6px;\n  font-size: 11px;\n  font-weight: 700;\n  line-height: 22px;\n  color: ${({ valid, theme }) => (valid ? theme.nol.colorPrimaryNol : 'white')};\n  background-color: ${({ valid }) =>\n    valid ? '#F5F6FF' : 'rgba(0, 0, 0, 0.2)'};\n`\n\nconst PartnerBadge = styled(Badge)<{ valid: boolean }>`\n  margin-bottom: 0;\n  font-size: 10px;\n  line-height: 20px;\n  color: #6e6f73;\n  background-color: #f2f3f7;\n  opacity: ${({ valid }) => (valid ? 1 : 0.5)};\n`\n\nconst CouponTitle = styled(Text).attrs({\n  size: 13,\n  bold: true,\n  lineHeight: '16px',\n})`\n  color: #1b1c1f;\n`\n\nconst CouponPrice = styled(Text).attrs({\n  size: 32,\n  bold: true,\n  lineHeight: '38px',\n  margin: { bottom: 12 },\n})`\n  color: #1b1c1f;\n`\n\nconst CouponPropertyText = styled(Text).attrs({\n  size: 12,\n  lineHeight: '14px',\n})`\n  color: #6e6f73;\n  font-weight: 400;\n`\n\nconst CouponContainerWithNotch = styled.button<{ valid: boolean }>`\n  --border-color: ${({ valid }) => (valid ? '#dfe2ff' : '#e9eaef')};\n\n  width: 254px;\n  height: 142px;\n  background: white;\n  border-radius: 20px;\n  border: 2px solid var(--border-color);\n  text-align: left;\n  position: relative;\n\n  &::before {\n    content: '';\n    width: 20px;\n    height: 20px;\n    position: absolute;\n    top: 50%;\n    right: -22px;\n    transform: translate(-50%, -50%) rotate(225deg);\n    border-radius: 50%;\n    border: 2px solid transparent;\n    border-top-color: var(--border-color);\n    border-right-color: var(--border-color);\n    background-color: ${nolBackgroundColor};\n  }\n\n  > div {\n    padding: 18px;\n  }\n\n  ${CouponTitle}, ${CouponPrice}, ${CouponPropertyText} {\n    color: ${({ valid }) => !valid && 'rgba(0, 0, 0, 0.30)'};\n  }\n`\n\nexport function NolCouponContentBubble({ coupon, onClick }: CouponBubbleProp) {\n  const valid = isAfter(new Date(coupon.period.endAt), new Date())\n  return (\n    <CouponContainer valid={valid}>\n      <Coupon valid={valid}>\n        <Badge valid={valid}>쉿! 🤫 고객님께만 드려요!</Badge>\n        <Text\n          css={{\n            color: 'inherit',\n            fontSize: '30px',\n            fontWeight: 700,\n            lineHeight: '38px',\n            marginBottom: '6px',\n          }}\n        >\n          {formatNumber(coupon.discount.value)}원\n        </Text>\n        <Text\n          css={{\n            color: 'inherit',\n            fontSize: '12px',\n            fontWeight: 700,\n            lineHeight: '14px',\n            marginBottom: '2px',\n          }}\n        >\n          중복사용가능\n        </Text>\n        <Text\n          css={{\n            color: 'inherit',\n            fontSize: '12px',\n            fontWeight: 400,\n            lineHeight: '14px',\n            marginBottom: '2px',\n          }}\n        >\n          {valid\n            ? `${format(\n                subMinutes(new Date(coupon.period.endAt), 1),\n                'yyyy.MM.dd(HH:mm)',\n              )}까지 사용`\n            : '쿠폰 사용 기간 만료'}\n        </Text>\n        <Text\n          css={{\n            color: 'inherit',\n            fontSize: '12px',\n            fontWeight: 400,\n            lineHeight: '14px',\n          }}\n          maxLines={1}\n        >\n          {coupon.propertyName}\n        </Text>\n      </Coupon>\n      <DownloadButton\n        disabled={!valid}\n        onClick={() => valid && onClick?.(coupon, 'download')}\n      >\n        <DownloadIcon disabled={!valid} />\n      </DownloadButton>\n    </CouponContainer>\n  )\n}\n\nexport function NolCouponButtonBubble({\n  id,\n  my,\n  coupon,\n  onClick,\n  ...props\n}: CouponBubbleProp) {\n  const valid = isAfter(new Date(coupon.period.endAt), new Date())\n  return (\n    <ButtonBubble\n      id={id}\n      my={my}\n      label=\"쿠폰 바로 사용하기\"\n      action={{ type: 'button' }}\n      onButtonClick={() => valid && onClick?.(coupon, 'product')}\n      disabled={!valid}\n      hasArrow={false}\n      {...props}\n    />\n  )\n}\n\nexport function NolCouponContentPartnerBubble({\n  coupon,\n  onClick,\n}: CouponBubbleProp) {\n  const valid = isAfter(new Date(coupon.period.endAt), new Date())\n\n  return (\n    <CouponContainerWithNotch\n      valid={valid}\n      onClick={() => valid && onClick?.(coupon, 'download')}\n    >\n      <div>\n        <FlexBox\n          flex\n          flexDirection=\"row\"\n          alignItems=\"center\"\n          justifyContent=\"space-between\"\n        >\n          <CouponTitle>{coupon.name}</CouponTitle>\n          <PartnerBadge valid={valid}>중복사용가능</PartnerBadge>\n        </FlexBox>\n        <CouponPrice>{formatNumber(coupon.discount.value)}원</CouponPrice>\n        <CouponPropertyText css={{ marginBottom: '4px' }}>\n          {valid\n            ? `${format(\n                subMinutes(new Date(coupon.period.endAt), 1),\n                'yyyy.MM.dd(HH:mm)',\n              )}까지 사용`\n            : '사용 기간 만료'}\n        </CouponPropertyText>\n        <CouponPropertyText maxLines={1}>\n          {coupon.propertyName}\n        </CouponPropertyText>\n      </div>\n    </CouponContainerWithNotch>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/nol/full-text-view/elements.tsx",
    "content": "import { styled, useTheme } from 'styled-components'\nimport { Container } from '@titicaca/tds-ui'\nimport { ButtonHTMLAttributes } from 'react'\n\nimport { TextFullViewArrowIcon } from '../../../icons/text-full-view-arrow-icon'\n\nconst getColorVariable =\n  (condition: boolean, reverse: boolean) =>\n  (colorA: string, colorB: string) => {\n    if (reverse) {\n      return condition ? colorB : colorA\n    }\n    return condition ? colorA : colorB\n  }\n\nexport const NolFullTextViewText = styled.div`\n  max-height: 45.6rem;\n  overflow: hidden;\n`\n\nconst Divider = styled.hr<{ color: string }>`\n  border: none;\n  height: 1px;\n  margin: 8px 0;\n  background-color: ${({ color }) => color};\n`\n\nconst Button = styled.button.attrs({ type: 'button' })<{\n  color: string\n}>`\n  background-color: unset;\n  float: right;\n  font-size: 1.2rem;\n  line-height: 1.6rem;\n  position: relative;\n  font-weight: 700;\n  color: ${({ color }) => color};\n`\n\nexport const NolFullTextViewButton = ({\n  my,\n  reverseColor = false,\n  ...props\n}: {\n  my: boolean\n  reverseColor?: boolean\n} & ButtonHTMLAttributes<HTMLButtonElement>) => {\n  const getColor = getColorVariable(my, reverseColor)\n  const theme = useTheme()\n\n  return (\n    <>\n      <Divider\n        color={getColor(theme.nol.colorNeutralW20, theme.nol.colorNeutralB10)}\n      />\n      <Button\n        color={getColor(theme.nol.colorNeutralW100, theme.nol.colorNeutralG80)}\n        {...props}\n      >\n        전체보기\n        <TextFullViewArrowIcon color={getColor('white', '#545457')} />\n      </Button>\n    </>\n  )\n}\n\nexport const NolFullTextViewContent = styled(Container)`\n  padding: 12px 20px;\n  font-size: 1.5rem;\n  line-height: 1.4;\n  color: ${({ theme }) => theme.nol.colorNeutralB100};\n  white-space: pre-wrap;\n  word-break: break-word;\n\n  & > a {\n    color: ${({ theme }) => theme.nol.colorPrimaryNol};\n    text-decoration: underline;\n    line-break: anywhere;\n  }\n`\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/nol/full-text-view/index.ts",
    "content": "export * from './elements'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/nol/index.ts",
    "content": "export * from './full-text-view'\nexport * from './styles'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/nol/styles.ts",
    "content": "import { css } from 'styled-components'\n\nexport const nolBackgroundColor = '#F6F8FF'\n\nexport const PARTNER_ROOM_BUBBLE_STYLE = {\n  received: {\n    alteredTextColor: 'var(--color-gray500)',\n    css: css`\n      color: var(--color-gray);\n      line-height: 1.2;\n\n      a {\n        text-decoration: none;\n      }\n    `,\n  },\n  sent: {\n    alteredTextColor: 'var(--color-white)',\n    css: css`\n      color: var(--color-gray);\n      background-color: #d7e9ff;\n      line-height: 1.2;\n\n      a {\n        color: var(--color-blue);\n        text-decoration: none;\n      }\n    `,\n  },\n}\n\nexport const NOL_SPACING = {\n  message: 6,\n  messageGroup: 16,\n  bubbleInfo: 4,\n  failureHandler: 4,\n  dateDivider: 20,\n}\n\nconst NOL_BUBBLE_BASE_STYLE = css`\n  font-size: 1.4rem;\n  line-height: 1.9rem;\n  font-weight: 400;\n  padding: 12px 11px;\n`\n\nexport const NOL_PARTNER_ROOM_BUBBLE_STYLE = {\n  borderRadius: 24,\n  arrowRadius: 4,\n  white: {\n    alteredTextColor: css`\n      ${({ theme }) => theme.nol.colorNeutralB80}\n    `,\n    css: css`\n      ${NOL_BUBBLE_BASE_STYLE}\n\n      background-color: ${({ theme }) => theme.nol.colorNeutralW100};\n      color: ${({ theme }) => theme.nol.colorNeutralB100};\n      box-shadow: 0 0 0 1px #e4e7ff;\n\n      a,\n      button {\n        color: ${({ theme }) => theme.nol.colorPrimaryNol};\n      }\n\n      a {\n        text-decoration: underline;\n      }\n\n      button[disabled] {\n        color: #b6b7bb;\n      }\n    `,\n  },\n  blue: {\n    alteredTextColor: css`\n      ${({ theme }) => theme.nol.colorNeutralW100}\n    `,\n    css: css`\n      ${NOL_BUBBLE_BASE_STYLE}\n\n      color: ${({ theme }) => theme.nol.colorNeutralW100};\n      background-color: ${({ theme }) => theme.nol.colorPrimaryNol};\n\n      a,\n      button {\n        color: inherit;\n      }\n\n      a {\n        text-decoration: underline;\n      }\n\n      button[disabled] {\n        color: #ffffff80;\n      }\n    `,\n  },\n}\n\nconst NOL_BUBBLE_INFO_BASE_STYLE = css`\n  color: ${({ theme }) => theme.nol.colorNeutralG60};\n  font-weight: 400;\n`\n\nconst RETRY_SVG = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\">\n  <path d=\"M12.1765 10.0957C11.4332 11.6604 9.83839 12.7422 7.99089 12.7422C5.43308 12.7422 3.35957 10.6687 3.35957 8.11086C3.35957 5.55305 5.43308 3.47954 7.99089 3.47954C9.96595 3.47954 11.6522 4.71586 12.3181 6.45682\" stroke=\"white\" stroke-width=\"1.3\" stroke-linecap=\"round\"/>\n  <path d=\"M10.7359 6.33256L12.7017 6.60742L12.9765 4.64169\" stroke=\"white\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>`\n\nconst DELETE_SVG = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\">\n  <path d=\"M2 10L10 2\" stroke=\"white\" stroke-width=\"1.3\" stroke-linecap=\"round\"/>\n  <path d=\"M2 2L10 10\" stroke=\"white\" stroke-width=\"1.3\" stroke-linecap=\"round\"/>\n</svg>`\n\nconst getSVG = (svg: string) => {\n  return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`\n}\n\nexport const NOL_PARTNER_ROOM_BUBBLE_INFO_STYLE = {\n  failureHandler: {\n    css: css`\n      border-radius: 8px;\n      background-color: ${({ theme }) => theme.nol.colorBrandShoppingRed400};\n      width: auto;\n      position: relative;\n\n      & > button {\n        background-color: inherit;\n        width: 27px;\n        border: none;\n\n        & > * {\n          visibility: hidden;\n        }\n\n        &:first-of-type {\n          background-image: url('${getSVG(RETRY_SVG)}');\n          background-size: 16px 16px;\n        }\n\n        &:last-of-type {\n          background-image: url('${getSVG(DELETE_SVG)}');\n          background-size: 12px 12px;\n        }\n      }\n\n      &::after {\n        content: '';\n        position: absolute;\n        top: 50%;\n        left: 50%;\n        transform: translate(-50%, -50%);\n        width: 1px;\n        height: 16px;\n        background-color: ${({ theme }) => theme.nol.colorNeutralW20};\n      }\n    `,\n  },\n  dateDivider: {\n    css: css`\n      ${NOL_BUBBLE_INFO_BASE_STYLE}\n      margin-top: 40px;\n      font-size: 1.2rem;\n      line-height: 1.6rem;\n    `,\n  },\n  dateTime: {\n    css: css`\n      ${NOL_BUBBLE_INFO_BASE_STYLE}\n\n      font-size: 1rem;\n      line-height: 1.4rem;\n    `,\n  },\n  profile: {\n    css: css`\n      color: ${({ theme }) => theme.nol.colorNeutralG60};\n      font-size: 1.1rem;\n      line-height: 1.6rem;\n      font-weight: 700;\n      margin-bottom: 4px;\n    `,\n  },\n  unreadCount: {\n    css: css`\n      color: ${({ theme }) => theme.nol.colorPrimaryNol};\n    `,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/parent/index.ts",
    "content": "export { default } from './parent-ui'\nexport type { ParentMessageUIProp } from './parent-ui'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/parent/parent-message.tsx",
    "content": "import { Container, Text, FlexBox } from '@titicaca/tds-ui'\nimport { styled, CSSProp } from 'styled-components'\n\nconst PreviewImage = styled.img`\n  margin-right: 10px;\n  width: 34px;\n  height: 34px;\n  object-fit: 'cover';\n`\n\ninterface ParentMessageType {\n  id: string\n  senderName: string\n  previewImageUrl?: string\n  text: string\n  titleColor?: string\n  previewTextColor?: string\n  onClick?: (id: string) => void\n  css?: CSSProp\n}\n\nexport default function ParentMessage({\n  id,\n  senderName,\n  previewImageUrl,\n  text,\n  titleColor,\n  previewTextColor,\n  onClick,\n  ...props\n}: ParentMessageType) {\n  return (\n    <FlexBox\n      onClick={() => onClick?.(id)}\n      flex\n      borderRadius={13}\n      css={{\n        padding: '8px 11px',\n        marginBottom: 11,\n        alignItems: 'center',\n        backgroundColor: 'var(--color-white)',\n      }}\n      {...props}\n    >\n      {previewImageUrl ? <PreviewImage src={previewImageUrl} /> : null}\n      <Container>\n        <Text\n          css={{\n            color: titleColor,\n            fontSize: 12,\n            fontWeight: 500,\n          }}\n        >\n          {senderName}님에게 답장\n        </Text>\n        <Text\n          margin={{ top: 2, bottom: 1 }}\n          color={previewTextColor}\n          size={12}\n          css={{\n            display: '-webkit-box',\n            overflow: 'hidden',\n            textOverflow: 'ellipsis',\n            opacity: '0.7',\n            '-webkit-box-orient': 'vertical',\n            '-webkit-line-clamp': '1',\n          }}\n        >\n          {text}\n        </Text>\n      </Container>\n    </FlexBox>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/parent/parent-ui.tsx",
    "content": "import { CSSProp } from 'styled-components'\n\nimport { MetaDataInterface, UserInterface } from '../../types'\nimport {\n  DEFAULT_MAX_USERNAME_LENGTH,\n  formatUsername,\n} from '../../utils/profile'\nimport { ALTERNATIVE_TEXT_MESSAGE } from '../constants'\n\nimport ParentMessage from './parent-message'\n\ninterface ParentMessageInterface {\n  id: string\n  sender: UserInterface\n}\ninterface TextParentMessage extends ParentMessageInterface {\n  type: 'text'\n  value: {\n    message: string\n  }\n}\n\ninterface ImageParentMessage extends ParentMessageInterface {\n  type: 'image'\n  value: {\n    images: MetaDataInterface[]\n  }\n}\n\nexport type ParentMessageUIProp = (TextParentMessage | ImageParentMessage) & {\n  blinded: boolean\n  deleted: boolean\n  style?: { css?: CSSProp; titleColor?: string; previewTextColor?: string }\n  onClick?: (id: string) => void\n}\n\nexport default function ParentMessageUI({\n  id,\n  type,\n  value,\n  blinded,\n  deleted,\n  sender,\n  style,\n  ...props\n}: ParentMessageUIProp) {\n  const senderName = formatUsername({\n    name: sender.profile.name,\n    unregistered: sender.unregistered,\n    maxLength: DEFAULT_MAX_USERNAME_LENGTH,\n  })\n\n  if (blinded || deleted || sender.unfriended) {\n    return (\n      <ParentMessage\n        id={id}\n        text={getTextPreview({\n          deleted,\n          blinded,\n          unfriended: sender.unfriended,\n        })}\n        senderName={senderName}\n        previewTextColor={style?.previewTextColor}\n        titleColor={style?.titleColor}\n        css={style?.css}\n        {...props}\n      />\n    )\n  }\n\n  if (type === 'text') {\n    return (\n      <ParentMessage\n        id={id}\n        senderName={senderName}\n        text={value.message}\n        previewTextColor={style?.previewTextColor}\n        titleColor={style?.titleColor}\n        css={style?.css}\n        {...props}\n      />\n    )\n  } else {\n    return (\n      <ParentMessage\n        id={id}\n        senderName={senderName}\n        text=\"사진\"\n        previewImageUrl={value.images[0]?.sizes.large.url}\n        previewTextColor={style?.previewTextColor}\n        titleColor={style?.titleColor}\n        css={style?.css}\n        {...props}\n      />\n    )\n  }\n}\n\nfunction getTextPreview({\n  deleted,\n  blinded,\n  unfriended,\n}: {\n  deleted?: boolean\n  blinded?: boolean\n  unfriended?: boolean\n}) {\n  if (unfriended) {\n    return ALTERNATIVE_TEXT_MESSAGE.unfriended\n  } else if (blinded) {\n    return ALTERNATIVE_TEXT_MESSAGE.blinded\n  } else if (deleted) {\n    return ALTERNATIVE_TEXT_MESSAGE.deleted\n  }\n  return ''\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/parent/parent.stories.tsx",
    "content": "import { StoryFn } from '@storybook/react'\n\nimport Bubble from '../bubble'\nimport { TextItem } from '../item'\nimport { ScrollProvider } from '../../chat'\n\nimport ParentMessage from './parent-message'\nimport { ParentMessageUIProp } from './parent-ui'\n\nconst Template: StoryFn<ParentMessageUIProp> = (args) => (\n  <ScrollProvider>\n    <Bubble id=\"bubble\" my={false} parentMessage={args}>\n      <TextItem text=\"안녕하세요\" />\n    </Bubble>\n  </ScrollProvider>\n)\n\nexport default {\n  title: 'tds-widget / chat / BubbleWithParent',\n  component: ParentMessage,\n  render: Template,\n  argTypes: {\n    id: {\n      type: 'text',\n      required: true,\n    },\n    value: {\n      type: 'object',\n      required: true,\n    },\n    blinded: {\n      type: 'boolean',\n      required: true,\n    },\n  },\n}\n\nexport const Image = {\n  args: {\n    id: 'parent-message',\n    type: 'image',\n    sender: { profile: { name: '트리플', photo: '' }, unregistered: false },\n    value: {\n      images: [\n        {\n          cloudinaryBucket: 'triple-dev',\n          cloudinaryId: 'cloudinary',\n          id: 'image',\n          type: 'image',\n          width: 1125,\n          height: 2436,\n          sizes: {\n            full: {\n              url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n            },\n            large: {\n              url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n            },\n            smallSquare: {\n              url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n            },\n          },\n        },\n      ],\n    },\n  },\n}\n\nexport const Text = {\n  args: {\n    id: 'parent-message',\n    type: 'text',\n    sender: { profile: { name: '트리플' }, unregistered: false },\n    value: {\n      message: '반가워요',\n    },\n    blinded: false,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/product.tsx",
    "content": "import { FlexBox, Text } from '@titicaca/tds-ui'\nimport { Theme } from '@titicaca/tds-theme'\n\nimport { CustomerBookingStatus } from '../types'\n\nimport {\n  Badge,\n  ProductHr,\n  ProductImage,\n  ProductInfo,\n  ProductName,\n} from './elements'\nimport Bubble from './bubble'\nimport { ProductBubbleProp } from './type'\n\nconst PRODUCT_BADGE_COLOR: Record<\n  CustomerBookingStatus,\n  keyof Theme['colors']\n> = {\n  BOOKED: 'blue',\n  ONGOING: 'blue',\n  COMPLETED: 'mint',\n  CANCEL_REQUESTED: 'gray300',\n  CANCELED: 'gray300',\n}\n\nconst PRODUCT_BADGE_LABEL: Record<CustomerBookingStatus, string> = {\n  BOOKED: '예약접수',\n  ONGOING: '예약진행중',\n  COMPLETED: '예약확정',\n  CANCEL_REQUESTED: '예약취소중',\n  CANCELED: '예약취소',\n}\n\nexport const ProductBubble = ({\n  id,\n  my,\n  product,\n  ...props\n}: ProductBubbleProp) => {\n  const {\n    customerBookingStatus,\n    productName,\n    productThumbnail,\n    itemName,\n    optionName,\n    dateOfUse,\n    bookingId,\n  } = product\n\n  return (\n    <Bubble\n      id={id}\n      my={my}\n      css={{\n        width: 'calc(100% - 15px)',\n        maxWidth: '768px',\n        margin: my ? '0 0 0 8px' : undefined,\n        padding: '12px 14px',\n      }}\n      {...props}\n    >\n      {customerBookingStatus && (\n        <Badge backgroundColor={PRODUCT_BADGE_COLOR[customerBookingStatus]}>\n          {PRODUCT_BADGE_LABEL[customerBookingStatus]}\n        </Badge>\n      )}\n\n      <FlexBox\n        flex\n        gap=\"16px\"\n        alignItems=\"flex-start\"\n        justifyContent=\"space-between\"\n        css={{ width: '100%', margin: '6px 0' }}\n      >\n        <FlexBox\n          flex\n          gap=\"6px\"\n          justifyContent=\"center\"\n          alignItems=\"flex-start\"\n          flexDirection=\"column\"\n        >\n          <ProductName color=\"gray\" size={15}>\n            {productName}\n          </ProductName>\n          {itemName && (\n            <Text color=\"gray700\" size={13}>\n              {itemName}\n            </Text>\n          )}\n        </FlexBox>\n\n        <ProductImage src={productThumbnail} alt=\"상품 사진\" />\n      </FlexBox>\n\n      {(optionName || dateOfUse || bookingId) && (\n        <ProductHr compact color=\"var(--color-gray50)\" />\n      )}\n\n      <ProductInfo title=\"선택옵션\" label={optionName} />\n      <ProductInfo title=\"이용예정\" label={dateOfUse} />\n      <ProductInfo title=\"예약번호\" label={bookingId?.toString()} />\n    </Bubble>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/rich.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { generatePreviewImage } from '../utils'\n\nimport { ImageItem, TextItem } from './item'\nimport Bubble from './bubble'\nimport { RichBubbleProp } from './type'\n\nconst Button = styled.a`\n  box-sizing: border-box;\n  display: block;\n  width: 100%;\n  padding: 11px 10px 11px 12px;\n  margin: 14px 0 16px;\n  border-radius: 4px;\n  background-color: #fff;\n  color: inherit;\n  text-decoration: none;\n\n  &::after {\n    display: block;\n    float: right;\n    width: 16px;\n    height: 18px;\n    content: '';\n    background-image: url('http://assets.triple.guide/images/ico-arrow-right-black@3x.png');\n    background-size: 16px 18px;\n  }\n`\n\nexport function RichBubble({\n  my,\n  blocks,\n  cloudinaryName,\n  mediaUrlBase,\n  onImageClick,\n  onButtonClickBeforeRouting,\n  textItemStyle,\n  imageItemStyle,\n  buttonItemStyle,\n  ...props\n}: RichBubbleProp) {\n  return (\n    <Bubble my={my} css={{ margin: my ? '0 0 0 8px' : undefined }} {...props}>\n      {blocks.map((block, index) => {\n        switch (block.type) {\n          case 'text':\n            return <TextItem text={block.message} css={textItemStyle} />\n          case 'images': {\n            if (block.images.length === 0) {\n              return null\n            }\n            const imageUrl = generatePreviewImage({\n              imageInfo: block.images[0],\n              cloudinaryName,\n              mediaUrlBase,\n            })\n            return (\n              <ImageItem\n                key={index}\n                src={imageUrl}\n                onClick={() => {\n                  onImageClick?.(block.images)\n                }}\n                css={imageItemStyle}\n              />\n            )\n          }\n          case 'button':\n            return (\n              <Button\n                key={index}\n                href={block.action.param}\n                onClick={onButtonClickBeforeRouting}\n                css={buttonItemStyle}\n              >\n                {block.label}\n              </Button>\n            )\n          default:\n            return null\n        }\n      })}\n    </Bubble>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/text.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled, css } from 'styled-components'\nimport { Button } from '@titicaca/tds-ui'\n\nimport useATagNavigator from '../utils/a-tag-navigator'\nimport { ArrowRight } from '../icons/arrow-right-icon'\n\nimport { TextItem } from './item'\nimport Bubble from './bubble'\nimport { TextBubbleProp } from './type'\nimport { FullTextMessageView } from './full-text-message-view'\n\nconst MAX_VIEWABLE_TEXT_LENGTH = 300\n\nconst FullTextViewButton = styled(Button)<{ my: boolean }>`\n  display: flex;\n  align-items: center;\n  gap: 5px;\n  margin-top: 10px;\n  padding: 0 0 0 5px;\n  background-color: unset;\n  font-size: 13px;\n  font-weight: 500;\n  color: ${({ my }) => (my ? 'white' : 'var(--color-gray500)')};\n`\n\nexport function TextBubble({\n  id,\n  message,\n  my,\n  created,\n  CustomFullTextViewController,\n  onOpenMenu,\n  fullTextViewAvailable,\n  openFullTextView,\n  closeFullTextView,\n  isFullTextViewOpen,\n  onParentMessageClick,\n  onLinkClick,\n  ...props\n}: TextBubbleProp) {\n  const aTagNavigator = useATagNavigator(onLinkClick)\n  const isEllipsis =\n    !CustomFullTextViewController &&\n    fullTextViewAvailable &&\n    openFullTextView &&\n    closeFullTextView &&\n    isFullTextViewOpen &&\n    message.length > MAX_VIEWABLE_TEXT_LENGTH\n\n  return (\n    <Bubble\n      id={id}\n      css={css`\n        a {\n          color: ${my ? '#B5FFFB' : 'var(--color-blue)'};\n          text-decoration: underline;\n        }\n      `}\n      my={my}\n      onParentMessageClick={onParentMessageClick}\n      {...props}\n    >\n      {CustomFullTextViewController ? (\n        <CustomFullTextViewController my={my} id={id}>\n          <TextItem text={message} onClick={(e) => aTagNavigator(e)} />\n        </CustomFullTextViewController>\n      ) : (\n        <>\n          <TextItem\n            text={\n              isEllipsis\n                ? `${message.substring(0, MAX_VIEWABLE_TEXT_LENGTH)}...`\n                : message\n            }\n            onClick={(e) => aTagNavigator(e)}\n          />\n          {isEllipsis ? (\n            <TextBubbleWithFullTextView\n              id={id}\n              my={my}\n              openFullTextView={openFullTextView}\n              closeFullTextView={closeFullTextView}\n              isFullTextViewOpen={isFullTextViewOpen}\n              created={created}\n              onOpenMenu={onOpenMenu}\n            >\n              <TextItem text={message} onClick={(e) => aTagNavigator(e)} />\n            </TextBubbleWithFullTextView>\n          ) : null}\n        </>\n      )}\n    </Bubble>\n  )\n}\n\nfunction TextBubbleWithFullTextView({\n  openFullTextView,\n  closeFullTextView,\n  onOpenMenu,\n  created,\n  isFullTextViewOpen,\n  my,\n  id,\n  children,\n}: PropsWithChildren<\n  Required<\n    Pick<\n      TextBubbleProp,\n      | 'my'\n      | 'id'\n      | 'openFullTextView'\n      | 'closeFullTextView'\n      | 'isFullTextViewOpen'\n    >\n  > &\n    Pick<TextBubbleProp, 'onOpenMenu' | 'created'>\n>) {\n  return (\n    <>\n      <FullTextViewButton my={my} onClick={() => openFullTextView(id)}>\n        전체보기\n        <ArrowRight\n          color={my ? 'var(--color-white900)' : 'var(--color-gray500)'}\n          style={{ width: 4, height: 8 }}\n        />\n      </FullTextViewButton>\n      {isFullTextViewOpen(id) && (\n        <FullTextMessageView\n          open\n          onClose={closeFullTextView}\n          openMenu={onOpenMenu}\n          disableMenu={!created}\n        >\n          {children}\n        </FullTextMessageView>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble/type.ts",
    "content": "import { ComponentType, MouseEvent, PropsWithChildren } from 'react'\nimport { LongPressCallbackMeta, LongPressReactEvents } from 'use-long-press'\nimport { CSSProp } from 'styled-components'\n\nimport { CouponItem, ProductItem, RichItem, MetaDataInterface } from '../types'\n\nimport { ParentMessageUIProp } from './parent'\n\nexport interface BubbleCSSProp {\n  maxWidthOffset?: number\n  my: boolean\n  hasArrow?: boolean\n  arrowRadius?: number\n  borderRadius?: number\n  maxWidth?: number\n}\n\nexport type BaseBubbleProp = BubbleCSSProp & {\n  id: string\n  onClick?: (e: MouseEvent, messageId: string) => void\n  onLongPress?: (\n    messageId: string,\n    target: LongPressReactEvents<Element>,\n    context: LongPressCallbackMeta<unknown>,\n  ) => void\n  css?: CSSProp\n}\n\nexport type BubbleProp = BaseBubbleProp & {\n  parentMessage?: ParentMessageUIProp\n}\n\nexport type TextBubbleProp = {\n  message: string\n  my: boolean\n  parentMessage?: ParentMessageUIProp\n  created?: boolean\n  /**\n   * TextBubble의 내용을 감싸는 컴포넌트로, 커스텀한 전체보기 동작을 위해 사용합니다.\n   * CustomFullTextViewController 제공되는 경우, fullTextViewAvailable, openFullTextView, closeFullTextView, isFullTextViewOpen, onOpenMenu은 무시됩니다.\n   */\n  CustomFullTextViewController?: ComponentType<\n    PropsWithChildren<{ my: boolean; id: string }>\n  >\n  fullTextViewAvailable?: boolean\n  onOpenMenu?: () => void\n  isFullTextViewOpen?: (id: string) => boolean\n  openFullTextView?: (id: string) => void\n  closeFullTextView?: () => void\n  onParentMessageClick?: (id: string) => void\n  onLinkClick?: (href: string) => void\n} & BubbleProp\n\nexport type RichBubbleProp = {\n  my: boolean\n  blocks: RichItem[]\n  cloudinaryName: string\n  mediaUrlBase: string\n  onImageClick?: (imageInfos: MetaDataInterface[]) => void\n  onButtonClickBeforeRouting?: () => void\n  textItemStyle?: CSSProp\n  imageItemStyle?: CSSProp\n  buttonItemStyle?: CSSProp\n} & BubbleProp\n\nexport interface ImageBubbleProp {\n  id: string\n  images: MetaDataInterface[]\n  onClick?: (\n    e: MouseEvent,\n    images: MetaDataInterface[],\n    clickedImageIndex?: number,\n  ) => void\n  onLongPress?: (\n    messageId: string,\n    target: LongPressReactEvents<Element>,\n    context: LongPressCallbackMeta<unknown>,\n  ) => void\n}\n\nexport type ProductBubbleProp = {\n  my: boolean\n  product: ProductItem\n} & BubbleProp\n\nexport type CouponBubbleClickType = 'download' | 'product'\n\nexport interface CouponBubbleProp {\n  id: string\n  my: boolean\n  coupon: CouponItem\n  onClick?: (coupon: CouponItem, target: CouponBubbleClickType) => void\n}\n\nexport type BlindedBubbleProp = {\n  my: boolean\n  alternativeText?: string\n  textColor?: CSSProp\n} & BubbleProp\n\ninterface LinkButtonAction {\n  param: string\n  type: 'link'\n}\n\ninterface ButtonAction {\n  type: 'button'\n}\n\ninterface ButtonProps {\n  onButtonClick?: () => void\n  action: ButtonAction\n}\n\ninterface LinkButtonProps {\n  onLinkClick?: (href: string) => void\n  action: LinkButtonAction\n}\n\nexport type ButtonBubbleProp = {\n  my: boolean\n  label: string\n  disabled?: boolean\n} & BubbleProp &\n  (ButtonProps | LinkButtonProps)\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble-container/bubble-container.stories.tsx",
    "content": "import { Meta, StoryFn } from '@storybook/react'\n\nimport { TextBubble } from '../bubble/text'\n\nimport BubbleContainer, { BubbleContainerProp } from './bubble-container'\n\nexport default {\n  title: 'tds-widget / chat / BubbleContainer',\n  component: BubbleContainer,\n} as Meta<typeof BubbleContainer>\n\nconst Template: StoryFn<BubbleContainerProp> = (args) => (\n  <BubbleContainer {...args}>\n    <TextBubble\n      message={'안녕하세요\\nhttps://www.google.com'}\n      my={args.my}\n      id=\"text-bubble\"\n    />\n  </BubbleContainer>\n)\n\nexport const SentBubbleContainer = {\n  render: Template,\n  argTypes: {\n    my: {\n      type: 'boolean',\n      required: true,\n    },\n    createdAt: {\n      type: 'date',\n    },\n    unreadCount: {\n      type: 'number',\n    },\n    showInfo: {\n      control: {\n        type: 'boolean',\n      },\n    },\n  },\n  args: {\n    my: true,\n    createdAt: new Date(2022, 10, 1).toISOString(),\n    unreadCount: null,\n    showInfo: true,\n  },\n  thanks: { count: 1, haveMine: false },\n}\n\nexport const ReceivedBubbleContainer = {\n  render: Template,\n  argTypes: {\n    my: {\n      type: 'boolean',\n      required: true,\n    },\n    createdAt: {\n      type: 'date',\n    },\n    unreadCount: {\n      type: 'number',\n    },\n    showInfo: {\n      control: {\n        type: 'boolean',\n      },\n    },\n    user: {\n      control: {\n        type: 'object',\n      },\n    },\n  },\n  args: {\n    my: false,\n    createdAt: new Date(2022, 10, 1).toISOString(),\n    unreadCount: null,\n    showInfo: true,\n    user: {\n      photo:\n        'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n      name: '테스트계정',\n      userId: 'test',\n      unregistered: false,\n    },\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble-container/bubble-container.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { css, CSSProp } from 'styled-components'\nimport { Container } from '@titicaca/tds-ui'\n\nimport { DEFAULT_MESSAGE_ID_PREFIX } from '../chat/constants'\nimport { DEFAULT_MAX_USERNAME_LENGTH, formatUsername } from '../utils/profile'\n\nimport { BubbleInfo } from './bubble-info'\nimport {\n  DeleteButton,\n  ProfileImage,\n  ProfileName,\n  RetryButton,\n  SendingFailureHandlerContainer,\n  Thanks,\n} from './elements'\nimport { DeleteIcon, RetryIcon } from './icons'\n\nconst CHAT_CONTAINER_STYLES = {\n  position: 'relative',\n  width: '100%',\n  userSelect: 'none',\n} as const\n\ninterface ContainerBaseProp {\n  id: string\n  /** 메시지 생성 시간 */\n  createdAt?: string // Date?\n  /** 해당 메시지를 읽지 않은 유저의 수 */\n  unreadCount: number | null\n  /** 정보 영역 노출 여부 (시간, 안읽음 등) */\n  showInfo?: boolean\n  /** 날짜 정보의 노출 여부 */\n  showDateInfo?: boolean\n  /** 시간 정보의 노출 여부 */\n  showTimeInfo?: boolean\n  /** 좋아요 정보 */\n  thanks?: { count: number; haveMine: boolean }\n  /** 좋아요 아이콘 클릭 시 작동하는 함수 */\n  onThanksClick?: () => void\n  /** 답장하기 아이콘 클릭 시 작동하는 함수 */\n  onReplyClick?: () => void\n  /** 메세지 ref에 주입되는 callback 함수 */\n  messageRefCallback?: (id: string) => void\n  /** 메세지 부가 정보 (날짜, 프로필 등) 커스텀 스타일 */\n  bubbleInfoStyle?: {\n    dateDivider?: { css?: CSSProp }\n    unreadCount?: { css?: CSSProp }\n    dateTime?: { css?: CSSProp }\n    profile?: { css?: CSSProp }\n    thanks?: { css?: CSSProp }\n    failureHandler?: { css?: CSSProp }\n  }\n  bubbleInfoGap?: number\n  failureHandlerGap?: number\n}\n\ntype SentBubbleContainerProp = PropsWithChildren<\n  ContainerBaseProp & {\n    /** 전송 실패한 메시지 재전송 시도 함수 */\n    onRetry?: () => void\n    /** 전송 실패한 메시지 삭제 함수 */\n    onRetryCancel?: () => void\n  }\n>\n\nfunction SentBubbleContainer({\n  id,\n  createdAt,\n  onRetry,\n  onRetryCancel,\n  unreadCount,\n  showInfo = true,\n  showDateInfo,\n  showTimeInfo,\n  thanks,\n  onThanksClick,\n  onReplyClick,\n  messageRefCallback,\n  children,\n  bubbleInfoStyle,\n  bubbleInfoGap = 4,\n  failureHandlerGap = 6,\n  ...props\n}: SentBubbleContainerProp) {\n  return (\n    <Container\n      id={`${DEFAULT_MESSAGE_ID_PREFIX}-${id}`}\n      css={{ textAlign: 'right', ...CHAT_CONTAINER_STYLES }}\n      ref={() => messageRefCallback?.(id)}\n      {...props}\n    >\n      <div>\n        {!createdAt && onRetry && onRetryCancel ? (\n          <SendingFailureHandlerContainer\n            css={css`\n              margin-right: ${failureHandlerGap}px;\n\n              ${bubbleInfoStyle?.failureHandler?.css}\n            `}\n          >\n            <RetryButton onClick={onRetry}>\n              <RetryIcon />\n            </RetryButton>\n            <DeleteButton onClick={onRetryCancel}>\n              <DeleteIcon />\n            </DeleteButton>\n          </SendingFailureHandlerContainer>\n        ) : null}\n\n        {createdAt && showInfo ? (\n          <BubbleInfo\n            align=\"right\"\n            unreadCount={unreadCount}\n            date={createdAt}\n            showDateInfo={showDateInfo}\n            showTimeInfo={showTimeInfo}\n            onReplyClick={onReplyClick}\n            css={{ marginRight: bubbleInfoGap, textAlign: 'right' }}\n            dateTimeStyle={bubbleInfoStyle?.dateTime}\n            unreadCountStyle={bubbleInfoStyle?.unreadCount}\n          />\n        ) : null}\n\n        {children}\n      </div>\n\n      {thanks && onThanksClick ? (\n        <Thanks\n          count={thanks.count}\n          haveMine={thanks.haveMine}\n          onClick={onThanksClick}\n          css={css`\n            display: inline-flex;\n            margin-top: 6px;\n            margin-right: 10px;\n\n            ${bubbleInfoStyle?.thanks?.css}\n          `}\n        />\n      ) : null}\n    </Container>\n  )\n}\n\ntype ReceivedBubbleContainerProp = PropsWithChildren<\n  ContainerBaseProp & {\n    /** 메시지 발신인 정보 */\n    user?: {\n      photo?: string\n      name: string\n      userId: string\n      unregistered?: boolean\n    }\n    /** 프로필 노출 여부 */\n    showProfile?: boolean\n    /** 유저 프로필 클릭 */\n    onUserClick?: (userId: string, unregistered: boolean) => void\n    showProfilePhoto?: boolean\n  }\n>\n\nfunction ReceivedBubbleContainer({\n  id,\n  user,\n  unreadCount,\n  createdAt,\n  showInfo,\n  showDateInfo,\n  showTimeInfo,\n  showProfile = true,\n  thanks,\n  onThanksClick,\n  onReplyClick,\n  messageRefCallback,\n  onUserClick,\n  children,\n  bubbleInfoStyle,\n  bubbleInfoGap = 4,\n  showProfilePhoto = true,\n  ...props\n}: ReceivedBubbleContainerProp) {\n  return (\n    <Container\n      id={`${DEFAULT_MESSAGE_ID_PREFIX}-${id}`}\n      css={{ ...CHAT_CONTAINER_STYLES }}\n      ref={() => messageRefCallback?.(id)}\n      {...props}\n    >\n      {showProfile && showProfilePhoto ? (\n        <ProfileImage\n          src={\n            user && !user.unregistered && user.photo\n              ? user.photo\n              : 'https://assets.triple.guide/images/ico-default-profile.svg'\n          }\n          onClick={() =>\n            onUserClick && user\n              ? onUserClick(user.userId, user.unregistered || false)\n              : undefined\n          }\n        />\n      ) : null}\n      <Container css={{ marginLeft: showProfilePhoto ? 40 : 0 }}>\n        {showProfile ? (\n          <ProfileName\n            size=\"mini\"\n            alpha={0.8}\n            margin={{ bottom: 5 }}\n            css={bubbleInfoStyle?.profile?.css}\n          >\n            {user\n              ? formatUsername({\n                  name: user?.name,\n                  unregistered: user?.unregistered,\n                  maxLength: DEFAULT_MAX_USERNAME_LENGTH,\n                })\n              : ''}\n          </ProfileName>\n        ) : null}\n\n        {children}\n\n        {createdAt && showInfo ? (\n          <BubbleInfo\n            align=\"left\"\n            unreadCount={unreadCount}\n            showDateInfo={showDateInfo}\n            showTimeInfo={showTimeInfo}\n            onReplyClick={onReplyClick}\n            date={createdAt}\n            css={{ marginLeft: bubbleInfoGap, textAlign: 'left' }}\n            dateTimeStyle={bubbleInfoStyle?.dateTime}\n            unreadCountStyle={bubbleInfoStyle?.unreadCount}\n          />\n        ) : null}\n\n        {thanks && onThanksClick ? (\n          <Thanks\n            count={thanks.count}\n            haveMine={thanks.haveMine}\n            onClick={onThanksClick}\n            css={{ marginTop: 6 }}\n          />\n        ) : null}\n      </Container>\n    </Container>\n  )\n}\n\nexport type BubbleContainerProp = { my: boolean } & SentBubbleContainerProp &\n  ReceivedBubbleContainerProp\n\nexport default function BubbleContainer({\n  my,\n  children,\n  ...props\n}: BubbleContainerProp) {\n  if (my) {\n    return <SentBubbleContainer {...props}>{children}</SentBubbleContainer>\n  }\n  return (\n    <ReceivedBubbleContainer {...props}>{children}</ReceivedBubbleContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble-container/bubble-info.tsx",
    "content": "import { Container, Text } from '@titicaca/tds-ui'\nimport styled, { CSSProp } from 'styled-components'\nimport { format, setDefaultOptions } from 'date-fns'\nimport { ko } from 'date-fns/locale'\n\nimport { ReplyMessageIcon } from '../icons/reply-message-icon'\nimport { REPLY_BUTTON_DATA_ID } from '../chat/constants'\n\nconst BubbleInfoContainer = styled(Container)`\n  vertical-align: bottom;\n`\n\nconst UnreadMessageCountText = styled.div`\n  color: #26cec2;\n  font-size: 10px;\n`\n\nconst ReplyActionButton = styled.button<{\n  align: 'left' | 'right'\n}>`\n  display: flex;\n  align-items: flex-end;\n  justify-content: ${({ align }) => (align === 'right' ? 'flex-end' : 'auto')};\n  width: 100%;\n  height: 22px;\n  padding-bottom: 3px;\n  cursor: pointer;\n`\n\nsetDefaultOptions({ locale: ko })\n\nexport function BubbleInfo({\n  align,\n  unreadCount,\n  date,\n  showTimeInfo = true,\n  showDateInfo = false,\n  onReplyClick,\n  dateTimeStyle,\n  unreadCountStyle,\n  ...props\n}: {\n  align: 'left' | 'right'\n  unreadCount: number | null\n  date: string\n  showTimeInfo?: boolean\n  showDateInfo?: boolean\n  onReplyClick?: () => void\n  dateTimeStyle?: { css?: CSSProp }\n  unreadCountStyle?: { css?: CSSProp }\n}) {\n  return (\n    <BubbleInfoContainer position=\"relative\" display=\"inline-block\" {...props}>\n      {onReplyClick ? (\n        <ReplyActionButton\n          align={align}\n          onClick={onReplyClick}\n          data-id={REPLY_BUTTON_DATA_ID}\n        >\n          <ReplyMessageIcon />\n        </ReplyActionButton>\n      ) : null}\n\n      {unreadCount ? (\n        <UnreadMessageCountText css={unreadCountStyle?.css}>\n          {unreadCount}\n        </UnreadMessageCountText>\n      ) : null}\n\n      {showDateInfo ? (\n        <Text size={10} alpha={0.51} css={dateTimeStyle?.css}>\n          {format(new Date(date), 'MM.dd')}\n        </Text>\n      ) : null}\n\n      {showTimeInfo ? (\n        <Text size={10} alpha={0.51} css={dateTimeStyle?.css}>\n          {format(new Date(date), 'a h:mm')}\n        </Text>\n      ) : null}\n    </BubbleInfoContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble-container/elements.tsx",
    "content": "import { Button, Text } from '@titicaca/tds-ui'\nimport { styled, css } from 'styled-components'\n\nexport const HiddenElement = styled.div`\n  height: 1px;\n`\n\nexport const ProfileImage = styled.img`\n  width: 32px;\n  height: 32px;\n  border-radius: 32px;\n  box-shadow: rgba(0, 0, 0, 0.02) 0 0 0 1px;\n  outline-offset: -1px;\n  float: left;\n  object-fit: cover;\n`\n\nexport const ProfileName = styled(Text)`\n  font-size: 11px;\n  font-weight: 600;\n`\n\nexport const SendingFailureHandlerContainer = styled.div`\n  display: inline-block;\n  vertical-align: bottom;\n  width: 48px;\n  height: 24px;\n  border-radius: 6px;\n  overflow: hidden;\n  font-size: 10px;\n`\n\nconst sendingFailureHandlerStyle = css`\n  height: 100%;\n  border: none;\n  outline: none;\n  background-color: #fd2e69;\n  background-size: 14px 14px;\n  background-position: center;\n  background-repeat: no-repeat;\n`\n\nexport const RetryButton = styled.button`\n  ${sendingFailureHandlerStyle};\n\n  width: 23.5px;\n`\n\nexport const DeleteButton = styled.button`\n  ${sendingFailureHandlerStyle};\n\n  width: 24.5px;\n  border-left: 1px solid rgba(255, 255, 255, 0.3);\n`\n\nconst ThanksButton = styled(Button)<{ $haveMine: boolean }>`\n  display: flex;\n  gap: 3px;\n  height: 20px;\n  align-items: center;\n  background-color: ${({ $haveMine }) =>\n    $haveMine ? 'var(--color-white)' : 'var(--color-gray50)'};\n  color: ${({ $haveMine }) => ($haveMine ? '#1DBEB2' : 'var(--color-gray700)')};\n  ${({ $haveMine }) => ($haveMine ? 'border: 1px solid #1DBEB2;' : '')}\n  padding: 3.5px 6px 4.5px 7px;\n  font-weight: ${({ $haveMine }) => ($haveMine ? '700' : '500')};\n  font-size: 10px;\n`\n\nconst ThanksCount = styled.span`\n  font-size: 10px;\n  line-height: 11px;\n`\n\nexport function Thanks({\n  count,\n  haveMine,\n  onClick,\n  ...props\n}: {\n  count: number\n  haveMine: boolean\n  onClick?: () => void\n}) {\n  return (\n    <ThanksButton $haveMine={haveMine} onClick={() => onClick?.()} {...props}>\n      <img\n        src=\"https://assets.triple.guide/images/ic_chat_thumbsup_on.svg\"\n        alt=\"좋아요 아이콘\"\n        width={11}\n        height={11}\n      />\n      {count === 0 ? null : <ThanksCount>{count}</ThanksCount>}\n    </ThanksButton>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble-container/icons.tsx",
    "content": "export function RetryIcon() {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M11.1765 9.09523C10.4332 10.6599 8.83833 11.7417 6.99083 11.7417C4.43302 11.7417 2.3595 9.66818 2.3595 7.11037C2.3595 4.55256 4.43302 2.47905 6.99083 2.47905C8.96589 2.47905 10.6522 3.71537 11.3181 5.45633\"\n        stroke=\"white\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n      />\n      <path\n        d=\"M9.73587 5.33219L11.7016 5.60706L11.9765 3.64133\"\n        stroke=\"white\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n\nexport function DeleteIcon() {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M3.047 11L7 7.024L3 3\"\n        stroke=\"white\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M10.953 11L7 7.024L11 3\"\n        stroke=\"white\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/bubble-container/index.ts",
    "content": "export { default as BubbleContainer } from './bubble-container'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-room-messages/chat-api-service.ts",
    "content": "import qs from 'qs'\n\nimport {\n  ChatMessageInterface,\n  ChatRoomMemberInterface,\n  ChatUserInterface,\n  HasUnreadOfRoomInterface,\n  ReactionType,\n  UserType,\n} from '../../types'\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype BodyType = BodyInit | { [key: string]: any } | undefined\n\nexport interface ChatFetcherOptions<B = BodyType> {\n  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'\n  body?: B\n  parseBody?: boolean\n  [key: string]: unknown\n}\n\nexport type ChatFetcher<\n  B = BodyType,\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  T = any,\n> = (url: string, options?: ChatFetcherOptions<B>) => Promise<T>\n\ninterface ReactionPayload {\n  messageId: number\n  reactionType: ReactionType\n}\n\ninterface Sender {\n  id?: ChatUserInterface['id']\n  roomMemberId?: ChatRoomMemberInterface['roomMemberId']\n  profile: {\n    name: ChatUserInterface['profile']['name']\n  }\n}\n\nexport class ChatApiService<T = UserType> {\n  private fetcher: ChatFetcher\n\n  /**\n   * legacy API인 triple-chat을 사용하는 경우 true\n   */\n  private isTripleChat: boolean\n\n  public constructor(fetcher: ChatFetcher, isTripleChat = false) {\n    this.fetcher = fetcher\n    this.isTripleChat = isTripleChat\n  }\n\n  public getRoomMemberMe({\n    roomId,\n  }: {\n    roomId: string\n  }): Promise<ChatRoomMemberInterface<T>> {\n    return this.fetcher(`/rooms/${roomId}/members/me`)\n  }\n\n  public getMessages({\n    roomId,\n    backward,\n    lastMessageId,\n  }: {\n    roomId: string\n    backward?: boolean\n    lastMessageId: number | string | null\n  }): Promise<\n    | { messages: ChatMessageInterface<T>[]; nextToken?: number }\n    | ChatMessageInterface<T>[] // legacy API\n  > {\n    return this.fetcher(\n      `/rooms/${roomId}/messages?${qs.stringify({ backward, lastMessageId })}`,\n    )\n  }\n\n  public sendMessage({\n    roomId,\n    payload,\n    sender,\n  }: {\n    roomId: string\n    payload: ChatMessageInterface<T>['payload']\n    sender?: Sender\n  }): Promise<ChatMessageInterface<T>[]> {\n    return this.fetcher(`/rooms/${roomId}/send`, {\n      method: 'POST',\n      body: { payload, sender },\n    })\n  }\n\n  /**\n   * unread 관련\n   */\n  public fetchGetUnreadRoom({\n    roomId,\n    lastSeenMessageId,\n  }: {\n    roomId: string\n    lastSeenMessageId: number\n  }): Promise<HasUnreadOfRoomInterface> {\n    return this.fetcher(\n      `/rooms/${roomId}/has-unread?lastSeenMessageId=${lastSeenMessageId}`,\n    )\n  }\n\n  public updateLastSeenMessageId({\n    roomId,\n    lastSeenMessageId,\n  }: {\n    roomId: string\n    lastSeenMessageId: number\n  }): Promise<void> {\n    return this.fetcher(`/rooms/${roomId}/last-message`, {\n      method: this.isTripleChat ? 'POST' : 'PUT',\n      body: { lastSeenMessageId },\n    })\n  }\n\n  /**\n   * reaction 관련\n   */\n  public addReaction({ messageId, reactionType }: ReactionPayload) {\n    return this.fetcher('/reactions', {\n      method: 'POST',\n      body: { messageId, reactionType },\n    })\n  }\n\n  public removeReaction({ messageId, reactionType }: ReactionPayload) {\n    return this.fetcher(`/reactions/${reactionType}/messages/${messageId}`, {\n      method: 'DELETE',\n      parseBody: false,\n    })\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-room-messages/chat-message-context.tsx",
    "content": "import {\n  createContext,\n  Dispatch,\n  PropsWithChildren,\n  useContext,\n  useMemo,\n} from 'react'\n\nimport {\n  type MessagesAction,\n  MessagesActions,\n  UnsentMessage,\n  useMessagesReducer,\n} from '../messages-reducer'\nimport {\n  ChatMessageInterface,\n  isCreatedChatRoom,\n  UserType,\n  WelcomeMessageInterface,\n} from '../../types'\nimport { useRoom } from '../room-context'\n\nimport { ChatFetcher, ChatApiService } from './chat-api-service'\n\nexport interface ChatMessagesProviderProps<T = UserType> {\n  messages?: ChatMessageInterface<T>[]\n  prevToken?: ChatMessageInterface<T>['id']\n  welcomeMessages?: WelcomeMessageInterface<T>[]\n  fetcher: ChatFetcher\n  /**\n   * legacy API인 triple-chat을 사용하는 경우 true\n   */\n  useTripleChat?: boolean\n}\n\nexport interface ChatMessagesContextValue<T = UserType> {\n  /**\n   * MessagesReducer의 state 및 dispatch\n   */\n  messages: ChatMessageInterface<T>[]\n  pendingMessages: UnsentMessage<ChatMessageInterface<T>>[]\n  failedMessages: UnsentMessage<ChatMessageInterface<T>>[]\n  hasPrevMessage: boolean\n  prevToken?: ChatMessageInterface<T>['id']\n  dispatch: Dispatch<\n    MessagesAction<ChatMessageInterface<T>, ChatMessageInterface<T>['id']>\n  >\n  welcomeMessages: WelcomeMessageInterface<T>[]\n  initMessages: () => Promise<void>\n  useTripleChat: boolean\n}\n\nexport const ChatApiServiceContext = createContext<ChatApiService | null>(null)\n\nexport const ChatMessagesContext =\n  createContext<ChatMessagesContextValue | null>(null)\n\nexport function ChatMessagesProvider<T = UserType>({\n  welcomeMessages = [],\n  messages: initialMessages = [],\n  prevToken: initialPrevToken,\n  fetcher,\n  useTripleChat = false,\n  children,\n}: PropsWithChildren<ChatMessagesProviderProps<T>>) {\n  const { room } = useRoom()\n\n  const chatApiService = useMemo(\n    () => new ChatApiService<T>(fetcher, useTripleChat),\n    [fetcher, useTripleChat],\n  )\n\n  const [\n    { messages, pendingMessages, failedMessages, hasPrevMessage, prevToken },\n    dispatch,\n  ] = useMessagesReducer<\n    ChatMessageInterface<T>,\n    ChatMessageInterface<T>['id']\n  >()\n\n  const initMessages = async () => {\n    if (!isCreatedChatRoom(room)) {\n      dispatch({\n        action: MessagesActions.HAS_PREV,\n        hasPrevMessage: false,\n      })\n    } else {\n      let messages = initialMessages\n      let prevToken: number | undefined | null = initialPrevToken\n\n      if (!messages.length) {\n        try {\n          const result = await chatApiService.getMessages({\n            roomId: room.id,\n            backward: true,\n            lastMessageId: Number(room.lastMessageId) + 1,\n          })\n          if ('messages' in result) {\n            messages = result.messages\n            prevToken = result.nextToken\n          } else {\n            messages = result\n            prevToken = null\n          }\n          // eslint-disable-next-line @typescript-eslint/no-unused-vars\n        } catch (error) {}\n      }\n\n      dispatch({\n        action: MessagesActions.INIT,\n        messages,\n        ...(prevToken !== null && {\n          prevToken,\n        }),\n      })\n    }\n  }\n\n  const value = {\n    messages,\n    pendingMessages,\n    failedMessages,\n    hasPrevMessage: hasPrevMessage || !!prevToken,\n    prevToken,\n    dispatch,\n    welcomeMessages,\n    initMessages,\n    useTripleChat,\n  } as unknown as ChatMessagesContextValue\n\n  return (\n    <ChatApiServiceContext.Provider\n      value={chatApiService as unknown as ChatApiService}\n    >\n      <ChatMessagesContext.Provider value={value}>\n        {children}\n      </ChatMessagesContext.Provider>\n    </ChatApiServiceContext.Provider>\n  )\n}\n\nexport function useChatApiService<T = UserType>() {\n  const context = useContext(ChatApiServiceContext)\n  if (!context) {\n    throw new Error(\n      'useChatApiService must be used within a ChatMessagesProvider',\n    )\n  }\n  return context as ChatApiService<T>\n}\n\nexport function useChatMessagesContext<T = UserType>() {\n  const context = useContext(ChatMessagesContext)\n  if (!context) {\n    throw new Error(\n      'useChatMessagesContext must be used within a ChatMessagesProvider',\n    )\n  }\n  return context as unknown as ChatMessagesContextValue<T>\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-room-messages/chat-room-messages-provider.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { ChatRoomInterface, ChatUserInterface, UserType } from '../../types'\nimport { ScrollProvider } from '../scroll-context'\nimport { RoomProvider, type RoomProviderProps } from '../room-context'\n\nimport {\n  ChatMessagesProvider,\n  type ChatMessagesProviderProps,\n} from './chat-message-context'\n\nexport interface ChatRoomMessagesProviderProps<\n  T = ChatRoomInterface,\n  U = ChatUserInterface,\n  V = UserType,\n> extends ChatMessagesProviderProps<V>,\n    RoomProviderProps<T, U> {}\n\nexport function ChatRoomMessagesProvider<\n  T = ChatRoomInterface,\n  U = ChatUserInterface,\n  V = UserType,\n>({\n  room,\n  me,\n  children,\n  ...props\n}: PropsWithChildren<ChatRoomMessagesProviderProps<T, U, V>>) {\n  return (\n    <RoomProvider room={room} me={me}>\n      <ScrollProvider>\n        <ChatMessagesProvider {...props}>{children}</ChatMessagesProvider>\n      </ScrollProvider>\n    </RoomProvider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-room-messages/constants.ts",
    "content": "import { ChatMessageInterface } from '../../types'\n\nexport const DEFAULT_MESSAGE_PROPERTIES: Partial<ChatMessageInterface> = {\n  displayTarget: 'all',\n}\n\nexport const REACTION_ENABLED_MESSAGE_PROPERTIES: Partial<ChatMessageInterface> =\n  {\n    ...DEFAULT_MESSAGE_PROPERTIES,\n    reactions: {\n      thanks: {\n        count: 0,\n        haveMine: false,\n      },\n    },\n  }\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-room-messages/index.ts",
    "content": "export {\n  useChatApiService,\n  useChatMessagesContext,\n} from './chat-message-context'\nexport * from './constants'\nexport * from './use-unread-messages'\nexport * from './use-chat-room-messages'\nexport {\n  default as ChatRoomMessages,\n  type ChatRoomMessageInterface,\n} from './messages'\nexport * from './chat-room-messages-provider'\nexport * from './use-pending-intersections'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-room-messages/messages.tsx",
    "content": "import { ComponentProps } from 'react'\nimport DOMPurify from 'dompurify'\n\nimport {\n  ChatMessageInterface,\n  ChatMessagePayloadType,\n  ChatRoomUser,\n  DisplayTargetAll,\n  UserInterface,\n  UserType,\n} from '../../types'\nimport OriginalMessages from '../../messages'\nimport { getProfileImageUrl } from '../../utils'\nimport { UnsentMessage } from '../messages-reducer'\nimport { getUserIdentifier } from '../../utils/user'\nimport { RichBubbleUIProp } from '../../bubble/bubble-ui'\nimport { BubbleMessageInterface } from '../../messages/type'\n\nexport type ChatRoomMessageInterface<T = UserType> = Omit<\n  ChatMessageInterface<T>,\n  'sender'\n> & {\n  sender: UserInterface\n}\n\ntype OriginalMessagesPropTypes<T = UserType> = ComponentProps<\n  typeof OriginalMessages<ChatRoomMessageInterface<T>, UserInterface>\n>\n\nexport default function Messages<T = UserType>({\n  me,\n  messages,\n  pendingMessages,\n  failedMessages,\n  displayTarget,\n  showReactions = false,\n  shouldSplitRichMessage = false,\n  ...props\n}: {\n  me: ChatRoomUser<T>\n  messages: ChatMessageInterface<T>[]\n  pendingMessages: UnsentMessage<ChatMessageInterface<T>>[]\n  failedMessages: UnsentMessage<ChatMessageInterface<T>>[]\n  displayTarget: T\n  showReactions?: boolean\n  shouldSplitRichMessage?: boolean\n} & Omit<\n  OriginalMessagesPropTypes<T>,\n  | 'me'\n  | 'messages'\n  | 'pendingMessages'\n  | 'failedMessages'\n  | 'richMessageSplitter'\n>) {\n  return (\n    <OriginalMessages<ChatRoomMessageInterface<T>, UserInterface>\n      messages={convertMessages(me, messages, displayTarget, showReactions)}\n      pendingMessages={convertMessages(\n        me,\n        pendingMessages,\n        displayTarget,\n        showReactions,\n      )}\n      failedMessages={convertMessages(\n        me,\n        failedMessages,\n        displayTarget,\n        showReactions,\n      )}\n      me={convertChatUserToMessageUser(me)}\n      richMessageSplitter={\n        shouldSplitRichMessage ? richMessageSplitter : undefined\n      }\n      bubbleMessageConverter={bubbleMessageConverter}\n      {...props}\n    />\n  )\n}\n\nfunction richMessageSplitter<T = UserType>(\n  message: OriginalMessagesPropTypes<T>['messages'][number],\n  block: RichBubbleUIProp['value']['blocks'][number],\n) {\n  return {\n    ...message,\n    type: block.type,\n    ...('message' in block && {\n      value: { message: block.message },\n    }),\n    ...('images' in block && {\n      value: { images: block.images },\n    }),\n    value: {\n      ...block,\n    },\n  }\n}\n\nfunction bubbleMessageConverter<T = UserType>(\n  message: OriginalMessagesPropTypes<T>['messages'][number],\n):\n  | BubbleMessageInterface<ChatRoomMessageInterface<T>, UserInterface>[]\n  | undefined {\n  if (message.type === 'coupon' && message.value.coupon.type === 'random') {\n    return [\n      {\n        ...message,\n        type: 'nol-coupon-content',\n      },\n      {\n        ...message,\n        type: 'nol-coupon-button',\n      },\n    ]\n  }\n\n  if (message.type === 'coupon' && message.value.coupon.type === 'partner') {\n    return [\n      {\n        ...message,\n        type: 'nol-coupon-content-partner',\n      },\n    ]\n  }\n}\n\nfunction convertMessages<T = UserType>(\n  me: ChatRoomUser<T>,\n  messages: ChatMessageInterface<T>[],\n  roomDisplayTarget: T,\n  showReactions?: boolean,\n): OriginalMessagesPropTypes<T>['messages']\n\nfunction convertMessages<T = UserType>(\n  me: ChatRoomUser<T>,\n  messages: UnsentMessage<ChatMessageInterface<T>>[],\n  roomDisplayTarget: T,\n  showReactions?: boolean,\n): OriginalMessagesPropTypes<T>['pendingMessages']\n\nfunction convertMessages<T = UserType>(\n  me: ChatRoomUser<T>,\n  messages:\n    | ChatMessageInterface<T>[]\n    | UnsentMessage<ChatMessageInterface<T>>[],\n  roomDisplayTarget: T,\n  showReactions = false,\n):\n  | OriginalMessagesPropTypes<T>['messages']\n  | OriginalMessagesPropTypes<T>['pendingMessages'] {\n  return messages.map(({ displayTarget: messageDisplayTarget, ...message }) => {\n    const payload = getDisplayedPayload({\n      payload: message.payload,\n      alternativePayload: message.alternative,\n      messageDisplayTarget,\n      roomDisplayTarget,\n    })\n\n    const { type, value } = getMessageTypeAndValue(payload)\n\n    const sender = convertChatUserToMessageUser(message.sender || me)\n\n    return {\n      ...message,\n      id: message.id,\n      sender,\n      ...('createdAt' in message && { createdAt: message.createdAt }),\n      blinded: !!message.blindedAt,\n      type,\n      value,\n      extra: message.payload?.extra,\n      ...(showReactions &&\n        message.reactions?.thanks && {\n          thanks: message.reactions.thanks,\n        }),\n    }\n  })\n}\n\nfunction getDisplayedPayload<T = UserType>({\n  payload,\n  alternativePayload,\n  messageDisplayTarget,\n  roomDisplayTarget,\n}: {\n  payload: ChatMessageInterface<T>['payload']\n  alternativePayload?: ChatMessageInterface<T>['payload']\n  messageDisplayTarget?: T[] | DisplayTargetAll\n  roomDisplayTarget: T\n}) {\n  if (!messageDisplayTarget || messageDisplayTarget === 'all') {\n    return payload\n  }\n  if (messageDisplayTarget.includes(roomDisplayTarget)) {\n    return payload\n  }\n  return alternativePayload ?? payload\n}\n\nfunction getMessageTypeAndValue<T = UserType>(\n  payload: ChatMessageInterface<T>['payload'],\n) {\n  switch (payload.type) {\n    case ChatMessagePayloadType.TEXT:\n      return {\n        type: payload.type,\n        value: { message: DOMPurify.sanitize(payload.message) },\n      }\n    case ChatMessagePayloadType.IMAGES:\n      return { type: payload.type, value: { images: payload.images } }\n    case ChatMessagePayloadType.RICH:\n      return { type: payload.type, value: { blocks: payload.items } }\n    case ChatMessagePayloadType.PRODUCT:\n      return { type: payload.type, value: { product: payload.product } }\n    case ChatMessagePayloadType.COUPON:\n      return { type: payload.type, value: { coupon: payload.coupon } }\n  }\n}\n\nfunction convertChatUserToMessageUser<T = UserType>(\n  user: ChatMessageInterface<T>['sender'] | ChatRoomUser<T>,\n) {\n  return {\n    id: getUserIdentifier(user),\n    profile: {\n      name: user.profile.name,\n      photo: user.profile.thumbnail || getProfileImageUrl(user),\n    },\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-room-messages/use-chat-room-messages.ts",
    "content": "import { useCallback, useEffect, useRef } from 'react'\nimport DOMPurify from 'dompurify'\n\nimport {\n  ChatMessageData,\n  ChatMessageInterface,\n  ChatMessagePayloadType,\n  ChatRoomDetailInterface,\n  ChatRoomInterface,\n  ChatRoomMemberInterface,\n  ChatRoomMetadata,\n  ChatRoomUser,\n  isChatRoomMember,\n  isCreatedChatRoom,\n  ReactionType,\n  RoomType,\n  UserType,\n} from '../../types'\nimport { useRoom } from '../room-context'\nimport { MessagesActions, UnsentMessage } from '../messages-reducer'\nimport { getUserIdentifier } from '../../utils'\n\nimport { useScroll } from './use-scroll'\nimport { DEFAULT_MESSAGE_PROPERTIES } from './constants'\nimport {\n  useChatApiService,\n  useChatMessagesContext,\n} from './chat-message-context'\nimport { ChatRoomMessageInterface } from './messages'\n\ninterface ChatMessagesProps<\n  T = RoomType,\n  U = UserType,\n  V = ChatRoomMetadata<T>,\n  R extends ChatRoomDetailInterface<T, U, V> = ChatRoomDetailInterface<T, U, V>,\n> {\n  scrollToBottomOnNewMessage?: boolean\n  defaultMessageProperties?: Partial<ChatMessageInterface<U>>\n  createRoom?: () => Promise<R | undefined>\n}\n\nexport function useChatMessages<\n  T = RoomType,\n  U = UserType,\n  V = ChatRoomMetadata<T>,\n  R extends ChatRoomDetailInterface<T, U, V> = ChatRoomDetailInterface<T, U, V>,\n>(\n  {\n    scrollToBottomOnNewMessage = true,\n    defaultMessageProperties = DEFAULT_MESSAGE_PROPERTIES as Partial<\n      ChatMessageInterface<U>\n    >,\n    createRoom,\n  }: ChatMessagesProps<T, U, V, R> = {\n    scrollToBottomOnNewMessage: true,\n    defaultMessageProperties: DEFAULT_MESSAGE_PROPERTIES as Partial<\n      ChatMessageInterface<U>\n    >,\n  },\n) {\n  const { room, me, updateRoom, updateMe } = useRoom<\n    ChatRoomInterface<T, U, V>,\n    ChatRoomUser<U>\n  >()\n\n  const { setScrollY, getScrollContainerHeight, triggerScrollToBottom } =\n    useScroll()\n\n  const firstRenderForPrevScrollRef = useRef(true)\n  const isWelcomeMessagePendingRef = useRef(false)\n\n  const {\n    messages,\n    pendingMessages,\n    failedMessages,\n    hasPrevMessage,\n    prevToken,\n    dispatch,\n    initMessages,\n    welcomeMessages,\n    useTripleChat,\n  } = useChatMessagesContext<U>()\n  const api = useChatApiService<U>()\n\n  useEffect(() => {\n    ;(async function () {\n      await initMessages()\n\n      if (welcomeMessages.length > 0) {\n        welcomeMessages.forEach((welcomeMessage) => {\n          dispatch({\n            action: MessagesActions.PENDING,\n            message: welcomeMessage as unknown as ChatMessageInterface<U>, // TODO: TF 내 Pending message 타입 수정 (펜딩 메시지는 id가 옵셔널 할 수 있음)\n          })\n        })\n        isWelcomeMessagePendingRef.current = true\n      }\n\n      setScrollY(0)\n    })()\n  }, []) // eslint-disable-line react-hooks/exhaustive-deps\n\n  async function handleSendMessageAction({\n    roomId,\n    payload,\n    sender,\n    tempMessageId,\n    skipPending = false,\n    skipFailed = false,\n    onError,\n  }: {\n    roomId: string\n    payload: UnsentMessage<ChatMessageInterface<U>>['payload']\n    sender?: UnsentMessage<ChatMessageInterface<U>>['sender']\n    tempMessageId?: number\n    skipPending?: boolean\n    skipFailed?: boolean\n    onError?: () => void\n  }) {\n    const tempMessage: UnsentMessage<ChatMessageInterface<U>> = {\n      id: tempMessageId || new Date().getTime(),\n      roomId,\n      sender,\n      payload,\n      ...defaultMessageProperties,\n    }\n\n    if (!skipPending) {\n      dispatch({\n        action: MessagesActions.PENDING,\n        message: tempMessage,\n      })\n      setTimeout(() => {\n        triggerScrollToBottom()\n      }, 100)\n    }\n\n    try {\n      const messagesFromResponse = (await api.sendMessage({\n        roomId,\n        payload,\n        ...(sender && { sender }),\n      })) || [{ roomId, payload }]\n\n      const updatedMessage =\n        messagesFromResponse[messagesFromResponse.length - 1]\n      const success = !!updatedMessage.createdAt\n\n      if (success) {\n        removeUnsentMessages({ id: tempMessage.id })\n        dispatch({\n          action: MessagesActions.NEW,\n          messages: [updatedMessage],\n        })\n        return { success }\n      }\n\n      throw new Error('Failed to send message')\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    } catch (error) {\n      if (!skipFailed) {\n        onMessageFailed(tempMessage, { onError })\n      }\n      return { success: false }\n    }\n  }\n\n  async function handleSendWelcomeMessage({\n    room,\n    me,\n    onError,\n  }: {\n    room: R\n    me: ChatRoomUser<U>\n    onError?: () => void\n  }) {\n    const welcomeMessagesInPending = pendingMessages.filter(\n      (message) =>\n        message.sender &&\n        getUserIdentifier(message.sender) !== getUserIdentifier(me),\n    )\n\n    if (!welcomeMessagesInPending.length) {\n      return\n    }\n\n    let allSuccess = true\n    for (const {\n      id: tempId,\n      payload,\n      sender,\n      roomId,\n    } of welcomeMessagesInPending) {\n      /**\n       * 룸이 생성되기 전 생성된 welcomeMessage의 sender 정보는 임시 정보(roomMemberId)를 가지므로 업데이트된 룸의 정보에서 sender를 찾아서 사용합니다.\n       */\n      const messageSender = roomId\n        ? sender\n        : findSenderFromRoomMembers(room, me, sender)\n\n      const { success } = messageSender\n        ? await handleSendMessageAction({\n            roomId: room.id,\n            tempMessageId: tempId,\n            payload,\n            sender: messageSender,\n            skipPending: true,\n            skipFailed: true,\n            onError,\n          })\n        : { success: false }\n\n      if (!success) {\n        allSuccess = false\n      }\n    }\n\n    if (allSuccess) {\n      isWelcomeMessagePendingRef.current = false\n    }\n  }\n\n  const getOrCreateRoom = async () => {\n    if (!isCreatedChatRoom(room) && createRoom) {\n      const newRoom = await createRoom()\n\n      if (newRoom) {\n        updateRoom(newRoom)\n        return newRoom\n      }\n    }\n    return room\n  }\n\n  async function getChatRoomMemberId({ roomId }: { roomId: string }) {\n    if (isChatRoomMember(me)) {\n      return me\n    }\n\n    try {\n      const memberMe = await api.getRoomMemberMe({ roomId })\n      updateMe(memberMe)\n      return memberMe\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    } catch (error) {}\n  }\n\n  async function initializeRoomAndMember(): Promise<\n    | {\n        currentRoom: R\n        roomMemberMe: ChatRoomUser<U>\n        isValid: true\n      }\n    | {\n        currentRoom: ChatRoomInterface<T, U, V> | R\n        roomMemberMe: ChatRoomUser<U> | undefined\n        isValid: false\n      }\n  > {\n    /** roomId 생성 이전에 보낸 메세지는 pusher 이벤트로 받을 수 없기 때문에,\n          roomId가 없는 경우에는 먼저 room을 생성하는 과정을 거칩니다.\n      */\n    const currentRoom = await getOrCreateRoom()\n    const currentRoomId = 'id' in currentRoom ? currentRoom.id : ''\n\n    const skipRoomMemberMe = 'identifier' in me && 'id' in me\n\n    const roomMemberMe =\n      currentRoomId && !skipRoomMemberMe\n        ? await getChatRoomMemberId({ roomId: currentRoomId })\n        : me\n\n    /**\n     * room과 member 초기화가 성공적으로 완료되었는지 검증합니다.\n     * - currentRoom이 생성된 ChatRoom인지 (isCreatedChatRoom)\n     * - roomMemberMe가 존재하는지\n     * - member 정보가 유효한지\n     */\n    const isValid =\n      isCreatedChatRoom(currentRoom) &&\n      !!roomMemberMe &&\n      (skipRoomMemberMe || isChatRoomMember(roomMemberMe))\n\n    return isValid\n      ? {\n          currentRoom: currentRoom as R,\n          roomMemberMe,\n          isValid: true as const,\n        }\n      : {\n          currentRoom,\n          roomMemberMe,\n          isValid: false as const,\n        }\n  }\n\n  async function onSendMessage(\n    payload: ChatMessageInterface<U>['payload'],\n    {\n      onError,\n      onRoomAndMemberInitialized,\n    }: { onError?: () => void; onRoomAndMemberInitialized?: () => void } = {},\n  ) {\n    const tempMessage: UnsentMessage<ChatMessageInterface<U>> = {\n      id: new Date().getTime(),\n      roomId: '',\n      payload:\n        payload.type === ChatMessagePayloadType.TEXT\n          ? { ...payload, message: DOMPurify.sanitize(payload.message) }\n          : payload,\n      ...defaultMessageProperties,\n    }\n\n    let skipPending = false\n\n    if (!isCreatedChatRoom(room)) {\n      dispatch({\n        action: MessagesActions.PENDING,\n        message: tempMessage,\n      })\n      setTimeout(() => {\n        triggerScrollToBottom()\n      }, 100)\n      skipPending = true\n    }\n\n    const result = await initializeRoomAndMember()\n\n    onRoomAndMemberInitialized?.()\n\n    if (!result.isValid) {\n      onMessageFailed(tempMessage)\n      return\n    }\n\n    const { currentRoom, roomMemberMe } = result\n\n    /** 첫 렌더링 시에만 자동 메세지를 보내도록 합니다. */\n    if (isWelcomeMessagePendingRef.current) {\n      dispatch({\n        action: MessagesActions.PENDING,\n        message: tempMessage,\n      })\n      setTimeout(() => {\n        triggerScrollToBottom()\n      }, 100)\n      skipPending = true\n\n      await handleSendWelcomeMessage({\n        room: currentRoom,\n        me: roomMemberMe,\n        onError,\n      })\n    }\n\n    const { success } = await handleSendMessageAction({\n      roomId: currentRoom.id,\n      tempMessageId: tempMessage.id,\n      payload: tempMessage.payload,\n      skipPending,\n    })\n\n    if (!success) {\n      onError?.()\n    }\n  }\n\n  const onPrevScroll = async ({\n    isIntersecting,\n  }: IntersectionObserverEntry) => {\n    const scrollable =\n      isCreatedChatRoom(room) &&\n      isIntersecting &&\n      !firstRenderForPrevScrollRef.current &&\n      (hasPrevMessage || !!prevToken) &&\n      messages.length > 0\n\n    if (scrollable) {\n      const prevScrollY = getScrollContainerHeight()\n      let pastMessages: ChatMessageInterface<U>[] = []\n      let prevToken: number | undefined | null\n\n      try {\n        const result = await api.getMessages({\n          roomId: room.id,\n          lastMessageId: prevToken ?? messages[0].id,\n          backward: true,\n        })\n\n        if ('messages' in result) {\n          pastMessages = result.messages\n          prevToken = result.nextToken\n        } else {\n          pastMessages = result\n        }\n\n        // eslint-disable-next-line @typescript-eslint/no-unused-vars\n      } catch (error) {}\n\n      dispatch({\n        action: MessagesActions.PAST,\n        messages: pastMessages,\n        ...(prevToken !== null && {\n          prevToken,\n        }),\n      })\n      setScrollY(prevScrollY)\n    } else if (isIntersecting && firstRenderForPrevScrollRef.current) {\n      firstRenderForPrevScrollRef.current = false\n    }\n  }\n\n  function onMessageFailed(\n    tempMessage: UnsentMessage<ChatMessageInterface<U>>,\n    { onError }: { onError?: () => void } = {},\n  ) {\n    onError?.()\n    dispatch({\n      action: MessagesActions.FAIL,\n      message: tempMessage,\n    })\n  }\n\n  function removeUnsentMessages(\n    message: Pick<ChatRoomMessageInterface<U>, 'id'>,\n  ) {\n    dispatch({\n      action: MessagesActions.REMOVE,\n      message,\n    })\n  }\n\n  function onRetry(\n    { id, payload }: UnsentMessage<ChatRoomMessageInterface<U>>,\n    {\n      onComplete,\n    }: {\n      onComplete?: () => void\n    } = {},\n  ) {\n    removeUnsentMessages({ id })\n\n    if (payload.type !== 'rich') {\n      onSendMessage?.(payload)\n      onComplete?.()\n    }\n  }\n\n  function onRetryCancel(\n    { id }: UnsentMessage<ChatRoomMessageInterface<U>>,\n    {\n      onComplete,\n    }: {\n      onComplete?: () => void\n    } = {},\n  ) {\n    removeUnsentMessages({ id })\n    onComplete?.()\n  }\n\n  const onSendMessageEvent = useCallback(\n    (\n      { message }: Pick<ChatMessageData<U>, 'message'>,\n      {\n        onComplete,\n      }: {\n        onComplete?: (message: ChatMessageInterface<U>, my: boolean) => void\n      } = {},\n    ) => {\n      if (message && message.payload) {\n        const typeEnsuredMessage = useTripleChat\n          ? message\n          : ensureMessageWithNumberId(message)\n        /**\n            pendingMessage와 messages 간의 부드러운 UI 전환을 위해\n            me의 메세지일 경우 handleSendMessageAction 함수 내에서 dispatch합니다.\n            coupon 메세지는 서버에서 직접 전송되므로 항상 푸셔 이벤트로 dispatch합니다.\n          */\n        const myMessage =\n          getUserIdentifier(me) === getUserIdentifier(typeEnsuredMessage.sender)\n        if (!myMessage || typeEnsuredMessage.payload.type === 'coupon') {\n          dispatch({\n            action: MessagesActions.NEW,\n            messages: [typeEnsuredMessage],\n            filterPendingMessages: isWelcomeMessagePendingRef.current\n              ? filterPendingMessage(typeEnsuredMessage)\n              : undefined,\n          })\n        }\n        onComplete?.(typeEnsuredMessage, myMessage)\n        if (\n          scrollToBottomOnNewMessage ||\n          (myMessage && typeEnsuredMessage.payload.type === 'coupon')\n        ) {\n          triggerScrollToBottom()\n        }\n      }\n    },\n    [\n      dispatch,\n      me,\n      scrollToBottomOnNewMessage,\n      triggerScrollToBottom,\n      useTripleChat,\n    ],\n  )\n\n  /**\n   * 리액션이 있을때만 사용합니다.\n   */\n  async function addReactionToMessage(\n    messageId: number,\n    reactionType: ReactionType,\n    onSuccess?: () => void,\n  ) {\n    try {\n      await api.addReaction({ messageId, reactionType })\n      onSuccess?.()\n      return { success: true }\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    } catch (error) {\n      return { success: false }\n    }\n  }\n\n  async function removeReactionToMessage(\n    messageId: number,\n    reactionType: ReactionType,\n    onSuccess?: () => void,\n  ) {\n    try {\n      await api.removeReaction({ messageId, reactionType })\n      onSuccess?.()\n      return { success: true }\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    } catch (error) {\n      return { success: false }\n    }\n  }\n\n  async function onThanksClick(\n    message: ChatRoomMessageInterface<U>,\n    {\n      onAddSuccess,\n      onRemoveSuccess,\n    }: {\n      onAddSuccess?: () => void\n      onRemoveSuccess?: () => void\n    },\n  ) {\n    const haveMyThanks = !!message.reactions?.thanks?.haveMine\n\n    if (!haveMyThanks) {\n      const { success } = await addReactionToMessage(\n        message.id,\n        'thanks',\n        onAddSuccess,\n      )\n      if (success) {\n        updateMessageReaction(message.id)\n      }\n    } else {\n      const { success } = await removeReactionToMessage(\n        message.id,\n        'thanks',\n        onRemoveSuccess,\n      )\n      if (success) {\n        updateMessageReaction(message.id, false)\n      }\n    }\n  }\n\n  function updateMessageReaction(\n    messageId: ChatMessageInterface<U>['id'],\n    add: boolean = true,\n  ) {\n    const updatedMessage = messages.find((msg) => msg.id === messageId)\n\n    if (updatedMessage) {\n      dispatch({\n        action: MessagesActions.UPDATE,\n        message: {\n          ...updatedMessage,\n          reactions: {\n            thanks: {\n              count: Math.max(\n                (updatedMessage.reactions?.thanks?.count || 0) + (add ? 1 : -1),\n                0,\n              ),\n              haveMine: add,\n            },\n          },\n        },\n      })\n    }\n  }\n\n  return {\n    triggerScrollToBottom,\n    messages,\n    pendingMessages,\n    failedMessages,\n    onSendMessage,\n    onPrevScroll,\n    onRetry,\n    onRetryCancel,\n    onThanksClick,\n    onSendMessageEvent,\n    hasPrevMessage,\n    initializeRoomAndMember,\n  }\n}\n\nfunction findSenderFromRoomMembers<\n  T,\n  U,\n  V,\n  R extends ChatRoomDetailInterface<T, U, V>,\n>(room: R, me: ChatRoomUser<U>, sender?: ChatRoomMemberInterface<U>) {\n  if (sender) {\n    return room.members.find(\n      (member) =>\n        getUserIdentifier(member) === getUserIdentifier(sender) ||\n        (room.isDirect && getUserIdentifier(member) !== getUserIdentifier(me)),\n    ) as ChatRoomMemberInterface<U>\n  }\n}\n\nfunction filterPendingMessage<T>(\n  message: Required<ChatMessageData<T>>['message'],\n) {\n  return (pendingMessages: UnsentMessage<ChatMessageInterface<T>>[]) => {\n    return pendingMessages.filter(\n      ({ sender, payload }) =>\n        !sender ||\n        getUserIdentifier(sender) !== getUserIdentifier(message.sender) ||\n        !compareChatMessagePayloads(payload, message.payload),\n    )\n  }\n}\n\n/**\n * welcomeMessage의 타입인 'text'와 'product'의 경우에만 비교합니다.\n */\nfunction compareChatMessagePayloads<T extends ChatMessagePayloadType>(\n  payloadA: ChatMessageInterface<T>['payload'],\n  payloadB: ChatMessageInterface<T>['payload'],\n) {\n  if (payloadA.type === 'text' && payloadB.type === 'text') {\n    return payloadA.message === payloadB.message\n  }\n\n  if (payloadA.type === 'product' && payloadB.type === 'product') {\n    return payloadA.product.productName === payloadB.product.productName\n  }\n\n  return false\n}\n\nfunction ensureMessageWithNumberId<T>(message: ChatMessageInterface<T>) {\n  return {\n    ...message,\n    id: typeof message.id === 'number' ? message.id : Number(message.id),\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-room-messages/use-pending-intersections.ts",
    "content": "import { useVisibilityChange } from '@titicaca/react-hooks'\nimport { useRef, useEffect, useCallback } from 'react'\n\ninterface PendingIntersection {\n  entry: IntersectionObserverEntry\n  id: string | number\n  createdMessage?: boolean\n}\n\nexport function usePendingIntersections(\n  processFn: (params: PendingIntersection) => void,\n) {\n  const isPageVisibleRef = useRef(true)\n  const pendingIntersectionsRef = useRef<PendingIntersection[]>([])\n\n  const addPendingIntersection = (\n    pending: PendingIntersection,\n    add: boolean = false,\n  ) => {\n    if (add) {\n      pendingIntersectionsRef.current.push(pending)\n      return\n    }\n\n    pendingIntersectionsRef.current = [pending]\n  }\n\n  const processPendingIntersections = useCallback(() => {\n    if (pendingIntersectionsRef.current.length > 0) {\n      pendingIntersectionsRef.current.forEach((params) => {\n        processFn(params)\n      })\n      pendingIntersectionsRef.current = []\n    }\n  }, [processFn])\n\n  const removeAllPendingIntersections = () => {\n    pendingIntersectionsRef.current = []\n  }\n\n  useEffect(() => {\n    isPageVisibleRef.current = !document.hidden\n  }, [])\n\n  const handleVisibilityChange = (visible: boolean) => {\n    const wasHidden = isPageVisibleRef.current === false\n    isPageVisibleRef.current = visible\n\n    // 페이지가 숨겨졌다가 다시 보일 때 pending 작업들 처리\n    if (wasHidden && visible) {\n      processPendingIntersections()\n    }\n  }\n\n  useVisibilityChange(handleVisibilityChange, [processPendingIntersections])\n\n  const isVisible = () => isPageVisibleRef.current\n\n  return {\n    isVisible,\n    addPendingIntersection,\n    processPendingIntersections,\n    removeAllPendingIntersections,\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-room-messages/use-scroll.tsx",
    "content": "import { useEffect, useRef, useState } from 'react'\n\nimport { useScroll as useBaseScroll, ScrollOptions } from '../scroll-context'\n\nexport function useScroll() {\n  const { setScrollY, getScrollContainerHeight, scrollToBottom } =\n    useBaseScroll()\n\n  const [shouldScrollToBottom, setShouldScrollToBottom] =\n    useState<boolean>(false)\n  const scrollOptionsRef = useRef<ScrollOptions>({})\n\n  /** pusher의 이벤트 핸들러에서 scrollToBottom을 호출할 경우 이벤트가 늦게 발생하여 state로 우회합니다. */\n  useEffect(() => {\n    const resetScrollOptions = () => {\n      setShouldScrollToBottom(false)\n      scrollOptionsRef.current = {}\n    }\n\n    if (shouldScrollToBottom) {\n      scrollToBottom(scrollOptionsRef.current)\n      resetScrollOptions()\n    }\n  }, [shouldScrollToBottom, scrollToBottom])\n\n  return {\n    setScrollY,\n    getScrollContainerHeight,\n    triggerScrollToBottom: (options?: ScrollOptions) => {\n      setShouldScrollToBottom(true)\n      scrollOptionsRef.current = options || {}\n    },\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-room-messages/use-unread-messages.ts",
    "content": "import { useEffect, useState } from 'react'\n\nimport { useRoom } from '../room-context'\nimport { isCreatedChatRoom, OtherUnreadInterface, UserType } from '../../types'\nimport { shouldUseLegacyMemberId } from '../../utils'\n\nimport { useChatApiService } from './chat-message-context'\nimport { ChatRoomMessageInterface } from './messages'\n\nexport function useUnreadMessages<T = UserType>() {\n  const { room } = useRoom()\n\n  /**\n   * 마지막으로 읽은 메시지의 id\n   */\n  const [lastMessageId, setLastMessageId] = useState<number | undefined>(\n    isCreatedChatRoom(room) ? Number(room.lastMessageId) : 0,\n  )\n  const [otherReadInfo, setOtherReadInfo] = useState<OtherUnreadInterface[]>([])\n\n  const api = useChatApiService()\n\n  // TODO pusher unread 이벤트 api 리팩토링 완료시 해당 코드를 삭제합니다.\n  const handleUnreadEvent = async () => {\n    if (lastMessageId && isCreatedChatRoom(room)) {\n      try {\n        await api.updateLastSeenMessageId({\n          roomId: room.id,\n          lastSeenMessageId: lastMessageId,\n        })\n        // eslint-disable-next-line @typescript-eslint/no-unused-vars\n      } catch (error) {\n        return\n      }\n\n      try {\n        const { others: otherUnreadInfo } = await api.fetchGetUnreadRoom({\n          roomId: room.id,\n          lastSeenMessageId: lastMessageId,\n        })\n        setOtherReadInfo(otherUnreadInfo)\n        // eslint-disable-next-line @typescript-eslint/no-unused-vars\n      } catch (error) {\n        setOtherReadInfo([])\n      }\n    }\n  }\n\n  const calculateUnreadCount = ({\n    id,\n    sender,\n  }: ChatRoomMessageInterface<T>) => {\n    return otherReadInfo.reduce(\n      (prev, info) =>\n        Number(info.lastSeenMessageId) < Number(id) &&\n        (shouldUseLegacyMemberId(room) ? info.memberId : info.roomMemberId) !==\n          sender.id\n          ? prev + 1\n          : prev,\n      0,\n    )\n  }\n\n  // TODO pusher unread 이벤트 api 리팩토링이 완료시 주석을 해제합니다.\n  // const handleUnreadEvent = useCallback(\n  //   ({ otherUnreadInfo }: UpdatedChatData) => {\n  //     if (otherUnreadInfo) {\n  //       setOtherReadInfo(otherUnreadInfo.others)\n  //     }\n  //   },\n  //   [],\n  // )\n\n  // TODO pusher unread 이벤트 api 리팩토링 완료시 해당 코드를 삭제합니다.\n  useEffect(() => {\n    handleUnreadEvent()\n  }, [(room as { id?: string }).id, lastMessageId]) // eslint-disable-line react-hooks/exhaustive-deps\n\n  return {\n    calculateUnreadCount,\n    lastMessageId,\n    setLastMessageId,\n    // handleUnreadEvent\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/chat-scroll-container.tsx",
    "content": "import { PropsWithChildren, useEffect } from 'react'\nimport { InView } from 'react-intersection-observer'\nimport { Container } from '@titicaca/tds-ui'\n\nimport { useScroll } from './scroll-context'\n\nexport interface ChatScrollContainerProps {\n  onTopIntersecting?: (entry: IntersectionObserverEntry) => void\n  onBottomIntersecting?: (entry: IntersectionObserverEntry) => void\n  /**\n   * 스크롤 시 실행되는 리스너입니다.\n   * iOS에서 키보드가 내려가도록 closeKeyboard interface를 전달하여 사용할 수 있습니다.\n   * ref: https://github.com/titicacadev/triple-chat-web/pull/37\n   */\n  onTouchMove?: () => void\n}\n\n/** \n  스크롤이나 페이지네이션 로직을 사용할 수 있는 컨테이너입니다.\n  사용하기 위해서는 해당 컴포넌트 상위에 ScrollProvider가 등록되어야 합니다.\n*/\nexport function ChatScrollContainer({\n  onTopIntersecting,\n  onBottomIntersecting,\n  onTouchMove,\n  children,\n  ...props\n}: PropsWithChildren<ChatScrollContainerProps>) {\n  const { chatContainerRef, scrollContainerRef, bottomRef } = useScroll()\n\n  useEffect(() => {\n    const chatContainerElement = chatContainerRef.current\n\n    if (chatContainerElement && onTouchMove) {\n      chatContainerElement.addEventListener('touchmove', onTouchMove)\n      return () => {\n        chatContainerElement.removeEventListener('touchmove', onTouchMove)\n      }\n    }\n  }, []) // eslint-disable-line react-hooks/exhaustive-deps\n\n  return (\n    <Container {...props}>\n      <Container\n        ref={chatContainerRef}\n        css={{ height: 'inherit', overflowY: 'scroll' }}\n      >\n        <InView onChange={(_inView, entry) => onTopIntersecting?.(entry)} />\n\n        <Container ref={scrollContainerRef}>{children}</Container>\n\n        <InView onChange={(_inView, entry) => onBottomIntersecting?.(entry)}>\n          <div ref={bottomRef} />\n        </InView>\n      </Container>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/constants.ts",
    "content": "export const DEFAULT_MESSAGE_ID_PREFIX = 'message'\n\nexport const REPLY_BUTTON_DATA_ID = 'reply-button'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/index.ts",
    "content": "export * from './scroll-context'\nexport * from './constants'\nexport * from './chat-scroll-container'\nexport * from './messages-reducer'\nexport * from './chat-room-messages'\nexport * from './room-context'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/messages-reducer.ts",
    "content": "import { useReducer } from 'react'\n\nexport enum MessagesActions {\n  INIT = 'INIT', // 최초 메시지 세팅\n  PAST = 'PAST', // 이전 메세지 추가\n  NEW = 'NEW', // 최신 메세지 추가\n  UPDATE = 'UPDATE', // 메세지 업데이트\n  MULTIPLE_UPDATE = 'MULTIPLE_UPDATE', // 여러 메세지 업데이트\n  PENDING = 'PENDING', // 메세지 전송 응답 대기\n  FAIL = 'FAIL', // 메시지 전송 실패\n  REMOVE = 'REMOVE', // 전송 실패 메세지 삭제\n  HAS_PREV = 'HAS_PREV', // 이전 메세지 페이지네이션 플래그 설정\n  HAS_NEXT = 'HAS_NEXT', // 다음 메세지 페이지네이션 플래그 설정\n}\n\nexport type UnsentMessage<\n  Message extends MessageBase<Id, Sender>,\n  Id = string | number,\n  Sender = unknown,\n> = Omit<Message, 'createdAt' | 'sender'> & { sender?: Message['sender'] }\n\nexport interface MessageBase<Id = string | number, Sender = unknown> {\n  id: Id\n  createdAt?: string\n  parentMessage?: MessageBase<Id> | null\n  sender?: Sender\n}\n\nexport interface MessagesState<Message extends MessageBase<Id>, Id = string> {\n  messages: Message[]\n  pendingMessages: UnsentMessage<Message, Id>[]\n  failedMessages: UnsentMessage<Message, Id>[]\n  hasPrevMessage: boolean\n  hasNextMessage: boolean\n  /**\n   * [nol-chat] 다음 페이지 요청 cursor\n   * nol-chat에서는 hasPrevMessage 대신 prevToken을 사용\n   */\n  prevToken?: Id\n}\n\nexport const initialMessagesState = {\n  messages: [],\n  pendingMessages: [],\n  failedMessages: [],\n  hasPrevMessage: true,\n  hasNextMessage: true,\n}\n\nexport type MessagesAction<Message extends MessageBase<Id>, Id = string> =\n  | {\n      action: MessagesActions.INIT\n      messages: Message[]\n      prevToken?: Id\n    }\n  | {\n      action: MessagesActions.PAST\n      messages: Message[]\n      prevToken?: Id\n    }\n  | {\n      action: MessagesActions.NEW\n      messages: Message[]\n      filterPendingMessages?: (\n        pendingMessages: UnsentMessage<Message, Id>[],\n      ) => UnsentMessage<Message, Id>[]\n    }\n  | {\n      action: MessagesActions.UPDATE\n      message: Message\n    }\n  | {\n      action: MessagesActions.MULTIPLE_UPDATE\n      messages: Message[]\n    }\n  | {\n      action: MessagesActions.PENDING\n      message: UnsentMessage<Message, Id>\n    }\n  | {\n      action: MessagesActions.FAIL\n      message: UnsentMessage<Message, Id>\n    }\n  | {\n      action: MessagesActions.REMOVE\n      message: Pick<Message, 'id'>\n    }\n  | {\n      action: MessagesActions.HAS_PREV\n      hasPrevMessage: boolean\n    }\n  | {\n      action: MessagesActions.HAS_NEXT\n      hasNextMessage: boolean\n    }\n\nfunction MessagesReducer<Message extends MessageBase<Id>, Id = string>(\n  state: MessagesState<Message, Id>,\n  action: MessagesAction<Message, Id>,\n): MessagesState<Message, Id> {\n  switch (action.action) {\n    case MessagesActions.INIT:\n      return {\n        ...state,\n        messages: action.messages,\n        prevToken: action.prevToken,\n        ...('prevToken' in action && { hasPrevMessage: !!action.prevToken }),\n      }\n\n    case MessagesActions.PAST:\n      return {\n        ...state,\n        messages: [...action.messages, ...state.messages],\n        hasPrevMessage:\n          'prevToken' in action\n            ? !!action.prevToken\n            : action.messages.length > 0,\n        prevToken: action.prevToken,\n      }\n\n    case MessagesActions.NEW:\n      return {\n        ...state,\n        ...(action.filterPendingMessages && {\n          pendingMessages: action.filterPendingMessages(state.pendingMessages),\n        }),\n        messages: deduplicateAndSortMessages<Message, Id>(\n          state.messages,\n          action.messages,\n        ),\n      }\n\n    case MessagesActions.UPDATE:\n      return {\n        ...state,\n        messages: state.messages.map((message) => {\n          if (message.id === action.message.id) {\n            return { ...message, ...action.message }\n          }\n\n          if (message.parentMessage?.id === action.message.id) {\n            return {\n              ...message,\n              parentMessage: { ...message.parentMessage, ...action.message },\n            }\n          }\n\n          return message\n        }),\n      }\n\n    case MessagesActions.MULTIPLE_UPDATE:\n      return {\n        ...state,\n        messages: state.messages.map((message) => {\n          const updatedMessage = action.messages.find(\n            (updated) => updated.id === message.id,\n          )\n          return updatedMessage || message\n        }),\n      }\n\n    case MessagesActions.PENDING:\n      return {\n        ...state,\n        pendingMessages: [...state.pendingMessages, action.message],\n        failedMessages: state.failedMessages.filter(\n          (message) => message.id !== action.message.id,\n        ),\n      }\n\n    case MessagesActions.FAIL:\n      return {\n        ...state,\n        pendingMessages: state.pendingMessages.filter(\n          (message) => message.id !== action.message.id,\n        ),\n        failedMessages: [...state.failedMessages, action.message],\n      }\n\n    case MessagesActions.REMOVE:\n      return {\n        ...state,\n        pendingMessages: state.pendingMessages.filter(\n          (message) => message.id !== action.message.id,\n        ),\n        failedMessages: state.failedMessages.filter(\n          (message) => message.id !== action.message.id,\n        ),\n      }\n\n    case MessagesActions.HAS_PREV:\n      return {\n        ...state,\n        hasPrevMessage: action.hasPrevMessage,\n      }\n\n    case MessagesActions.HAS_NEXT:\n      return {\n        ...state,\n        hasNextMessage: action.hasNextMessage,\n      }\n\n    default:\n      throw new Error('unexpected action')\n  }\n}\n\nexport function useMessagesReducer<\n  Message extends MessageBase<Id>,\n  Id = string,\n>() {\n  return useReducer<\n    React.Reducer<MessagesState<Message, Id>, MessagesAction<Message, Id>>\n  >(MessagesReducer, initialMessagesState)\n}\n\nfunction deduplicateAndSortMessages<\n  Message extends MessageBase<Id>,\n  Id = string,\n>(messagesA: Message[], messagesB: Message[]) {\n  const copiedMessages = [...messagesA, ...messagesB]\n\n  const deduplicatedMessages = copiedMessages.filter(\n    (messageInFilter, index) =>\n      index ===\n      copiedMessages.findIndex(\n        (messageInFindIndex) => messageInFilter.id === messageInFindIndex.id,\n      ),\n  )\n\n  deduplicatedMessages.sort(\n    (a, b) =>\n      new Date(a.createdAt || '').getTime() -\n      new Date(b.createdAt || '').getTime(),\n  )\n\n  return deduplicatedMessages\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/room-context.tsx",
    "content": "import { createContext, useContext, PropsWithChildren, useState } from 'react'\nimport deepmerge from 'deepmerge'\n\nimport type { ChatRoomInterface, ChatRoomUser } from '../types'\n\nexport interface RoomProviderProps<T = ChatRoomInterface, U = ChatRoomUser> {\n  room: T\n  me: U\n}\n\ninterface RoomContextValue<T = ChatRoomInterface, U = ChatRoomUser> {\n  room: T\n  me: U\n  updateRoom: (room: Partial<T>, options?: { deepMerge?: boolean }) => void\n  updateMe: (me: U) => void\n}\n\nexport const RoomContext = createContext<RoomContextValue | null>(null)\n\nexport function RoomProvider<T = ChatRoomInterface, U = ChatRoomUser>({\n  room: initialRoom,\n  me: initialMe,\n  children,\n}: PropsWithChildren<RoomProviderProps<T, U>>) {\n  const [room, setRoom] = useState<T>(initialRoom)\n  const [me, setMe] = useState<U>(initialMe)\n\n  const updateMe = (me: U) => {\n    setMe((prev) => ({ ...prev, ...me }))\n  }\n\n  const updateRoom = (room: Partial<T>, options?: { deepMerge?: boolean }) => {\n    setRoom((prevRoom) =>\n      options?.deepMerge ? deepmerge(prevRoom, room) : { ...prevRoom, ...room },\n    )\n  }\n\n  const value = {\n    room,\n    me,\n    updateRoom,\n    updateMe,\n  } as unknown as RoomContextValue\n\n  return <RoomContext.Provider value={value}>{children}</RoomContext.Provider>\n}\n\nexport function useRoom<T = ChatRoomInterface, U = ChatRoomUser>() {\n  const context = useContext(RoomContext)\n\n  if (!context) {\n    throw new Error('Room context가 존재하지 않습니다.')\n  }\n  return context as unknown as RoomContextValue<T, U>\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/chat/scroll-context.tsx",
    "content": "import {\n  createContext,\n  Dispatch,\n  MutableRefObject,\n  SetStateAction,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useContext,\n  ReactNode,\n} from 'react'\n\nimport { DEFAULT_MESSAGE_ID_PREFIX } from './constants'\n\nconst useLayoutEffectSafeInSsr =\n  typeof window === 'undefined' ? useEffect : useLayoutEffect\n\nexport interface ScrollOptions {\n  /** 최하단으로 이동하기 위해 페이지네이션 fetching이 필요할 경우 true로 설정해주세요. */\n  shouldFetchRecentPage?: boolean\n  /**\n    메세지 스크롤 시 해당 메세지가 화면에 존재하지 않을 경우 실행하는 함수입니다.\n    페이지네이션 등에 활용할 수 있습니다.\n  */\n  handleNonExistentMessage?: (messageId?: string | number) => void\n  scrollBehavior?: ScrollBehavior\n}\n\nexport interface ScrollContextValue {\n  /** ChatScrollContainer의 최하단(bottomRef)으로 이동합니다. */\n  scrollToBottom: (options?: ScrollOptions) => void\n  /** 해당 message로 이동합니다. 사용하기 전, 메세지 노드에 messageIdPrefix를 넣은 id를 추가해야 합니다. */\n  scrollToMessage: (messageId: string, options?: ScrollOptions) => void\n  /** 절대적인 좌표로 스크롤합니다. 페이지네이션, 메세지 이동 등에 사용할 수 있습니다. */\n  setScrollY: Dispatch<SetStateAction<number | null>>\n  /** 상대적인 좌표로 스크롤합니다. input resize 이벤트, 키보드 이벤트 등에 사용할 수 있습니다. */\n  setScrollBy: Dispatch<SetStateAction<number | null>>\n  /** setScrollY, setScrollBy가 실행되지 않도록 설정했는지의 여부입니다. */\n  scrollPrevented: boolean\n  /** setScrollY, setScrollBy가 실행되지 않도록 설정할 수 있습니다. */\n  preventScroll: () => void\n  /** 스크롤을 하기위한 element ref입니다. ChatScrollContainer 컴포넌트에서 사용합니다.  */\n  chatContainerRef: MutableRefObject<HTMLDivElement | null>\n  /** 스크롤값을 계산하기 위한 ref입니다. ChatScrollContainer 컴포넌트에서 사용합니다.  */\n  scrollContainerRef: MutableRefObject<HTMLDivElement | null>\n  /** 최하단으로 이동하기 위한 ref입니다. ChatScrollContainer 컴포넌트에서 사용합니다. */\n  bottomRef: MutableRefObject<HTMLDivElement | null>\n  /** scrollContainerRef가 걸린 element의 height를 반환합니다 */\n  getScrollContainerHeight: () => number\n}\n\nexport const ScrollContext = createContext<ScrollContextValue | null>(null)\n\nexport function ScrollProvider({ children }: { children: ReactNode }) {\n  const chatContainerRef = useRef<HTMLDivElement | null>(null)\n  const scrollContainerRef = useRef<HTMLDivElement | null>(null)\n  const bottomRef = useRef<HTMLDivElement | null>(null)\n\n  const [scrollY, setScrollY] = useState<number | null>(null)\n  const [scrollBy, setScrollBy] = useState<number | null>(null)\n  const [scrollPrevented, setScrollPrevented] = useState<boolean>(false)\n\n  const scrollToBottom = ({\n    shouldFetchRecentPage,\n    handleNonExistentMessage,\n    scrollBehavior = 'smooth',\n  }: ScrollOptions = {}) => {\n    if (shouldFetchRecentPage) {\n      return handleNonExistentMessage?.()\n    }\n\n    if (bottomRef && bottomRef.current) {\n      /* \n        iOS 스크롤 시 화면이 보이지 않는 현상을 위해 추가합니다.\n        ref: https://github.com/titicacadev/triple-geochat-web/pull/99  \n      */\n      if (scrollBehavior !== 'smooth' && chatContainerRef.current) {\n        chatContainerRef.current.style.overflowY = 'hidden'\n      }\n\n      bottomRef.current.scrollIntoView({ behavior: scrollBehavior })\n\n      if (scrollBehavior !== 'smooth' && chatContainerRef.current) {\n        chatContainerRef.current.style.overflowY = 'scroll'\n      }\n    }\n  }\n\n  const scrollToMessage = (\n    messageId: string,\n    options: Pick<ScrollOptions, 'handleNonExistentMessage'> = {},\n  ) => {\n    const messageElement = document.getElementById(\n      `${DEFAULT_MESSAGE_ID_PREFIX}-${messageId}`,\n    )\n\n    if (messageElement) {\n      messageElement.scrollIntoView({ behavior: 'smooth' })\n    } else {\n      options.handleNonExistentMessage?.(messageId)\n    }\n  }\n\n  const getScrollContainerHeight = () => {\n    return scrollContainerRef.current?.getBoundingClientRect().height || 0\n  }\n\n  const preventScroll = () => {\n    setScrollPrevented(true)\n  }\n\n  useLayoutEffectSafeInSsr(() => {\n    if (scrollY !== null && chatContainerRef.current && !scrollPrevented) {\n      /* \n        iOS 스크롤 시 화면이 보이지 않는 현상을 위해 추가합니다.\n        ref: https://github.com/titicacadev/triple-geochat-web/pull/99  \n      */\n      chatContainerRef.current.style.overflowY = 'hidden'\n      chatContainerRef.current.scrollTo(0, getScrollContainerHeight() - scrollY)\n      chatContainerRef.current.style.overflowY = 'scroll'\n    }\n\n    if (scrollPrevented) {\n      setScrollPrevented(false)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [chatContainerRef, scrollY])\n\n  useLayoutEffectSafeInSsr(() => {\n    if (scrollBy !== null && chatContainerRef.current && !scrollPrevented) {\n      chatContainerRef.current.scrollBy({ top: scrollBy })\n      setScrollBy(null)\n    }\n\n    if (scrollPrevented) {\n      setScrollPrevented(false)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [chatContainerRef, scrollBy])\n\n  const value = {\n    scrollToBottom,\n    scrollToMessage,\n    setScrollY,\n    setScrollBy,\n    scrollPrevented,\n    preventScroll,\n    chatContainerRef,\n    scrollContainerRef,\n    bottomRef,\n    getScrollContainerHeight,\n  }\n\n  return (\n    <ScrollContext.Provider value={value}>{children}</ScrollContext.Provider>\n  )\n}\n\nexport function useScroll() {\n  const context = useContext(ScrollContext)\n\n  if (!context) {\n    throw new Error(\n      'ChatScrollContainer 사용 시 컴포넌트 상위에 ScrollProvider를 등록해야 합니다',\n    )\n  }\n  return context\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/expired/elements.tsx",
    "content": "import styled, { css } from 'styled-components'\nimport { Container as BaseContainer } from '@titicaca/tds-ui'\n\nimport TalkIconBase from '../icons/talk-icon'\n\nconst buttonIconCss = css`\n  width: 16px;\n  height: 16px;\n  display: inline-block;\n  vertical-align: text-top;\n`\n\nexport const TalkIcon = styled(TalkIconBase)`\n  ${buttonIconCss}\n`\n\nexport const ButtonIcon = styled.div<{ src: string }>`\n  ${buttonIconCss}\n\n  background-image: url(${({ src }) => src});\n  background-size: 16px 16px;\n`\n\nexport const Button = styled.button.attrs({ type: 'button' })`\n  padding: 8px 16px;\n  color: ${({ theme }) => theme.nol.colorNeutralB100};\n  font-weight: 400;\n  border: 1px solid ${({ theme }) => theme.nol.colorNeutralB15};\n  border-radius: 8px;\n  font-size: 14px;\n  line-height: 19px;\n  background-color: ${({ theme }) => theme.nol.colorNeutralW100};\n\n  ${ButtonIcon} {\n    margin-right: 6px;\n  }\n\n  ${TalkIcon} {\n    margin-right: 6px;\n  }\n`\n\nexport const Container = styled(BaseContainer)`\n  background-color: #eff1fa;\n  padding: 30px 26px 24px;\n  font-size: 14px;\n  line-height: 19px;\n  align-items: center;\n  text-align: center;\n  color: #666;\n  font-weight: 400;\n  white-space: pre-line;\n\n  h2 {\n    font-size: 16px;\n    line-height: 22px;\n    font-weight: 700;\n    color: ${({ theme }) => theme.nol.colorNeutralB100};\n    margin-bottom: 4px;\n  }\n\n  ${Button} {\n    margin-top: 12px;\n\n    & + ${Button} {\n      margin-top: 8px;\n    }\n  }\n`\n"
  },
  {
    "path": "packages/tds-widget/src/chat/expired/expired.stories.tsx",
    "content": "import { ComponentProps } from 'react'\nimport type { Meta, StoryFn } from '@storybook/react'\n\nimport { NolThemeProvider } from '../nol-theme-provider'\n\nimport { Expired } from './expired'\n\nexport default {\n  title: 'tds-widget / chat / Expired',\n  component: Expired,\n  decorators: [\n    (Story) => (\n      <NolThemeProvider\n        theme={{\n          'color-neutral-b-100': 'rgba(41, 41, 45, 1)',\n          'color-neutral-b-15': 'rgba(41, 41, 45, 0.15)',\n          'color-neutral-b-10': 'rgba(41, 41, 45, 0.1)',\n          'color-neutral-w-100': 'rgba(255, 255, 255, 1)',\n        }}\n      >\n        <Story />\n      </NolThemeProvider>\n    ),\n  ],\n} as Meta<ComponentProps<typeof Expired>>\n\nexport const Default: StoryFn<ComponentProps<typeof Expired>> = () => (\n  <Expired />\n)\n"
  },
  {
    "path": "packages/tds-widget/src/chat/expired/expired.tsx",
    "content": "import { ForwardedRef, forwardRef, PropsWithChildren } from 'react'\n\nimport { Button, Container, TalkIcon } from './elements'\n\ninterface ExpiredProps {\n  description?: string\n  onChatRestart?: () => void\n  restartButtonText?: string\n}\n\nconst DEFAULT_DESCRIPTION = `추가 문의가 필요하신 경우\\n파트너에게 새로운 채팅으로 문의해주세요.`\n\n/**\n * nol-theme-provider를 사용하는 컴포넌트 입니다.\n */\nexport function ExpiredImpl(\n  {\n    description = DEFAULT_DESCRIPTION,\n    onChatRestart,\n    restartButtonText,\n    children,\n    ...props\n  }: PropsWithChildren<ExpiredProps>,\n  ref: ForwardedRef<HTMLDivElement>,\n) {\n  return (\n    <Container {...props} ref={ref}>\n      <h2>대화가 종료된 채팅입니다</h2>\n      <p>{description}</p>\n      {onChatRestart ? (\n        <Button onClick={onChatRestart}>\n          <TalkIcon />\n          {restartButtonText || '새로운 채팅 시작하기'}\n        </Button>\n      ) : null}\n      {children}\n    </Container>\n  )\n}\n\nexport const Expired = forwardRef(ExpiredImpl)\n"
  },
  {
    "path": "packages/tds-widget/src/chat/expired/index.ts",
    "content": "export * from './expired'\nexport {\n  Button as ExpiredButton,\n  ButtonIcon as ExpiredButtonIcon,\n} from './elements'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/icons/ExclamationMarkIcon.tsx",
    "content": "import { styled, CSSProp } from 'styled-components'\n\nconst Path = styled.path<{ stroke?: CSSProp; fill?: CSSProp }>`\n  stroke: ${({ stroke }) => stroke};\n  fill: ${({ fill }) => fill};\n`\n\nexport default function ExclamationMarkIcon({\n  color = '#3A3A3A',\n}: {\n  color?: CSSProp\n}) {\n  return (\n    <svg\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      style={{ flex: '0 0 16px' }}\n    >\n      <Path\n        d=\"M7.99995 14.3C11.4793 14.3 14.3 11.4794 14.3 8.00001C14.3 4.52062 11.4793 1.70001 7.99995 1.70001C4.52056 1.70001 1.69995 4.52062 1.69995 8.00001C1.69995 11.4794 4.52056 14.3 7.99995 14.3Z\"\n        stroke={color}\n        strokeWidth=\"1.2\"\n      />\n      <Path\n        d=\"M7.38477 9.03192L7.26758 4.35419H8.73242L8.61523 9.03192H7.38477ZM8 11.4587C7.55566 11.4587 7.2041 11.1218 7.2041 10.6921C7.2041 10.2624 7.55566 9.92548 8 9.92548C8.44434 9.92548 8.7959 10.2624 8.7959 10.6921C8.7959 11.1218 8.44434 11.4587 8 11.4587Z\"\n        fill={color}\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/icons/arrow-bottom-16-icon.tsx",
    "content": "export function ArrowBottom16Icon({\n  color = '#545457',\n  ...props\n}: {\n  color?: string\n}) {\n  return (\n    <svg\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M14 5.07011L8.03526 11L2 5\"\n        stroke={color}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/icons/arrow-right-icon.tsx",
    "content": "import { CSSProperties } from 'react'\n\nexport function ArrowRight({\n  color = '#3A3A3A',\n  style,\n}: {\n  color?: string\n  style?: CSSProperties\n}) {\n  return (\n    <svg\n      width=\"6\"\n      height=\"10\"\n      viewBox=\"0 0 6 10\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      style={style}\n    >\n      <path\n        d=\"M1.04674 1L5 4.97649L1 9\"\n        stroke={color}\n        strokeOpacity=\"1\"\n        strokeWidth=\"1.2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/icons/arrow-top-icon.tsx",
    "content": "export default function ArrowTopIcon({\n  color = '#29292D',\n  ...props\n}: {\n  color?: string\n}) {\n  return (\n    <svg\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 12 12\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M10 8L6 4L2 8\"\n        stroke={color}\n        strokeWidth=\"1.3\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/icons/reply-meesage-icon.tsx",
    "content": "export function ReplyMessageIcon() {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <rect width=\"14\" height=\"14\" rx=\"3\" fill=\"#EAF2F2\" />\n      <path\n        d=\"M8 6L10 8.05594L8 10\"\n        stroke=\"#A8ADAD\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M9 8L5 8C4.44772 8 4 7.55228 4 7L4 4\"\n        stroke=\"#A8ADAD\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/icons/reply-message-icon.tsx",
    "content": "export function ReplyMessageIcon() {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <rect width=\"14\" height=\"14\" rx=\"3\" fill=\"#EAF2F2\" />\n      <path\n        d=\"M8 6L10 8.05594L8 10\"\n        stroke=\"#A8ADAD\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M9 8L5 8C4.44772 8 4 7.55228 4 7L4 4\"\n        stroke=\"#A8ADAD\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/icons/select-photo-icon.tsx",
    "content": "export default function SelectPhotoIcon({ ...props }) {\n  return (\n    <svg\n      width=\"26\"\n      height=\"26\"\n      viewBox=\"0 0 26 26\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <mask\n        id=\"mask0_242_8844\"\n        style={{ maskType: 'alpha' }}\n        maskUnits=\"userSpaceOnUse\"\n        x=\"2\"\n        y=\"2\"\n        width=\"22\"\n        height=\"22\"\n      >\n        <rect x=\"2.5\" y=\"2.5\" width=\"21\" height=\"21\" rx=\"4\" fill=\"#D9D9D9\" />\n      </mask>\n      <g mask=\"url(#mask0_242_8844)\">\n        <rect x=\"2.5\" y=\"2.5\" width=\"21\" height=\"21\" rx=\"4\" fill=\"#7E7E81\" />\n        <path\n          d=\"M20.4262 14.9262L25.0899 19.5899C25.3525 19.8525 25.5 20.2086 25.5 20.5799V25.6C25.5 26.3732 24.8732 27 24.1 27H2.9001C2.1269 27 1.5001 26.3732 1.5001 25.6V17.5498C1.5001 17.1964 1.63373 16.8561 1.87418 16.5971L7.01143 11.0646C7.55138 10.4831 8.4662 10.4662 9.0273 11.0273L14.5738 16.5738C15.095 17.095 15.931 17.1229 16.4857 16.6375L18.5143 14.8625C19.069 14.3771 19.905 14.405 20.4262 14.9262Z\"\n          stroke=\"#F9FAFF\"\n          strokeWidth=\"1.96429\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <circle cx=\"17\" cy=\"8\" r=\"2\" fill=\"#F9FAFF\" />\n      </g>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/icons/send-icon.tsx",
    "content": "export default function SendIcon({\n  color = '#545457',\n  ...props\n}: {\n  color?: string\n}) {\n  return (\n    <svg\n      width=\"20\"\n      height=\"20\"\n      viewBox=\"0 0 20 20\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M19.2821 2.57764C19.7173 1.37025 18.5423 0.205275 17.3387 0.650773L1.67294 6.44915C0.318803 6.94094 0.264353 8.83827 1.59063 9.40588L1.59432 9.40745L6.88585 11.6472L11.9 6.54897C12.292 6.15034 12.9277 6.15034 13.3197 6.54897C13.7118 6.9476 13.7118 7.59391 13.3197 7.99254L8.30319 13.0932L10.6197 18.4764C11.1807 19.8033 13.0783 19.7598 13.5767 18.4061L19.2821 2.57764Z\"\n        fill={color}\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/icons/talk-icon.tsx",
    "content": "export default function TalkIcon({\n  color = '#29292D',\n  ...props\n}: {\n  color?: string\n}) {\n  return (\n    <svg\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <mask id=\"path-1-inside-1_987_10529\" fill=\"white\">\n        <path\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M8 0.5C12.1421 0.5 15.5 3.85786 15.5 8C15.5 9.51665 15.0498 10.9282 14.2758 12.1082L14.75 14.4793C14.8524 14.991 14.3893 15.4361 13.882 15.3137L11.3629 14.7056C10.3514 15.2139 9.20913 15.5 8 15.5C3.85786 15.5 0.5 12.1421 0.5 8C0.5 3.85786 3.85786 0.5 8 0.5Z\"\n        />\n      </mask>\n      <path\n        d=\"M14.2758 12.1082L13.0991 12.3435C13.0373 12.0345 13.0995 11.7135 13.2724 11.45L14.2758 12.1082ZM14.75 14.4793L15.9267 14.2439H15.9267L14.75 14.4793ZM13.882 15.3137L14.1636 14.1472L14.1636 14.1472L13.882 15.3137ZM11.3629 14.7056L10.8241 13.6334C11.0778 13.5059 11.3685 13.4725 11.6445 13.5391L11.3629 14.7056ZM14.3 8C14.3 4.52061 11.4794 1.7 8 1.7V-0.7C12.8049 -0.7 16.7 3.19512 16.7 8H14.3ZM13.2724 11.45C13.9221 10.4595 14.3 9.27573 14.3 8H16.7C16.7 9.75756 16.1776 11.3968 15.2792 12.7663L13.2724 11.45ZM15.4525 11.8728L15.9267 14.2439L13.5733 14.7146L13.0991 12.3435L15.4525 11.8728ZM15.9267 14.2439C16.201 15.6153 14.9599 16.8083 13.6004 16.4802L14.1636 14.1472C13.8186 14.0639 13.5037 14.3666 13.5733 14.7146L15.9267 14.2439ZM13.6004 16.4802L11.0813 15.8721L11.6445 13.5391L14.1636 14.1472L13.6004 16.4802ZM8 14.3C9.01783 14.3 9.97603 14.0595 10.8241 13.6334L11.9017 15.7779C10.7268 16.3682 9.40044 16.7 8 16.7V14.3ZM1.7 8C1.7 11.4794 4.52061 14.3 8 14.3V16.7C3.19512 16.7 -0.700001 12.8049 -0.700001 8H1.7ZM8 1.7C4.52061 1.7 1.7 4.52061 1.7 8H-0.700001C-0.700001 3.19512 3.19512 -0.7 8 -0.7V1.7Z\"\n        fill={color}\n        mask=\"url(#path-1-inside-1_987_10529)\"\n      />\n      <circle\n        cx=\"0.878049\"\n        cy=\"0.878049\"\n        r=\"0.878049\"\n        transform=\"matrix(-1 0 0 1 12.3896 7.26758)\"\n        fill={color}\n      />\n      <circle\n        cx=\"0.878049\"\n        cy=\"0.878049\"\n        r=\"0.878049\"\n        transform=\"matrix(-1 0 0 1 8.91455 7.26758)\"\n        fill={color}\n      />\n      <circle\n        cx=\"0.878049\"\n        cy=\"0.878049\"\n        r=\"0.878049\"\n        transform=\"matrix(-1 0 0 1 5.43848 7.26758)\"\n        fill={color}\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/icons/text-full-view-arrow-icon.tsx",
    "content": "export function TextFullViewArrowIcon({\n  color = '#545457',\n}: {\n  color?: string\n}) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 12 12\"\n      fill=\"none\"\n      style={{ margin: '0 0 2px' }}\n    >\n      <path\n        d=\"M4 2L8 5.97649L4 10\"\n        stroke={color}\n        strokeWidth=\"1.3\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/index.ts",
    "content": "export * from './types'\nexport * from './bubble'\nexport * from './bubble-container'\nexport * from './chat'\nexport * from './utils'\nexport * from './navbar'\nexport * from './input-area'\nexport { default as Messages } from './messages'\nexport * from './reservation-info'\nexport * from './expired'\nexport * from './preview'\nexport * from './list'\nexport * from './scroll-buttons-area'\nexport * from './nol-theme-provider'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/input-area/index.tsx",
    "content": "export * from './input-area-ui'\nexport {\n  NolInputAreaUI,\n  INPUT_AREA_HEIGHT as NOL_INPUT_AREA_HEIGHT,\n} from './nol-input-area-ui'\nexport { type NolInputAreaUIProps } from './nol-input-area-ui/types'\nexport * from './nol-input-area-ui/use-input-resize-observer'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/input-area/input-area-ui/elements.ts",
    "content": "import { Container } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\n\nexport const MainContainer = styled(Container)`\n  position: sticky;\n  bottom: 0;\n  display: flex;\n  align-items: flex-end;\n  gap: 12px;\n  border-top: 1px solid var(--color-gray100);\n  padding: 10px 12px;\n  background-color: var(--color-white);\n`\n\nexport const InputArea = styled(Container)`\n  display: flex;\n  align-items: flex-end;\n  gap: 20px;\n  width: 100%;\n  border-radius: 15px;\n  border: 1px solid var(--color-gray100);\n  background-color: var(--color-gray20);\n  padding: 10px 15px;\n`\n\nexport const UploadImageButton = styled.label`\n  width: 26px;\n  height: 26px;\n  margin-bottom: 8px;\n  background: url('https://assets.triple.guide/images/img-review-select-photo@3x.png')\n    no-repeat 0 0;\n  background-size: 26px 26px;\n`\n\nexport const FileInput = styled.input`\n  position: absolute;\n  visibility: hidden;\n  width: 260px;\n`\n\nexport const TextArea = styled.textarea`\n  width: 100%;\n  height: 20px;\n  min-height: 20px;\n  color: var(--color-black);\n  font-size: 15px;\n  background-color: transparent;\n  overflow-y: scroll;\n  line-height: 20px;\n  resize: none;\n  border: none;\n  outline: none;\n  box-shadow: none;\n\n  &::placeholder {\n    color: var(--color-gray300);\n  }\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n`\n\nexport const SendMessageButton = styled.button<{ $color?: 'mint' | 'blue' }>`\n  flex-shrink: 0;\n  background: transparent;\n  color: ${({ $color }) =>\n    $color === 'mint' ? 'var(--color-mint)' : 'var(--color-blue)'};\n  font-size: 15px;\n  font-weight: 700;\n  outline: none;\n`\n"
  },
  {
    "path": "packages/tds-widget/src/chat/input-area/input-area-ui/index.tsx",
    "content": "import { ChangeEventHandler, KeyboardEventHandler, useRef } from 'react'\n\nimport { handleSendClick, textAreaAutoResize } from '../utils'\n\nimport {\n  FileInput,\n  InputArea,\n  MainContainer,\n  SendMessageButton,\n  TextArea,\n  UploadImageButton,\n} from './elements'\n\nconst MIN_TEXTAREA_HEIGHT = 20\nconst MAX_TEXTAREA_HEIGHT = 100\nconst MAX_TEXT_LENGTH = 2000\n\nexport interface InputAreaUIProps {\n  inputValue: string\n  placeholder?: string\n  setInputValue: (value: string) => void\n  onImageUpload: ChangeEventHandler<HTMLInputElement>\n  onSendMessage: () => void\n  onInputClick?: () => void\n  onInputKeydown?: KeyboardEventHandler\n  buttonColor?: 'mint' | 'blue'\n  buttonText?: string\n  buttonDisabled?: boolean\n  maxTextLength?: number\n  multipleImageUpload?: boolean\n  dismissKeyboardOnSend?: boolean\n}\n\nexport function InputAreaUI({\n  buttonColor = 'blue',\n  buttonText,\n  buttonDisabled,\n  inputValue,\n  setInputValue,\n  placeholder,\n  onImageUpload,\n  onSendMessage,\n  onInputClick,\n  onInputKeydown,\n  maxTextLength = MAX_TEXT_LENGTH,\n  multipleImageUpload = false,\n  dismissKeyboardOnSend = true,\n  ...props\n}: InputAreaUIProps) {\n  const textareaRef = useRef<HTMLTextAreaElement>(null)\n\n  return (\n    <MainContainer {...props}>\n      <UploadImageButton htmlFor=\"image_upload\" />\n      <FileInput\n        id=\"image_upload\"\n        type=\"file\"\n        name=\"file\"\n        accept=\"image/png, image/jpeg\"\n        multiple={multipleImageUpload}\n        onChange={onImageUpload}\n      />\n\n      <InputArea>\n        <TextArea\n          value={inputValue}\n          onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {\n            setInputValue(e.target.value)\n            textAreaAutoResize(e, MAX_TEXTAREA_HEIGHT)\n          }}\n          onKeyDown={onInputKeydown}\n          onClick={onInputClick}\n          ref={textareaRef}\n          placeholder={placeholder}\n          maxLength={maxTextLength}\n        />\n        <SendMessageButton\n          $color={buttonColor}\n          onClick={(e) =>\n            handleSendClick(e, {\n              textareaRef,\n              inputValue,\n              onSendMessage,\n              dismissKeyboardOnSend,\n              minHeight: MIN_TEXTAREA_HEIGHT,\n            })\n          }\n          disabled={buttonDisabled}\n        >\n          {buttonText || '보내기'}\n        </SendMessageButton>\n      </InputArea>\n    </MainContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/input-area/input-area.stories.tsx",
    "content": "import { useState } from 'react'\nimport type { Meta, StoryFn } from '@storybook/react'\n\nimport { NolThemeProvider } from '../nol-theme-provider'\n\nimport { InputAreaUI, InputAreaUIProps, NolInputAreaUI } from './index'\n\nexport default {\n  title: 'tds-widget / chat / InputArea',\n  component: InputAreaUI,\n  argTypes: {},\n} as Meta<typeof InputAreaUI>\n\nconst Template: StoryFn<InputAreaUIProps> = (args) => <InputAreaUI {...args} />\n\nexport const Default = {\n  render: ({\n    inputValue: initialInputValue,\n    ...args\n  }: Omit<InputAreaUIProps, 'setInputValue'>) => {\n    const [inputValue, setInputValue] = useState(initialInputValue)\n\n    return (\n      <Template\n        inputValue={inputValue}\n        setInputValue={setInputValue}\n        {...args}\n      />\n    )\n  },\n\n  args: {\n    placeholder: '문의 내용을 입력하세요',\n    inputValue: '',\n    onImageUpload: () => {},\n    onSendMessage: () => {},\n  },\n}\n\nconst NolTemplate: StoryFn<InputAreaUIProps> = (args) => (\n  <NolThemeProvider\n    theme={{\n      'color-neutral-b-100': 'rgba(41, 41, 45, 1)',\n      'color-neutral-w-100': 'rgba(255, 255, 255, 1)',\n      'color-neutral-g-50': 'rgba(148, 148, 150, 1)',\n      'color-neutral-g-15': 'rgba(223, 223, 224, 1)',\n      'color-primary-nol': 'rgba(65, 84, 255, 1)',\n    }}\n  >\n    <NolInputAreaUI {...args} />\n  </NolThemeProvider>\n)\n\nexport const Nol = {\n  render: ({\n    inputValue: initialInputValue,\n    ...args\n  }: Omit<InputAreaUIProps, 'setInputValue'>) => {\n    const [inputValue, setInputValue] = useState(initialInputValue)\n\n    return (\n      <NolTemplate\n        inputValue={inputValue}\n        setInputValue={setInputValue}\n        {...args}\n      />\n    )\n  },\n\n  args: {\n    placeholder: '문의 내용을 입력하세요',\n    inputValue: '',\n    onImageUpload: () => {},\n    onSendMessage: () => {},\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/input-area/nol-input-area-ui/index.tsx",
    "content": "import { Container } from '@titicaca/tds-ui'\nimport { ChangeEvent, ForwardedRef, forwardRef, useRef } from 'react'\nimport styled from 'styled-components'\n\nimport SelectPhotoIcon from '../../icons/select-photo-icon'\nimport { handleSendClick, textAreaAutoResize } from '../utils'\nimport SendIcon from '../../icons/send-icon'\n\nimport { NolInputAreaUIProps } from './types'\n\nconst MAX_TEXT_LENGTH = 1000\nconst TEXTAREA_MIN_HEIGHT = 19\nconst TEXTAREA_MAX_HEIGHT = 110\nexport const INPUT_AREA_HEIGHT = 60\n\nconst InputAreaContainer = styled(Container)`\n  padding: 8px 16px;\n  display: flex;\n  flex-direction: row;\n  align-items: flex-end;\n  gap: 12px;\n`\n\nconst TextArea = styled.textarea<{\n  $color?: string\n  placeholderColor?: string\n}>`\n  flex-grow: 1;\n  height: ${TEXTAREA_MIN_HEIGHT}px;\n  resize: none;\n  border: none;\n  outline: none;\n  box-shadow: none;\n  font-size: 14px;\n  line-height: ${TEXTAREA_MIN_HEIGHT}px;\n  color: ${({ color, theme }) => color || theme.nol.colorNeutralB100};\n\n  &::placeholder {\n    color: ${({ placeholderColor, theme }) =>\n      placeholderColor || theme.nol.colorNeutralG30};\n  }\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n`\n\nconst SendButton = styled.button<{\n  disabled: boolean\n  activeButtonColor?: string\n}>`\n  border: none;\n  padding: 6px 14px;\n  border-radius: 17px;\n  background-color: ${({\n    theme,\n    disabled,\n    activeButtonColor = theme.nol.colorPrimaryNol,\n  }) => (disabled ? 'transparent' : activeButtonColor)};\n  cursor: pointer;\n  flex-shrink: 0;\n  font-size: 0;\n  align-self: flex-end;\n\n  > img {\n    width: 20px;\n    height: 20px;\n  }\n`\n\nconst UploadImageButtonWrapper = styled.div`\n  display: flex;\n  align-items: center;\n  height: 44px;\n`\n\nconst UploadImageButton = styled.label`\n  width: 26px;\n  height: 26px;\n`\n\nconst FileInput = styled.input`\n  position: absolute;\n  visibility: hidden;\n  width: 260px;\n`\n\nconst InputContainer = styled(Container)`\n  padding: 6px 6px 6px 16px;\n  border-radius: 24px;\n  box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.04);\n  display: flex;\n  flex: 1;\n  align-items: center;\n  gap: 16px;\n`\n\nfunction NolInputAreaUIImpl(\n  {\n    disabled = false,\n    buttonDisabled = false,\n    inputValue,\n    setInputValue,\n    placeholder,\n    onSendMessage,\n    onInputClick,\n    onInputKeydown,\n    maxTextLength = MAX_TEXT_LENGTH,\n    color,\n    placeholderColor,\n    inputContainerColor,\n    activeButtonColor,\n    onBlur,\n    onFocus,\n    CustomEmptyStateButton,\n    dismissKeyboardOnSend = true,\n    ...props\n  }: NolInputAreaUIProps,\n  ref: ForwardedRef<HTMLDivElement>,\n) {\n  const textareaRef = useRef<HTMLTextAreaElement>(null)\n\n  const onTextAreaChange = (e: ChangeEvent<HTMLTextAreaElement>) => {\n    setInputValue(e.target.value)\n    textAreaAutoResize(e, TEXTAREA_MAX_HEIGHT)\n  }\n\n  const {\n    CustomSelectActionButton,\n    multipleImageUpload,\n    onImageUpload,\n    ...rest\n  } = {\n    ...props,\n    ...('CustomSelectActionButton' in props\n      ? {\n          CustomSelectActionButton: props.CustomSelectActionButton,\n          onImageUpload: () => {},\n          multipleImageUpload: false,\n        }\n      : {\n          CustomSelectActionButton: null,\n          onImageUpload: props.onImageUpload,\n          multipleImageUpload: props.multipleImageUpload || false,\n        }),\n  }\n\n  return (\n    <InputAreaContainer {...rest} ref={ref}>\n      {CustomSelectActionButton || (\n        <UploadImageButtonWrapper>\n          <UploadImageButton htmlFor=\"image_upload\">\n            <SelectPhotoIcon />\n          </UploadImageButton>\n          <FileInput\n            id=\"image_upload\"\n            type=\"file\"\n            name=\"file\"\n            accept=\"image/png, image/jpeg\"\n            multiple={multipleImageUpload}\n            onChange={onImageUpload}\n            disabled={disabled}\n          />\n        </UploadImageButtonWrapper>\n      )}\n      <InputContainer\n        css={{\n          backgroundColor: inputContainerColor || 'var(--color-neutral-w-100)',\n        }}\n      >\n        <TextArea\n          disabled={disabled}\n          onChange={onTextAreaChange}\n          value={inputValue}\n          onKeyDown={onInputKeydown}\n          onClick={onInputClick}\n          ref={textareaRef}\n          placeholder={placeholder}\n          maxLength={maxTextLength}\n          $color={color}\n          placeholderColor={placeholderColor}\n          onFocus={onFocus}\n          onBlur={onBlur}\n        />\n        {CustomEmptyStateButton && inputValue.trim().length === 0 ? (\n          CustomEmptyStateButton\n        ) : (\n          <SendButton\n            activeButtonColor={activeButtonColor}\n            disabled={disabled || buttonDisabled}\n            onClick={(e) =>\n              handleSendClick(e, {\n                textareaRef,\n                inputValue,\n                onSendMessage,\n                dismissKeyboardOnSend,\n                minHeight: TEXTAREA_MIN_HEIGHT,\n              })\n            }\n          >\n            <SendIcon color={disabled || buttonDisabled ? '#BFBFC0' : '#FFF'} />\n          </SendButton>\n        )}\n      </InputContainer>\n    </InputAreaContainer>\n  )\n}\n\nexport const NolInputAreaUI = forwardRef(NolInputAreaUIImpl)\n"
  },
  {
    "path": "packages/tds-widget/src/chat/input-area/nol-input-area-ui/types.ts",
    "content": "import { TextareaHTMLAttributes } from 'react'\n\nimport type { InputAreaUIProps } from '../input-area-ui'\n\ntype SelectActionButtonProps =\n  | WithDefaultImageUploadButton\n  | WithCustomSelectActionButton\n\ninterface WithDefaultImageUploadButton\n  extends Pick<InputAreaUIProps, 'onImageUpload' | 'multipleImageUpload'> {}\n\ninterface WithCustomSelectActionButton {\n  CustomSelectActionButton: React.ReactNode\n}\n\ninterface NolInputAreaUIBaseProps\n  extends Omit<\n      InputAreaUIProps,\n      'buttonText' | 'buttonColor' | 'multipleImageUpload' | 'onImageUpload'\n    >,\n    Pick<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onBlur' | 'onFocus'> {\n  color?: string\n  inputContainerColor?: string\n  placeholderColor?: string\n  activeButtonColor?: string\n  disabled?: boolean\n  CustomEmptyStateButton?: React.ReactNode\n}\n\nexport type NolInputAreaUIProps = NolInputAreaUIBaseProps &\n  SelectActionButtonProps\n"
  },
  {
    "path": "packages/tds-widget/src/chat/input-area/nol-input-area-ui/use-input-resize-observer.ts",
    "content": "import { useState, useEffect, useRef } from 'react'\n\nimport { useScroll } from '../../chat'\n\nexport function useInputResizeObserver(defaultHeight: number) {\n  const { setScrollBy, chatContainerRef } = useScroll()\n  const inputContainerRef = useRef<HTMLDivElement | null>(null)\n\n  const inputContainerHeightRef = useRef<number>(defaultHeight)\n  const [inputContainerHeight, setInputContainerHeight] =\n    useState<number>(defaultHeight)\n\n  useEffect(() => {\n    inputContainerHeightRef.current = inputContainerHeight\n  }, [inputContainerHeight])\n\n  useEffect(() => {\n    const inputContainer = inputContainerRef?.current\n\n    if (!inputContainer) {\n      return\n    }\n\n    const observer = new ResizeObserver(() => {\n      const diff = inputContainer.clientHeight - inputContainerHeightRef.current\n\n      const chatContainer = chatContainerRef?.current\n\n      if (\n        diff > 0 ||\n        !(\n          chatContainer &&\n          chatContainer.scrollTop + chatContainer.clientHeight ===\n            chatContainer.scrollHeight\n        )\n      ) {\n        setScrollBy(diff)\n      }\n      setInputContainerHeight(inputContainer.clientHeight)\n    })\n\n    observer.observe(inputContainer, { box: 'border-box' })\n\n    return () => {\n      observer.disconnect()\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  return {\n    inputContainerRef,\n    inputContainerHeight,\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/input-area/utils.ts",
    "content": "import { RefObject, SyntheticEvent } from 'react'\n\nexport function textAreaAutoResize(e: SyntheticEvent, maxHeight: number) {\n  const textareaElement = e.target as HTMLTextAreaElement\n  textareaElement.style.height = ''\n  textareaElement.style.height =\n    Math.min(textareaElement.scrollHeight, maxHeight) + 'px'\n}\n\nexport function handleSendClick(\n  e: React.MouseEvent<HTMLButtonElement, MouseEvent>,\n  options: {\n    dismissKeyboardOnSend: boolean\n    textareaRef: RefObject<HTMLTextAreaElement>\n    inputValue: string\n    onSendMessage: () => void\n    minHeight: number\n  },\n) {\n  e.preventDefault()\n  const {\n    dismissKeyboardOnSend,\n    textareaRef,\n    inputValue,\n    onSendMessage,\n    minHeight,\n  } = options\n  if (!dismissKeyboardOnSend) {\n    textareaRef.current?.focus()\n  }\n  if (inputValue.trim().length > 0) {\n    if (textareaRef.current) {\n      textareaRef.current.style.height = `${minHeight}px`\n    }\n    onSendMessage()\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/list/hooks.ts",
    "content": "import { useMemo, useReducer } from 'react'\n\nimport { RoomType, UserType } from '../types'\n\nimport {\n  BaseChatListAction,\n  BaseChatListState,\n  ExtensibleReducerResult,\n  Extension,\n  ExtensionAction,\n} from './types'\nimport { isBaseChatListAction } from './utils'\nimport { ChatListReducer } from './reducer'\n\nexport const initialChatState: BaseChatListState<{\n  readOnly: boolean\n  searchValue: null | string\n  page: number\n}> = {\n  currentPage: 0,\n  pageSize: 5,\n  totalPage: 0,\n  rooms: [],\n  total: NaN,\n  filter: {\n    readOnly: false,\n    searchValue: null,\n    page: 1,\n  },\n  searchUserId: undefined,\n}\n\nexport function useExtensibleReducer<\n  F,\n  S,\n  T = RoomType,\n  U = UserType,\n  A extends { action: string } = { action: string },\n>(\n  extension: Extension<F, S, T, U, A> | null = null,\n): ExtensibleReducerResult<BaseChatListState<F, T, U> & S, F, T, U, A> {\n  type CombinedState = BaseChatListState<F, T, U> & S\n\n  const combinedReducer = useMemo(() => {\n    return (\n      state: CombinedState,\n      action: BaseChatListAction<F, T, U> | ExtensionAction<A>,\n    ): CombinedState => {\n      if (isBaseChatListAction(action)) {\n        return ChatListReducer(state, action) as CombinedState\n      }\n\n      if (extension) {\n        const actionKey = action.action as keyof typeof extension.reducers\n        const handler = extension.reducers[actionKey]\n        if (handler) {\n          return (\n            handler as (\n              state: CombinedState,\n              action: ExtensionAction<A>,\n            ) => CombinedState\n          )(state, action)\n        }\n      }\n\n      return state\n    }\n  }, [extension])\n\n  const mergedInitialState = useMemo(() => {\n    return {\n      ...initialChatState,\n      ...extension?.initialState,\n      filter: {\n        ...initialChatState.filter,\n        ...extension?.initialState?.filter,\n      },\n    } as CombinedState\n  }, [extension])\n\n  const [state, dispatchBase] = useReducer(combinedReducer, mergedInitialState)\n\n  const dispatch = useMemo(() => {\n    return (action: BaseChatListAction<F, T, U> | ExtensionAction<A>) => {\n      dispatchBase(action)\n    }\n  }, [dispatchBase])\n\n  return [state, dispatch]\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/list/index.ts",
    "content": "export * from './hooks'\nexport * from './reducer'\nexport * from './types'\nexport * from './utils'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/list/reducer.ts",
    "content": "import { RoomType, UserType } from '../types'\n\nimport { BaseChatListAction, BaseChatListState, ChatListActions } from './types'\n\nexport const ChatListReducer = <F, T = RoomType, U = UserType>(\n  state: BaseChatListState<F, T, U>,\n  chatAction: BaseChatListAction<F, T, U>,\n): BaseChatListState<F, T, U> => {\n  switch (chatAction.action) {\n    case ChatListActions.INIT_CHAT_LIST:\n      return {\n        ...state,\n        rooms: chatAction.rooms,\n        total: chatAction.total,\n        totalPage: Math.ceil(chatAction.total / (state.pageSize ?? 0)),\n        filter: { ...chatAction.filter },\n        searchUserId: chatAction.searchUserId,\n        me: chatAction.me,\n        lastMessageId: Math.max(\n          ...chatAction.rooms.map((room) => room.lastMessageId),\n        ),\n      }\n\n    case ChatListActions.REFRESH_LIST:\n      return {\n        ...state,\n        rooms: chatAction.rooms,\n        lastMessageId: Math.max(\n          ...chatAction.rooms.map((room) => room.lastMessageId),\n        ),\n        total: chatAction.total,\n        totalPage: Math.ceil(chatAction.total / (state.pageSize ?? 0)),\n      }\n\n    case ChatListActions.CHANGE_FILTER:\n      return {\n        ...state,\n        filter: chatAction.filter,\n      }\n\n    default:\n      return state\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/list/types.ts",
    "content": "import {\n  ChatRoomListItemInterface,\n  ChatUserInterface,\n  RoomInterface,\n  RoomType,\n  UserType,\n} from '../types'\nimport { ValueOf } from '../types/base'\n\nexport interface BaseChatListState<F, T = RoomType, U = UserType> {\n  rooms: (RoomInterface<T, U> | ChatRoomListItemInterface<T, U>)[]\n  total: number\n  me?: ChatUserInterface<U>\n  filter: F\n  searchUserId?: string\n  lastMessageId?: number\n  pageSize?: number\n  totalPage?: number\n  currentPage?: number\n}\n\nexport const ChatListActions = {\n  INIT_CHAT_LIST: 'INIT_CHAT_LIST',\n  REFRESH_LIST: 'REFRESH_LIST',\n  CHANGE_FILTER: 'CHANGE_FILTER',\n} as const\n\nexport type ChatListActions = ValueOf<typeof ChatListActions>\n\nexport type BaseChatListAction<F, T = RoomType, U = UserType> =\n  | ({\n      action: 'INIT_CHAT_LIST'\n    } & Pick<\n      BaseChatListState<F, T, U>,\n      'rooms' | 'filter' | 'me' | 'total' | 'searchUserId'\n    >)\n  | ({\n      action: 'REFRESH_LIST'\n    } & Pick<BaseChatListState<F, T, U>, 'rooms' | 'total'>)\n  | ({\n      action: 'CHANGE_FILTER'\n    } & Pick<BaseChatListState<F, T, U>, 'filter'>)\n\nexport type ExtensionAction<T extends { action: string }> = {\n  [K in T['action']]: Extract<T, { action: K }>\n}[T['action']] & {\n  [key: string]: unknown\n}\n\ntype ExtensionReducers<S, T extends { action: string }> = {\n  [K in T['action']]: (state: S, action: Extract<T, { action: K }>) => S\n}\n\nexport interface Extension<\n  F,\n  S,\n  T = RoomType,\n  U = UserType,\n  A extends { action: string } = { action: string },\n> {\n  name: string\n  initialState?: Partial<BaseChatListState<F, T, U> & S>\n  reducers:\n    | ExtensionReducers<BaseChatListState<F, T, U>, BaseChatListAction<F, T, U>>\n    | ExtensionReducers<BaseChatListState<F, T, U> & S, ExtensionAction<A>>\n}\n\nexport type ExtensibleReducerResult<\n  State,\n  F,\n  T = RoomType,\n  U = UserType,\n  A extends { action: string } = { action: string },\n> = [State, (action: BaseChatListAction<F, T, U> | ExtensionAction<A>) => void]\n"
  },
  {
    "path": "packages/tds-widget/src/chat/list/utils.ts",
    "content": "import { RoomType, UserType } from '../types'\n\nimport {\n  BaseChatListAction,\n  ChatListActions,\n  Extension,\n  ExtensionAction,\n} from './types'\n\nexport function createExtension<\n  F,\n  S,\n  T,\n  U,\n  A extends { action: string } = { action: string },\n>(config: Extension<F, S, T, U, A>): Extension<F, S, T, U, A> {\n  return {\n    name: config.name,\n    initialState: config.initialState || {},\n    reducers: config.reducers,\n  }\n}\n\nexport function isBaseChatListAction<\n  F,\n  T = RoomType,\n  U = UserType,\n  A extends { action: string } = { action: string },\n>(\n  action: BaseChatListAction<F, T, U> | ExtensionAction<A>,\n): action is BaseChatListAction<F, T, U> {\n  return Object.values<string>(ChatListActions).includes(\n    action.action as string,\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/messages/date-divider.tsx",
    "content": "import { Text } from '@titicaca/tds-ui'\nimport { format } from 'date-fns'\n\nexport function DateDivider({ date, ...props }: { date: Date }) {\n  return (\n    <Text\n      textAlign=\"center\"\n      size={11}\n      color=\"gray700\"\n      margin={{ top: 30 }}\n      {...props}\n    >\n      {format(date, 'yyyy년 MM월 dd일')}\n    </Text>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/messages/index.tsx",
    "content": "import { ComponentType, Fragment } from 'react'\nimport { CSSProp } from 'styled-components'\nimport { InView } from 'react-intersection-observer'\n\nimport BubbleContainer, {\n  BubbleContainerProp,\n} from '../bubble-container/bubble-container'\nimport BubbleUI, { BubbleUIProps, RichBubbleUIProp } from '../bubble/bubble-ui'\nimport { UserInterface } from '../types'\nimport AlteredBubble from '../bubble/altered'\nimport { ALTERNATIVE_TEXT_MESSAGE } from '../bubble/constants'\n\nimport { BubbleMessageInterface, MessageBase, MessageInterface } from './type'\nimport {\n  isBubbleType,\n  compareSender,\n  compareDate,\n  isCompositeBubbleType,\n} from './utils'\nimport { DateDivider } from './date-divider'\n\ninterface MessagesProp<\n  Message extends MessageBase<User>,\n  User extends UserInterface,\n> extends Pick<BubbleContainerProp, 'bubbleInfoStyle'> {\n  messages: MessageInterface<Message, User>[]\n  pendingMessages: MessageInterface<Message, User>[]\n  failedMessages: MessageInterface<Message, User>[]\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  customBubble?: { [key: string]: ComponentType<any> }\n  me: UserInterface\n  onRetry?: (message: MessageInterface<Message, User>) => void\n  onRetryCancel?: (message: MessageInterface<Message, User>) => void\n  onThanksClick?: (message: MessageInterface<Message, User>) => void\n  onReplyClick?: (message: MessageInterface<Message, User>) => void\n  onMessageIntersecting?: (\n    entry: IntersectionObserverEntry,\n    id: MessageInterface<Message, User>['id'],\n    createdMessage: boolean,\n  ) => void\n  calculateUnreadCount?: (\n    message: MessageInterface<Message, User>,\n  ) => number | null\n  bubbleStyle?: {\n    borderRadius?: number\n    arrowRadius?: number\n    received?: {\n      css?: CSSProp\n      alteredTextColor?: CSSProp\n    }\n    sent?: { css?: CSSProp; alteredTextColor?: CSSProp }\n  }\n  spacing?: {\n    message?: number\n    messageGroup?: number\n    bubbleInfo?: number\n    failureHandler?: number\n    dateDivider?: number\n  }\n  hasDateDivider?: boolean\n  messageRefCallback?: (id: MessageInterface<Message, User>['id']) => void\n  fullTextViewAvailable?: boolean\n  onOpenMenu?: (message: MessageInterface<Message, User>) => void\n  onParentMessageClick?: (id: MessageInterface<Message, User>['id']) => void\n  onUserClick?: (userId: string, unregistered: boolean) => void\n  showProfilePhoto?: boolean\n  /**\n   * message.payload의 extra를 렌더하는 컴포넌트\n   * 해당 메시지 하단에 렌더링됨\n   */\n  BubbleExtra?: ComponentType<Required<Pick<MessageBase<User>, 'extra'>>>\n  /**\n   * pendingMessages와 failedMessages 사이에 렌더되는 컴포넌트\n   */\n  interactionStatusSlot?: JSX.Element\n  /**\n   * rich 메시지를 블록 단위로 나누어 렌더링할 때 사용하는 함수\n   */\n  richMessageSplitter?: (\n    message: MessageInterface<Message, User>,\n    block: RichBubbleUIProp['value']['blocks'][number],\n  ) => BubbleMessageInterface<Message, User>\n  bubbleMessageConverter?: (\n    message: MessageInterface<Message, User>,\n  ) => BubbleMessageInterface<Message, User>[] | undefined\n}\n\nexport default function Messages<\n  Message extends MessageBase<User>,\n  User extends UserInterface,\n>({\n  messages,\n  pendingMessages,\n  failedMessages,\n  me,\n  onRetry,\n  onRetryCancel,\n  onThanksClick,\n  onReplyClick,\n  onMessageIntersecting,\n  calculateUnreadCount,\n  customBubble,\n  bubbleStyle,\n  hasDateDivider = true,\n  hasArrow,\n  messageRefCallback,\n  fullTextViewAvailable,\n  onOpenMenu,\n  onParentMessageClick,\n  onUserClick,\n  bubbleInfoStyle,\n  spacing,\n  showProfilePhoto = true,\n  BubbleExtra,\n  interactionStatusSlot,\n  richMessageSplitter,\n  bubbleMessageConverter,\n  ...bubbleProps\n}: MessagesProp<Message, User> &\n  Omit<\n    BubbleUIProps,\n    | 'id'\n    | 'my'\n    | 'blinded'\n    | 'deleted'\n    | 'unfriended'\n    | 'type'\n    | 'value'\n    | 'onOpenMenu'\n    | 'css'\n  >) {\n  function getBubble({\n    message,\n    my,\n    hasArrow = true,\n  }: {\n    message: BubbleMessageInterface<Message, User>\n    my: boolean\n    hasArrow?: boolean\n  }) {\n    const { id, sender, type, value, blinded, deleted, createdAt, ...rest } =\n      message\n\n    const CustomBubble = customBubble?.[type]\n    if (CustomBubble) {\n      if (blinded || deleted || sender.unfriended) {\n        return (\n          <AlteredBubble\n            key={id}\n            id={id.toString()}\n            my={my}\n            alternativeText={\n              sender.unfriended\n                ? ALTERNATIVE_TEXT_MESSAGE.unfriended\n                : blinded\n                  ? ALTERNATIVE_TEXT_MESSAGE.blinded\n                  : ALTERNATIVE_TEXT_MESSAGE.deleted\n            }\n            textColor={\n              my\n                ? bubbleStyle?.sent?.alteredTextColor\n                : bubbleStyle?.received?.alteredTextColor\n            }\n            hasArrow={hasArrow}\n          />\n        )\n      }\n      return <CustomBubble {...message} my={my} />\n    }\n\n    if (!isBubbleType(type)) {\n      throw new Error(`${type}에 해당하는 Bubble이 존재하지 않습니다.`)\n    }\n\n    return (\n      <BubbleUI\n        key={id}\n        id={id.toString()}\n        my={my}\n        created={!!createdAt}\n        blinded={blinded}\n        deleted={deleted}\n        unfriended={sender.unfriended}\n        type={type}\n        value={value}\n        alteredTextColor={\n          my\n            ? bubbleStyle?.sent?.alteredTextColor\n            : bubbleStyle?.received?.alteredTextColor\n        }\n        hasArrow={hasArrow}\n        onOpenMenu={() => onOpenMenu?.(message)}\n        onParentMessageClick={onParentMessageClick}\n        fullTextViewAvailable={fullTextViewAvailable}\n        css={my ? bubbleStyle?.sent?.css : bubbleStyle?.received?.css}\n        arrowRadius={bubbleStyle?.arrowRadius}\n        borderRadius={bubbleStyle?.borderRadius}\n        {...rest}\n        {...bubbleProps}\n      />\n    )\n  }\n\n  function convertToBubbleMessages(\n    message: MessageInterface<Message, User>,\n  ): BubbleMessageInterface<Message, User>[] {\n    const bubbleMessages = bubbleMessageConverter?.(message)\n    if (bubbleMessages) {\n      return bubbleMessages\n    }\n\n    const { type } = message\n\n    if (!isCompositeBubbleType(type)) {\n      return [message]\n    }\n\n    switch (type) {\n      case 'rich': {\n        return richMessageSplitter\n          ? (message.value as RichBubbleUIProp['value']).blocks.map((block) =>\n              richMessageSplitter(message, block),\n            )\n          : [message]\n      }\n      case 'coupon': {\n        throw new Error(`${type}에 해당하는 Bubble이 존재하지 않습니다.`)\n      }\n    }\n  }\n\n  function renderMessages({\n    listType,\n    messages,\n    lastMessageOfPrevList,\n  }: {\n    listType: 'normal' | 'failed' | 'pending'\n    messages: MessageInterface<Message, User>[]\n    lastMessageOfPrevList: MessageInterface<Message, User> | null\n  }) {\n    return messages.map((message, index) => {\n      const { id, sender, createdAt, type, thanks } = message\n      const my = sender.id === me.id\n\n      const prevMessage =\n        index === 0 ? lastMessageOfPrevList : messages[index - 1]\n      const nextMessage =\n        index < messages.length - 1 ? messages[index + 1] : null\n\n      const { isSameSenderAsPrevMessage, isSameSenderAsNextMessage } =\n        compareSender(prevMessage, message, nextMessage)\n\n      const { isFirstMessageOfDate, isSameMinuteAsNextMessage } = compareDate(\n        prevMessage,\n        message,\n        nextMessage,\n      )\n\n      const showTimeInfo =\n        !isSameSenderAsNextMessage ||\n        !isSameMinuteAsNextMessage ||\n        !nextMessage?.createdAt ||\n        nextMessage?.type === 'product'\n\n      const showProfile = isFirstMessageOfDate || !isSameSenderAsPrevMessage\n      const isFirstPendingOrFailedMessageOfDate =\n        listType !== 'normal' && isFirstMessageOfDate\n\n      const IntersectionObserver = onMessageIntersecting ? InView : Fragment\n\n      const bubbleMessages = convertToBubbleMessages(message)\n\n      return (\n        <Fragment key={id}>\n          {hasDateDivider && isFirstMessageOfDate ? (\n            <DateDivider\n              date={\n                isFirstPendingOrFailedMessageOfDate || !message.createdAt\n                  ? new Date()\n                  : new Date(message.createdAt)\n              }\n              css={bubbleInfoStyle?.dateDivider?.css}\n            />\n          ) : null}\n\n          <IntersectionObserver\n            onChange={\n              onMessageIntersecting\n                ? (_inView, entry) =>\n                    onMessageIntersecting(entry, id, !!createdAt)\n                : undefined\n            }\n          >\n            {bubbleMessages.map((bubbleMessage, index, { length }) => (\n              <BubbleContainer\n                key={`${id}-${index}`}\n                id={id.toString() + `${index ? `-${index}` : ''}`}\n                my={my}\n                user={{\n                  photo: sender.profile.photo,\n                  name: sender.profile.name,\n                  userId: sender.id,\n                  unregistered: sender.unregistered,\n                }}\n                unreadCount={null}\n                {...(index === length - 1 && {\n                  unreadCount: calculateUnreadCount\n                    ? calculateUnreadCount(message)\n                    : null,\n                  createdAt,\n                  showInfo: type !== 'product',\n                  showDateInfo: !hasDateDivider,\n                  showTimeInfo: listType === 'normal' && showTimeInfo,\n                  ...(listType === 'failed' && {\n                    onRetry: () => {\n                      onRetry?.(message)\n                    },\n                    onRetryCancel: () => {\n                      onRetryCancel?.(message)\n                    },\n                  }),\n                  thanks,\n                  onThanksClick:\n                    thanks && onThanksClick\n                      ? () => onThanksClick(message)\n                      : undefined,\n                  onReplyClick: onReplyClick\n                    ? () => onReplyClick(message)\n                    : undefined,\n                })}\n                showProfile={showProfile && index === 0}\n                showProfilePhoto={showProfilePhoto}\n                messageRefCallback={messageRefCallback}\n                css={{\n                  marginTop:\n                    isFirstMessageOfDate && index === 0\n                      ? spacing?.dateDivider || 20\n                      : showProfile && index === 0\n                        ? spacing?.messageGroup || 16\n                        : spacing?.message || 5,\n                }}\n                bubbleInfoGap={spacing?.bubbleInfo || 4}\n                failureHandlerGap={spacing?.failureHandler || 6}\n                onUserClick={onUserClick}\n                bubbleInfoStyle={bubbleInfoStyle}\n              >\n                {getBubble({\n                  message: bubbleMessage,\n                  my,\n                  hasArrow: showProfile,\n                })}\n              </BubbleContainer>\n            ))}\n\n            {message.extra && BubbleExtra && (\n              <BubbleExtra extra={message.extra} />\n            )}\n          </IntersectionObserver>\n        </Fragment>\n      )\n    })\n  }\n\n  return (\n    <>\n      <div id=\"messages_list\">\n        {renderMessages({\n          listType: 'normal',\n          messages,\n          lastMessageOfPrevList: null,\n        })}\n      </div>\n      <div id=\"pending_messages_list\">\n        {renderMessages({\n          listType: 'pending',\n          messages: pendingMessages,\n          lastMessageOfPrevList: messages[messages.length - 1],\n        })}\n      </div>\n      {interactionStatusSlot}\n      <div id=\"failed_messages_list\">\n        {renderMessages({\n          listType: 'failed',\n          messages: failedMessages,\n          lastMessageOfPrevList:\n            pendingMessages.length > 0\n              ? pendingMessages[pendingMessages.length - 1]\n              : messages[messages.length - 1],\n        })}\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/messages/messages.stories.tsx",
    "content": "import { ComponentProps } from 'react'\nimport { createGlobalStyle } from 'styled-components'\n\nimport {\n  ScrollProvider,\n  ChatRoomMessages as NolMessageComponent,\n} from '../chat'\nimport {\n  NOL_PARTNER_ROOM_BUBBLE_INFO_STYLE,\n  NOL_PARTNER_ROOM_BUBBLE_STYLE as BASE_NOL_PARTNER_ROOM_BUBBLE_STYLE,\n} from '../bubble'\nimport { NolThemeProvider } from '../nol-theme-provider'\nimport { NOL_COLOR } from '../nol-theme-provider/constants'\n\nimport MessagesComponent from './'\n\nexport default {\n  title: 'tds-widget / chat / Messages',\n  component: MessagesComponent,\n  render: (args: ComponentProps<typeof MessagesComponent>) => (\n    <ScrollProvider>\n      <MessagesComponent {...args} />\n    </ScrollProvider>\n  ),\n}\n\nexport const Messages = {\n  args: {\n    messages: [\n      {\n        type: 'text',\n        value: { message: '안녕하세요.' },\n        id: 'text message',\n        sender: {\n          id: 'test user',\n          profile: {\n            name: 'test user',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n        thanks: { count: 1, haveMine: false },\n        parentMessage: {\n          id: 'parent_message',\n          type: 'text',\n          blinded: false,\n          value: { message: '안녕하세요' },\n          sender: {\n            profile: { name: '트리플' },\n            unregistered: false,\n          },\n        },\n      },\n      {\n        type: 'another',\n        value: { text: 'Another Message.' },\n        id: 'another message',\n        sender: {\n          id: 'test user',\n          profile: {\n            name: 'test user',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n      },\n      {\n        type: 'text',\n        value: {\n          message: '안녕하세요. 이 메시지는 삭제된 메시지 테스트용입니다.',\n        },\n        id: 'deleted message',\n        deleted: true,\n        sender: {\n          id: 'test user',\n          profile: {\n            name: 'test user',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n      },\n      {\n        type: 'product',\n        value: {\n          product: {\n            customerBookingStatus: 'BOOKED',\n            productName: '상품 이름',\n            productThumbnail:\n              'https://media.triple.guide/triple-cms/c_limit,f_auto,w_1024/3ec44da6-ef5f-4804-bdd8-ab9aebc28e2b.jpeg',\n          },\n        },\n        id: 'product message',\n        sender: {\n          id: 'test user',\n          profile: {\n            name: 'test user',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n      },\n      {\n        type: 'text',\n        value: { message: '안녕하세요.' },\n        id: 'my text message',\n        sender: {\n          id: 'test',\n          profile: {\n            name: 'test',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n      },\n      {\n        type: 'images',\n        id: 'image message',\n        value: {\n          images: [\n            {\n              id: 'test image',\n              sizes: {\n                large: {\n                  url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n                },\n              },\n            },\n          ],\n        },\n        sender: {\n          id: 'test user',\n          profile: {\n            name: 'test user',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n      },\n      {\n        type: 'images',\n        id: 'image message2',\n        value: {\n          images: [\n            {\n              id: 'test image',\n              sizes: {\n                large: {\n                  url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n                },\n              },\n            },\n            {\n              id: 'test image2',\n              sizes: {\n                large: {\n                  url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n                },\n              },\n            },\n          ],\n        },\n        sender: {\n          id: 'test user',\n          profile: {\n            name: 'test user',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 2).toISOString(),\n      },\n    ],\n    pendingMessages: [],\n    failedMessages: [],\n    me: {\n      id: 'test',\n      profile: {\n        name: '테스트',\n        photo:\n          'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n      },\n    },\n    customBubble: {\n      another: (message: { id: string; value: { text: string } }) => {\n        return (\n          <div key={message.id} css={{ display: 'inline-block' }}>\n            {message.value.text}\n          </div>\n        )\n      },\n    },\n    hasDateDivider: true,\n  },\n}\n\nexport const MessagesWithDateDivider = {\n  args: {\n    messages: [\n      {\n        type: 'text',\n        value: { message: '안녕하세요.' },\n        id: 'text message 1',\n        sender: {\n          id: 'test user',\n          profile: {\n            name: 'test user',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1, 10, 30).toISOString(),\n        thanks: { count: 1, haveMine: false },\n      },\n      {\n        type: 'text',\n        value: { message: '상단 메세지와 같은 날짜 + 같은 시간' },\n        id: 'text message 2',\n        sender: {\n          id: 'test user',\n          profile: {\n            name: 'test user',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1, 10, 30).toISOString(),\n        thanks: { count: 1, haveMine: false },\n      },\n      {\n        type: 'text',\n        value: { message: '상단 메세지와 같은 날짜 + 다른 시간' },\n        id: 'text message 3',\n        sender: {\n          id: 'test user',\n          profile: {\n            name: 'test user',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1, 10, 31).toISOString(),\n        thanks: { count: 1, haveMine: false },\n      },\n      {\n        type: 'text',\n        value: {\n          message: '상단 메세지와 다른 날짜',\n        },\n        id: 'text message 4',\n        sender: {\n          id: 'test user',\n          profile: {\n            name: 'test user',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 2, 10, 30).toISOString(),\n      },\n      {\n        type: 'text',\n        value: { message: '보낸 첫 메세지' },\n        id: 'my text message 1',\n        sender: {\n          id: 'test',\n          profile: {\n            name: 'test',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 2, 10, 32).toISOString(),\n      },\n      {\n        type: 'text',\n        value: { message: '상단 메세지와 같은 날짜 + 같은 시간' },\n        id: 'my text message 2',\n        sender: {\n          id: 'test',\n          profile: {\n            name: 'test',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 2, 10, 32).toISOString(),\n      },\n      {\n        type: 'text',\n        value: { message: '상단 메세지와 같은 날짜 + 다른 시간' },\n        id: 'my text message 3',\n        sender: {\n          id: 'test',\n          profile: {\n            name: 'test',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 2, 10, 33).toISOString(),\n      },\n      {\n        type: 'text',\n        value: { message: '상단 메세지와 다른 날짜' },\n        id: 'my text message 4',\n        sender: {\n          id: 'test',\n          profile: {\n            name: 'test',\n            photo:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 3, 10, 30).toISOString(),\n      },\n    ],\n    pendingMessages: [],\n    failedMessages: [],\n    me: {\n      id: 'test',\n      profile: {\n        name: '테스트',\n        photo:\n          'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n      },\n    },\n    hasDateDivider: true,\n  },\n}\n\nconst NolGlobalStyle = createGlobalStyle`\n  html {\n    text-size-adjust: none;\n    -webkit-touch-callout: none;\n    touch-action: manipulation;\n    -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n    font-size: 62.5%;\n    background-color: #fff;\n  }\n\n  body {\n    font-weight: normal;\n    font-size: 1.3rem;\n    line-height: 1.3;\n\n    * {\n      letter-spacing: 0;\n      word-spacing: 0;\n    }\n  }\n`\n\nexport const NolMessages = {\n  render: (args: ComponentProps<typeof NolMessageComponent>) => (\n    <NolThemeProvider theme={NOL_COLOR}>\n      <ScrollProvider>\n        <NolGlobalStyle />\n        <NolMessageComponent {...args} />\n      </ScrollProvider>\n    </NolThemeProvider>\n  ),\n  args: {\n    messages: [\n      {\n        payload: {\n          type: 'rich',\n          items: [\n            {\n              type: 'button',\n              label: '버튼 메시지 바로가기',\n              action: { type: 'link', param: 'https://www.triple.guide' },\n            },\n          ],\n        },\n        id: 'button message',\n        sender: {\n          roomMemberId: 'test user',\n          profile: {\n            name: 'test user',\n            thumbnail:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n        thanks: { count: 1, haveMine: false },\n      },\n      {\n        payload: {\n          type: 'rich',\n          items: [\n            {\n              type: 'button',\n              label: '버튼 메시지 바로가기 disabled',\n              action: { type: 'link', param: 'https://www.triple.guide' },\n            },\n          ],\n        },\n        disabled: true,\n        id: 'button message disabled',\n        sender: {\n          roomMemberId: 'test user',\n          profile: {\n            name: 'test user',\n            thumbnail:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n        thanks: { count: 1, haveMine: false },\n      },\n      {\n        payload: {\n          type: 'rich',\n          items: [\n            {\n              type: 'button',\n              label: '버튼 메시지 바로가기',\n              action: { type: 'link', param: 'https://www.triple.guide' },\n            },\n          ],\n        },\n        id: 'my button message',\n        sender: {\n          roomMemberId: 'test',\n          profile: {\n            name: 'test',\n            thumbnail:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n        thanks: { count: 1, haveMine: false },\n      },\n      {\n        payload: {\n          type: 'text',\n          message: '안녕하세요.',\n        },\n        id: 'text message',\n        sender: {\n          roomMemberId: 'test user',\n          profile: {\n            name: 'test user',\n            thumbnail:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n        thanks: { count: 1, haveMine: false },\n      },\n      {\n        payload: {\n          type: 'text',\n          message: '안녕하세요.',\n        },\n        id: 'my text message',\n        sender: {\n          roomMemberId: 'test',\n          profile: {\n            name: 'test',\n            thumbnail:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n      },\n      {\n        payload: {\n          type: 'text',\n          message: '연속 두번째로 보내는 메시지 입니다.',\n        },\n        id: 'my text message 2',\n        sender: {\n          roomMemberId: 'test',\n          profile: {\n            name: 'test',\n            thumbnail:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n      },\n      {\n        id: 'my coupon message',\n        payload: {\n          type: 'coupon',\n          coupon: {\n            name: '빨리 예약하세요~ 오늘까지만 사용 가능한 쿠폰~',\n            discount: {\n              type: 'AMOUNT',\n              value: 5000,\n              maxDiscountAmount: 5000,\n            },\n            period: {\n              startAt: '2025-05-23T00:00:00+09:00',\n              endAt: '2035-05-24T00:00:00+09:00',\n            },\n            code: 'KYCHS7TFRJ577XLA',\n            propertyId: '10003136',\n            propertyName: '오즈 모텔',\n            type: 'random',\n          },\n        },\n        sender: {\n          roomMemberId: 'test',\n          profile: {\n            name: 'test',\n            thumbnail:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n      },\n      {\n        id: 'coupon message',\n        payload: {\n          type: 'coupon',\n          coupon: {\n            name: '빨리 예약하세요~ 오늘까지만 사용 가능한 쿠폰~',\n            discount: {\n              type: 'AMOUNT',\n              value: 5000,\n              maxDiscountAmount: 5000,\n            },\n            period: {\n              startAt: '2025-05-23T00:00:00+09:00',\n              endAt: '2035-05-24T00:00:00+09:00',\n            },\n            code: 'KYCHS7TFRJ577XLA',\n            propertyId: '10003136',\n            propertyName: '오즈 모텔',\n            type: 'random',\n          },\n        },\n        sender: {\n          roomMemberId: 'test user',\n          profile: {\n            name: 'test user',\n            thumbnail:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n      },\n    ],\n    pendingMessages: [\n      {\n        payload: {\n          type: 'text',\n          message: '보내는 중인 메시지.',\n        },\n        id: 'text message pending',\n        sender: {\n          roomMemberId: 'test',\n          profile: {\n            name: 'test',\n            thumbnail:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n        createdAt: new Date(2022, 10, 1).toISOString(),\n      },\n    ],\n    failedMessages: [\n      {\n        payload: {\n          type: 'text',\n          message: '실패한 메시지 메시지.',\n        },\n        id: 'text message pending',\n        sender: {\n          roomMemberId: 'test',\n          profile: {\n            name: 'test',\n            thumbnail:\n              'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n          },\n          unregistered: false,\n          unfriended: false,\n        },\n      },\n    ],\n    me: {\n      roomMemberId: 'test',\n      profile: {\n        name: '테스트',\n        thumbnail:\n          'https://assets.triple-dev.titicaca-corp.com/images/app-download@2x.png',\n      },\n    },\n    customBubble: {\n      another: (message: { id: string; value: { text: string } }) => {\n        return (\n          <div key={message.id} css={{ display: 'inline-block' }}>\n            {message.value.text}\n          </div>\n        )\n      },\n    },\n    hasDateDivider: true,\n    displayTarget: 'TNA_PARTNER',\n    onRetry: () => {},\n    onRetryCancel: () => {},\n    bubbleStyle: {\n      ...BASE_NOL_PARTNER_ROOM_BUBBLE_STYLE,\n      sent: BASE_NOL_PARTNER_ROOM_BUBBLE_STYLE.white,\n      received: BASE_NOL_PARTNER_ROOM_BUBBLE_STYLE.blue,\n    },\n    bubbleInfoStyle: NOL_PARTNER_ROOM_BUBBLE_INFO_STYLE,\n    showProfilePhoto: false,\n    spacing: { message: 6 },\n    shouldSplitRichMessage: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/messages/type.ts",
    "content": "import {\n  ButtonBubbleUIProp,\n  CouponBubbleUIProp,\n  ImageBubbleUIProp,\n  ProductBubbleUIProp,\n  RichBubbleUIProp,\n  TextBubbleUIProp,\n} from '../bubble/bubble-ui'\nimport { UserInterface } from '../types/user'\nimport { NolBubbleUIProps } from '../bubble/nol/bubble-ui'\n\nexport interface MessageBase<User extends UserInterface> {\n  id: string | number\n  sender: User\n  createdAt?: string\n  blinded?: boolean\n  deleted?: boolean\n  thanks?: { count: number; haveMine: boolean }\n  extra?: Record<string, unknown>\n}\n\nexport type MessageInterface<\n  Message extends MessageBase<User>,\n  User extends UserInterface,\n> = Message &\n  (\n    | TextBubbleUIProp\n    | ImageBubbleUIProp\n    | RichBubbleUIProp\n    | ProductBubbleUIProp\n    | CouponBubbleUIProp\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    | { type: string; value?: any }\n  )\n\nexport type BubbleMessageInterface<\n  Message extends MessageBase<User>,\n  User extends UserInterface,\n> = Message &\n  (\n    | TextBubbleUIProp\n    | ImageBubbleUIProp\n    | RichBubbleUIProp\n    | ProductBubbleUIProp\n    | ButtonBubbleUIProp\n    | NolBubbleUIProps\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    | { type: string; value?: any }\n  )\n"
  },
  {
    "path": "packages/tds-widget/src/chat/messages/utils.ts",
    "content": "import { isSameDay, isSameMinute } from 'date-fns'\n\nimport {\n  BubbleType,\n  BubbleTypeArray,\n  CompositeBubbleType,\n  CompositeBubbleTypeArray,\n} from '../bubble/bubble-ui'\nimport { UserInterface } from '../types'\n\nimport { MessageBase } from './type'\n\nexport function isBubbleType(type: string): type is BubbleType {\n  return BubbleTypeArray.includes(type as BubbleType)\n}\n\nexport function isCompositeBubbleType(\n  type: string,\n): type is CompositeBubbleType {\n  return CompositeBubbleTypeArray.includes(type as CompositeBubbleType)\n}\n\nexport function compareSender<\n  Message extends MessageBase<User>,\n  User extends UserInterface,\n>(\n  prevMessage: Message | null,\n  currentMessage: Message,\n  nextMessage: Message | null,\n) {\n  return {\n    isSameSenderAsPrevMessage:\n      prevMessage?.sender.id === currentMessage.sender.id,\n    isSameSenderAsNextMessage:\n      nextMessage?.sender.id === currentMessage.sender.id,\n  }\n}\n\nexport function compareDate<\n  Message extends MessageBase<User>,\n  User extends UserInterface,\n>(\n  prevMessage: Message | null,\n  currentMessage: Message,\n  nextMessage: Message | null,\n) {\n  /** createdAt이 없는 경우는 pending, failed 메세지임을 가정합니다. */\n  const prevMessageCreatedAt = prevMessage\n    ? prevMessage.createdAt\n      ? new Date(prevMessage?.createdAt)\n      : new Date()\n    : null\n\n  const currentMessageCreatedAt = currentMessage.createdAt\n    ? new Date(currentMessage.createdAt)\n    : new Date()\n\n  const nextMessageCreatedAt = nextMessage\n    ? nextMessage.createdAt\n      ? new Date(nextMessage?.createdAt)\n      : new Date()\n    : null\n\n  const isSameDateAsPrevMessage = !!(\n    prevMessageCreatedAt &&\n    currentMessageCreatedAt &&\n    isSameDay(prevMessageCreatedAt, currentMessageCreatedAt)\n  )\n  const isSameMinuteAsPrevMessage = !!(\n    prevMessageCreatedAt &&\n    currentMessageCreatedAt &&\n    isSameMinute(prevMessageCreatedAt, currentMessageCreatedAt)\n  )\n\n  const isSameMinuteAsNextMessage = !!(\n    nextMessageCreatedAt &&\n    currentMessageCreatedAt &&\n    isSameMinute(nextMessageCreatedAt, currentMessageCreatedAt)\n  )\n\n  const isFirstMessageOfDate = !prevMessage || !isSameDateAsPrevMessage\n\n  return {\n    isSameMinuteAsPrevMessage,\n    isSameMinuteAsNextMessage,\n    isFirstMessageOfDate,\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/navbar/index.tsx",
    "content": "import { Navbar } from '@titicaca/tds-ui'\nimport { PropsWithChildren } from 'react'\n\nexport enum NavbarItemType {\n  BACK = 'back',\n  MORE = 'more',\n}\n\nexport interface NavbarItem {\n  type: NavbarItemType\n  onClick: () => void\n}\n\nexport interface ChatNavbarUIProps {\n  title: JSX.Element\n  items?: NavbarItem[]\n}\n\nexport function ChatNavbarUI({\n  title,\n  items,\n  children,\n  ...props\n}: PropsWithChildren<ChatNavbarUIProps>) {\n  const isTextTitle = typeof title === 'string'\n\n  return (\n    <Navbar\n      position=\"fixed\"\n      title={isTextTitle ? title : undefined}\n      renderTitle={isTextTitle ? undefined : () => title}\n      {...props}\n    >\n      {items\n        ? items.map(({ type, ...props }) => {\n            switch (type) {\n              case NavbarItemType.BACK:\n                return (\n                  <Navbar.Item\n                    key={NavbarItemType.BACK}\n                    floated=\"left\"\n                    icon=\"back\"\n                    {...props}\n                  />\n                )\n              case NavbarItemType.MORE:\n                return (\n                  <Navbar.Item\n                    key={NavbarItemType.MORE}\n                    floated=\"right\"\n                    icon=\"more\"\n                    {...props}\n                  />\n                )\n              default:\n                return null\n            }\n          })\n        : null}\n      {children}\n    </Navbar>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/navbar/navbar.stories.tsx",
    "content": "import type { Meta, StoryFn } from '@storybook/react'\n\nimport { ChatNavbarUI, ChatNavbarUIProps, NavbarItemType } from './index'\n\nexport default {\n  title: 'tds-widget / chat / ChatNavbar',\n  component: ChatNavbarUI,\n} as Meta<typeof ChatNavbarUI>\n\nconst Template: StoryFn<ChatNavbarUIProps> = (args) => (\n  <ChatNavbarUI {...args} />\n)\n\nexport const Default = {\n  render: Template,\n\n  args: {\n    title: '1:1 문의',\n    items: [\n      { type: NavbarItemType.BACK, onClick: () => {} },\n      { type: NavbarItemType.MORE, onClick: () => {} },\n    ],\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/nol-theme-provider/constants.ts",
    "content": "export const NOL_COLOR = {\n  // black\n  'color-neutral-b-100': 'rgba(41, 41, 45, 1)',\n  'color-neutral-b-80': 'rgba(41, 41, 45, 0.8)',\n  'color-neutral-b-60': 'rgba(41, 41, 45, 0.6)',\n  'color-neutral-b-50': 'rgba(41, 41, 45, 0.5)',\n  'color-neutral-b-15': 'rgba(41, 41, 45, 0.15)',\n  'color-neutral-b-10': 'rgba(41, 41, 45, 0.1)',\n  'color-neutral-b-5': 'rgba(41, 41, 45, 0.05)',\n\n  // white\n  'color-neutral-w-100': 'rgba(255, 255, 255, 1)',\n  'color-neutral-w-20': 'rgba(255, 255, 255, 0.2)',\n  'color-neutral-w-8': 'rgba(255, 255, 255, 0.08)',\n\n  // gray\n  'color-neutral-g-80': 'rgba(84, 84, 87, 1)',\n  'color-neutral-g-60': 'rgba(126, 126, 129, 1)',\n  'color-neutral-g-50': 'rgba(148, 148, 150, 1)',\n  'color-neutral-g-30': 'rgba(191, 191, 192, 1)',\n  'color-neutral-g-15': 'rgba(223, 223, 224, 1)',\n  'color-neutral-g-10': 'rgba(234, 234, 234, 1)',\n  'color-neutral-g-5': 'rgba(244, 244, 245, 1)',\n\n  // primary\n  'color-primary-nol': 'rgba(65, 84, 255, 1)',\n\n  // red\n  'color-primary-red': 'rgba(255, 50, 46, 1)',\n  'color-brand-shopping-red-400': 'rgba(255, 114, 98, 1)',\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/nol-theme-provider/converter.ts",
    "content": "export const convertKeysToCamelCase = (obj: {\n  [key: string]: string\n}): { [key: string]: string } => {\n  if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {\n    throw Error('Invalid input: An object is expected.')\n  }\n\n  const toCamelCase = (str: string): string => {\n    return str\n      .split('-')\n      .map((part: string, index: number): string => {\n        if (index === 0) {\n          return part\n        }\n        if (!part) {\n          return ''\n        }\n        return part.charAt(0).toUpperCase() + part.slice(1)\n      })\n      .join('')\n  }\n\n  return Object.entries(obj).reduce<{ [key: string]: string }>(\n    (accumulator, [key, value]) => {\n      const camelKey = toCamelCase(key)\n      accumulator[camelKey] = value\n      return accumulator\n    },\n    {},\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/nol-theme-provider/index.ts",
    "content": "export * from './nol-theme-provider'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/nol-theme-provider/nol-theme-provider.tsx",
    "content": "import { ReactNode } from 'react'\nimport { ThemeProvider } from 'styled-components'\nimport { DefaultTheme } from 'styled-components/dist/types'\n\nimport { convertKeysToCamelCase } from './converter'\n\nexport function NolThemeProvider<T extends { [key: string]: string }>({\n  children,\n  theme,\n}: {\n  children: ReactNode\n  theme: T\n}) {\n  return (\n    <ThemeProvider\n      theme={{ nol: convertKeysToCamelCase(theme) } as unknown as DefaultTheme}\n    >\n      {children}\n    </ThemeProvider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/preview/elements.tsx",
    "content": "import { Container, List, Text } from '@titicaca/tds-ui'\nimport { css, styled } from 'styled-components'\n\nimport { convertDateTime as defaultConvertDateTime } from './utils'\n\nexport const PreviewListItem = styled(List.Item)<{ isSelected: boolean }>`\n  cursor: pointer;\n  padding: 0 20px;\n  ${({ isSelected }) =>\n    isSelected\n      ? css`\n          background-color: var(--color-mint100);\n        `\n      : null};\n  border-bottom: 1px solid #f5f5f5;\n`\n\nexport const ChatRoomThumbnail = styled.img`\n  position: absolute;\n  top: 20px;\n  left: 0;\n  width: 50px;\n  height: 50px;\n  border-radius: 25px;\n`\n\nexport const ChatRoomTitle = styled(Text).attrs({\n  size: 'medium',\n  bold: true,\n  ellipsis: true,\n})``\n\nexport const ChatRoomMessage = styled(Text).attrs({\n  size: 'small',\n  ellipsis: true,\n  color: 'gray600',\n  maxLines: 2,\n  lineHeight: '16px',\n  margin: { top: 3 },\n})``\n\nexport const ChatRoomCreatedAt = ({\n  createdAt,\n  convertDateTime = defaultConvertDateTime,\n  ...props\n}: {\n  createdAt: string\n  convertDateTime?: (createdAt: string, formatType?: string) => string\n}) => {\n  return (\n    <Container\n      position=\"absolute\"\n      css={{\n        top: '20px',\n        right: 0,\n      }}\n      {...props}\n    >\n      <Text size={12} color=\"gray500\" inlineBlock lineHeight=\"21px\">\n        {convertDateTime(createdAt)}\n      </Text>\n    </Container>\n  )\n}\n\nexport const ChatRoomUnread = ({\n  unreadCount,\n  ...props\n}: {\n  unreadCount: number\n}) => {\n  return (\n    <Container\n      position=\"absolute\"\n      backgroundColor=\"red\"\n      borderRadius={20}\n      css={{\n        width: 20,\n        height: 20,\n        top: '44px',\n        right: 0,\n      }}\n      {...props}\n    >\n      <Text color=\"white\" lineHeight=\"20px\" textAlign=\"center\">\n        {unreadCount}\n      </Text>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/preview/index.ts",
    "content": "export * from './elements'\nexport * from './preview'\nexport * from './utils'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/preview/preview.stories.tsx",
    "content": "import type { Meta, StoryFn } from '@storybook/react'\n\nimport { RoomType, UserType } from '../types'\n\nimport { Preview, PreviewProps } from './preview'\n\nexport default {\n  title: 'tds-widget / chat / Preview',\n  component: Preview,\n} as Meta<typeof Preview>\n\nconst Template: StoryFn<PreviewProps<RoomType, UserType>> = (args) => (\n  <Preview {...args} />\n)\n\nexport const Default = {\n  render: Template,\n\n  args: {\n    titleMessageContainerStyle: {\n      css: {\n        margin: '0 65px 0 0',\n      },\n    },\n    chatRoom: {\n      id: 'test1',\n      createdAt: '2025-03-12T05:47:38.956Z',\n      type: 'interpark-tna-product',\n      privateChannel: true,\n      name: 'DEV',\n      isDirect: false,\n      lastMessageId: '14039',\n      memberIds: ['test2', 'test3'],\n      members: [\n        {\n          id: 'test3',\n          createdAt: '2022-01-21T07:59:44.652Z',\n          type: 'TNA_PARTNER',\n          identifier: '130',\n          code: 'TEST_PARTNER',\n          profile: {\n            name: '노출 파트너명',\n            thumbnail: '',\n            message: '',\n          },\n        },\n        {\n          id: 'test2',\n          createdAt: '2025-01-31T01:01:00.085Z',\n          type: 'INTERPARK_USER',\n          identifier: '1000',\n          code: 'test',\n          profile: {\n            name: '테스트',\n            thumbnail: '',\n            message: '',\n          },\n        },\n      ],\n      lastMessage: {\n        id: '14039',\n        createdAt: '2025-03-10T04:14:12.701Z',\n        roomId: 'test1',\n        senderId: 'test2',\n        displayTarget: 'all',\n        payload: {\n          type: 'text',\n          message: 'TF14입니닷',\n        },\n      },\n      memberCounts: 2,\n      unreadCount: 4,\n    },\n    me: {\n      id: 'test3',\n      createdAt: '2022-01-21T07:59:44.652Z',\n      type: 'TNA_PARTNER',\n      identifier: '130',\n      code: 'TEST_PARTNER',\n      profile: {\n        name: '노출 파트너명',\n        thumbnail: '',\n        message: '',\n      },\n      channel: {\n        channel: 'TRIPLE_CHAT_USER_CHANNEL_test3',\n        events: {\n          unread: 'TRIPLE_CHAT_USER_UNREAD_test3',\n          send: 'TRIPLE_CHAT_USER_SEND_test3',\n          join: 'TRIPLE_CHAT_ROOM_JOIN_test3',\n          refresh: 'REFRESH',\n        },\n        needAuth: false,\n      },\n    },\n    handleRoomClick: () => {},\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/preview/preview.tsx",
    "content": "import { Container, TextProps } from '@titicaca/tds-ui'\nimport { CSSProp } from 'styled-components'\nimport { ComponentType, ImgHTMLAttributes, ReactNode } from 'react'\n\nimport { getProfileImageUrl } from '../utils'\nimport {\n  ChatRoomListItemInterface,\n  ChatUserInterface,\n  RoomInterface,\n  RoomType,\n  UserType,\n} from '../types'\n\nimport { getTextMessage } from './utils'\nimport { ChatRoomMessage, ChatRoomTitle } from './elements'\n\nexport interface PreviewProps<T, U> {\n  chatRoom: RoomInterface<T, U> | ChatRoomListItemInterface<T, U>\n  me: ChatUserInterface<U>\n  handleRoomClick: (roomId: string) => void\n  containerStyle?: { css?: CSSProp }\n  titleMessageContainerStyle?: { css?: CSSProp }\n  Thumbnail?: ComponentType<ImgHTMLAttributes<HTMLImageElement>>\n  Title?: ComponentType<TextProps>\n  Message?: ComponentType<TextProps>\n  CreatedAt?: ComponentType<{\n    createdAt: string\n    convertDateTime?: (createdAt: string, formatType?: string) => string\n  }>\n  Unread?: ComponentType<{ unreadCount: number }>\n  customTitle?: ReactNode\n  customElement?: ReactNode\n}\n\nexport function Preview<T = RoomType, U = UserType>({\n  chatRoom,\n  me,\n  handleRoomClick,\n  containerStyle,\n  titleMessageContainerStyle,\n  Thumbnail,\n  Title = ChatRoomTitle,\n  Message = ChatRoomMessage,\n  CreatedAt,\n  Unread,\n  customTitle,\n  customElement,\n}: PreviewProps<T, U>) {\n  const { lastMessage, unreadCount, members, id } = chatRoom\n  const { payload, createdAt } = lastMessage || {}\n\n  const others = members.filter(\n    ({ type, profile }) => type !== me.type || profile.name !== me.profile.name,\n  )\n  const title = others.map(({ profile: { name } }) => name).join(',')\n  const profileImageUrl = getProfileImageUrl(others[0])\n\n  return (\n    <Container\n      position=\"relative\"\n      onClick={() => handleRoomClick(id)}\n      {...containerStyle}\n    >\n      {Thumbnail ? (\n        <Thumbnail src={profileImageUrl} role=\"presentation none\" alt=\"\" />\n      ) : null}\n\n      <Container\n        css={\n          titleMessageContainerStyle?.css ?? {\n            margin: '0 80px 0 65px',\n            minHeight: 50,\n          }\n        }\n      >\n        <Title>{title}</Title>\n        {customTitle}\n\n        {payload && Message ? (\n          <Message>{getTextMessage(payload)}</Message>\n        ) : null}\n\n        {createdAt && CreatedAt ? <CreatedAt createdAt={createdAt} /> : null}\n      </Container>\n\n      {unreadCount && Unread ? <Unread unreadCount={unreadCount} /> : null}\n\n      {customElement}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/preview/utils.ts",
    "content": "import { differenceInCalendarDays, format, isSameDay, parseISO } from 'date-fns'\n\nimport { ChatMessagePayload, ChatMessagePayloadType } from '../types'\n\nconst DATE_FORMAT = 'yyyy.MM.dd'\n\nexport const getTextMessage = (payload: ChatMessagePayload) => {\n  const replaceNewlinesWithSpaces = (text: string) =>\n    text.replace(/(\\r\\n|\\n|\\r)/gm, ' ')\n\n  switch (payload.type) {\n    case ChatMessagePayloadType.TEXT:\n      return replaceNewlinesWithSpaces(payload.message)\n    case ChatMessagePayloadType.IMAGES:\n      return '사진을 보냈습니다.'\n    case ChatMessagePayloadType.RICH: {\n      const textTypeMessage = payload.items.find((item) => item.type === 'text')\n      return replaceNewlinesWithSpaces(\n        (textTypeMessage &&\n          'message' in textTypeMessage &&\n          textTypeMessage.message) ||\n          '',\n      )\n    }\n    case ChatMessagePayloadType.COUPON:\n      return '쿠폰이 발급되었어요.'\n    default:\n      return ''\n  }\n}\n\nexport function convertDateTime(\n  createdAt: string,\n  formatType?: string,\n): string {\n  const dateTime = new Date(createdAt)\n\n  if (isSameDay(dateTime, new Date())) {\n    return format(dateTime, 'a h:mm')\n  } else {\n    return format(dateTime, formatType ?? DATE_FORMAT)\n  }\n}\n\nexport function formatRelativeTime(\n  createdAt: string,\n  formatString?: string,\n): string {\n  const dateTime = parseISO(createdAt)\n  const today = new Date()\n  const diff = differenceInCalendarDays(today, dateTime)\n\n  if (isSameDay(dateTime, today)) {\n    return format(dateTime, 'a h:mm')\n  } else if (diff <= 7) {\n    return `${diff}일 전`\n  } else {\n    return format(dateTime, formatString ?? DATE_FORMAT)\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/reservation-info/elements.tsx",
    "content": "import {\n  Container as BaseContainer,\n  maxLinesMixin,\n  Text,\n} from '@titicaca/tds-ui'\nimport styled, { css } from 'styled-components'\n\nconst RESERVATION_INFO_MIN_CONTENT_HEIGHT = 40\nconst PRODUCT_INFO_MIN_CONTENT_HEIGHT = 24.5\n\nexport const Container = styled(BaseContainer)`\n  padding: 12px;\n  box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.04);\n  border-radius: 12px;\n  background-color: ${({ theme }) => theme.nol.colorNeutralW100};\n  margin: 8px 16px 0;\n`\n\nexport const Details = styled.dl<{ expanded: boolean }>`\n  margin-top: ${({ expanded }) => (expanded ? '8px' : '1.5px')};\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n  font-size: 12px;\n  line-height: 16px;\n  letter-spacing: 0;\n\n  > div {\n    display: flex;\n    align-items: flex-start;\n\n    dt {\n      flex-shrink: 0;\n      color: ${({ theme }) => theme.nol.colorNeutralB50};\n      margin-right: 6px;\n      width: 64px;\n    }\n\n    dd {\n      color: ${({ theme }) => theme.nol.colorNeutralG60};\n      ${({ expanded }) => !expanded && maxLinesMixin({ maxLines: 1 })}\n      word-break: break-all;\n      display: -webkit-inline-box;\n\n      & + dd {\n        &::before {\n          content: '';\n          display: inline-block;\n          margin: 0 8px;\n          width: 1px;\n          height: 8px;\n          background-color: #ddd;\n        }\n      }\n    }\n  }\n`\n\nexport const ContentContainer = styled(BaseContainer)`\n  display: flex;\n  flex-direction: row;\n  position: relative;\n`\n\nexport const Thumbnail = styled.img<{ small?: boolean }>`\n  width: ${({ small = false }) => (small ? '30' : '40')}px;\n  height: ${({ small = false }) => (small ? '30' : '40')}px;\n  border-radius: 6px;\n  margin-right: 12px;\n  object-fit: cover;\n`\n\nexport const DetailContainer = styled(BaseContainer)<{ expanded: boolean }>`\n  max-height: ${({ expanded }) =>\n    expanded ? 'none' : `${PRODUCT_INFO_MIN_CONTENT_HEIGHT}px`};\n  overflow: hidden;\n  flex: 1;\n\n  &:has(${Details}) {\n    max-height: ${({ expanded }) =>\n      expanded ? 'none' : `${RESERVATION_INFO_MIN_CONTENT_HEIGHT}px`};\n  }\n`\n\nexport const ArrowButton = styled.button.attrs({ type: 'button' })<{\n  expanded: boolean\n  expandable: boolean\n}>`\n  position: absolute;\n  right: 0;\n  width: 12px;\n  height: 12px;\n  display: flex;\n\n  & > svg {\n    transform: rotate(\n      ${({ expandable, expanded }) =>\n        !expandable ? '90deg' : expanded ? '0deg' : '180deg'}\n    );\n  }\n`\n\nexport const TitleContainer = styled(BaseContainer)`\n  display: flex;\n  flex-direction: row;\n  gap: 16px;\n\n  &:has(${ArrowButton}) {\n    padding-right: 18px;\n  }\n`\n\nexport const Title = styled(Text).attrs({\n  size: 14,\n  lineHeight: '19px',\n  bold: true,\n})`\n  color: ${({ theme }) => theme.nol.colorNeutralB100};\n  flex-grow: 1;\n`\n\nexport type LabelColor = 'blue' | 'red' | 'gray'\n\nexport const Label = styled(Text).attrs({\n  size: 11,\n  lineHeight: '16px',\n  bold: true,\n})<{ color?: LabelColor }>`\n  flex-shrink: 0;\n  padding: 3px 6px;\n  border-radius: 6px;\n  height: fit-content;\n  cursor: pointer;\n\n  ${({ color, theme: { nol = {} } }) =>\n    color && getLabelColorVariants(color, nol)}\n`\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction getLabelColorVariants(color: LabelColor, nolTheme: any) {\n  let colorVariants: {\n    backgroundColor?: string\n    color?: string\n  } = {}\n\n  switch (color) {\n    case 'blue':\n      colorVariants = {\n        backgroundColor: 'rgba(65, 84, 255, 0.1)',\n        color: nolTheme.colorPrimaryNol,\n      }\n      break\n    case 'red':\n      colorVariants = {\n        backgroundColor: 'rgba(255, 50, 46, 0.1)',\n        color: nolTheme.colorPrimaryRed,\n      }\n      break\n    case 'gray':\n      colorVariants = {\n        backgroundColor: nolTheme.colorNeutralG5,\n        color: nolTheme.colorNeutralB60,\n      }\n      break\n  }\n\n  return css`\n    background-color: ${colorVariants.backgroundColor || 'transparent'};\n    color: ${colorVariants.color || 'inherit'};\n  `\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/reservation-info/index.ts",
    "content": "export * from './reservation-info'\nexport { Label as ChatReservationInfoLabel } from './elements'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/reservation-info/reservation-info.stories.tsx",
    "content": "import type { Meta, StoryFn } from '@storybook/react'\nimport { Button } from '@titicaca/tds-ui'\n\nimport { NolThemeProvider } from '../nol-theme-provider'\nimport { NOL_COLOR } from '../nol-theme-provider/constants'\n\nimport { ReservationInfo, type ReservationInfoProps } from './reservation-info'\n\nexport default {\n  title: 'tds-widget / chat / ReservationInfo',\n  component: ReservationInfo,\n  decorators: [\n    (Story) => (\n      <NolThemeProvider theme={NOL_COLOR}>\n        <Story />\n      </NolThemeProvider>\n    ),\n  ],\n} as Meta<typeof ReservationInfo>\n\nconst BOOKING_INFO: ReservationInfoProps = {\n  label: { text: '예약확정', color: 'blue' },\n  title: '누사페나다 데이 크루즈&섬 투어 (발리 출발)',\n  thumbnail:\n    'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/42c71ad6-78bd-4f27-b777-cdb508b70ade',\n  details: [\n    { label: '예약확인번호', value: '5F72CD' },\n    {\n      label: '상품정보',\n      value: '누사페나다 일일 투어(발리 출발) 아이템명아이템명아이템명',\n    },\n    { label: '이용예정', value: '2025.02.11(금)' },\n    { label: '인원 · 수량', value: '성인 1명' },\n  ],\n}\n\nexport const WithDetails: StoryFn<ReservationInfoProps> = () => (\n  <ReservationInfo {...BOOKING_INFO} />\n)\n\nconst PRODUCT_INFO: ReservationInfoProps = {\n  title:\n    '누사페나다 데이 크루즈&섬 투어 (발리 출발)누사페나다 데이 크루즈&섬 투어 (발리 출발)누사페나다 데이 크루즈&섬 투어 (발리 출발)',\n  thumbnail:\n    'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/42c71ad6-78bd-4f27-b777-cdb508b70ade',\n}\n\nexport const WithoutDetails: StoryFn<ReservationInfoProps> = () => (\n  <ReservationInfo {...PRODUCT_INFO} />\n)\n\nexport const Link: StoryFn<ReservationInfoProps> = () => (\n  <ReservationInfo\n    {...PRODUCT_INFO}\n    type=\"link\"\n    onClick={() => alert('onClick')}\n  />\n)\n\nexport const WithActions: StoryFn<ReservationInfoProps> = () => (\n  <ReservationInfo\n    {...BOOKING_INFO}\n    actions={\n      <>\n        <Button onClick={() => alert('상품 보기')}>상품 보기</Button>\n        <Button onClick={() => alert('예약 상세 보기')}>예약 상세 보기</Button>\n      </>\n    }\n  />\n)\n"
  },
  {
    "path": "packages/tds-widget/src/chat/reservation-info/reservation-info.tsx",
    "content": "import {\n  useState,\n  ForwardedRef,\n  forwardRef,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  ReactNode,\n} from 'react'\nimport { CSSProp } from 'styled-components'\n\nimport ArrowTopIcon from '../icons/arrow-top-icon'\n\nimport {\n  Container,\n  ContentContainer,\n  Thumbnail,\n  DetailContainer,\n  TitleContainer,\n  Title,\n  ArrowButton,\n  Label,\n  Details,\n  type LabelColor,\n} from './elements'\n\nconst useIsomorphicLayoutEffect =\n  typeof window !== 'undefined' ? useLayoutEffect : useEffect\n\ntype ReservationInfoActionType = 'default' | 'link'\n\ninterface ReservationInfoActionPropsBase {\n  type?: ReservationInfoActionType\n}\n\ninterface DefaultReservationInfoActionProps\n  extends ReservationInfoActionPropsBase {\n  type?: 'default'\n  onClick?: () => void\n}\n\ninterface LinkReservationInfoActionProps\n  extends ReservationInfoActionPropsBase {\n  type?: 'link'\n  onClick: () => void\n}\n\ntype ReservationInfoActionProps =\n  | DefaultReservationInfoActionProps\n  | LinkReservationInfoActionProps\n\nexport type ReservationInfoProps = {\n  thumbnail?: string\n  label?: {\n    text: string\n    color?: LabelColor\n    css?: CSSProp\n  }\n  details?: {\n    label: string\n    value: string | string[]\n  }[]\n  title: string\n  /**\n   * 커스텀 액션 영역. ReactNode를 전달하면 자유롭게 버튼이나 다른 요소를 배치할 수 있습니다.\n   */\n  actions?: ReactNode\n} & ReservationInfoActionProps\n\n/**\n * nol-theme-provider를 사용하는 컴포넌트 입니다.\n */\nfunction ReservationInfoImpl(\n  {\n    type = 'default',\n    details = [],\n    thumbnail,\n    label,\n    title,\n    onClick,\n    actions,\n    ...props\n  }: ReservationInfoProps,\n  ref: ForwardedRef<HTMLDivElement>,\n) {\n  const hasDetails = type !== 'link' && details.length > 0\n\n  const [expanded, setExpanded] = useState(false)\n  const [expandable, setExpandable] = useState(hasDetails)\n\n  const titleRef = useRef<HTMLDivElement>(null)\n\n  useIsomorphicLayoutEffect(() => {\n    if (type !== 'link' && titleRef.current && !expandable) {\n      setExpandable(\n        titleRef.current.scrollHeight > titleRef.current.clientHeight,\n      )\n    }\n  }, [])\n\n  const handleClick =\n    expandable || (type === 'link' && onClick)\n      ? () => {\n          if (expandable) {\n            setExpanded(!expanded)\n          }\n          onClick?.()\n        }\n      : undefined\n\n  return (\n    <Container ref={ref} {...props}>\n      <ContentContainer>\n        {thumbnail ? <Thumbnail src={thumbnail} small={!hasDetails} /> : null}\n        <DetailContainer\n          expanded={expanded}\n          onClick={handleClick}\n          css={handleClick ? { cursor: 'pointer' } : {}}\n        >\n          <TitleContainer>\n            {title ? (\n              <Title\n                ref={titleRef}\n                maxLines={expanded ? undefined : 1}\n                css={{\n                  paddingTop: hasDetails ? '1.5px' : '5.5px',\n                }}\n              >\n                {title}\n              </Title>\n            ) : null}\n            {label ? (\n              <Label color={label.color} css={label.css}>\n                {label.text}\n              </Label>\n            ) : null}\n            {expandable || type === 'link' ? (\n              <ArrowButton\n                expandable={expandable}\n                expanded={expanded}\n                css={{ top: hasDetails ? '5px' : '9.5px' }}\n              >\n                <ArrowTopIcon />\n              </ArrowButton>\n            ) : null}\n          </TitleContainer>\n          {hasDetails && (\n            <Details expanded={expanded}>\n              {details.map(({ label, value }) => (\n                <div key={label}>\n                  <dt>{label}</dt>\n                  {typeof value === 'string' ? (\n                    <dd>{value}</dd>\n                  ) : (\n                    <div>\n                      {value.map((_value, index) => (\n                        <dd key={index}>{_value}</dd>\n                      ))}\n                    </div>\n                  )}\n                </div>\n              ))}\n            </Details>\n          )}\n        </DetailContainer>\n      </ContentContainer>\n      {actions}\n    </Container>\n  )\n}\n\nexport const ReservationInfo = forwardRef(ReservationInfoImpl)\n"
  },
  {
    "path": "packages/tds-widget/src/chat/scroll-buttons-area/elements.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport styled from 'styled-components'\nimport { Container as BaseContainer, Text } from '@titicaca/tds-ui'\n\nimport { ArrowBottom16Icon } from '../icons/arrow-bottom-16-icon'\nimport { ChatMessageInterface, UserType } from '../types'\nimport { getTextMessage } from '../preview'\n\nexport const Container = styled.div`\n  position: relative;\n`\n\nconst ButtonContainer = styled(BaseContainer)`\n  position: absolute;\n  right: 16px;\n  top: -52px;\n\n  & > button {\n    background-color: ${({ theme }) => theme.nol.colorNeutralW100};\n    border-radius: 50%;\n    width: 40px;\n    height: 40px;\n    box-shadow: 0 4px 14px 0 rgba(0, 0, 0, 0.04);\n  }\n`\n\nexport function ScrollToBottomButton({\n  onClick,\n  delay = 200,\n  ...props\n}: {\n  onClick: (behavior: ScrollBehavior) => void\n  delay?: number\n}) {\n  const [isVisible, setIsVisible] = useState(false)\n\n  useEffect(() => {\n    setTimeout(() => {\n      setIsVisible(true)\n    }, delay)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  if (isVisible) {\n    return (\n      <ButtonContainer {...props}>\n        <button onClick={() => onClick('instant')} type=\"button\">\n          <ArrowBottom16Icon />\n        </button>\n      </ButtonContainer>\n    )\n  }\n}\n\nconst NewMessageButton = styled.button.attrs({ type: 'button' })`\n  padding: 8px 12px;\n  background-color: ${({ theme }) => theme.nol.colorNeutralW100};\n  border-radius: 8px;\n  border: 1px solid ${({ theme }) => theme.nol.colorNeutralB10};\n  box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.12);\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  gap: 6px;\n`\n\nconst Thumbnail = styled.img`\n  width: 24px;\n  height: 24px;\n  border-radius: 50%;\n  box-shadow: 0 0 0 1px ${({ theme }) => theme.nol.colorNeutralG15};\n  object-fit: cover;\n  flex-shrink: 0;\n`\n\nconst Name = styled(Text).attrs({\n  maxLines: 1,\n  bold: true,\n  size: 12,\n  lineHeight: '16px',\n  ellipsis: true,\n})`\n  max-width: 70px;\n  color: ${({ theme }) => theme.nol.colorNeutralG60};\n  flex-shrink: 0;\n  overflow: hidden;\n`\n\nconst Message = styled(Text).attrs({\n  maxLines: 1,\n  size: 12,\n  lineHeight: '16px',\n  ellipsis: true,\n})`\n  color: ${({ theme }) => theme.nol.colorNeutralB100};\n  overflow: hidden;\n  flex-grow: 1;\n`\n\nconst NewMessageContainer = styled(BaseContainer)`\n  position: absolute;\n  top: 100px;\n  width: 100%;\n  display: flex;\n  justify-content: center;\n  padding: 0 16px;\n  transition: all 200ms ease-out;\n\n  &:has(${NewMessageButton}) {\n    top: -48px;\n  }\n`\n\nexport function NewMessage<T = UserType>({\n  message,\n  onClick,\n  ...props\n}: {\n  message: ChatMessageInterface<T> | null\n  onClick: () => void\n}) {\n  return (\n    <NewMessageContainer {...props}>\n      {message && (\n        <NewMessageButton onClick={onClick}>\n          {message.sender.profile.thumbnail ? (\n            <Thumbnail src={message.sender.profile.thumbnail} />\n          ) : null}\n          <Name>{message.sender.profile.name}</Name>\n          <Message>{getTextMessage(message.payload)}</Message>\n        </NewMessageButton>\n      )}\n    </NewMessageContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/scroll-buttons-area/index.tsx",
    "content": "import {\n  PropsWithChildren,\n  useEffect,\n  useRef,\n  useState,\n  useImperativeHandle,\n  forwardRef,\n  ForwardedRef,\n} from 'react'\nimport { Container } from '@titicaca/tds-ui'\n\nimport { ChatMessageInterface, UserType } from '../types'\nimport { useScroll } from '../chat/chat-room-messages/use-scroll'\n\nimport { ScrollButtons, type ScrollButtonsProps } from './scroll-buttons'\n\ninterface InspectionConfig {\n  id?: number\n  isIntersecting: boolean\n}\n\ninterface ScrollButtonsAreaProps<T = UserType>\n  extends Pick<ScrollButtonsProps<T>, 'scrollButtonsStyle'> {\n  lastSeenMessageId?: number\n  lastMessage?: ChatMessageInterface<T>\n  clickActionDelay?: number\n  resetKey?: string\n}\n\nexport interface ScrollButtonsAreaHandler {\n  onNewOthersMessage: () => void\n  setBottomIntersecting: (data: InspectionConfig) => void\n}\n\n/**\n * nol-theme-provider를 사용하는 컴포넌트 입니다.\n */\nfunction ScrollButtonsAreaImpl<T = UserType>(\n  {\n    resetKey,\n    lastSeenMessageId,\n    lastMessage,\n    scrollButtonsStyle,\n    clickActionDelay,\n    children,\n  }: PropsWithChildren<ScrollButtonsAreaProps<T>>,\n  ref: ForwardedRef<ScrollButtonsAreaHandler>,\n) {\n  const [currentBottomIntersecting, setCurrentBottomIntersecting] =\n    useState<InspectionConfig>({ id: lastSeenMessageId, isIntersecting: false })\n\n  const prevBottomIntersecting = useRef<InspectionConfig>(\n    currentBottomIntersecting,\n  )\n\n  const mounted = useRef(false)\n\n  const { triggerScrollToBottom } = useScroll()\n\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const onButtonClick = (behavior: ScrollBehavior = 'smooth') => {\n    triggerScrollToBottom({ scrollBehavior: behavior })\n  }\n\n  const handleClickScrollToBottom = (behavior?: ScrollBehavior) => {\n    if (typeof clickActionDelay !== 'undefined') {\n      setTimeout(() => {\n        onButtonClick(behavior)\n      }, clickActionDelay)\n    } else {\n      onButtonClick(behavior)\n    }\n  }\n\n  useImperativeHandle(ref, () => {\n    return {\n      onNewOthersMessage() {\n        if (prevBottomIntersecting.current.isIntersecting) {\n          onButtonClick()\n        }\n      },\n      setBottomIntersecting(data: InspectionConfig) {\n        setCurrentBottomIntersecting(data)\n        if (data.id === prevBottomIntersecting.current.id) {\n          prevBottomIntersecting.current = data\n        }\n      },\n    }\n  }, [onButtonClick])\n\n  useEffect(() => {\n    mounted.current = true\n\n    return () => {\n      mounted.current = false\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [resetKey])\n\n  useEffect(() => {\n    if (lastSeenMessageId !== prevBottomIntersecting.current.id) {\n      prevBottomIntersecting.current = currentBottomIntersecting\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [lastSeenMessageId])\n\n  const isNewMessageActive = !currentBottomIntersecting.id\n    ? false\n    : !(\n        currentBottomIntersecting.isIntersecting ||\n        !lastMessage ||\n        !lastSeenMessageId ||\n        Number(lastMessage.id) === Number(lastSeenMessageId)\n      )\n\n  return (\n    <Container>\n      {mounted.current && lastMessage !== undefined && (\n        <ScrollButtons\n          key={resetKey}\n          scrollButtonsStyle={scrollButtonsStyle}\n          onClick={handleClickScrollToBottom}\n          message={lastMessage}\n          isBottomIntersecting={currentBottomIntersecting.isIntersecting}\n          newMessageActive={isNewMessageActive}\n        />\n      )}\n      {children}\n    </Container>\n  )\n}\n\nexport const ScrollButtonsArea = forwardRef(ScrollButtonsAreaImpl) as <\n  T = UserType,\n>(\n  props: PropsWithChildren<ScrollButtonsAreaProps<T>> & {\n    ref?: React.ForwardedRef<ScrollButtonsAreaHandler>\n  },\n) => ReturnType<typeof ScrollButtonsAreaImpl>\n"
  },
  {
    "path": "packages/tds-widget/src/chat/scroll-buttons-area/scroll-buttons.tsx",
    "content": "import { useEffect, useRef, useState } from 'react'\nimport { CSSProp } from 'styled-components'\n\nimport { ChatMessageInterface, UserType } from '../types'\n\nimport { NewMessage, ScrollToBottomButton } from './elements'\n\nexport interface ScrollButtonsProps<T = UserType> {\n  onClick: (behavior: ScrollBehavior) => void\n  message?: ChatMessageInterface<T>\n  newMessageActive: boolean\n  isBottomIntersecting: boolean\n  scrollButtonsStyle?: {\n    scrollToButton?: {\n      css?: CSSProp\n    }\n    newMessage?: {\n      css?: CSSProp\n    }\n  }\n}\n\nexport function ScrollButtons<T = UserType>({\n  message: latestMessage,\n  onClick,\n  isBottomIntersecting,\n  newMessageActive,\n  scrollButtonsStyle = {},\n}: ScrollButtonsProps<T>) {\n  const [message, setMessage] = useState<ChatMessageInterface<T> | null>(null)\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  useEffect(() => {\n    if (latestMessage && latestMessage.id !== message?.id && newMessageActive) {\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current)\n      }\n      setMessage(latestMessage)\n\n      timeoutRef.current = setTimeout(() => {\n        setMessage(null)\n      }, 3000)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [latestMessage])\n\n  const handleClick = () => {\n    onClick('instant')\n    if (timeoutRef.current) {\n      clearTimeout(timeoutRef.current)\n    }\n    setMessage(null)\n  }\n\n  return (\n    <>\n      {!(isBottomIntersecting || message) && (\n        <ScrollToBottomButton\n          onClick={onClick}\n          css={scrollButtonsStyle?.scrollToButton?.css}\n        />\n      )}\n      <NewMessage\n        message={message}\n        onClick={handleClick}\n        css={scrollButtonsStyle?.newMessage?.css}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/types/base.ts",
    "content": "export type ValueOf<T> = T[keyof T]\n\nexport const ChatChannelEvents = {\n  REFRESH: 'refresh',\n  UNREAD: 'unread',\n  SEND: 'send',\n  JOIN: 'join',\n  LEFT: 'left',\n} as const\n\nexport type ChatChannelEventsType = ValueOf<typeof ChatChannelEvents>\n\nexport interface ChatChannelInfo {\n  channel: string\n  events: Record<ChatChannelEventsType, string>\n  needAuth: boolean\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/types/image.ts",
    "content": "export interface CloudinaryImageInterface {\n  public_id: string\n  version: number\n  signature: string\n  width: number\n  height: number\n  format: string\n  resource_type: string\n  created_at: string\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  tags: any[]\n  bytes: number\n  type: string\n  etag: string\n  placeholder: boolean\n  url: string\n  secure_url: string\n  backup_url: string\n  original_filename: string\n}\n\nexport interface ImageMetadataInterface {\n  media: MetaDataInterface[]\n}\n\n/**\n * TODO: type-definitions의 ImageMeta와 연관성 조사\n */\nexport interface MetaDataInterface {\n  cloudinaryBucket: string\n  cloudinaryId: string\n  originalUrl?: string\n  fileName?: string\n  id: string\n  type: string\n  sizes: {\n    full: {\n      url: string\n    }\n    large: {\n      url: string\n    }\n    smallSquare: {\n      url: string\n    }\n  }\n  width: number\n  height: number\n}\n\nexport interface TokenInterface {\n  signature: string\n  public_id: string\n  api_key: string\n  timestamp: string\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/types/index.ts",
    "content": "export * from './base'\nexport * from './image'\nexport * from './message'\nexport * from './room'\nexport * from './ui'\nexport * from './unread'\nexport * from './user'\nexport * from './pusher'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/types/message.ts",
    "content": "import { UnsentMessage } from '../chat'\n\nimport { MetaDataInterface } from './image'\nimport {\n  ChatRoomMemberInterface,\n  PreDirectRoomMemberInterface,\n  TripleChatRoomMemberInterface,\n  UserType,\n} from './user'\n\nexport type DisplayTargetAll = 'all'\n\nexport type ReactionType = 'thanks'\n\nexport enum ChatMessagePayloadType {\n  WELCOME = 'welcome',\n  TEXT = 'text',\n  IMAGES = 'images',\n  BUTTON = 'button',\n  RICH = 'rich',\n  FORM = 'form',\n  SUBMIT = 'submit',\n  PRODUCT = 'product',\n  COUPON = 'coupon',\n}\n\ntype RichItemType =\n  | ChatMessagePayloadType.TEXT\n  | ChatMessagePayloadType.IMAGES\n  | ChatMessagePayloadType.BUTTON\n\ninterface RichItemBase {\n  type: RichItemType\n}\n\ninterface RichItemText extends RichItemBase {\n  type: ChatMessagePayloadType.TEXT\n  message: string\n}\ninterface RichItemImages extends RichItemBase {\n  type: ChatMessagePayloadType.IMAGES\n  images: MetaDataInterface[]\n}\n\ninterface RichItemButton extends RichItemBase {\n  type: ChatMessagePayloadType.BUTTON\n  label: string\n  action: {\n    param: string\n    type: 'link'\n  }\n}\n\nexport type RichItem = RichItemText | RichItemImages | RichItemButton\n\nexport interface ChatMessagePayloadBase {\n  type: ChatMessagePayloadType\n  extra?: Record<string, unknown>\n}\n\ninterface ChatTextMessagePayload extends ChatMessagePayloadBase {\n  type: ChatMessagePayloadType.TEXT\n  message: string\n}\n\ninterface ChatImagesMessagePayload extends ChatMessagePayloadBase {\n  type: ChatMessagePayloadType.IMAGES\n  images: MetaDataInterface[]\n}\n\ninterface ChatRichMessagePayload extends ChatMessagePayloadBase {\n  type: ChatMessagePayloadType.RICH\n  items: (RichItemText | RichItemImages | RichItemButton)[]\n}\n\ninterface ChatProductMessagePayload extends ChatMessagePayloadBase {\n  type: ChatMessagePayloadType.PRODUCT\n  product: ProductItem\n}\n\ninterface ChatCouponMessagePayload extends ChatMessagePayloadBase {\n  type: ChatMessagePayloadType.COUPON\n  coupon: CouponItem\n}\n\nexport type ChatMessagePayload =\n  | ChatTextMessagePayload\n  | ChatImagesMessagePayload\n  | ChatRichMessagePayload\n  | ChatProductMessagePayload\n  | ChatCouponMessagePayload\n\ntype ChatAlternativeMessagePayload =\n  | ChatTextMessagePayload\n  | ChatRichMessagePayload\n\n/**\n * triple-chat 서버 응답으로 받는 ChatMessageInterface\n */\nexport interface TripleChatMessageInterface<T = UserType>\n  extends Omit<ChatMessageInterface<T>, 'sender'> {\n  /**\n   * @deprecated\n   */\n  senderId?: string\n  sender: TripleChatRoomMemberInterface<T>\n}\n\n/**\n * nol-chat 서버 응답으로 받는 ChatMessageInterface\n */\nexport interface ChatMessageInterface<T = UserType> {\n  id: number\n  roomId: string\n  payload: ChatMessagePayload\n  createdAt?: string\n  displayTarget?: T[] | DisplayTargetAll\n  alternative?: ChatAlternativeMessagePayload\n  blindedAt?: string\n  reactions?: { [type in ReactionType]?: { count: number; haveMine: boolean } }\n  sender: ChatRoomMemberInterface<T>\n}\n\nexport type WelcomeMessageInterface<T = UserType> = UnsentMessage<\n  Omit<ChatMessageInterface<T>, 'roomId' | 'sender'> & {\n    roomId?: string\n    sender:\n      | ChatMessageInterface<T>['sender']\n      | PreDirectRoomMemberInterface<UserType>\n  }\n>\n\nexport type CustomerBookingStatus =\n  | 'BOOKED'\n  | 'ONGOING'\n  | 'COMPLETED'\n  | 'CANCEL_REQUESTED'\n  | 'CANCELED'\n\nexport interface ProductItem {\n  customerBookingStatus?: CustomerBookingStatus\n  productName: string\n  productThumbnail?: string\n  itemName?: string\n  optionName?: string\n  dateOfUse?: string\n  bookingId?: number\n}\n\nexport interface CouponItem {\n  type: string\n  name: string\n  discount: {\n    type: string\n    value: number\n    maxDiscountAmount: number\n  }\n  period: {\n    startAt: string\n    endAt: string\n  }\n  code: string\n  propertyId: string\n  propertyName: string\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/types/pusher.ts",
    "content": "import { ChatMessageInterface } from './message'\nimport { RoomType } from './room'\nimport { HasUnreadOfRoomInterface } from './unread'\nimport { ChatUserInterface, UserType } from './user'\n\ninterface ChannelUser<T = UserType>\n  extends Omit<ChatUserInterface<T>, 'channel'> {\n  roomMemberId: string\n}\n\ninterface ChannelRoom<T = RoomType> {\n  id: string\n  name?: string\n  isDirect: boolean\n  lastMessageId: number\n  memberIds: string[]\n  type: T\n  privateChannel: boolean\n}\n\ninterface ChannelRoomMetadata {\n  memberCounts: number\n}\n\ninterface UnreadChatMessage<T = UserType>\n  extends Pick<\n    ChatMessageInterface<T>,\n    'displayTarget' | 'payload' | 'alternative' | 'createdAt'\n  > {\n  roomId: string\n  senderId: string\n}\n\n/**\n * sendUnreadMessage 이벤트로 전달되는 데이터 타입\n */\nexport interface UnreadChatMessageData<T = UserType> {\n  message?: UnreadChatMessage<T>\n  otherUnreadInfo?: HasUnreadOfRoomInterface\n  roomId: string\n}\n\n/**\n * sendMessage 이벤트로 전달되는 데이터 타입\n */\nexport interface ChatMessageData<T = UserType> {\n  message?: ChatMessageInterface<T>\n  metadata: ChannelRoomMetadata\n}\n\nexport interface JoinedChatData<T = RoomType, U = UserType> {\n  room: ChannelRoom<T>\n  members: ChannelUser<U>[]\n  memberCounts: number\n  metadata: ChannelRoomMetadata\n}\n\nexport interface LeftChatData<T = RoomType, U = UserType> {\n  room: ChannelRoom<T>\n  members: ChannelUser<U>[]\n  memberCounts: number\n  metadata: ChannelRoomMetadata\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/types/room.ts",
    "content": "import { ChatChannelInfo, ValueOf } from './base'\nimport { ChatMessageInterface } from './message'\nimport {\n  ChatRoomMemberInterface,\n  PreDirectRoomMemberInterface,\n  TripleChatUserInterface,\n  UserType,\n} from './user'\n\nexport const RoomType = {\n  DEFAULT: 'default', // 기존 파트너센터 챗\n  EVENT: 'event', // 행사용 그룹 챗\n} as const\n\nexport type RoomType = ValueOf<typeof RoomType>\n\nexport interface RoomListResultInterface<T = RoomInterface> {\n  total: number\n  rooms: T[]\n}\n\nexport interface RoomListResultWithPagingInterface<T = RoomInterface>\n  extends RoomListResultInterface<T> {\n  page: number\n  size: number\n}\n\ntype RoomMetaDataType = 'EVENT' | 'PRODUCT' | 'BOOKING'\n\ninterface RoomMetaDataBase {\n  type: RoomMetaDataType\n}\n\ninterface EventRoomMetaData extends RoomMetaDataBase, EventMetaData {\n  type: 'EVENT'\n}\n\nexport interface ProductRoomMetaData extends RoomMetaDataBase, ProductMetaData {\n  type: 'PRODUCT'\n}\n\nexport interface BookingRoomMetaData extends RoomMetaDataBase, BookingMetaData {\n  type: 'BOOKING'\n}\n\nexport interface EventMetaData {\n  name: string\n  articleId?: string\n}\n\ninterface UserMetaData {\n  type: string\n  id: string\n}\n\ninterface ProductMetaData {\n  productId: string\n  productName: string\n  productThumbnail?: string\n  itemName?: string\n  itemId?: string\n  optionName?: string\n  optionId?: string\n  user: UserMetaData\n}\n\ninterface BookingMetaData {\n  bookingId: string\n  status: string\n  statusDescription: string\n  product: Omit<ProductMetaData, 'user'>\n  dateOfUse?: DateOfUseDate | DateOfUseRange\n  user: UserMetaData\n}\n\ninterface DateOfUseDate {\n  type: 'DATE'\n  date: string\n}\n\ninterface DateOfUseRange {\n  type: 'RANGE'\n  from: string\n  to: string\n}\n\nexport interface ChatRoomMetadataMap {\n  [RoomType.EVENT]: EventRoomMetaData\n}\n\nexport type ChatRoomMetadata<T, U = ChatRoomMetadataMap> = T extends keyof U\n  ? U[T]\n  : undefined\n\ntype ExpirePolicyType = 'AFTER_DATE_OF_USE' | 'AFTER_MESSAGE' | 'AFTER_CANCEL'\n\ninterface ExpirePolicyBase {\n  type: ExpirePolicyType\n  duration: {\n    days: number\n    hours: number\n    minutes: number\n    seconds: number\n    milliSeconds: number\n  }\n  baseAt?: string\n  expiredAt?: string\n}\n\ninterface AfterDateOfUsePolicy extends ExpirePolicyBase {\n  type: 'AFTER_DATE_OF_USE'\n}\n\ninterface AfterMessagePolicy extends ExpirePolicyBase {\n  type: 'AFTER_MESSAGE'\n}\n\ninterface AfterCancelPolicy extends ExpirePolicyBase {\n  type: 'AFTER_CANCEL'\n}\n\ntype ExpirePolicy =\n  | AfterDateOfUsePolicy\n  | AfterMessagePolicy\n  | AfterCancelPolicy\n\n/**\n * @deprecated\n * 기존 트리플 파트너챗에서 /direct로 진입하는 생성되지 않은 채팅방\n */\nexport interface PreDirectRoomInterface<T = RoomType, U = UserType> {\n  preDirectRoom: true\n  type: T\n  members: PreDirectRoomMemberInterface<U>[]\n  me: PreDirectRoomMemberInterface<U>\n  other?: PreDirectRoomMemberInterface<U>\n}\n\n/**\n * 초대 링크로 진입한 생성되지 않은 RoomInterface\n *\n * NOTE: invitationType은 현재 분기 처리에 사용되지 않아 string으로 고정.\n * invitationType별 타입 검증이나 분기가 필요해지면 제네릭으로 변경 필요.\n */\nexport interface InvitationRoomInterface<\n  T = RoomType,\n  U = UserType,\n  V = ChatRoomMetadata<T>,\n> extends Pick<\n    InvitationInterface<string, T, U, V>,\n    'expirePolicies' | 'other'\n  > {\n  type: T\n  metadata?: V\n}\n\n/**\n * nol-chat 서버 응답으로 받는 RoomInterface\n */\nexport interface ChatRoomDetailInterface<\n  T = RoomType,\n  U = UserType,\n  V = ChatRoomMetadata<T>,\n> {\n  id: string\n  type: T\n  name?: string\n  lastMessageId: number\n  isDirect: boolean\n  createdAt: string\n  privateChannel: boolean\n  channel: ChatChannelInfo\n  /**\n   * 채팅방 만료 여부\n   */\n  expired: boolean\n  expireAt?: string\n  memberCounts: number\n  members: ChatRoomMemberInterface<U>[]\n  metadata?: V\n  expirePolicies: ExpirePolicy[]\n  /**\n   * 재문의 가능 여부\n   */\n  canReactivation?: boolean\n}\n\nexport interface ChatRoomListItemInterface<T = RoomType, U = UserType>\n  extends Pick<\n    ChatRoomDetailInterface<T, U>,\n    | 'id'\n    | 'createdAt'\n    | 'isDirect'\n    | 'name'\n    | 'lastMessageId'\n    | 'type'\n    | 'expired'\n    | 'members'\n  > {\n  lastMessage?: ChatMessageInterface<U>\n  unreadCount?: number\n}\n\n/**\n * TF/chat 컴포넌트 내에서 사용하는 RoomInterface\n */\nexport type ChatRoomInterface<\n  T = RoomType,\n  U = UserType,\n  V = ChatRoomMetadata<T>,\n> =\n  | ChatRoomDetailInterface<T, U, V>\n  | InvitationRoomInterface<T, U, V>\n  | PreDirectRoomInterface<T, U>\n\n/**\n * @deprecated\n * 기존 트리플 파트너챗에서 /direct로 진입하는 생성되지 않은 채팅방인지 확인합니다.\n */\nexport function isPreDirectRoom<\n  T = RoomType,\n  U = UserType,\n  V = ChatRoomMetadata<T>,\n>(room: ChatRoomInterface<T, U, V>): room is PreDirectRoomInterface<T, U> {\n  return !!(room as { preDirectRoom?: boolean }).preDirectRoom\n}\n\n/**\n * 생성된 채팅방인지 확인합니다.\n */\nexport function isCreatedChatRoom<\n  T = RoomType,\n  U = UserType,\n  V = ChatRoomMetadata<T>,\n>(room: ChatRoomInterface<T, U, V>): room is ChatRoomDetailInterface<T, U, V> {\n  return 'id' in room\n}\n\n/**\n * @deprecated\n * 기존 triple-chat에서 사용하는 RoomInterface\n * nol-chat으로 변경 시 ChatRoomDetailInterface를 사용해 주세요.\n */\nexport interface RoomInterface<T = RoomType, U = UserType> {\n  id: string\n  type: T\n  name?: string\n  lastMessageId: number\n  lastMessage: ChatMessageInterface<U>\n  unreadCount?: number\n  members: TripleChatUserInterface<U>[]\n  isDirect: boolean\n  createdAt: string\n  metadata?: EventMetaData\n  channel?: ChatChannelInfo\n}\n\nexport const InvitationType = {\n  TRIPLE_EVENT: 'triple-event',\n  TRIPLE_TNA_PRODUCT: 'triple-tna-product',\n  TRIPLE_TNA_BOOKING: 'triple-tna-booking',\n} as const\n\nexport type InvitationType = ValueOf<typeof InvitationType>\n\nexport interface InvitationAcceptInterface {\n  id: string\n  roomId: string\n  acceptedAt: string\n}\n\nexport interface InvitationInterface<\n  T = InvitationType,\n  R = RoomType,\n  U = UserType,\n  V = ChatRoomMetadata<R>,\n> {\n  invitationType: T\n  invitationIdentifier: string\n  validInvitation: boolean\n  metadata: V\n  accept?: InvitationAcceptInterface\n  expirePolicies: ExpirePolicy[]\n  other?: Pick<ChatRoomMemberInterface<U>, 'type' | 'profile'>\n  roomType: R\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/types/ui.ts",
    "content": "export type BackgroundColor = 'mint' | 'blue' | 'gray' | 'darkGray'\n\nexport interface BackgroundColorInterface {\n  sent: Extract<BackgroundColor, 'mint' | 'blue'>\n  received: Extract<BackgroundColor, 'gray' | 'darkGray'>\n}\n\nexport interface BubbleStyle<T extends 'sent' | 'received'> {\n  backgroundColor: BackgroundColorInterface[T]\n  textColor: {\n    normal: string\n    blinded?: string\n  }\n  link?: {\n    color?: string\n    underline?: boolean\n  }\n}\n\nexport interface ChatBubbleStyle {\n  sent: BubbleStyle<'sent'>\n  received: BubbleStyle<'received'>\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/types/unread.ts",
    "content": "import { ChatMessageInterface } from './message'\nimport { UserType } from './user'\n\nexport interface HasUnreadInterface {\n  hasUnread: boolean\n}\n\nexport interface HasUnreadOfRoomInterface extends HasUnreadInterface {\n  others: OtherUnreadInterface[]\n  lastMessageId: number\n}\n\nexport interface OtherUnreadInterface {\n  /**\n   * @deprecated\n   */\n  memberId: string\n  roomMemberId: string\n  lastSeenMessageId: number\n}\n\n/**\n * @deprecated\n * case별로 UnreadChatMessageData, ChatMessageData, JoinedChatData로 분리하여 사용해 주세요.\n */\nexport interface UpdatedChatData<T = UserType> {\n  message?: ChatMessageInterface<T>\n  otherUnreadInfo?: HasUnreadOfRoomInterface\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/types/user.ts",
    "content": "import { ChatChannelInfo, ValueOf } from './base'\n\nexport const UserType = {\n  TRIPLE_USER: 'TRIPLE_USER',\n  TRIPLE_OPERATOR: 'TRIPLE_OPERATOR',\n  TNA_PARTNER: 'TNA_PARTNER',\n} as const\n\nexport type UserType = ValueOf<typeof UserType>\n\n/**\n * TF/chat 컴포넌트 내에서 사용하는 UserInterface\n */\nexport interface UserInterface {\n  id: string\n  profile: ProfileInterface\n  unregistered?: boolean\n  unfriended?: boolean\n}\n\ninterface ProfileInterface {\n  name: string\n  photo: string\n}\n\n/**\n * @deprecated\n * triple-chat 서버 응답으로 받는 UserInterface\n */\nexport interface TripleChatUserInterface<T = UserType>\n  extends ChatUserInterface<T> {\n  /**\n   * @deprecated\n   */\n  identifier: string\n  /**\n   * @deprecated\n   */\n  code: string\n}\n\n/**\n * @deprecated\n * triple-chat 서버 응답으로 받는 RoomMemberInterface\n */\nexport interface TripleChatRoomMemberInterface<T = UserType>\n  extends TripleChatUserInterface<T> {\n  roomMemberId: string\n}\n\nexport type ChatRoomUser<T = UserType> =\n  | ChatUserInterface<T>\n  | ChatRoomMemberInterface<T>\n\n/**\n * nol-chat 서버 응답으로 받는 UserInterface\n */\nexport interface ChatUserInterface<T = UserType> {\n  id: string\n  createdAt: string\n  type: T\n  profile: ChatUserProfileInterface\n  channel: ChatChannelInfo\n}\n\n/**\n * nol-chat 서버 응답으로 받는 RoomMemberInterface\n */\nexport interface ChatRoomMemberInterface<T = UserType>\n  extends Omit<ChatUserInterface<T>, 'id' | 'channel' | 'createdAt'> {\n  roomMemberId: string\n  leftAt?: string\n}\n\n/**\n * @deprecated\n * 기존 트리플 파트너챗에서 /direct로 진입하는 생성되지 않은 채팅방의 RoomMemberInterface\n */\nexport interface PreDirectRoomMemberInterface<T = UserType>\n  extends Pick<ChatUserInterface<T>, 'id' | 'type' | 'profile'> {\n  /**\n   * 룸이 생성되기 전의 임시 아이디이기 때문에 룸이 생성되면 변경됩니다\n   */\n  roomMemberId: string\n}\n\ninterface ChatUserProfileInterface {\n  name: string\n  thumbnail: string\n  message: string\n}\n\nexport function isChatRoomMember<T = UserType>(\n  member: ChatRoomMemberInterface<T> | ChatUserInterface<T>,\n): member is ChatRoomMemberInterface<T> {\n  return 'roomMemberId' in member\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/utils/a-tag-navigator.ts",
    "content": "import { MouseEvent } from 'react'\n\nimport { TextBubbleProp } from '../bubble/type'\n\nexport default function useATagNavigator(\n  onLinkClick?: TextBubbleProp['onLinkClick'],\n) {\n  const aTagNavigator = (event: MouseEvent) => {\n    event.preventDefault()\n    event.stopPropagation()\n\n    const eventTarget = event.target as HTMLElement\n\n    const href =\n      eventTarget.tagName === 'A'\n        ? (eventTarget.getAttribute('href') ?? '')\n        : event.currentTarget.tagName === 'BUTTON'\n          ? (event.currentTarget.getAttribute('data-link') ?? '')\n          : ''\n\n    if (href) {\n      if (onLinkClick) {\n        onLinkClick(href)\n      } else {\n        window.open(href, '_blank', 'noopener')\n      }\n    }\n  }\n\n  return aTagNavigator\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/utils/image.ts",
    "content": "import {\n  MetaDataInterface,\n  UserType,\n  ChatMessageInterface,\n  ChatRoomUser,\n} from '../types'\n\nimport { getUserIdentifier } from './user'\n\nexport function getProfileImageUrl<T = UserType>(\n  user: ChatMessageInterface<T>['sender'] | ChatRoomUser<T>,\n) {\n  if (user?.profile.thumbnail) {\n    return user.profile.thumbnail\n  } else {\n    let imageNumber = 0\n    try {\n      imageNumber = parseInt(getUserIdentifier(user).substr(0, 4), 16) % 5\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    } catch (e) {\n      return `https://assets.triple.guide/images/ico-random-profile-${imageNumber}@3x.png`\n    }\n    return `https://assets.triple.guide/images/ico-random-profile-${imageNumber}@3x.png`\n  }\n}\n\nexport const MAX_CHAT_IMAGE_WIDTH = 224\n\nexport function generatePreviewImage({\n  imageInfo,\n  customWidth = MAX_CHAT_IMAGE_WIDTH,\n  mediaUrlBase,\n  cloudinaryName,\n}: {\n  imageInfo: MetaDataInterface\n  customWidth?: number\n  mediaUrlBase: string\n  cloudinaryName: string\n}) {\n  const { cloudinaryId, width, cloudinaryBucket } = imageInfo\n\n  return `${mediaUrlBase}/${\n    cloudinaryBucket || cloudinaryName\n  }/image/upload/c_fill,w_${Math.min(\n    width,\n    customWidth,\n  )},q_auto,f_auto/${cloudinaryId}.jpeg`\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/utils/index.ts",
    "content": "export * from './image'\nexport { default as useATagNavigator } from './a-tag-navigator'\nexport * from './user'\n"
  },
  {
    "path": "packages/tds-widget/src/chat/utils/profile.ts",
    "content": "export const DEFAULT_MAX_USERNAME_LENGTH = 10\n\nexport function formatUsername({\n  name,\n  unregistered = false,\n  maxLength,\n}: {\n  name: string\n  unregistered?: boolean | null\n  maxLength?: number\n}) {\n  if (unregistered) {\n    return '***'\n  }\n\n  if (maxLength) {\n    return name.length > maxLength ? `${name.slice(0, maxLength)}⋯` : name\n  }\n\n  return name\n}\n"
  },
  {
    "path": "packages/tds-widget/src/chat/utils/user.ts",
    "content": "import {\n  ChatMessageInterface,\n  ChatRoomInterface,\n  ChatRoomUser,\n  isChatRoomMember,\n  UserType,\n} from '../types'\n\n// TODO: nol-chat으로 마이그레이션 후 제거\nexport function getUserIdentifier<T = UserType>(\n  user: ChatMessageInterface<T>['sender'] | ChatRoomUser<T>,\n) {\n  /**\n   * triple-chat을 사용한다면 id를 우선적으로 사용하고, 그렇지 않다면 roomMemberId를 사용합니다.\n   */\n  if ('identifier' in user && 'id' in user) {\n    return user.id\n  }\n\n  return isChatRoomMember(user) ? user.roomMemberId : user.id\n}\n\n// TODO: nol-chat으로 마이그레이션 후 제거\nexport function shouldUseLegacyMemberId(room: ChatRoomInterface) {\n  return (\n    'members' in room &&\n    'identifier' in room.members[0] &&\n    'id' in room.members[0]\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/content-sharing/content-sharing.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { ContentSharing } from './content-sharing'\n\nexport default {\n  title: 'tds-widget / content-sharing / ContentSharing',\n  component: ContentSharing,\n} as Meta<typeof ContentSharing>\n\nexport const Basic: StoryObj<typeof ContentSharing> = {\n  args: {\n    label: '친구들과 여행 정보를 공유하세요',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/content-sharing/content-sharing.tsx",
    "content": "import { styled } from 'styled-components'\nimport { Container, Text } from '@titicaca/tds-ui'\n\nconst ShareIcon = styled.img`\n  margin: 0 5px;\n  width: 46px;\n  height: 46px;\n`\n\nexport enum Method {\n  Kakao = 'kakao',\n  Clipboard = 'clipboard',\n  Other = 'other',\n}\n\n/**\n * 콘텐츠 하단에 들어가는 공유 영역입니다. 카카오톡 공유, 클립보드 복사, 기타 공유 버튼을 표시하고 있습니다.\n */\nexport function ContentSharing({\n  onShareClick,\n  label,\n}: {\n  onShareClick: ({ method }: { method: Method }) => void\n  label: string\n}) {\n  return (\n    <Container\n      css={{\n        textAlign: 'center',\n        margin: '50px 0',\n      }}\n    >\n      <ShareIcon\n        src=\"https://assets.triple.guide/images/btn-end-invite-kakao@3x.png\"\n        onClick={() => onShareClick({ method: Method.Kakao })}\n      />\n      <ShareIcon\n        src=\"https://assets.triple.guide/images/btn-end-invite-copy@3x.png\"\n        onClick={() => onShareClick({ method: Method.Clipboard })}\n      />\n      <ShareIcon\n        src=\"https://assets.triple.guide/images/btn-end-invite-more@3x.png\"\n        onClick={() => onShareClick({ method: Method.Other })}\n      />\n      <Text margin={{ top: 19 }} center alpha={1}>\n        {label}\n      </Text>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/content-sharing/index.ts",
    "content": "export * from './content-sharing'\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/constants.ts",
    "content": "import moment from 'moment'\nimport MomentLocaleUtils from 'react-day-picker/moment'\n\nimport { formatMonthTitle } from './utils'\n\nexport const LOCALE = 'ko'\nexport const WEEKDAY_SHORT_LABEL = moment.localeData('ko').weekdaysShort()\nexport const LOCALE_UTILS = { ...MomentLocaleUtils, formatMonthTitle }\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/date-styles.stories.tsx",
    "content": "import type { Meta, StoryFn, StoryObj } from '@storybook/react'\nimport { styled } from 'styled-components'\n\nimport { dateLabelMixin, rangeMixin } from './mixins'\nimport { PickerFrame, generateSelectedCircleStyle } from './picker-frame'\n\nconst Table = styled.table`\n  border-collapse: separate;\n  border-spacing: 8px;\n  text-align: center;\n  empty-cells: show;\n\n  td {\n    width: auto !important;\n  }\n`\n\nexport default {\n  title: 'tds-widget / date-picker / 날짜 스타일',\n  decorators: [\n    (Story) => (\n      <PickerFrame\n        $height=\"500px\"\n        $sideSpacing={10}\n        $monthPadding=\"0\"\n        $hideTodayLabel={false}\n      >\n        <div className=\"DayPicker\">\n          <Table className=\"DayPicker-Month\">\n            <Story />\n          </Table>\n        </div>\n      </PickerFrame>\n    ),\n  ],\n} as Meta\n\nexport const Common: StoryFn = () => {\n  return (\n    <tbody className=\"DayPicker-Body\">\n      <tr className=\"DayPicker-Week\">\n        <td>기본</td>\n        <td className=\"DayPicker-Day\">1</td>\n        <td className=\"DayPicker-Day DayPicker-Day--disabled\">2</td>\n        <td className=\"DayPicker-Day DayPicker-Day--saturday\">3</td>\n        <td className=\"DayPicker-Day DayPicker-Day--sunday\">4</td>\n        <td className=\"DayPicker-Day DayPicker-Day--publicHolidays\">5</td>\n        <td className=\"DayPicker-Day DayPicker-Day--today\">6</td>\n        <td className=\"DayPicker-Day DayPicker-Day--selected\">7</td>\n      </tr>\n\n      <tr className=\"DayPicker-Week\">\n        <td>outside</td>\n        <td className=\"DayPicker-Day DayPicker-Day--outside\" />\n        <td className=\"DayPicker-Day DayPicker-Day--outside DayPicker-Day--disabled\" />\n        <td className=\"DayPicker-Day DayPicker-Day--outside DayPicker-Day--saturday\" />\n        <td className=\"DayPicker-Day DayPicker-Day--outside DayPicker-Day--sunday\" />\n        <td className=\"DayPicker-Day DayPicker-Day--outside DayPicker-Day--publicHolidays\" />\n        <td className=\"DayPicker-Day DayPicker-Day--outside DayPicker-Day--today\" />\n        <td className=\"DayPicker-Day DayPicker-Day--outside DayPicker-Day--selected\" />\n      </tr>\n\n      <tr className=\"DayPicker-Week\">\n        <td>오늘</td>\n        <td className=\"DayPicker-Day DayPicker-Day--today\">1</td>\n        <td className=\"DayPicker-Day DayPicker-Day--today DayPicker-Day--disabled\">\n          2\n        </td>\n        <td className=\"DayPicker-Day DayPicker-Day--today DayPicker-Day--saturday\">\n          3\n        </td>\n      </tr>\n\n      <tr className=\"DayPicker-Week\">\n        <td>knob</td>\n        <td className={['DayPicker-Day'].join(' ')}>42</td>\n      </tr>\n    </tbody>\n  )\n}\n\nconst DayContainer = styled.tbody`\n  ${generateSelectedCircleStyle('.DayPicker-Day--selected')}\n`\n\nexport const DayPicker: StoryObj = {\n  render: () => {\n    return (\n      <>\n        <tr className=\"DayPicker-Week\">\n          <td>selected</td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected\">1</td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--disabled\">\n            2\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--saturday\">\n            3\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--sunday\">\n            4\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--publicHolidays\">\n            5\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--today\">\n            6\n          </td>\n        </tr>\n\n        <tr className=\"DayPicker-Week\">\n          <td>knob</td>\n          <td className={['DayPicker-Day'].join(' ')}>42</td>\n        </tr>\n      </>\n    )\n  },\n\n  decorators: [\n    (Story) => (\n      <DayContainer className=\"DayPicker-Body\">\n        <Story />\n      </DayContainer>\n    ),\n  ],\n}\n\nconst RangeContainer = styled.tbody`\n  ${generateSelectedCircleStyle('.DayPicker-Day--from,.DayPicker-Day--to')}\n\n  ${rangeMixin({})}\n\n  ${dateLabelMixin({\n    selector: '.DayPicker-Day--from',\n    label: '출발일',\n  })}\n\n  ${dateLabelMixin({ selector: '.DayPicker-Day--to', label: '귀국일' })}\n\n  ${dateLabelMixin({\n    selector: '.DayPicker-Day--from.DayPicker-Day--to',\n    label: '당일왕복',\n  })}\n`\n\nexport const RangePicker: StoryObj = {\n  render: () => {\n    return (\n      <>\n        <tr className=\"DayPicker-Week\">\n          <td>outside 구간</td>\n          <td className=\"DayPicker-Day DayPicker-Day--outside DayPicker-Day--from DayPicker-Day--selected DayPicker-Day--included-range\" />\n          <td className=\"DayPicker-Day DayPicker-Day--publicHolidays DayPicker-Day--outside DayPicker-Day--selected DayPicker-Day--included-range\" />\n          <td className=\"DayPicker-Day DayPicker-Day--disabled DayPicker-Day--outside DayPicker-Day--selected DayPicker-Day--included-range\" />\n          <td className=\"DayPicker-Day DayPicker-Day--outside DayPicker-Day--today DayPicker-Day--selected DayPicker-Day--included-range\" />\n          <td className=\"DayPicker-Day DayPicker-Day--outside DayPicker-Day--today DayPicker-Day--publicHolidays  DayPicker-Day--selected DayPicker-Day--included-range\" />\n        </tr>\n\n        <tr className=\"DayPicker-Week\">\n          <td>구간</td>\n\n          <td className=\"DayPicker-Day DayPicker-Day--selected\">1</td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--disabled\">\n            2\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--saturday\">\n            3\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--sunday\">\n            4\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--publicHolidays\">\n            5\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--today\">\n            6\n          </td>\n        </tr>\n\n        <tr className=\"DayPicker-Week\">\n          <td>from, to</td>\n          <td />\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--from\">\n            1\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--to\">\n            2\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--from DayPicker-Day--to\">\n            3\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--from DayPicker-Day--saturday\">\n            4\n          </td>\n          <td className=\"DayPicker-Day DayPicker-Day--selected DayPicker-Day--from DayPicker-Day--today\">\n            5\n          </td>\n        </tr>\n\n        <tr className=\"DayPicker-Week\">\n          <td>knob</td>\n          <td className={['DayPicker-Day'].join(' ')}>42</td>\n        </tr>\n      </>\n    )\n  },\n\n  decorators: [\n    (Story) => (\n      <RangeContainer className=\"DayPicker-Body\">\n        <Story />\n      </RangeContainer>\n    ),\n  ],\n}\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/day-picker.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { DayPicker } from './day-picker'\n\nexport default {\n  title: 'tds-widget / date-picker / DayPicker',\n  component: DayPicker,\n  parameters: {\n    date: new Date('1/1/2022'),\n  },\n} as Meta\n\nexport const Basic: StoryObj<typeof DayPicker> = {\n  name: '단일 날짜 선택 컴포넌트',\n  args: {\n    day: null,\n    hideTodayLabel: true,\n    canChangeMonth: false,\n    height: '300px',\n    numberOfMonths: 3,\n    fromMonth: new Date().toDateString(),\n    toMonth: new Date().toDateString(),\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/day-picker.tsx",
    "content": "import moment from 'moment'\nimport { styled } from 'styled-components'\nimport ReactDayPicker, { DayModifiers, Modifiers } from 'react-day-picker'\nimport { memo, ReactNode, useMemo, useCallback } from 'react'\n\nimport { PickerFrame, generateSelectedCircleStyle } from './picker-frame'\nimport { LOCALE, WEEKDAY_SHORT_LABEL, LOCALE_UTILS } from './constants'\nimport useDisabledDays, { DisableDaysProps } from './use-disabled-days'\nimport { usePublicHolidays } from './use-public-holidays'\n\nconst MemoDayPicker = memo(ReactDayPicker)\n\nconst DayContainer = styled(PickerFrame)`\n  ${generateSelectedCircleStyle('.DayPicker-Day--selected')}\n`\n\nconst DateContainer = styled.div`\n  position: relative;\n`\n\nconst DayInfoContainer = styled.div`\n  position: absolute;\n  top: 25px;\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--color-gray);\n`\n\nexport function DayPicker({\n  day,\n  beforeBlock,\n  afterBlock,\n  numberOfMonths = 3,\n  onDateChange,\n  disabledDays: disabledDaysFromProps,\n  height,\n  publicHolidays: publicHolidaysFromProps,\n  hideTodayLabel = false,\n  canChangeMonth = false,\n  renderDayInfo,\n  fromMonth,\n  toMonth,\n}: DisableDaysProps & {\n  day: string | null\n  onDateChange: (date: Date) => void\n  renderDayInfo?: Record<string, ReactNode>\n  numberOfMonths?: number\n  hideTodayLabel?: boolean\n  height?: string\n  canChangeMonth?: boolean\n  fromMonth?: string\n  toMonth?: string\n  publicHolidays?: Date[]\n}) {\n  const hasRangeMonth = fromMonth && toMonth\n  const diffRangeMonth = moment(toMonth).diff(moment(fromMonth), 'months', true)\n  const hasRangeMonthDiff = Math.ceil(diffRangeMonth) > 1\n\n  const disabledDays = useDisabledDays({\n    disabledDays: disabledDaysFromProps,\n    beforeBlock,\n    afterBlock,\n  })\n\n  const publicHolidays = usePublicHolidays({\n    numberOfMonths,\n    skip: !!publicHolidaysFromProps,\n  })\n\n  const selectedDay = useMemo(\n    () => (day ? moment(day).toDate() : undefined),\n    [day],\n  )\n\n  const formattedFromMonth = useMemo(\n    () => (fromMonth ? moment(fromMonth).toDate() : undefined),\n    [fromMonth],\n  )\n\n  const formattedToMonth = useMemo(\n    () =>\n      toMonth\n        ? hasRangeMonthDiff\n          ? moment(toMonth).toDate()\n          : moment(toMonth).add(1, 'month').toDate()\n        : undefined,\n    [hasRangeMonthDiff, toMonth],\n  )\n\n  const modifiers: Partial<Modifiers> = useMemo(\n    () => ({\n      publicHolidays: publicHolidaysFromProps || publicHolidays,\n      sunday: (day) => day.getDay() === 0,\n      saturday: (day) => day.getDay() === 6,\n    }),\n    [publicHolidaysFromProps, publicHolidays],\n  )\n\n  const renderDay = useCallback(\n    (day: Date, _: DayModifiers) => {\n      const convertedDay = day.getDate()\n      const date = day.toISOString().split('T')[0]\n      return (\n        <DateContainer>\n          <div>{convertedDay}</div>\n          {renderDayInfo?.[date] && (\n            <DayInfoContainer>{renderDayInfo?.[date]}</DayInfoContainer>\n          )}\n        </DateContainer>\n      )\n    },\n    [renderDayInfo],\n  )\n\n  const handleDayClick = useCallback(\n    (day: Date, modifiers: DayModifiers): void => {\n      if (modifiers.disabled) {\n        return\n      }\n      onDateChange(day)\n    },\n    [onDateChange],\n  )\n\n  return (\n    <DayContainer\n      $height={height || '300px'}\n      $sideSpacing={10}\n      $monthPadding=\"40px 0 0 0\"\n      $hideTodayLabel={hideTodayLabel}\n      $canChangeMonth={canChangeMonth}\n    >\n      <MemoDayPicker\n        locale={LOCALE}\n        weekdaysShort={WEEKDAY_SHORT_LABEL}\n        localeUtils={LOCALE_UTILS}\n        selectedDays={selectedDay}\n        onDayClick={handleDayClick}\n        numberOfMonths={\n          hasRangeMonth\n            ? hasRangeMonthDiff\n              ? diffRangeMonth + 1\n              : 1\n            : numberOfMonths\n        }\n        modifiers={modifiers}\n        disabledDays={disabledDays}\n        canChangeMonth={canChangeMonth}\n        month={hasRangeMonth ? formattedFromMonth : undefined}\n        fromMonth={formattedFromMonth}\n        toMonth={formattedToMonth}\n        renderDay={renderDay}\n      />\n    </DayContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/index.ts",
    "content": "export * from './day-picker'\nexport * from './mixins'\nexport * from './picker-frame'\nexport * from './range-picker-v2'\nexport * from './range-picker'\nexport * from './utils'\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/mixins.ts",
    "content": "import { css } from 'styled-components'\n\nexport function todayMixin({\n  top = '30px',\n  fontSize = '11px',\n  fontWeight,\n  color = 'var(--color-blue)',\n}: {\n  top?: string\n  fontSize?: string\n  fontWeight?: number\n  color?: string\n}) {\n  return css`\n    /* stylelint-disable selector-class-pattern */\n    .DayPicker-Day--today:not(.DayPicker-Day--selected):not(\n        .DayPicker-Day--outside\n      ) {\n      color: ${color};\n\n      &::before {\n        top: ${top};\n        left: 0;\n        content: '오늘';\n        position: absolute;\n        display: inline-block;\n        font-size: ${fontSize};\n        width: 100%;\n        color: ${color};\n        ${fontWeight && `font-weight : ${fontWeight};`}\n      }\n\n      &.DayPicker-Day--sunday,\n      &.DayPicker-Day--saturday,\n      &.DayPicker-Day--publicHolidays {\n        color: var(--color-red);\n\n        &::before {\n          color: var(--color-red);\n        }\n      }\n\n      &.DayPicker-Day--disabled {\n        color: var(--color-gray500);\n\n        &::before {\n          color: var(--color-gray500);\n        }\n      }\n    }\n  `\n}\n\nexport function rangeMixin({\n  backgroundColor = 'var(--color-blue100)',\n}: {\n  backgroundColor?: string\n}) {\n  return css`\n    .DayPicker-Day--selected {\n      background: ${backgroundColor};\n    }\n\n    .DayPicker-Day--from {\n      background: linear-gradient(\n        to right,\n        #fafafa 50%,\n        ${backgroundColor} 50%\n      );\n    }\n\n    .DayPicker-Day--to {\n      background: linear-gradient(to left, #fafafa 50%, ${backgroundColor} 50%);\n    }\n\n    .DayPicker-Day--from.DayPicker-Day--to {\n      background: none;\n    }\n\n    .DayPicker-Day--outside {\n      background: none;\n\n      &.DayPicker-Day--included-range {\n        background: ${backgroundColor};\n      }\n    }\n  `\n}\n\nexport function dateLabelMixin({\n  selector,\n  label,\n  top = '35px',\n  fontSize = '11px',\n  fontWeight,\n  color = 'var(--color-blue)',\n}: {\n  selector: string\n  label: string\n  top?: string\n  fontSize?: string\n  fontWeight?: number\n  color?: string\n}) {\n  return css`\n    ${selector} {\n      &:not(.DayPicker-Day--outside)::before {\n        color: ${color};\n        position: absolute;\n        top: ${top};\n        left: 0;\n        display: inline-block;\n        font-size: ${fontSize};\n        ${fontWeight && `font-weight : ${fontWeight};`}\n        width: 100%;\n        transform: translateY(0);\n        background-color: transparent;\n        height: auto !important;\n        content: '${label}';\n      }\n    }\n  `\n}\n\nexport const sideSpacingMixin = css<{ $sideSpacing: number }>`\n  ${({ $sideSpacing }) => `\n    .DayPicker-Weekday {\n      &:first-child {\n        padding-left: ${$sideSpacing}px;\n      }\n      &:last-child {\n        padding-right: ${$sideSpacing}px;\n      }\n    }\n\n    .DayPicker-Day {\n      &--sunday {\n        padding-left: ${$sideSpacing}px !important;\n\n        &:before {\n          /* date label */\n          transform: translate(${$sideSpacing / 2}px) !important;\n        }\n\n        &:after {\n          /* Select circle */\n          transform: translate(calc(-50% + ${\n            $sideSpacing / 2\n          }px), -50%) !important;\n        }\n      }\n\n      &--saturday {\n        padding-right: ${$sideSpacing}px !important;\n\n        &:before {\n          /* date label */\n          transform: translate(${($sideSpacing / 2) * -1}px) !important;\n        }\n\n        &:after {\n          /* Select circle */\n          transform: translate(\n            calc(-50% + ${($sideSpacing / 2) * -1}px),\n            -50%\n          ) !important;\n        }\n      }\n    }\n  `}\n`\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/picker-frame.ts",
    "content": "import { styled, css } from 'styled-components'\n\nimport { todayMixin, sideSpacingMixin } from './mixins'\n\nexport function generateSelectedCircleStyle(\n  selector: string,\n  backgroundColor: string = 'var(--color-blue)',\n) {\n  return css`\n    /* stylelint-disable selector-class-pattern */\n    ${selector} {\n      z-index: 0;\n      color: var(--color-white) !important;\n\n      &::before {\n        top: 35px !important;\n      }\n\n      &::after {\n        z-index: -1;\n        display: block;\n        width: 32px;\n        height: 32px;\n        position: absolute;\n        top: 50%;\n        bottom: 0;\n        left: 50%;\n        transform: translate(-50%, -50%);\n        background-color: ${backgroundColor};\n        content: '';\n        border-radius: 100%;\n      }\n\n      &.DayPicker-Day--outside {\n        &::before,\n        &::after {\n          content: none;\n        }\n      }\n    }\n  `\n}\n\nconst navStyle = css`\n  position: relative;\n  z-index: 1;\n\n  .DayPicker-NavButton--prev {\n    margin-right: 24px;\n    transform: rotate(180deg);\n    background-image: url('https://assets.triple.guide/images/ic-paging-next@3x.png');\n  }\n\n  .DayPicker-NavButton--next {\n    background-image: url('https://assets.triple.guide/images/ic-paging-next@3x.png');\n  }\n\n  .DayPicker-NavButton {\n    position: absolute;\n    top: 15px;\n    right: 13px;\n    left: auto;\n    display: inline-block;\n    margin-top: 2px;\n    width: 36px;\n    height: 36px;\n    background-position: center;\n    background-size: 50%;\n    background-repeat: no-repeat;\n    color: #8b9898;\n    cursor: pointer;\n  }\n`\n\ninterface PickerFrameProps {\n  $height: string\n  $sideSpacing: number\n  $monthPadding: string\n  $hideTodayLabel: boolean\n  $canChangeMonth?: boolean\n  $defaultColor?: string\n}\n\nexport const PickerFrame = styled.div<PickerFrameProps>`\n  border-top: 1px solid var(--color-gray100);\n  border-bottom: 1px solid var(--color-gray100);\n\n  .DayPicker {\n    overflow: auto;\n    color: #3a3a3a;\n    font-weight: bold;\n    font-size: 14px;\n    background: #fafafa;\n\n    ${({ $height }) => `height: ${$height};`}\n    ${sideSpacingMixin}\n  }\n\n  .DayPicker-wrapper {\n    max-width: 768px;\n    margin: 0 auto;\n  }\n\n  .DayPicker-NavBar {\n    ${({ $canChangeMonth }) => $canChangeMonth && navStyle}\n  }\n\n  .DayPicker-Month {\n    position: relative;\n    display: table;\n    text-align: center;\n    width: 100%;\n    border-spacing: 0 25px;\n    user-select: none;\n\n    ${({ $monthPadding }) => `padding: ${$monthPadding};`}\n  }\n\n  .DayPicker-Caption {\n    position: absolute;\n    top: 25px;\n    left: 20px;\n    color: #222;\n\n    & > div {\n      font-size: 14px;\n      font-weight: 600;\n    }\n  }\n\n  .DayPicker-Weekdays {\n    display: table-row-group;\n  }\n\n  .DayPicker-WeekdaysRow {\n    display: table-row;\n  }\n\n  .DayPicker-Weekday {\n    display: table-cell;\n    color: #8b9898;\n    text-align: center;\n\n    abbr {\n      text-decoration: none;\n      color: var(--color-gray500);\n      font-size: 12px;\n    }\n  }\n\n  .DayPicker-Body {\n    display: table-row-group;\n  }\n\n  .DayPicker-Week {\n    display: table-row;\n\n    ${({ $hideTodayLabel, $defaultColor }) =>\n      !$hideTodayLabel &&\n      todayMixin({\n        color: $defaultColor,\n      })}\n\n    td:first-child {\n      margin: 0;\n      padding: 0;\n      border: 0;\n      vertical-align: baseline;\n    }\n  }\n\n  .DayPicker-Day {\n    position: relative;\n    display: table-cell;\n    padding: 9px 0;\n    width: 2%;\n    vertical-align: middle;\n    outline: none;\n  }\n\n  .DayPicker-Day--sunday,\n  .DayPicker-Day--saturday,\n  .DayPicker-Day--publicHolidays {\n    color: var(--color-red);\n  }\n\n  .DayPicker-Day--disabled {\n    color: var(--color-gray500);\n  }\n`\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/range-picker-v2/index.tsx",
    "content": "export * from './picker-frame'\nexport * from './range-picker'\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/range-picker-v2/picker-frame.ts",
    "content": "import { styled, css } from 'styled-components'\n\nimport { todayMixin, sideSpacingMixin } from '../mixins'\n\nexport function generateSelectedStyle({\n  selectedAll,\n}: {\n  selectedAll: boolean\n}) {\n  return css`\n    /* stylelint-disable selector-class-pattern */\n    .DayPicker-Day--from,\n    .DayPicker-Day--to {\n      z-index: 0;\n      color: var(--color-white) !important;\n\n      &::before {\n        top: 28px !important;\n      }\n\n      &::after {\n        z-index: -1;\n        display: block;\n        width: calc(100% - 1px);\n        height: 45px;\n        position: absolute;\n        top: 50%;\n        bottom: 0;\n        left: 50%;\n        transform: translate(-50%, -50%);\n        background-color: var(--color-blue);\n        ${!selectedAll && `border-radius: 4px;`}\n        content: '';\n      }\n\n      &.DayPicker-Day--outside {\n        &::before,\n        &::after {\n          content: none;\n        }\n      }\n    }\n\n    ${selectedAll &&\n    css`\n      .DayPicker-Day--from::after {\n        border-top-left-radius: 4px;\n        border-bottom-left-radius: 4px;\n      }\n\n      .DayPicker-Day--to::after {\n        border-top-right-radius: 4px;\n        border-bottom-right-radius: 4px;\n      }\n    `}\n  `\n}\n\ninterface PickerFrameProps {\n  $height: string\n  $sideSpacing: number\n  $monthPadding: string\n  $hideTodayLabel: boolean\n}\n\nexport const PickerFrameV2 = styled.div<PickerFrameProps>`\n  border-top: 1px solid var(--color-gray100);\n  border-bottom: 1px solid var(--color-gray100);\n\n  .DayPicker {\n    overflow: auto;\n    color: #3a3a3a;\n    font-weight: bold;\n    font-size: 14px;\n    background: #fafafa;\n\n    ${({ $height }) => `height: ${$height};`}\n    ${sideSpacingMixin}\n  }\n\n  .DayPicker-wrapper {\n    max-width: 768px;\n    margin: 0 auto;\n  }\n\n  .DayPicker-Month {\n    position: relative;\n    display: table;\n    text-align: center;\n    width: 100%;\n    border-spacing: 0 25px;\n    user-select: none;\n\n    ${({ $monthPadding }) => `padding: ${$monthPadding};`}\n  }\n\n  .DayPicker-Caption {\n    position: absolute;\n    top: 25px;\n    left: 20px;\n    color: #222;\n\n    & > div {\n      font-size: 14px;\n      font-weight: 600;\n    }\n  }\n\n  .DayPicker-Weekdays {\n    display: table-row-group;\n  }\n\n  .DayPicker-WeekdaysRow {\n    display: table-row;\n  }\n\n  .DayPicker-Weekday {\n    display: table-cell;\n    color: #8b9898;\n    text-align: center;\n\n    abbr {\n      text-decoration: none;\n      color: var(--color-gray500);\n      font-size: 12px;\n    }\n  }\n\n  .DayPicker-Body {\n    display: table-row-group;\n  }\n\n  .DayPicker-Week {\n    display: table-row;\n    height: 45px;\n\n    ${({ $hideTodayLabel }) =>\n      !$hideTodayLabel &&\n      todayMixin({\n        top: '28px',\n        fontSize: '10px',\n        fontWeight: 500,\n      })}\n  }\n\n  .DayPicker-Day {\n    position: relative;\n    display: table-cell;\n    width: 2%;\n    padding: 6px 0 4px;\n    outline: none;\n  }\n\n  .DayPicker-Day--sunday,\n  .DayPicker-Day--saturday,\n  .DayPicker-Day--publicHolidays {\n    color: var(--color-red);\n  }\n\n  .DayPicker-Day--disabled {\n    color: var(--color-gray500);\n  }\n`\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/range-picker-v2/range-picker.tsx",
    "content": "import moment from 'moment'\nimport { styled, css } from 'styled-components'\nimport DayPicker, { DayModifiers, Modifiers } from 'react-day-picker'\nimport { memo, ReactElement, ReactNode, useCallback, useMemo } from 'react'\n\nimport { usePublicHolidays } from '../use-public-holidays'\nimport { LOCALE, WEEKDAY_SHORT_LABEL, LOCALE_UTILS } from '../constants'\nimport useDisabledDays, { DisableDaysProps } from '../use-disabled-days'\nimport { isValidDate, generatePaddedRange } from '../utils'\nimport { rangeMixin, dateLabelMixin } from '../mixins'\n\nimport { PickerFrameV2, generateSelectedStyle } from './picker-frame'\n\nconst MemoDayPicker = memo(DayPicker)\n\nconst RangeContainer = styled(PickerFrameV2)<{\n  $selectedAll: boolean\n  $enableSameDay?: boolean\n  $startDateLabel?: string\n  $endDateLabel?: string\n  $sameDateLabel?: string\n}>`\n  ${({ $selectedAll, $startDateLabel, $endDateLabel }) => css`\n    ${generateSelectedStyle({ selectedAll: $selectedAll })}\n\n    ${$selectedAll && rangeMixin({})}\n\n    ${$startDateLabel &&\n    dateLabelMixin({\n      selector: '.DayPicker-Day--from',\n      label: $startDateLabel,\n      fontSize: '10px',\n      color: 'var(---color-white)',\n      fontWeight: 500,\n    })}\n\n      ${$endDateLabel &&\n    dateLabelMixin({\n      selector: '.DayPicker-Day--to',\n      label: $endDateLabel,\n      fontSize: '10px',\n      color: 'var(---color-white)',\n      fontWeight: 500,\n    })}\n  `}\n`\n\nfunction getInitialMonth() {\n  return moment().startOf('day').toDate()\n}\n\nexport function RangePickerV2({\n  startDate,\n  endDate,\n  startDateLabel,\n  endDateLabel,\n  sameDateLabel,\n  onDatesChange,\n  numberOfMonths = 25,\n  disabledDays: disabledDaysFromProps,\n  beforeBlock,\n  afterBlock,\n  height,\n  enableSameDay,\n  hideTodayLabel = false,\n  renderDay,\n  renderCaptionElement,\n  publicHolidays: publicHolidaysFromProps,\n}: DisableDaysProps & {\n  startDate: string | null\n  endDate: string | null\n  startDateLabel?: string\n  endDateLabel?: string\n  sameDateLabel?: string\n  hideTodayLabel?: boolean\n  onDatesChange: (params: {\n    startDate: string | null\n    endDate: string | null\n    nights: number\n  }) => void\n  numberOfMonths?: number\n  height?: string\n  enableSameDay?: boolean\n  publicHolidays?: Date[]\n  renderDay?: (date: Date, modifiers?: DayModifiers) => ReactNode\n  renderCaptionElement?: ({\n    date,\n    locale,\n  }: {\n    date: Date\n    locale: string\n  }) => ReactElement\n}) {\n  const disabledDays = useDisabledDays({\n    disabledDays: disabledDaysFromProps,\n    beforeBlock,\n    afterBlock,\n  })\n\n  const publicHolidays = usePublicHolidays({\n    numberOfMonths,\n    skip: !!publicHolidaysFromProps,\n  })\n\n  const initialMonth = useMemo(getInitialMonth, [])\n\n  const from = useMemo(\n    () => (startDate ? moment(startDate).toDate() : undefined),\n    [startDate],\n  )\n  const to = useMemo(\n    () => (endDate ? moment(endDate).toDate() : undefined),\n    [endDate],\n  )\n  const selectedDays = useMemo(\n    () =>\n      [from, from && to ? { from, to } : undefined].filter(\n        (day): day is Date | { from: Date; to: Date } => !!day,\n      ),\n    [from, to],\n  )\n  const modifiers: Partial<Modifiers> = useMemo(\n    () => ({\n      publicHolidays,\n      sunday: (day) => day.getDay() === 0,\n      saturday: (day) => day.getDay() === 6,\n      from,\n      to,\n      'included-range': from && to ? generatePaddedRange(from, to) : [],\n    }),\n    [from, to, publicHolidays],\n  )\n\n  const handleDayClick = useCallback(\n    (day: Date, modifiers: DayModifiers) => {\n      if (modifiers.disabled) {\n        return\n      }\n\n      const { from: nextFrom, to: nextTo } = DayPicker.DateUtils.addDayToRange(\n        day,\n        {\n          from,\n          to,\n        },\n      )\n\n      if (\n        !enableSameDay &&\n        nextFrom &&\n        nextTo &&\n        moment(nextFrom).startOf('day').isSame(moment(nextTo).startOf('day'))\n      ) {\n        return\n      }\n\n      if (!isValidDate(nextTo) || (from && to)) {\n        onDatesChange({\n          startDate: moment(day).format('YYYY-MM-DD'),\n          endDate: null,\n          nights: 0,\n        })\n        return\n      }\n\n      onDatesChange({\n        startDate: nextFrom ? moment(nextFrom).format('YYYY-MM-DD') : null,\n        endDate: nextTo ? moment(nextTo).format('YYYY-MM-DD') : null,\n        nights: nextTo && nextFrom ? moment(nextTo).diff(nextFrom, 'days') : 0,\n      })\n    },\n    [enableSameDay, from, onDatesChange, to],\n  )\n\n  return (\n    <RangeContainer\n      $height={height || '395px'}\n      $sideSpacing={6}\n      $monthPadding=\"32px 0 30px 0\"\n      $selectedAll={!!(startDate && endDate)}\n      $enableSameDay={enableSameDay}\n      $startDateLabel={startDateLabel}\n      $endDateLabel={endDateLabel}\n      $sameDateLabel={sameDateLabel}\n      $hideTodayLabel={hideTodayLabel}\n    >\n      <MemoDayPicker\n        locale={LOCALE}\n        weekdaysShort={WEEKDAY_SHORT_LABEL}\n        localeUtils={LOCALE_UTILS}\n        initialMonth={initialMonth}\n        selectedDays={selectedDays}\n        onDayClick={handleDayClick}\n        numberOfMonths={numberOfMonths}\n        modifiers={modifiers}\n        disabledDays={disabledDays}\n        renderDay={renderDay}\n        captionElement={renderCaptionElement}\n      />\n    </RangeContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/range-picker-v2.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { RangePickerV2, PickerFrameV2 } from './range-picker-v2'\n\nexport default {\n  title: 'tds-widget / date-picker / RangePickerV2',\n  component: RangePickerV2,\n  parameters: {\n    date: new Date('1/1/2022'),\n  },\n} as Meta\n\nexport const Basic: StoryObj<typeof RangePickerV2> = {\n  name: '날짜 구간 선택 컴포넌트 V2',\n  args: {\n    startDate: new Date().toDateString(),\n    endDate: new Date(new Date().getTime() + 86400000 * 5).toDateString(),\n    startDateLabel: '체크인',\n    endDateLabel: '체크아웃',\n    sameDateLabel: '당일',\n    numberOfMonths: 3,\n    height: '300px',\n    enableSameDay: false,\n    hideTodayLabel: false,\n  },\n}\n\nBasic.decorators = [\n  (Story) => (\n    <PickerFrameV2\n      $height=\"300px\"\n      $sideSpacing={10}\n      $monthPadding=\"30px\"\n      $hideTodayLabel={false}\n    >\n      <Story />\n    </PickerFrameV2>\n  ),\n]\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/range-picker.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { RangePicker } from './range-picker'\n\nexport default {\n  title: 'tds-widget / date-picker / RangePicker',\n  component: RangePicker,\n  parameters: {\n    date: new Date('1/1/2022'),\n  },\n} as Meta\n\nexport const Basic: StoryObj<typeof RangePicker> = {\n  name: '날짜 구간 선택 컴포넌트',\n  args: {\n    startDate: null,\n    endDate: null,\n    startDateLabel: '출국일',\n    endDateLabel: '귀국일',\n    sameDateLabel: '당일 왕복',\n    numberOfMonths: 3,\n    height: '300px',\n    enableSameDay: false,\n    hideTodayLabel: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/range-picker.test.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport moment from 'moment'\n\nimport { RangePicker } from './range-picker'\n\njest.mock('./use-public-holidays', () => ({\n  usePublicHolidays: () => [],\n}))\n\ndescribe('RangePicker', () => {\n  it('should select today when enableSameDay is false', () => {\n    const handleDatesChange = jest.fn()\n    render(\n      <RangePicker\n        startDate={null}\n        endDate={null}\n        onDatesChange={handleDatesChange}\n        enableSameDay={false}\n      />,\n    )\n\n    const dateCells = screen.getAllByRole('gridcell')\n    const todayCell = dateCells.find((el) =>\n      el.classList.contains('DayPicker-Day--today'),\n    )\n    if (!todayCell) {\n      throw new Error('Cannot find today date cell')\n    }\n\n    fireEvent.click(todayCell)\n    expect(handleDatesChange).toHaveBeenCalledWith({\n      startDate: moment().format('YYYY-MM-DD'),\n      endDate: null,\n      nights: 0,\n    })\n  })\n\n  it('should not select same day without enableSameDay', () => {\n    let startDate = null\n    const handleDatesChange = jest.fn(({ startDate: newStartDate }) => {\n      startDate = newStartDate\n    })\n\n    const { rerender } = render(\n      <RangePicker\n        startDate={null}\n        endDate={null}\n        onDatesChange={handleDatesChange}\n        enableSameDay={false}\n      />,\n    )\n\n    const dateCells = screen.getAllByRole('gridcell')\n    const targetCell = dateCells[0]\n    if (!targetCell) {\n      throw new Error('Cannot find target cell')\n    }\n\n    fireEvent.click(targetCell)\n\n    rerender(\n      <RangePicker\n        startDate={startDate}\n        endDate={null}\n        onDatesChange={handleDatesChange}\n        enableSameDay={false}\n      />,\n    )\n\n    fireEvent.click(targetCell)\n    expect(handleDatesChange).toHaveBeenCalledTimes(1)\n  })\n})\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/range-picker.tsx",
    "content": "import { memo, useMemo, useCallback } from 'react'\nimport moment from 'moment'\nimport { styled, css } from 'styled-components'\nimport DayPicker, {\n  DayModifiers,\n  DayPickerProps,\n  Modifiers,\n} from 'react-day-picker'\n\nimport { isValidDate, generatePaddedRange } from './utils'\nimport { rangeMixin, dateLabelMixin } from './mixins'\nimport { PickerFrame, generateSelectedCircleStyle } from './picker-frame'\nimport { LOCALE, WEEKDAY_SHORT_LABEL, LOCALE_UTILS } from './constants'\nimport useDisabledDays, { DisableDaysProps } from './use-disabled-days'\nimport { usePublicHolidays } from './use-public-holidays'\n\nconst MemoDayPicker = memo(DayPicker)\n\nconst RangeContainer = styled(PickerFrame)<{\n  $selectedAll: boolean\n  $enableSameDay?: boolean\n  $startDateLabel?: string\n  $endDateLabel?: string\n  $sameDateLabel?: string\n  $defaultColor?: string\n  $backgroundColor?: string\n}>`\n  ${({ $defaultColor = 'var(--color-blue)' }) => css`\n    ${generateSelectedCircleStyle(\n      '.DayPicker-Day--from,.DayPicker-Day--to',\n      $defaultColor,\n    )}\n  `}\n\n  ${({\n    $selectedAll,\n    $startDateLabel,\n    $endDateLabel,\n    $sameDateLabel,\n    $defaultColor,\n    $backgroundColor,\n  }) =>\n    $selectedAll &&\n    css`\n      ${rangeMixin({ backgroundColor: $backgroundColor })}\n\n      ${$startDateLabel &&\n      dateLabelMixin({\n        selector: '.DayPicker-Day--from',\n        label: $startDateLabel,\n        color: $defaultColor,\n      })}\n\n      ${$endDateLabel &&\n      dateLabelMixin({\n        selector: '.DayPicker-Day--to',\n        label: $endDateLabel,\n        color: $defaultColor,\n      })}\n\n      ${$sameDateLabel &&\n      dateLabelMixin({\n        selector: '.DayPicker-Day--from.DayPicker-Day--to',\n        label: $sameDateLabel,\n        color: $defaultColor,\n      })}\n    `}\n`\n\nfunction getInitialMonth() {\n  return moment().startOf('day').toDate()\n}\n\nexport function RangePicker({\n  startDate,\n  endDate,\n  startDateLabel,\n  endDateLabel,\n  sameDateLabel,\n  onDatesChange,\n  numberOfMonths = 25,\n  disabledDays: disabledDaysFromProps,\n  beforeBlock,\n  afterBlock,\n  height,\n  publicHolidays: publicHolidaysFromProps,\n  enableSameDay,\n  hideTodayLabel = false,\n  canChangeMonth,\n  initialMonth: initialMonthFromProps,\n  defaultColor,\n  backgroundColor,\n  ...props\n}: DisableDaysProps &\n  DayPickerProps & {\n    startDate: string | null\n    endDate: string | null\n    startDateLabel?: string\n    endDateLabel?: string\n    sameDateLabel?: string\n    hideTodayLabel?: boolean\n    onDatesChange: (params: {\n      startDate: string | null\n      endDate: string | null\n      nights: number\n    }) => void\n    numberOfMonths?: number\n    height?: string\n    publicHolidays?: Date[]\n    enableSameDay?: boolean\n    defaultColor?: string\n    backgroundColor?: string\n  }) {\n  const disabledDays = useDisabledDays({\n    disabledDays: disabledDaysFromProps,\n    beforeBlock,\n    afterBlock,\n  })\n\n  const publicHolidays = usePublicHolidays({\n    numberOfMonths,\n    skip: !!publicHolidaysFromProps,\n  })\n\n  const initialMonth = useMemo(\n    () => initialMonthFromProps ?? getInitialMonth(),\n    [initialMonthFromProps],\n  )\n\n  const from = useMemo(\n    () => (startDate ? moment(startDate).toDate() : undefined),\n    [startDate],\n  )\n  const to = useMemo(\n    () => (endDate ? moment(endDate).toDate() : undefined),\n    [endDate],\n  )\n  const selectedDays = useMemo(\n    () =>\n      [from, from && to ? { from, to } : undefined].filter(\n        (day): day is Date | { from: Date; to: Date } => !!day,\n      ),\n    [from, to],\n  )\n  const modifiers: Partial<Modifiers> = useMemo(\n    () => ({\n      publicHolidays: publicHolidaysFromProps || publicHolidays,\n      sunday: (day) => day.getDay() === 0,\n      saturday: (day) => day.getDay() === 6,\n      from,\n      to,\n      'included-range': from && to ? generatePaddedRange(from, to) : [],\n    }),\n    [from, publicHolidaysFromProps, to, publicHolidays],\n  )\n\n  const handleDayClick = useCallback(\n    (day: Date, modifiers: DayModifiers) => {\n      if (modifiers.disabled) {\n        return\n      }\n\n      const { from: nextFrom, to: nextTo } = DayPicker.DateUtils.addDayToRange(\n        day,\n        {\n          from,\n          to,\n        },\n      )\n\n      if (\n        !enableSameDay &&\n        nextFrom &&\n        nextTo &&\n        moment(nextFrom).startOf('day').isSame(moment(nextTo).startOf('day'))\n      ) {\n        return\n      }\n\n      if (!isValidDate(nextTo) || (from && to)) {\n        onDatesChange({\n          startDate: moment(day).format('YYYY-MM-DD'),\n          endDate: null,\n          nights: 0,\n        })\n        return\n      }\n\n      onDatesChange({\n        startDate: nextFrom ? moment(nextFrom).format('YYYY-MM-DD') : null,\n        endDate: nextTo ? moment(nextTo).format('YYYY-MM-DD') : null,\n        nights: nextTo && nextFrom ? moment(nextTo).diff(nextFrom, 'days') : 0,\n      })\n    },\n    [enableSameDay, from, onDatesChange, to],\n  )\n\n  return (\n    <RangeContainer\n      $height={height || '395px'}\n      $sideSpacing={6}\n      $monthPadding=\"32px 0 30px 0\"\n      $selectedAll={!!(startDate && endDate)}\n      $enableSameDay={enableSameDay}\n      $startDateLabel={startDateLabel}\n      $endDateLabel={endDateLabel}\n      $sameDateLabel={sameDateLabel}\n      $hideTodayLabel={hideTodayLabel}\n      $canChangeMonth={canChangeMonth}\n      $defaultColor={defaultColor}\n      $backgroundColor={backgroundColor}\n    >\n      <MemoDayPicker\n        locale={LOCALE}\n        weekdaysShort={WEEKDAY_SHORT_LABEL}\n        localeUtils={LOCALE_UTILS}\n        initialMonth={initialMonth}\n        selectedDays={selectedDays}\n        onDayClick={handleDayClick}\n        numberOfMonths={numberOfMonths}\n        modifiers={modifiers}\n        disabledDays={disabledDays}\n        canChangeMonth={canChangeMonth}\n        {...props}\n      />\n    </RangeContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/service.ts",
    "content": "import { authGuardedFetchers, RequestOptions } from '@titicaca/fetcher'\n\nexport interface Holiday {\n  name: string\n  date: string\n  type: string\n  country: string\n}\n\nexport async function fetchPublicHolidays(\n  { from, to }: { from: string; to: string },\n  options: RequestOptions,\n) {\n  return authGuardedFetchers.get<Holiday[]>(\n    `/api/calendar/holidays/KR/${from}/${to}`,\n    options,\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/use-disabled-days.ts",
    "content": "import { useMemo } from 'react'\nimport moment from 'moment'\nimport { BeforeModifier, AfterModifier } from 'react-day-picker'\n\nexport interface DisableDaysProps {\n  disabledDays?: string[]\n  beforeBlock?: string\n  afterBlock?: string\n}\n\nexport default function useDisabledDays({\n  disabledDays,\n  beforeBlock,\n  afterBlock,\n}: DisableDaysProps) {\n  return useMemo(\n    () => [\n      ...(disabledDays || []).map((date) => moment(date).toDate()),\n      beforeBlock || afterBlock\n        ? ({\n            before: beforeBlock ? moment(beforeBlock).toDate() : undefined,\n            after: afterBlock ? moment(afterBlock).toDate() : undefined,\n          } as BeforeModifier | AfterModifier) // HACK: before, after 중 하나만 존재할 때 undefiend 속성값을 허용하지 않아 타입 체크를 우회.\n        : undefined,\n    ],\n    [afterBlock, beforeBlock, disabledDays],\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/use-public-holidays.ts",
    "content": "import { useMemo, useEffect, useState } from 'react'\nimport moment from 'moment'\nimport { NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\n\nimport { fetchPublicHolidays, Holiday } from './service'\n\nconst EMPTY_ARRAY: Holiday[] = []\n\nexport function usePublicHolidays({\n  numberOfMonths,\n  skip = false,\n}: {\n  numberOfMonths: number\n  skip?: boolean\n}) {\n  const [publicHolidays, setPublicHolidays] = useState<Holiday[]>()\n\n  const from = moment().startOf('month').format('YYYY-MM-DD')\n  const to = moment()\n    .add(numberOfMonths, 'month')\n    .endOf('month')\n    .format('YYYY-MM-DD')\n\n  useEffect(() => {\n    if (skip) {\n      return\n    }\n\n    if (from && to) {\n      const fetchData = async () => {\n        const response = await fetchPublicHolidays({ from, to }, {})\n\n        if (response !== NEED_LOGIN_IDENTIFIER && response.ok === true) {\n          const { parsedBody } = response\n\n          setPublicHolidays(parsedBody)\n        }\n      }\n\n      fetchData()\n    }\n  }, [from, to, skip])\n\n  return useMemo(\n    () => (publicHolidays || EMPTY_ARRAY).map(({ date }) => new Date(date)),\n    [publicHolidays],\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/date-picker/utils.ts",
    "content": "import moment from 'moment'\n\nexport function formatMonthTitle(d: Date, locale = 'ko'): string {\n  moment.locale(locale)\n  return moment(d).format('YYYY년 Mo')\n}\n\nexport function isValidDate(d: unknown): boolean {\n  return d instanceof Date && !isNaN(d.getTime())\n}\n\nexport function generatePaddedRange(from: Date, to: Date): Date[] {\n  if (from.getMonth() === to.getMonth()) {\n    return []\n  }\n\n  const dates = []\n\n  const currentDate = moment(from)\n  const endDate = moment(to)\n\n  while (currentDate.diff(endDate) < 0) {\n    const firstDayOfNextMonth = currentDate\n      .clone()\n      .add(1, 'month')\n      .startOf('month')\n\n    if (firstDayOfNextMonth.diff(endDate) <= 0) {\n      const currentWeekday = currentDate.clone().endOf('month').startOf('week')\n\n      const endOfWeek = firstDayOfNextMonth.endOf('week')\n\n      while (currentWeekday.diff(endOfWeek) < 0) {\n        dates.push(currentWeekday.toDate())\n        currentWeekday.add(1, 'day')\n      }\n    }\n\n    currentDate.add(1, 'month')\n  }\n\n  return dates\n}\n"
  },
  {
    "path": "packages/tds-widget/src/directions-finder/ask-to-the-local.tsx",
    "content": "import { useCallback } from 'react'\nimport { styled } from 'styled-components'\nimport {\n  Button,\n  Container,\n  Drawer,\n  HR1,\n  Section,\n  Text,\n  safeAreaInsetMixin,\n  Popup,\n  SafeAreaInsetMixinProps,\n} from '@titicaca/tds-ui'\nimport { useTranslation } from '@titicaca/triple-web'\n\nconst DrawerContentContainer = styled(Container)<SafeAreaInsetMixinProps>`\n  ${safeAreaInsetMixin}\n`\n\nconst IconContainer = styled.div`\n  display: inline-block;\n  height: 17px;\n  margin-top: -2px;\n  margin-bottom: -1px;\n  margin-right: 2px;\n  overflow-y: visible;\n`\n\nconst Icon = styled.svg`\n  display: block;\n  width: 20px;\n  height: 20px;\n`\n\nconst CallButton = styled(Button)`\n  font-size: 14px;\n  line-height: 17px;\n  padding-top: 16px;\n  padding-bottom: 15px;\n`\n\nexport function AskToTheLocal({\n  open,\n  onClose,\n  localName,\n  localAddress,\n  primaryName,\n  phoneNumber,\n  isDomestic,\n}: {\n  open: boolean\n  onClose: () => void\n  localName: string\n  localAddress: string\n  primaryName: string\n  phoneNumber?: string\n  isDomestic?: boolean\n}) {\n  const t = useTranslation()\n\n  const handleCall = useCallback(() => {\n    if (phoneNumber) {\n      window.location.href = `tel:${phoneNumber}`\n    }\n  }, [phoneNumber])\n\n  return (\n    <Popup open={open} onClose={onClose} borderless>\n      <Section\n        css={{\n          marginTop: 20,\n        }}\n      >\n        <Text color=\"blue\" size={36}>\n          {localName}\n        </Text>\n        <Text margin={{ top: 10 }} size={28} css={{ lineHeight: '38px' }}>\n          {localAddress}\n        </Text>\n        <HR1 compact css={{ marginTop: 20, marginBottom: 20 }} />\n        <Text textStyle=\"M\" alpha={0.7} css={{ marginBottom: 150 }}>\n          {primaryName}\n        </Text>\n        {phoneNumber ? (\n          <Drawer active={open}>\n            <DrawerContentContainer\n              css={{\n                padding: '0 30px 10px',\n                background: 'var(--color-white)',\n              }}\n            >\n              <CallButton fluid borderRadius={4} onClick={handleCall}>\n                <IconContainer>\n                  <Icon viewBox=\"0 0 20 20\">\n                    <path\n                      fill=\"#FFF\"\n                      fillRule=\"evenodd\"\n                      stroke=\"#FFF\"\n                      strokeWidth=\".2\"\n                      d=\"M5.353 3.478L3.882 5.043c-.523.523-.51 1.613.035 2.99.607 1.53 1.782 3.214 3.308 4.74 1.525 1.526 3.21 2.7 4.74 3.307 1.379.547 2.467.558 2.99.035l1.565-1.565-2.701-2.606-2.076 2.171c-.168.168-.43.195-.626.065-1.005-.655-1.985-1.457-2.914-2.386-.927-.926-1.73-1.907-2.386-2.913-.13-.198-.101-.46.066-.627l2.17-2.17-2.7-2.606zm8.463 14.02c-.65 0-1.397-.163-2.218-.488-1.656-.656-3.46-1.91-5.08-3.53-1.621-1.62-2.874-3.425-3.53-5.08-.71-1.79-.643-3.234.187-4.064L4.74 2.771c.362-.36.954-.362 1.319 0l2.7 2.7c.176.176.274.41.274.66 0 .25-.098.484-.274.66l-1.88 1.88c.575.83 1.256 1.642 2.03 2.416.776.775 1.587 1.456 2.417 2.03l1.88-1.88c.363-.363.954-.363 1.319 0l2.7 2.701c.177.176.274.41.274.66 0 .25-.097.484-.274.66l-1.564 1.564c-.45.45-1.08.676-1.846.676h0z\"\n                    />\n                  </Icon>\n                </IconContainer>\n                {t('전화하기')}\n              </CallButton>\n              {isDomestic ? null : (\n                <Text\n                  textStyle=\"S3\"\n                  alpha={0.5}\n                  padding={{ top: 6, bottom: 0 }}\n                >\n                  {t('국제 전화 요금이 부과될 수 있습니다.')}\n                </Text>\n              )}\n            </DrawerContentContainer>\n          </Drawer>\n        ) : null}\n      </Section>\n    </Popup>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/directions-finder/constants.ts",
    "content": "export const HASH_ASK_TO_LOCALS_POPUP = 'popup.ask-to-locals'\n"
  },
  {
    "path": "packages/tds-widget/src/directions-finder/direction-buttons.tsx",
    "content": "import { useCallback } from 'react'\nimport { styled } from 'styled-components'\nimport {\n  useTrackEvent,\n  useHashRouter,\n  useAppInstallCtaModal,\n  useClientApp,\n  useTranslation,\n} from '@titicaca/triple-web'\nimport { Button, ButtonGroup, Container } from '@titicaca/tds-ui'\nimport { StaticIntersectionObserver } from '@titicaca/intersection-observer'\n\nimport { AskToTheLocal } from './ask-to-the-local'\nimport { HASH_ASK_TO_LOCALS_POPUP } from './constants'\n\nconst LinkBreak = styled(Container)`\n  flex-basis: 100%;\n  height: 0;\n`\n\nexport function DirectionButtons({\n  onDirectionsClick,\n  primaryName,\n  localName,\n  localAddress,\n  phoneNumber,\n  isDomestic = false,\n  onCallGrabButtonClick,\n  onCallGrabButtonIntersecting,\n}: {\n  onDirectionsClick: () => void\n  primaryName: string\n  localName?: string\n  localAddress?: string\n  phoneNumber?: string\n  isDomestic?: boolean\n  onCallGrabButtonClick?: () => void\n  onCallGrabButtonIntersecting?: (entry: IntersectionObserverEntry) => void\n}) {\n  const t = useTranslation()\n\n  const app = useClientApp()\n  const { uriHash, addUriHash, removeUriHash } = useHashRouter()\n  const { show: showAppInstallCtaModal } = useAppInstallCtaModal()\n  const trackEvent = useTrackEvent()\n\n  const handleAskToLocalsClick = useCallback(() => {\n    trackEvent({\n      ga: ['기본정보_현지에서길묻기'],\n      fa: {\n        action: '기본정보_현지에서길묻기',\n      },\n    })\n\n    app ? addUriHash(HASH_ASK_TO_LOCALS_POPUP) : showAppInstallCtaModal()\n  }, [trackEvent, app, addUriHash, showAppInstallCtaModal])\n\n  const hasAskToLocalsButton = !!(localName && localAddress)\n  const hasLineBreak = hasAskToLocalsButton && !!onCallGrabButtonClick\n\n  return (\n    <>\n      <ButtonGroup css={{ flexWrap: 'wrap', gap: '5px 10px' }}>\n        {hasAskToLocalsButton ? (\n          <Button\n            basic\n            color=\"gray\"\n            size=\"small\"\n            onClick={handleAskToLocalsClick}\n          >\n            {t('현지에서 길묻기')}\n          </Button>\n        ) : null}\n\n        {hasLineBreak ? <LinkBreak /> : null}\n\n        {onCallGrabButtonClick ? (\n          <StaticIntersectionObserver\n            onChange={(entry) => onCallGrabButtonIntersecting?.(entry)}\n          >\n            <Button\n              basic\n              inverted\n              color=\"blue\"\n              size=\"small\"\n              onClick={onCallGrabButtonClick}\n            >\n              {t('grab-hocul')}\n            </Button>\n          </StaticIntersectionObserver>\n        ) : null}\n\n        <Button\n          basic\n          inverted\n          color=\"blue\"\n          size=\"small\"\n          onClick={onDirectionsClick}\n        >\n          {t('길찾기')}\n        </Button>\n      </ButtonGroup>\n\n      {hasAskToLocalsButton ? (\n        <AskToTheLocal\n          open={uriHash === HASH_ASK_TO_LOCALS_POPUP}\n          onClose={removeUriHash}\n          localName={localName}\n          localAddress={localAddress}\n          primaryName={primaryName}\n          phoneNumber={phoneNumber}\n          isDomestic={isDomestic}\n        />\n      ) : null}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/directions-finder/directions-finder.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { DirectionButtons } from './direction-buttons'\n\nexport default {\n  title: 'tds-widget / directions-finder / DirectionButtons',\n  component: DirectionButtons,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta<typeof DirectionButtons>\n\nexport const Basic: StoryObj<typeof DirectionButtons> = {\n  args: {\n    primaryName: '도쿄 디즈니 랜드',\n    localName: '東京ディズニーランド',\n    localAddress: '〒279-0031 東京都千葉県浦安市舞浜11',\n    phoneNumber: '+81453305211',\n    onDirectionsClick: () => {},\n  },\n}\n\nexport const Grab: StoryObj<typeof DirectionButtons> = {\n  args: {\n    primaryName: '도쿄 디즈니 랜드',\n    localName: '東京ディズニーランド',\n    localAddress: '〒279-0031 東京都千葉県浦安市舞浜11',\n    phoneNumber: '+81453305211',\n    onCallGrabButtonClick: () => {},\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/directions-finder/index.ts",
    "content": "export * from './direction-buttons'\n"
  },
  {
    "path": "packages/tds-widget/src/flicking-carousel/arrow-icon.tsx",
    "content": "import { useTheme } from 'styled-components'\n\nconst SVG_ATTRIBUTES_BY_DIRECTION = {\n  left: {\n    d: 'M33.942 35L29 30.029 34 25',\n    transform:\n      'translate(-132 -541) translate(132 541) matrix(-1 0 0 1 60 0) matrix(-1 0 0 1 63 0)',\n  },\n  right: {\n    d: 'M23.942 25L19 20.029 24 15',\n    transform:\n      'translate(-833 -541) translate(833 541) translate(10 10) matrix(-1 0 0 1 43 0)',\n  },\n}\n\nexport function ArrowIcon({ direction }: { direction: 'left' | 'right' }) {\n  const { d, transform } = SVG_ATTRIBUTES_BY_DIRECTION[direction]\n  const { colors } = useTheme()\n  const stroke = colors.gray\n\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      xmlnsXlink=\"http://www.w3.org/1999/xlink\"\n      viewBox=\"0 0 60 60\"\n      width={60}\n      height={60}\n    >\n      <defs>\n        <filter\n          id=\"flg20b2sqa\"\n          width=\"137.5%\"\n          height=\"137.5%\"\n          x=\"-18.8%\"\n          y=\"-18.8%\"\n          filterUnits=\"objectBoundingBox\"\n        >\n          <feOffset in=\"SourceAlpha\" result=\"shadowOffsetOuter1\" />\n          <feGaussianBlur\n            in=\"shadowOffsetOuter1\"\n            result=\"shadowBlurOuter1\"\n            stdDeviation=\"2.5\"\n          />\n          <feColorMatrix\n            in=\"shadowBlurOuter1\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0\"\n          />\n        </filter>\n        <circle id=\"t13s0b9p6b\" cx=\"30\" cy=\"30\" r=\"20\" />\n      </defs>\n      <g fill=\"none\" fillRule=\"evenodd\">\n        <g>\n          <g>\n            <g>\n              <g transform=\"translate(-132 -541) translate(132 541) matrix(-1 0 0 1 60 0)\">\n                <use\n                  fill=\"#000\"\n                  filter=\"url(#flg20b2sqa)\"\n                  xlinkHref=\"#t13s0b9p6b\"\n                />\n                <use fill=\"#FFF\" xlinkHref=\"#t13s0b9p6b\" />\n              </g>\n              <path\n                stroke={stroke}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth={2}\n                opacity={1}\n                d={d}\n                transform={transform}\n              />\n            </g>\n          </g>\n        </g>\n      </g>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/flicking-carousel/flicking-carousel.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport IMAGES from './mocks/carousel.sample.json'\nimport { FlickingCarousel } from './flicking-carousel'\n\nconst meta: Meta<typeof FlickingCarousel> = {\n  title: 'tds-widget / flicking-carousel / FlickingCarousel',\n  component: FlickingCarousel,\n  parameters: {\n    docs: {\n      description: {\n        component: '@egjs/flicking를 적용한 Carousel 컴포넌트입니다.',\n      },\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof FlickingCarousel>\n\nexport const Default: Story = {\n  render: () => (\n    <FlickingCarousel>\n      {IMAGES.map((image, key) => (\n        <FlickingCarousel.Item key={key} size=\"large\">\n          <img\n            src={image.sizes.large.url}\n            alt=\"test\"\n            width={400}\n            height={400}\n          />\n        </FlickingCarousel.Item>\n      ))}\n    </FlickingCarousel>\n  ),\n}\n"
  },
  {
    "path": "packages/tds-widget/src/flicking-carousel/flicking-carousel.tsx",
    "content": "import { PropsWithChildren, useEffect, useRef, useState } from 'react'\nimport { css, styled } from 'styled-components'\nimport Flicking from '@egjs/react-flicking'\nimport {\n  Carousel,\n  Container,\n  formatMarginPadding,\n  marginMixin,\n  MarginPadding,\n} from '@titicaca/tds-ui'\nimport { useUserAgent } from '@titicaca/triple-web'\nimport { FlickingOptions } from '@egjs/flicking'\n\nimport { ArrowIcon } from './arrow-icon'\n\ninterface CarouselBaseProps {\n  margin?: MarginPadding\n  containerPadding?: { left: number; right: number }\n}\n\nexport type FlickingCarouselProps = PropsWithChildren<CarouselBaseProps>\n\nconst CarouselBase = styled.ul<CarouselBaseProps>`\n  ${marginMixin}\n  padding-bottom: 10px;\n  white-space: nowrap;\n  overflow: scroll hidden;\n  -webkit-overflow-scrolling: touch;\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n\n  ${({ containerPadding }) =>\n    containerPadding &&\n    css`\n      li:first-child {\n        margin-left: ${containerPadding.left || 0}px;\n      }\n\n      li:last-child {\n        margin-right: ${containerPadding.right || 0}px;\n      }\n    `};\n`\n\nconst FlickingScrollButton = styled.button<{\n  direction: 'left' | 'right'\n  containerPadding?: { left: number; right: number }\n}>`\n  position: absolute;\n  width: 60px;\n  height: 60px;\n  top: calc(50% - 30px);\n  ${({ direction, containerPadding }) => css`\n    ${direction}: ${(containerPadding?.[direction] || 0) - 30}px;\n  `}\n  z-index: 60;\n  outline: none;\n`\n\nconst FlickingContainer = styled.div`\n  .eg-flick-panel {\n    margin-left: 0 !important;\n  }\n`\n\nconst FLICK_ATTRIBUTES: Partial<FlickingOptions> = {\n  deceleration: 0.0075,\n  horizontal: true,\n  circular: true,\n  infinite: false,\n  infiniteThreshold: 0,\n  lastIndex: Infinity,\n  threshold: 40,\n  duration: 100,\n  panelEffect: (x: number) => 1 - Math.pow(1 - x, 3),\n  defaultIndex: 0,\n  thresholdAngle: 45,\n  bounce: 10,\n  autoResize: false,\n  adaptive: false,\n  bound: false,\n  overflow: false,\n  hanger: '50%',\n  anchor: '50%',\n  gap: 10,\n  moveType: { type: 'snap', count: 1 },\n  collectStatistics: false,\n  zIndex: 50,\n  classPrefix: 'eg-flick',\n}\n\nexport function FlickingCarousel({\n  children,\n  margin,\n  containerPadding,\n  ...props\n}: FlickingCarouselProps) {\n  const carouselRef = useRef<HTMLUListElement>(null)\n  const flickingRef = useRef<Flicking>(null)\n  const [scrollable, setScrollable] = useState(false)\n  const { isMobile } = useUserAgent()\n\n  useEffect(() => {\n    const carouselElement = carouselRef.current\n\n    if (!carouselElement) {\n      return\n    }\n\n    if (carouselElement.scrollWidth > carouselElement.clientWidth) {\n      setScrollable(true)\n    }\n  }, [carouselRef])\n\n  return !isMobile && scrollable ? (\n    <Container\n      position=\"relative\"\n      css={css`\n        ${formatMarginPadding(margin, 'margin')}\n        ${formatMarginPadding(containerPadding, 'padding')}\n      `}\n      {...props}\n    >\n      <FlickingScrollButton\n        containerPadding={containerPadding}\n        direction=\"left\"\n        onClick={() => flickingRef.current?.prev()}\n      >\n        <ArrowIcon direction=\"left\" />\n      </FlickingScrollButton>\n      <FlickingContainer>\n        <Flicking ref={flickingRef} {...FLICK_ATTRIBUTES}>\n          {children}\n        </Flicking>\n      </FlickingContainer>\n      <FlickingScrollButton\n        containerPadding={containerPadding}\n        direction=\"right\"\n        onClick={() => flickingRef.current?.next()}\n      >\n        <ArrowIcon direction=\"right\" />\n      </FlickingScrollButton>\n    </Container>\n  ) : (\n    <CarouselBase\n      ref={carouselRef}\n      margin={margin}\n      containerPadding={containerPadding}\n      {...props}\n    >\n      {children}\n    </CarouselBase>\n  )\n}\n\nFlickingCarousel.Item = Carousel.Item\n"
  },
  {
    "path": "packages/tds-widget/src/flicking-carousel/index.ts",
    "content": "export * from './flicking-carousel'\n"
  },
  {
    "path": "packages/tds-widget/src/flicking-carousel/mocks/carousel.sample.json",
    "content": "[\n  {\n    \"description\": null,\n    \"id\": \"c9ae4d27-b1b6-4842-9359-f4fd040da65f\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/c9ae4d27-b1b6-4842-9359-f4fd040da65f.jpeg\"\n      },\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/c9ae4d27-b1b6-4842-9359-f4fd040da65f.jpeg\"\n      },\n      \"smallSquare\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/c9ae4d27-b1b6-4842-9359-f4fd040da65f.jpeg\"\n      }\n    },\n    \"sourceUrl\": \"http://blog.naver.com/dowoonu00/220986693726\",\n    \"title\": null\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/jkchoi1021/220961631124\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1f1b8655-9979-414b-9a71-fed9e5355ceb.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1f1b8655-9979-414b-9a71-fed9e5355ceb.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1f1b8655-9979-414b-9a71-fed9e5355ceb.jpeg\"\n      }\n    },\n    \"description\": \"1521년 탐험가 마젤란이 항해 도중 세부에 도착해 세운 십자가로 관광객들과 현지인들의 발길이 끊이지 않는 곳이다.\",\n    \"id\": \"1f1b8655-9979-414b-9a71-fed9e5355ceb\",\n    \"title\": \"마젤란이 남기고 간 십자가\"\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/dowoonu00/220986693726\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/da0ee33e-ffe3-4342-ac64-50fb18944551.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/da0ee33e-ffe3-4342-ac64-50fb18944551.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/da0ee33e-ffe3-4342-ac64-50fb18944551.jpeg\"\n      }\n    },\n    \"description\": \"십자가가 보존되어 있는 팔각정 내부 천장을 올려다보면 당시 세례 의식 장면을 기록해 놓은 그림을 만나볼 수 있다.\",\n    \"id\": \"da0ee33e-ffe3-4342-ac64-50fb18944551\",\n    \"title\": \"세례의 순간을 담은 천장\"\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/rldudal0070/220932259105\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1373942a-30d9-46b3-bce6-08cfa9e407f0.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1373942a-30d9-46b3-bce6-08cfa9e407f0.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1373942a-30d9-46b3-bce6-08cfa9e407f0.jpeg\"\n      }\n    },\n    \"description\": null,\n    \"id\": \"1373942a-30d9-46b3-bce6-08cfa9e407f0\",\n    \"title\": null\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/rldudal0070/220932259105\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc.jpeg\"\n      }\n    },\n    \"description\": null,\n    \"id\": \"1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc\",\n    \"title\": null\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/rldudal0070/220932259105\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/5a9681a1-7f24-4c7f-96fe-33558ba5fd67.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/5a9681a1-7f24-4c7f-96fe-33558ba5fd67.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/5a9681a1-7f24-4c7f-96fe-33558ba5fd67.jpeg\"\n      }\n    },\n    \"description\": null,\n    \"id\": \"5a9681a1-7f24-4c7f-96fe-33558ba5fd67\",\n    \"title\": null\n  }\n]\n"
  },
  {
    "path": "packages/tds-widget/src/footer/elements/awards.tsx",
    "content": "import { Fragment } from 'react'\nimport { styled } from 'styled-components'\nimport { FlexBox } from '@titicaca/tds-ui'\n\nimport { FooterAward } from '../utils/type'\nimport { DESKTOP_MIN_WIDTH } from '../utils/constants'\n\nconst AwardImg = styled.img`\n  width: 26px;\n  height: 28px;\n`\n\nconst Tooltip = styled.div`\n  display: none;\n  position: absolute;\n  bottom: calc(100% + 11px);\n  right: 0;\n  border: 1px solid #dadbdf;\n  border-radius: 8px;\n  padding: 8px 11px;\n  background-color: var(--color-white);\n  font-size: 12px;\n  font-weight: 400;\n  color: #6e6f73;\n  line-height: 14px;\n  white-space: pre;\n`\n\nconst AwardFlexBox = styled(FlexBox)`\n  display: flex;\n  position: relative;\n  gap: 8px;\n  flex-shrink: 0;\n\n  ${AwardImg}:hover + ${Tooltip} {\n    display: block;\n  }\n\n  @media (max-width: ${DESKTOP_MIN_WIDTH - 1}px) {\n    display: none;\n  }\n`\n\nexport function AwardGroup({ awards }: { awards: FooterAward[] }) {\n  return (\n    <AwardFlexBox>\n      {awards.map(({ imageUrl, alt, text }, index) => (\n        <Fragment key={`award-${index}`}>\n          <AwardImg src={imageUrl} alt={alt} />\n          <Tooltip>{text}</Tooltip>\n        </Fragment>\n      ))}\n    </AwardFlexBox>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/elements/button-area/button.tsx",
    "content": "import { ReactNode } from 'react'\nimport { styled, css } from 'styled-components'\nimport { FlexBox } from '@titicaca/tds-ui'\nimport {\n  useLogin,\n  useLogout,\n  useSessionAvailability,\n  useTrackEvent,\n} from '@titicaca/triple-web'\n\nimport { DESKTOP_MIN_WIDTH } from '../../utils/constants'\nimport { FooterLinkButton } from '../../utils/type'\n\nexport const ButtonContainer = styled(FlexBox)`\n  flex-shrink: 1;\n  gap: 6px;\n\n  @media (max-width: ${DESKTOP_MIN_WIDTH - 1}px) {\n    width: 100%;\n  }\n`\n\nexport const buttonFlexItemCss = css`\n  flex-shrink: 0;\n\n  @media (max-width: ${DESKTOP_MIN_WIDTH - 1}px) {\n    flex-grow: 1;\n    width: 50%;\n  }\n`\n\nexport const buttonCss = css`\n  ${buttonFlexItemCss}\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  height: 40px;\n  padding: 0 12px;\n  font-size: 13px;\n  font-weight: bold;\n  line-height: 17px;\n  color: #1b1c1f;\n  text-align: center;\n  border-radius: 12px;\n  border: 1px solid #dadbdf;\n  background: #fafbfd;\n\n  span {\n    margin: 0 4px;\n  }\n\n  img {\n    width: 16px;\n  }\n`\n\nexport const Button = styled.a`\n  ${buttonCss}\n`\n\nexport const BUTTON_LIST: Record<string, ReactNode> = {\n  login: <LoginLogoutButton key=\"login\" />,\n}\n\nfunction LoginLogoutButton() {\n  const sessionAvailable = useSessionAvailability()\n  const login = useLogin()\n  const logout = useLogout()\n  const trackEvent = useTrackEvent()\n\n  return (\n    <Button\n      as=\"button\"\n      type=\"button\"\n      onClick={() => {\n        if (sessionAvailable) {\n          logout()\n          return\n        }\n\n        trackEvent({\n          ga: ['푸터_로그인'],\n          fa: {\n            action: '푸터_로그인',\n          },\n        })\n        login()\n      }}\n    >\n      {sessionAvailable === true ? '로그아웃' : '로그인'}\n    </Button>\n  )\n}\n\nexport function LinkButton({\n  href,\n  faEventAction,\n  label,\n  iconSrc,\n}: FooterLinkButton) {\n  const trackEvent = useTrackEvent()\n\n  return (\n    <Button\n      href={href}\n      onClick={() => {\n        trackEvent({\n          ga: [faEventAction],\n          fa: {\n            action: faEventAction,\n          },\n        })\n      }}\n    >\n      <span>{label}</span>\n      <img src={iconSrc} alt=\"link button arrow\" />\n    </Button>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/elements/button-area/dropdown.tsx",
    "content": "import { useEffect, useRef, useState } from 'react'\nimport { useTrackEvent } from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\n\nimport { FooterDropdownButton } from '../../utils/type'\n\nimport { buttonCss, buttonFlexItemCss } from './button'\n\nconst DropdownContainer = styled.div`\n  position: relative;\n  ${buttonFlexItemCss}\n\n  > button {\n    width: 100%;\n  }\n`\n\nconst DropdownOptions = styled.ul`\n  position: absolute;\n  bottom: calc(100% + 18px);\n  right: 0;\n  list-style: none;\n  padding: 12px 0;\n  min-width: 135px;\n  z-index: 999;\n  border-radius: 8px;\n  border: 1px solid #dadbdf;\n  color: #1b1c1f;\n  font-size: 12px;\n  font-weight: 700;\n  line-height: 14px;\n  background: var(--color-white);\n  box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.05);\n\n  a {\n    display: block;\n    padding: 8px 20px;\n  }\n`\n\nexport function Dropdown({\n  label,\n  options,\n  faEventAction,\n}: FooterDropdownButton) {\n  const [dropdownOptionsVisible, setDropdownOptionsVisible] =\n    useState<boolean>(false)\n  const trackEvent = useTrackEvent()\n\n  const buttonRef = useRef<HTMLButtonElement>(null)\n  const optionsRef = useRef<HTMLUListElement>(null)\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      const isOptionsClicked =\n        optionsRef.current && optionsRef.current.contains(event.target as Node)\n      const isButtonClicked =\n        buttonRef.current && buttonRef.current.contains(event.target as Node)\n\n      if (!isOptionsClicked && !isButtonClicked) {\n        setDropdownOptionsVisible(false)\n      }\n    }\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => {\n      document.removeEventListener('mousedown', handleClickOutside)\n    }\n  }, [])\n\n  return (\n    <DropdownContainer>\n      <button\n        ref={buttonRef}\n        css={buttonCss}\n        onClick={(e) => {\n          e.stopPropagation()\n          setDropdownOptionsVisible((prev) => !prev)\n          faEventAction && trackEvent({ fa: { action: faEventAction } })\n        }}\n      >\n        <span>{label}</span>\n        <DropdownArrow />\n      </button>\n\n      {dropdownOptionsVisible ? (\n        <DropdownOptions ref={optionsRef}>\n          {options.map(({ label, url, faEventAction }) => (\n            <li key={label} value={label}>\n              <a\n                href={url}\n                target=\"_blank\"\n                rel=\"noreferrer\"\n                onClick={() =>\n                  faEventAction\n                    ? trackEvent({ fa: { action: faEventAction } })\n                    : undefined\n                }\n              >\n                {label}\n              </a>\n            </li>\n          ))}\n        </DropdownOptions>\n      ) : null}\n    </DropdownContainer>\n  )\n}\n\nfunction DropdownArrow() {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n    >\n      <path\n        d=\"M6.0625 12.5L10.75 7.8125L6.0625 3.125\"\n        stroke=\"#1B1C1F\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/elements/button-area/index.tsx",
    "content": "import { FooterButton } from '../../utils/type'\n\nimport { BUTTON_LIST, ButtonContainer, LinkButton } from './button'\nimport { Dropdown } from './dropdown'\n\nexport function ButtonArea({ buttons }: { buttons: FooterButton[] }) {\n  return (\n    <ButtonContainer flex>\n      {buttons\n        .filter(({ hidden }) => !hidden)\n        .map((button) => {\n          switch (button.type) {\n            case 'button':\n              return BUTTON_LIST[button.key] || null\n            case 'link':\n              return <LinkButton {...button} key={button.key} />\n            case 'dropdown':\n              return <Dropdown {...button} key={button.key} />\n            default:\n              return null\n          }\n        })}\n    </ButtonContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/elements/company-info.tsx",
    "content": "import { styled } from 'styled-components'\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionTitle,\n  Container,\n} from '@titicaca/tds-ui'\nimport { useTrackEvent } from '@titicaca/triple-web'\nimport { Dispatch, SetStateAction, Fragment } from 'react'\n\nimport { FooterInfo, FooterText } from '../utils/type'\nimport { DESKTOP_MIN_WIDTH } from '../utils/constants'\n\nimport { ButtonArea } from './button-area'\nimport { Divider } from './divider'\n\nconst AccordionHeader = styled(Container)`\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n\n  @media (max-width: ${DESKTOP_MIN_WIDTH - 1}px) {\n    flex-direction: column-reverse;\n    align-items: flex-start;\n  }\n`\n\nconst Title = styled(AccordionTitle)`\n  display: flex;\n  align-items: center;\n  flex: 1;\n  color: #1b1c1f !important;\n  font-size: 14px !important;\n  font-weight: 700;\n  line-height: 17px;\n  margin-top: 12px;\n\n  &::after {\n    display: none;\n  }\n\n  @media (max-width: ${DESKTOP_MIN_WIDTH - 1}px) {\n    margin-top: 20px;\n  }\n`\n\nconst TextList = styled.ul`\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  margin-top: 12px;\n\n  li {\n    display: flex;\n    align-items: center;\n    color: #6e6f73;\n    font-size: 12px;\n    line-height: 14px;\n  }\n`\n\nconst LinkContainer = styled.a`\n  text-decoration: underline;\n`\n\ninterface CompanyInfoProps {\n  companyTexts: Array<FooterText[]>\n  buttons?: FooterInfo['buttons']\n  hideAppDownloadButton?: boolean\n  businessExpanded: boolean\n  setBusinessExpanded: Dispatch<SetStateAction<boolean>>\n}\n\nexport function CompanyInfo({\n  companyTexts: rowTexts,\n  buttons,\n  hideAppDownloadButton = false,\n  businessExpanded,\n  setBusinessExpanded,\n}: CompanyInfoProps) {\n  const trackEvent = useTrackEvent()\n\n  return (\n    <Accordion\n      active={businessExpanded}\n      onActiveChange={() =>\n        setBusinessExpanded((businessExpanded) => !businessExpanded)\n      }\n    >\n      <AccordionHeader>\n        <Title css={{ marginTop: hideAppDownloadButton ? 0 : undefined }}>\n          트리플 사업자정보\n          <ArrowIcon businessExpanded={businessExpanded} />\n        </Title>\n\n        {!hideAppDownloadButton && !!buttons?.length ? (\n          <ButtonArea buttons={buttons} />\n        ) : null}\n      </AccordionHeader>\n\n      <AccordionContent>\n        <TextList>\n          {rowTexts.map((columnTexts, index) => (\n            <li key={`company-text-line-${index}`}>\n              {columnTexts.map(({ text, url, faEventAction }, index) => (\n                <Fragment key={`company-text-${index}`}>\n                  {url ? (\n                    <LinkContainer\n                      onClick={\n                        faEventAction\n                          ? () => trackEvent({ fa: { action: faEventAction } })\n                          : undefined\n                      }\n                    >\n                      {text}\n                    </LinkContainer>\n                  ) : (\n                    text\n                  )}\n                  {index !== columnTexts.length - 1 ? <Divider /> : null}\n                </Fragment>\n              ))}\n              {index !== rowTexts.length - 1 ? <br /> : null}\n            </li>\n          ))}\n        </TextList>\n      </AccordionContent>\n    </Accordion>\n  )\n}\n\nfunction ArrowIcon({ businessExpanded }: { businessExpanded: boolean }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 12 12\"\n      fill=\"none\"\n      style={{\n        marginLeft: '2px',\n        transform: businessExpanded ? 'rotate(0deg)' : 'rotate(180deg)',\n        transition: 'transform 0.3s ease',\n      }}\n    >\n      <path\n        d=\"M10 8L6 4L2 8\"\n        stroke=\"#1B1C1F\"\n        strokeWidth=\"1.3\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/elements/divider.tsx",
    "content": "import styled from 'styled-components'\n\nexport const Divider = styled.div`\n  width: 1px;\n  height: 8px;\n  background: #dadbdf;\n  margin: 0 8px;\n`\n"
  },
  {
    "path": "packages/tds-widget/src/footer/elements/extra-link-group.tsx",
    "content": "import { Fragment } from 'react'\nimport { styled } from 'styled-components'\nimport { useTrackEvent } from '@titicaca/triple-web'\n\nimport { FooterLink } from '../utils/type'\n\nconst Link = styled.a`\n  display: inline-block;\n  color: var(--color-gray500);\n  font-size: 10px;\n  font-weight: 500;\n  text-decoration-line: underline;\n  cursor: pointer;\n  margin-top: 20px;\n`\n\nexport function ExtraLinkGroup({ extraLinks }: { extraLinks: FooterLink[] }) {\n  return extraLinks.map((link, index) => (\n    <Fragment key={`extra-link-${index}`}>\n      <ExtraLink {...link} />\n      {index !== extraLinks.length - 1 ? <br /> : null}\n    </Fragment>\n  ))\n}\n\nfunction ExtraLink({ label, url, faEventAction }: FooterLink) {\n  const trackEvent = useTrackEvent()\n\n  return (\n    <Link\n      href={url}\n      target=\"_blank\"\n      rel=\"noreferrer\"\n      onClick={() => trackEvent({ fa: { action: faEventAction } })}\n    >\n      {label}\n    </Link>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/elements/link-group.tsx",
    "content": "import { Fragment } from 'react'\nimport { styled } from 'styled-components'\nimport { Container } from '@titicaca/tds-ui'\nimport { useTrackEvent } from '@titicaca/triple-web'\n\nimport { FooterLink } from '../utils/type'\n\nimport { Divider } from './divider'\n\nconst LinksContainer = styled(Container)`\n  display: flex;\n  flex-flow: row wrap;\n  align-items: center;\n  margin-top: 20px;\n  row-gap: 8px;\n\n  a {\n    font-size: 12px;\n    line-height: 14px;\n    color: #3c3d40;\n    text-decoration: none;\n    word-break: keep-all;\n    flex-shrink: 0;\n  }\n`\n\nexport function LinkGroup({ links }: { links: FooterLink[] }) {\n  const trackEvent = useTrackEvent()\n\n  return (\n    <LinksContainer>\n      {links.map((link, index) => (\n        <Fragment key={`link-${index}`}>\n          <a\n            key={`link-${index}`}\n            href={link.url}\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            onClick={\n              link.faEventAction\n                ? () => trackEvent({ fa: { action: link.faEventAction } })\n                : undefined\n            }\n            css={{ fontWeight: link.bold ? 700 : 400 }}\n          >\n            {link.label}\n          </a>\n          {index < links.length - 1 ? <Divider /> : null}\n        </Fragment>\n      ))}\n    </LinksContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/footers/default-footer.tsx",
    "content": "import { useState } from 'react'\nimport { styled } from 'styled-components'\nimport { Text, Container } from '@titicaca/tds-ui'\n\nimport { LinkGroup } from '../elements/link-group'\nimport { CompanyInfo } from '../elements/company-info'\nimport { ExtraLinkGroup } from '../elements/extra-link-group'\nimport { useFooterInfo } from '../utils/use-footer-info'\nimport { FOOTER_MIN_HEIGHTS, DESKTOP_MIN_WIDTH } from '../utils/constants'\nimport { AwardGroup } from '../elements/awards'\n\nexport const FooterFrame = styled.footer<{ hideAppDownloadButton?: boolean }>`\n  background-color: #fafbfd;\n  min-height: ${({ hideAppDownloadButton }) =>\n    hideAppDownloadButton\n      ? FOOTER_MIN_HEIGHTS.DESKTOP_WITHOUT_APP_DOWNLOAD_BUTTON\n      : FOOTER_MIN_HEIGHTS.DESKTOP_WITH_APP_DOWNLOAD_BUTTON}px;\n\n  @media (max-width: ${DESKTOP_MIN_WIDTH - 1}px) {\n    min-height: ${({ hideAppDownloadButton }) =>\n      hideAppDownloadButton\n        ? FOOTER_MIN_HEIGHTS.MOBILE_WITHOUT_APP_DOWNLOAD_BUTTON\n        : FOOTER_MIN_HEIGHTS.MOBILE_WITH_APP_DOWNLOAD_BUTTON}px;\n  }\n`\n\nexport const FooterInnerContainer = styled(Container)`\n  margin: 0 auto;\n  padding: 30px 30px 60px;\n  max-width: ${DESKTOP_MIN_WIDTH}px;\n`\n\nconst Disclaimer = styled(Text)`\n  font-size: 12px;\n  line-height: 18px;\n  color: #8b8d92;\n  margin-top: 12px;\n  font-weight: 400;\n`\n\nconst LinkGroupContainer = styled(Container)`\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-end;\n  gap: 12px;\n`\n\nexport interface DefaultFooterProps {\n  hideAppDownloadButton?: boolean\n  extraLinkVisible?: boolean\n  awardsVisible?: boolean\n}\n\nexport function DefaultFooter({\n  hideAppDownloadButton = false,\n  extraLinkVisible = false,\n  awardsVisible = false,\n  ...props\n}: DefaultFooterProps) {\n  const footerInfo = useFooterInfo()\n  const [businessExpanded, setBusinessExpanded] = useState<boolean>(false)\n\n  if (!footerInfo) {\n    return (\n      <FooterFrame {...props} hideAppDownloadButton={hideAppDownloadButton} />\n    )\n  }\n\n  return (\n    <FooterFrame {...props} hideAppDownloadButton={hideAppDownloadButton}>\n      <FooterInnerContainer>\n        <CompanyInfo\n          companyTexts={footerInfo.companyTexts}\n          hideAppDownloadButton={hideAppDownloadButton}\n          businessExpanded={businessExpanded}\n          setBusinessExpanded={setBusinessExpanded}\n          buttons={footerInfo.buttons}\n        />\n        <Disclaimer>{footerInfo.disclaimer}</Disclaimer>\n\n        <LinkGroupContainer>\n          <LinkGroup links={footerInfo.links} />\n          {awardsVisible ? <AwardGroup awards={footerInfo.awards} /> : null}\n        </LinkGroupContainer>\n\n        {extraLinkVisible ? (\n          <ExtraLinkGroup extraLinks={footerInfo.extraLinks} />\n        ) : null}\n      </FooterInnerContainer>\n    </FooterFrame>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/footers/footer.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { http, HttpResponse } from 'msw'\n\nimport MockFooterInfo from '../mocks/footer.json'\n\nimport { DefaultFooter } from './default-footer'\n\nexport default {\n  title: 'footer / Footer',\n  component: DefaultFooter,\n} as Meta<typeof DefaultFooter>\n\nexport const Basic: StoryObj<typeof DefaultFooter> = {\n  argTypes: {\n    hideAppDownloadButton: {\n      control: 'boolean',\n      defaultValue: false,\n    },\n    extraLinkVisible: {\n      control: 'boolean',\n      defaultValue: true,\n    },\n    awardsVisible: {\n      control: 'boolean',\n      defaultValue: true,\n    },\n  },\n  args: {\n    hideAppDownloadButton: false,\n    extraLinkVisible: true,\n    awardsVisible: true,\n  },\n  parameters: {\n    msw: {\n      handlers: [\n        http.get(\n          'https://assets.triple-dev.titicaca-corp.com/footer/footer.json',\n          async () => {\n            return HttpResponse.json(MockFooterInfo)\n          },\n        ),\n      ],\n    },\n  },\n}\n\nexport const SkeletonFooter: StoryObj<typeof DefaultFooter> = {\n  parameters: {\n    msw: {\n      handlers: [\n        http.get(\n          'https://assets.triple-dev.titicaca-corp.com/footer/footer.json',\n          async () => {\n            return HttpResponse.json(null)\n          },\n        ),\n      ],\n    },\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/footers/logo-footer.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { LogoFooter } from './logo-footer'\n\nexport default {\n  title: 'tds-widget / footer / LogoFooter',\n  component: LogoFooter,\n} as Meta<typeof LogoFooter>\n\nexport const Basic: StoryObj<typeof LogoFooter> = {}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/footers/logo-footer.tsx",
    "content": "import { styled } from 'styled-components'\n\nconst FooterContainer = styled.footer`\n  height: 120px;\n  background-color: #f5f5f5;\n`\n\nconst TripleLogo = styled.div`\n  position: relative;\n  top: 30px;\n  width: 100%;\n  height: 20px;\n  background-image: url('https://assets.triple.guide/images/img-listend-logo@3x.png');\n  background-size: 46px 20px;\n  background-position: center;\n  background-repeat: no-repeat;\n`\n\nexport function LogoFooter() {\n  return (\n    <FooterContainer>\n      <TripleLogo />\n    </FooterContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/index.ts",
    "content": "export { DefaultFooter } from './footers/default-footer'\nexport { LogoFooter } from './footers/logo-footer'\n"
  },
  {
    "path": "packages/tds-widget/src/footer/mocks/footer.json",
    "content": "{\n  \"companyTexts\": [\n    [{ \"text\": \"㈜놀유니버스\" }, { \"text\": \"대표이사 배보찬\" }],\n    [{ \"text\": \"사업자 등록번호 824-81-02515\" }],\n    [{ \"text\": \"통신판매업 신고번호 2024-성남수정-0912\" }],\n    [{ \"text\": \"경기도 성남시 수정구 금토로 70 (금토동, 텐엑스타워)\" }],\n    [{ \"text\": \"호스팅서비스제공자: ㈜놀유니버스\" }],\n    [{ \"text\": \"항공, 숙소 및 투어·티켓 문의 1588-2539\" }],\n    [{ \"text\": \"help.triple@nol-universe.com\" }]\n  ],\n  \"disclaimer\": \"㈜놀유니버스는 일부 상품에 대해 통신판매중개자로서 통신판매의 당사자가 아니므로, 상품의 예약, 이용 및 환불 등 거래와 관련된 의무와 책임은 판매자에게 있으며 ㈜놀유니버스는 일체 책임을 지지 않습니다.\",\n  \"links\": [\n    {\n      \"label\": \"서비스 이용약관\",\n      \"url\": \"/pages/tos.html\"\n    },\n    {\n      \"label\": \"개인정보 처리방침\",\n      \"url\": \"/pages/privacy-policy.html\",\n      \"bold\": true\n    },\n    {\n      \"label\": \"위치정보 이용약관\",\n      \"url\": \"/pages/tolbs.html\"\n    },\n    {\n      \"label\": \"전자금융거래 이용약관\",\n      \"url\": \"/pages/e-financial-terms.html\"\n    },\n    {\n      \"label\": \"서비스 이용정책\",\n      \"url\": \"/pages/oppolicy.html\"\n    },\n    {\n      \"label\": \"청소년보호정책\",\n      \"url\": \"/pages/youth-protection-policy.html\"\n    },\n    {\n      \"label\": \"고객센터\",\n      \"url\": \"/cs-bridge/entry\"\n    }\n  ],\n  \"extraLinks\": [\n    {\n      \"label\": \"Interpark Global for Overseas Travelers\",\n      \"url\": \"https://interparkglobal.com\",\n      \"faEventAction\": \"푸터_트리플코리아링크\"\n    }\n  ],\n  \"awards\": [\n    {\n      \"text\": \"국제표준 정보보호 인증 취득\\nISO 27001, ISO 27701\",\n      \"alt\": \"국제표준 정보보호 인증마크 ISO27001, ISO 27701\",\n      \"imageUrl\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1060b728-6ef3-477d-a64a-0a38f5c3250e.jpeg\"\n    }\n  ],\n  \"buttons\": [\n    {\n      \"type\": \"button\",\n      \"key\": \"login\",\n      \"faEventAction\": \"푸터_로그인\",\n      \"hidden\": true\n    },\n    {\n      \"type\": \"link\",\n      \"key\": \"app-download\",\n      \"label\": \"트리플 앱\",\n      \"href\": \"https://triple.onelink.me/aZP6?pid=intro_web&af_dp=triple%3A%2F%2F%2Fmain\",\n      \"faEventAction\": \"푸터_트리플앱설치\",\n      \"iconSrc\": \"https://assets.triple-dev.titicaca-corp.com/images/ico_download@4x.png\"\n    },\n    {\n      \"type\": \"dropdown\",\n      \"key\": \"family-site\",\n      \"label\": \"Family Site\",\n      \"options\": [\n        {\n          \"label\": \"NOL 인터파크\",\n          \"url\": \"https://nol.interpark.com\"\n        },\n        {\n          \"label\": \"인터파크 글로벌\",\n          \"url\": \"https://interparkglobal.com\"\n        },\n        {\n          \"label\": \"NOL\",\n          \"url\": \"https://nol.yanolja.com/\"\n        }\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "packages/tds-widget/src/footer/utils/constants.ts",
    "content": "export const FOOTER_MIN_HEIGHTS = {\n  MOBILE_WITH_APP_DOWNLOAD_BUTTON: 274,\n  MOBILE_WITHOUT_APP_DOWNLOAD_BUTTON: 214,\n  DESKTOP_WITH_APP_DOWNLOAD_BUTTON: 194,\n  DESKTOP_WITHOUT_APP_DOWNLOAD_BUTTON: 174,\n}\n\nexport const DESKTOP_MIN_WIDTH = 768\n"
  },
  {
    "path": "packages/tds-widget/src/footer/utils/type.ts",
    "content": "export interface FooterInfo {\n  companyTexts: Array<FooterText[]>\n  disclaimer: string\n  links: FooterLink[]\n  extraLinks: FooterLink[]\n  awards: FooterAward[]\n  buttons?: FooterButton[]\n}\n\nexport interface FooterText {\n  text: string\n  url?: string\n  faEventAction?: string\n}\n\nexport interface FooterLink {\n  label: string\n  url: string\n  faEventAction?: string\n  bold?: boolean\n}\n\nexport interface FooterAward {\n  text: string\n  alt: string\n  imageUrl: string\n}\n\nexport interface FooterButtonBase {\n  /**\n   * 고유 key값\n   * type이 button인 경우 선언된 버튼 컴포넌트를 참조하는데 사용됩니다.\n   */\n  key: string\n  faEventAction?: string\n  hidden?: boolean\n}\n\nexport interface FooterDefaultButton extends FooterButtonBase {\n  type: 'button'\n}\n\nexport interface FooterLinkButton extends FooterButtonBase {\n  type: 'link'\n  label: string\n  href: string\n  iconSrc?: string\n}\n\nexport interface FooterDropdownButton extends FooterButtonBase {\n  type: 'dropdown'\n  label: string\n  options: FooterLink[]\n}\n\nexport type FooterButton =\n  | FooterDefaultButton\n  | FooterLinkButton\n  | FooterDropdownButton\n"
  },
  {
    "path": "packages/tds-widget/src/footer/utils/use-footer-info.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { useEnv } from '@titicaca/triple-web'\n\nimport { FooterInfo } from './type'\n\nconst FOOTER_INFO_ASSET_FILE_PATH = '/footer/footer.json'\n\nexport function useFooterInfo() {\n  const [footerInfo, setFooterInfo] = useState<FooterInfo | null>(null)\n  const { webAssetsUrl } = useEnv()\n\n  useEffect(() => {\n    if (!webAssetsUrl) {\n      throw new Error('webAssetsUrl is not defined in EnvContext')\n    }\n\n    const getFooterInfo = async () => {\n      try {\n        const response = await fetch(webAssetsUrl + FOOTER_INFO_ASSET_FILE_PATH)\n        const data = await response.json()\n        setFooterInfo(data)\n        // eslint-disable-next-line @typescript-eslint/no-unused-vars\n      } catch (error) {\n        // do nothing\n      }\n    }\n\n    getFooterInfo()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  return footerInfo\n}\n"
  },
  {
    "path": "packages/tds-widget/src/hub-form/cell.tsx",
    "content": "import { PropsWithChildren, SyntheticEvent } from 'react'\nimport { styled, css } from 'styled-components'\nimport { Text } from '@titicaca/tds-ui'\n\ntype StyleType = 'SCHEDULE' | 'PEOPLE' | 'ORIGIN' | 'DESTINATION' | 'SEARCH'\n\nconst STYLE_BY_TYPES: { [type in StyleType]: ReturnType<typeof css> } = {\n  SCHEDULE: css`\n    background: url('https://assets.triple.guide/images/img-hub-date@3x.png')\n      center center no-repeat;\n  `,\n  PEOPLE: css`\n    background: url('https://assets.triple.guide/images/img-hub-people@3x.png')\n      center center no-repeat;\n  `,\n  ORIGIN: css`\n    background: url('https://assets.triple.guide/images/img-hub-departure@3x.png')\n      center center no-repeat;\n  `,\n  DESTINATION: css`\n    background: url('https://assets.triple.guide/images/img-hub-arrival@3x.png')\n      center center no-repeat;\n  `,\n  SEARCH: css`\n    background: url('https://assets.triple.guide/images/img-hub-search@3x.png')\n      center center no-repeat;\n  `,\n}\n\nconst CellContainer = styled.div<{ $type: StyleType }>`\n  position: relative;\n  line-height: 17px;\n  padding: 20px 0 20px 35px;\n  font-size: 15px;\n  font-weight: bold;\n\n  &::before {\n    content: '';\n    width: 20px;\n    height: 20px;\n    position: absolute;\n    left: 0;\n    top: 50%;\n    margin-top: -10px;\n    ${({ $type }) => STYLE_BY_TYPES[$type]};\n    background-size: 20px 20px;\n  }\n`\n\nexport function Cell({\n  type,\n  placeholder,\n  value,\n  onClick,\n}: {\n  type: StyleType\n  placeholder?: string\n  value?: string\n  onClick?: (e: SyntheticEvent) => void\n}) {\n  return (\n    <CellContainer $type={type} onClick={onClick}>\n      {value ? (\n        <Value>{value}</Value>\n      ) : (\n        <Placeholder>{placeholder || ''}</Placeholder>\n      )}\n    </CellContainer>\n  )\n}\n\nfunction Value({ children }: PropsWithChildren<unknown>) {\n  return (\n    <Text size=\"medium\" bold lineHeight=\"17px\">\n      {children}\n    </Text>\n  )\n}\n\nfunction Placeholder({ children }: PropsWithChildren<unknown>) {\n  return (\n    <Text color=\"gray\" size=\"medium\" bold alpha={0.3} lineHeight=\"17px\">\n      {children}\n    </Text>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/hub-form/cta.tsx",
    "content": "import { PropsWithChildren, SyntheticEvent } from 'react'\nimport { Button } from '@titicaca/tds-ui'\n\nexport function Cta({\n  available,\n  onSubmit,\n  children,\n}: PropsWithChildren<{\n  available?: boolean\n  onSubmit: (e: SyntheticEvent) => void\n}>) {\n  return (\n    <Button\n      size=\"small\"\n      fluid\n      borderRadius={4}\n      disabled={!available}\n      lineHeight=\"20px\"\n      onClick={available ? onSubmit : undefined}\n    >\n      {children}\n    </Button>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/hub-form/hub-form.stories.tsx",
    "content": "import type { Meta, StoryFn } from '@storybook/react'\n\nimport { Cell } from './cell'\nimport { Cta } from './cta'\nimport { HubForm } from './hub-form'\n\nexport default {\n  title: 'tds-widget / hub-form / HubForm',\n  component: HubForm,\n} as Meta<typeof HubForm>\n\nexport const Hotel: StoryFn<typeof HubForm> = () => {\n  return (\n    <>\n      <HubForm>\n        <Cell type=\"DESTINATION\" placeholder=\"도시, 또는 호텔\" value=\"\" />\n        <Cell type=\"SCHEDULE\" placeholder=\"날짜\" value=\"\" />\n        <Cell type=\"PEOPLE\" placeholder=\"\" value=\"인원 2명\" />\n      </HubForm>\n      <Cta available={false} onSubmit={() => {}}>\n        호텔 검색\n      </Cta>\n    </>\n  )\n}\n\nexport const Air: StoryFn<typeof HubForm> = () => {\n  return (\n    <>\n      <HubForm>\n        <Cell type=\"ORIGIN\" placeholder=\"출발 도시\" value=\"\" />\n        <Cell type=\"DESTINATION\" placeholder=\"도착 도시\" value=\"\" />\n        <Cell type=\"SCHEDULE\" placeholder=\"날짜\" value=\"\" />\n        <Cell type=\"PEOPLE\" placeholder=\"\" value=\"탑승객 1명, 일반석 외 1\" />\n      </HubForm>\n      <Cta available={false} onSubmit={() => {}}>\n        항공권 검색\n      </Cta>\n    </>\n  )\n}\n\nexport const Shadow: StoryFn<typeof HubForm> = () => {\n  return (\n    <>\n      <HubForm shadow=\"small\">\n        <Cell type=\"ORIGIN\" placeholder=\"shadow=small\" value=\"\" />\n      </HubForm>\n      <HubForm shadow=\"medium\">\n        <Cell type=\"ORIGIN\" placeholder=\"shadow=medium\" value=\"\" />\n      </HubForm>\n      <HubForm shadow=\"large\">\n        <Cell type=\"ORIGIN\" placeholder=\"shadow=large\" value=\"\" />\n      </HubForm>\n      <HubForm shadow=\"none\">\n        <Cell type=\"ORIGIN\" placeholder=\"shadow=none\" value=\"\" />\n      </HubForm>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/hub-form/hub-form.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\nimport { CardFrame, CardProps } from '@titicaca/tds-ui'\n\nconst HubFormFrame = styled(CardFrame)`\n  & > div:not(:last-child) {\n    &::after {\n      content: '';\n      position: absolute;\n      bottom: 0;\n      right: 0;\n      left: 35px;\n      background: rgba(239, 239, 239, 0.5);\n      height: 1px;\n    }\n  }\n`\n\nexport function HubForm({\n  children,\n  shadow,\n  ...props\n}: PropsWithChildren<CardProps>) {\n  return (\n    <HubFormFrame\n      shadow={shadow || 'medium'}\n      css={{\n        marginTop: 10,\n        marginBottom: 10,\n        paddingTop: 4,\n        paddingBottom: 4,\n        paddingRight: 18,\n        paddingLeft: 22,\n      }}\n      {...props}\n    >\n      {children}\n    </HubFormFrame>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/hub-form/index.ts",
    "content": "export * from './cell'\nexport * from './cta'\nexport * from './hub-form'\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/carousel.tsx",
    "content": "import { FlickingEvent, FlickingOptions } from '@egjs/flicking'\nimport Flicking, { FlickingProps } from '@egjs/react-flicking'\nimport { Container, MarginPadding, formatMarginPadding } from '@titicaca/tds-ui'\nimport { ReactNode, RefObject, useState } from 'react'\nimport { styled } from 'styled-components'\n\nexport interface CarouselProps\n  extends Partial<FlickingProps & FlickingOptions> {\n  flickingRef: RefObject<Flicking>\n  margin?: MarginPadding\n  borderRadius?: number\n  pageLabelRenderer: (params: { currentIndex: number }) => ReactNode\n}\n\nconst CarouselContainer = styled(Container)`\n  overflow: visible;\n\n  img {\n    pointer-events: none;\n  }\n`\nconst TopRightControl = styled.div`\n  position: absolute;\n  top: 0;\n  right: 0;\n  z-index: 2;\n`\n\nfunction Carousel({\n  margin,\n  borderRadius,\n  pageLabelRenderer,\n  children,\n  flickingRef,\n  zIndex = 1,\n  defaultIndex = 0,\n  autoResize = true,\n  horizontal = true,\n  bounce = [0, 0],\n  duration = 100,\n  onMoveStart,\n  onMove,\n  onMoveEnd,\n}: CarouselProps) {\n  const [currentIndex, setCurrentIndex] = useState(defaultIndex)\n\n  const handleMoveStart = (e: FlickingEvent) => {\n    onMoveStart?.(e)\n  }\n\n  const handleMove = (e: FlickingEvent) => {\n    onMove?.(e)\n  }\n\n  const handleMoveEnd = (e: FlickingEvent) => {\n    setCurrentIndex(e.index)\n\n    onMoveEnd?.(e)\n  }\n\n  const flickingProps = {\n    zIndex,\n    defaultIndex,\n    autoResize,\n    horizontal,\n    bounce,\n    duration,\n    collectStatistics: false,\n  }\n\n  const PageLabel = pageLabelRenderer({ currentIndex })\n\n  return (\n    <CarouselContainer\n      position=\"relative\"\n      borderRadius={borderRadius}\n      css={formatMarginPadding(margin, 'margin')}\n    >\n      <Flicking\n        ref={flickingRef}\n        onMoveStart={handleMoveStart}\n        onMove={handleMove}\n        onMoveEnd={handleMoveEnd}\n        {...flickingProps}\n      >\n        {children}\n      </Flicking>\n\n      {PageLabel ? <TopRightControl>{PageLabel}</TopRightControl> : null}\n    </CarouselContainer>\n  )\n}\n\nexport default Carousel\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/content.tsx",
    "content": "import {\n  FrameRatioAndSizes,\n  GlobalSizes,\n  ImageMeta,\n} from '@titicaca/type-definitions'\nimport { MouseEvent, ReactNode, RefObject } from 'react'\nimport Flicking from '@egjs/react-flicking'\n\nimport { ImageSource } from '../image-source'\n\nimport { ImageContent } from './image-content'\nimport { VideoContent } from './video-content'\nimport type { CarouselImageMeta } from './types'\n\ninterface Props {\n  flickingRef: RefObject<Flicking>\n  medium: ImageMeta\n  optimized?: boolean\n  height?: number\n  globalSize?: GlobalSizes\n  globalFrame?: FrameRatioAndSizes\n  overlay?: ReactNode\n  ImageSource?: typeof ImageSource\n  onClick?: (e?: MouseEvent, image?: CarouselImageMeta) => void\n}\n\nexport function Content({\n  flickingRef,\n  medium,\n  optimized,\n  height,\n  globalSize,\n  globalFrame,\n  overlay,\n  ImageSource,\n  onClick,\n}: Props) {\n  const isVideo = medium.type === 'video'\n\n  const handleClick = (event?: MouseEvent, media?: CarouselImageMeta) => {\n    !flickingRef.current?.isPlaying() && onClick?.(event, media)\n  }\n\n  if (isVideo) {\n    return (\n      <VideoContent\n        medium={medium}\n        height={height}\n        globalSize={globalSize}\n        globalFrame={globalFrame}\n        overlay={overlay}\n        onClick={(event) => handleClick(event, medium)}\n      />\n    )\n  }\n\n  return (\n    <ImageContent\n      medium={medium}\n      optimized={optimized}\n      height={height}\n      globalSize={globalSize}\n      globalFrame={globalFrame}\n      overlay={overlay}\n      ImageSource={ImageSource}\n      onImageClick={(event) => handleClick(event, medium)}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/image-carousel.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { styled } from 'styled-components'\n\nimport { ImageSource } from '../image-source'\n\nimport { ImageCarousel } from './image-carousel'\nimport IMAGES from './mocks/image-carousel.sample.json'\nimport VIDEOS from './mocks/video-carousel.sample.json'\nimport { PageLabel } from './page-label'\n\nconst MoreImageOverlayLink = styled.a`\n  width: 100%;\n  text-align: center;\n  color: white;\n  vertical-align: middle;\n  top: 50%;\n  position: absolute;\n  transform: translateY(-50%);\n  text-decoration: none;\n`\n\nconst MoreImageOverlayLinkIcon = styled.img`\n  width: 20px;\n  height: 20px;\n  vertical-align: sub;\n`\n\nconst meta: Meta<typeof ImageCarousel> = {\n  title: 'tds-widget / image-carousel / Image Carousel',\n  component: ImageCarousel,\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof ImageCarousel>\n\nconst OverlayContent = () => {\n  return (\n    <MoreImageOverlayLink href=\"https://triple.guide\">\n      트리플 앱에서 더보기\n      <MoreImageOverlayLinkIcon src=\"https://assets.triple.guide/images/ico-arrow@4x.png\" />\n    </MoreImageOverlayLink>\n  )\n}\n\nexport const Basic: Story = {\n  render: () => (\n    <ImageCarousel\n      size=\"medium\"\n      images={IMAGES}\n      borderRadius={6}\n      ImageSource={ImageSource}\n      showMoreRenderer={({ currentIndex, totalCount }) =>\n        totalCount > 5 && currentIndex === totalCount - 1 ? (\n          <OverlayContent />\n        ) : null\n      }\n      pageLabelRenderer={({ currentIndex, totalCount }) =>\n        totalCount <= 5 || currentIndex < totalCount - 1 ? (\n          <PageLabel currentIndex={currentIndex} totalCount={totalCount} />\n        ) : null\n      }\n    />\n  ),\n}\n\nexport const Video: Story = {\n  render: () => (\n    <ImageCarousel\n      size=\"medium\"\n      images={VIDEOS}\n      borderRadius={6}\n      ImageSource={ImageSource}\n      showMoreRenderer={({ currentIndex, totalCount }) =>\n        totalCount > 5 && currentIndex === totalCount - 1 ? (\n          <OverlayContent />\n        ) : null\n      }\n      pageLabelRenderer={({ currentIndex, totalCount }) =>\n        totalCount <= 5 || currentIndex < totalCount - 1 ? (\n          <PageLabel currentIndex={currentIndex} totalCount={totalCount} />\n        ) : null\n      }\n    />\n  ),\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/image-carousel.tsx",
    "content": "import { MouseEvent, ReactNode, useRef } from 'react'\nimport { GlobalSizes, FrameRatioAndSizes } from '@titicaca/type-definitions'\nimport Flicking from '@egjs/react-flicking'\n\nimport { ImageSource } from '../image-source'\n\nimport Carousel, { CarouselProps } from './carousel'\nimport { CarouselImageMeta, RendererParams } from './types'\nimport { PageLabel } from './page-label'\nimport { Content } from './content'\n\ninterface ImageCarouselProps extends Omit<CarouselProps, 'pageLabelRenderer'> {\n  images: CarouselImageMeta[]\n  size?: GlobalSizes\n  height?: number\n  frame?: FrameRatioAndSizes\n  ImageSource?: typeof ImageSource\n  onImageClick?: (e?: MouseEvent, image?: CarouselImageMeta) => void\n  showMoreRenderer?: (params: RendererParams) => ReactNode\n  pageLabelRenderer?: (params: RendererParams) => ReactNode\n  displayedTotalCount?: number\n  optimized?: boolean\n}\n\n/**\n * [egjs-flicking](https://github.com/naver/egjs-flicking)을 기반으로 제작된 이미지 캐러셀입니다.\n */\nexport function ImageCarousel({\n  images,\n  size: globalSize,\n  frame: globalFrame,\n  height,\n  ImageSource,\n  onImageClick,\n  showMoreRenderer,\n  pageLabelRenderer = (props) => PageLabel(props),\n  displayedTotalCount,\n  optimized,\n  margin,\n  borderRadius,\n  defaultIndex,\n  onMoveStart,\n  onMove,\n  onMoveEnd,\n}: ImageCarouselProps) {\n  const flickingRef = useRef<Flicking>(null)\n\n  const totalCount = displayedTotalCount ?? images.length\n\n  const handleContentClick = (\n    event?: MouseEvent,\n    media?: CarouselImageMeta,\n  ) => {\n    !flickingRef.current?.isPlaying() && onImageClick?.(event, media)\n  }\n\n  return (\n    <Carousel\n      flickingRef={flickingRef}\n      pageLabelRenderer={({ currentIndex }) =>\n        pageLabelRenderer({ currentIndex, totalCount })\n      }\n      margin={margin}\n      height={height}\n      borderRadius={borderRadius}\n      defaultIndex={defaultIndex}\n      onMoveStart={onMoveStart}\n      onMove={onMove}\n      onMoveEnd={onMoveEnd}\n    >\n      {images.map((image, index) => {\n        const overlay = showMoreRenderer\n          ? showMoreRenderer({ currentIndex: index, totalCount })\n          : null\n\n        return (\n          <Content\n            key={image.id}\n            flickingRef={flickingRef}\n            medium={image}\n            globalFrame={globalFrame}\n            globalSize={globalSize}\n            height={height}\n            optimized={optimized}\n            overlay={overlay}\n            ImageSource={ImageSource}\n            onClick={(event) => handleContentClick(event, image)}\n          />\n        )\n      })}\n    </Carousel>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/image-content.tsx",
    "content": "import { Image } from '@titicaca/tds-ui'\nimport { FrameRatioAndSizes, GlobalSizes } from '@titicaca/type-definitions'\nimport { MouseEventHandler, ReactNode } from 'react'\n\nimport { ImageSource } from '../image-source'\n\nimport { CarouselImageMeta } from './types'\n\ninterface Props {\n  medium: CarouselImageMeta\n  optimized?: boolean\n  height?: number\n  globalSize?: GlobalSizes\n  globalFrame?: FrameRatioAndSizes\n  overlay?: ReactNode\n  ImageSource?: typeof ImageSource\n  onImageClick?: MouseEventHandler\n}\n\nexport function ImageContent({\n  medium,\n  height,\n  optimized,\n  globalSize,\n  globalFrame,\n  overlay,\n  ImageSource,\n  onImageClick,\n}: Props) {\n  const {\n    frame: imageFrame,\n    size: imageSize,\n    sizes,\n    sourceUrl = '',\n    title,\n    description,\n  } = medium\n  const size = globalSize || imageSize\n  const frame = size ? undefined : globalFrame || imageFrame\n\n  const ImageFrame =\n    size || height ? Image.FixedDimensionsFrame : Image.FixedRatioFrame\n\n  return (\n    <Image borderRadius={0}>\n      <ImageFrame\n        size={size}\n        height={height}\n        frame={frame}\n        onClick={onImageClick}\n      >\n        <Image.SourceUrl>\n          {ImageSource ? <ImageSource sourceUrl={sourceUrl} /> : sourceUrl}\n        </Image.SourceUrl>\n\n        {overlay ? (\n          <Image.Overlay overlayType=\"dark\" zTier={1}>\n            {overlay}\n          </Image.Overlay>\n        ) : null}\n\n        {optimized ? (\n          <Image.OptimizedImg\n            cloudinaryId={medium.cloudinaryId as string}\n            cloudinaryBucket={medium.cloudinaryBucket}\n            alt={title || description || undefined}\n          />\n        ) : (\n          <Image.Img\n            src={sizes.large.url}\n            alt={title || description || undefined}\n          />\n        )}\n      </ImageFrame>\n    </Image>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/index.ts",
    "content": "export * from './image-carousel'\nexport * from './page-label'\nexport * from './types'\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/mocks/image-carousel.sample.json",
    "content": "[\n  {\n    \"description\": null,\n    \"id\": \"c9ae4d27-b1b6-4842-9359-f4fd040da65f\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/c9ae4d27-b1b6-4842-9359-f4fd040da65f.jpeg\"\n      },\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/c9ae4d27-b1b6-4842-9359-f4fd040da65f.jpeg\"\n      },\n      \"smallSquare\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/c9ae4d27-b1b6-4842-9359-f4fd040da65f.jpeg\"\n      }\n    },\n    \"sourceUrl\": \"http://blog.naver.com/dowoonu00/220986693726\",\n    \"title\": null\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/jkchoi1021/220961631124\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1f1b8655-9979-414b-9a71-fed9e5355ceb.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1f1b8655-9979-414b-9a71-fed9e5355ceb.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1f1b8655-9979-414b-9a71-fed9e5355ceb.jpeg\"\n      }\n    },\n    \"description\": \"1521년 탐험가 마젤란이 항해 도중 세부에 도착해 세운 십자가로 관광객들과 현지인들의 발길이 끊이지 않는 곳이다.\",\n    \"id\": \"1f1b8655-9979-414b-9a71-fed9e5355ceb\",\n    \"title\": \"마젤란이 남기고 간 십자가\"\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/dowoonu00/220986693726\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/da0ee33e-ffe3-4342-ac64-50fb18944551.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/da0ee33e-ffe3-4342-ac64-50fb18944551.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/da0ee33e-ffe3-4342-ac64-50fb18944551.jpeg\"\n      }\n    },\n    \"description\": \"십자가가 보존되어 있는 팔각정 내부 천장을 올려다보면 당시 세례 의식 장면을 기록해 놓은 그림을 만나볼 수 있다.\",\n    \"id\": \"da0ee33e-ffe3-4342-ac64-50fb18944551\",\n    \"title\": \"세례의 순간을 담은 천장\"\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/rldudal0070/220932259105\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1373942a-30d9-46b3-bce6-08cfa9e407f0.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1373942a-30d9-46b3-bce6-08cfa9e407f0.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1373942a-30d9-46b3-bce6-08cfa9e407f0.jpeg\"\n      }\n    },\n    \"description\": null,\n    \"id\": \"1373942a-30d9-46b3-bce6-08cfa9e407f0\",\n    \"title\": null\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/rldudal0070/220932259105\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc.jpeg\"\n      }\n    },\n    \"description\": null,\n    \"id\": \"1b8d956e-c35d-4a32-bb0a-5dd4c0af14bc\",\n    \"title\": null\n  },\n  {\n    \"sourceUrl\": \"http://blog.naver.com/rldudal0070/220932259105\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/5a9681a1-7f24-4c7f-96fe-33558ba5fd67.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/5a9681a1-7f24-4c7f-96fe-33558ba5fd67.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/5a9681a1-7f24-4c7f-96fe-33558ba5fd67.jpeg\"\n      }\n    },\n    \"description\": null,\n    \"id\": \"5a9681a1-7f24-4c7f-96fe-33558ba5fd67\",\n    \"title\": null\n  }\n]\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/mocks/video-carousel.sample.json",
    "content": "[\n  {\n    \"cloudinaryId\": \"91d9c1d0-300d-4fea-9146-31e339262344\",\n    \"id\": \"3f66edb6-b97d-420c-bfd4-54e3deb64e0d\",\n    \"type\": \"image\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n      },\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n      }\n    },\n    \"width\": 1080,\n    \"height\": 1440,\n    \"cloudinaryBucket\": \"triple-dev\"\n  },\n  {\n    \"cloudinaryId\": \"4b8d3d73-e959-417a-89e9-0443e9a41baf\",\n    \"id\": \"5ba70be6-5618-4f82-9ca7-a2e5cd233816\",\n    \"type\": \"video\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n      },\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_fill,f_auto,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n      }\n    },\n    \"width\": 720,\n    \"height\": 1280,\n    \"cloudinaryBucket\": \"triple-dev\",\n    \"video\": {\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n      },\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_fill,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n      }\n    }\n  },\n  {\n    \"cloudinaryId\": \"b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f\",\n    \"id\": \"7c6205d7-de16-4451-9057-083001e4bb9d\",\n    \"type\": \"image\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n      },\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n      }\n    },\n    \"width\": 1080,\n    \"height\": 1440,\n    \"cloudinaryBucket\": \"triple-dev\"\n  },\n  {\n    \"cloudinaryId\": \"cab00a17-2444-4671-9106-40170ed61e63\",\n    \"id\": \"0a784bbe-64b2-473f-88a3-9afba03cfa6d\",\n    \"type\": \"image\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n      },\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n      }\n    },\n    \"width\": 1080,\n    \"height\": 1440,\n    \"cloudinaryBucket\": \"triple-dev\"\n  }\n]\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/page-label.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { RendererParams } from './types'\n\nconst PageLabelText = styled.div`\n  font-size: 12px;\n  font-weight: bold;\n`\n\nconst PageLabelContainer = styled.div`\n  margin: 10px;\n  padding: 5px 7px;\n  color: #fff;\n  border-radius: 12px;\n  background-color: rgba(0, 0, 0, 0.2);\n`\n\nexport function PageLabel({ currentIndex, totalCount }: RendererParams) {\n  return (\n    <PageLabelContainer>\n      <PageLabelText>{`${currentIndex + 1} / ${totalCount}`}</PageLabelText>\n    </PageLabelContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/types.ts",
    "content": "import { GlobalSizes, ImageMeta } from '@titicaca/type-definitions'\n\nexport interface CarouselImageMeta extends ImageMeta {\n  size?: GlobalSizes\n}\n\nexport interface RendererParams {\n  currentIndex: number\n  totalCount: number\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-carousel/video-content.tsx",
    "content": "import { Container } from '@titicaca/tds-ui'\nimport { FrameRatioAndSizes, GlobalSizes } from '@titicaca/type-definitions'\nimport { MouseEventHandler, ReactNode, useEffect, useState } from 'react'\nimport { styled } from 'styled-components'\nimport { useClientApp } from '@titicaca/triple-web'\nimport { useIntersection } from '@titicaca/intersection-observer'\n\nimport { CarouselImageMeta } from './types'\n\ninterface Props {\n  medium: CarouselImageMeta\n  height?: number\n  globalSize?: GlobalSizes\n  globalFrame?: FrameRatioAndSizes\n  overlay?: ReactNode\n  onClick?: MouseEventHandler\n}\n\nconst HEIGHT_OPTIONS: Partial<Record<GlobalSizes, string>> = {\n  mini: '80px',\n  small: '110px',\n  medium: '200px',\n  large: '400px',\n}\n\nconst Frame = styled(Container)<{\n  $size?: GlobalSizes\n  $height?: number\n  $frame?: FrameRatioAndSizes\n}>`\n  border-radius: 6px;\n  position: relative;\n  overflow: hidden;\n  width: 100%;\n  height: ${({ $height, $size }) =>\n    ($height && `${$height}px`) || ($size ? HEIGHT_OPTIONS[$size] : '')};\n`\n\nconst Poster = styled.div`\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  width: 100%;\n  height: 100%;\n  background-size: cover;\n  background-position: center;\n`\n\nconst Video = styled.video<{ $isOncePlayed: boolean }>`\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  transition: opacity 0.3s;\n  opacity: ${({ $isOncePlayed }) => ($isOncePlayed ? 1 : 0)};\n`\n\nconst PLAY_BUTTON_IMAGE_URL =\n  'https://assets.triple.guide/images/btn-video-play@3x.png'\n\nconst PlayPauseButtonBase = styled.span`\n  position: absolute;\n  border: none;\n  background: none;\n  width: 60px;\n  height: 60px;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  background-image: url(${PLAY_BUTTON_IMAGE_URL});\n  background-size: cover;\n\n  &:focus {\n    outline: none;\n  }\n\n  transition: opacity 0.3s;\n`\n\nexport function VideoContent({\n  medium,\n  height,\n  globalSize,\n  globalFrame,\n  overlay,\n  onClick,\n}: Props) {\n  const [isOncePlayed, setIsOncePlayed] = useState(false)\n  const { ref, isIntersecting } = useIntersection<HTMLVideoElement>({\n    threshold: 0.5,\n  })\n  const clientApp = useClientApp()\n\n  const [videoAutoplay, setVideoAutoPlay] = useState(\n    !clientApp ||\n      clientApp?.device.autoplay === 'always' ||\n      (clientApp?.device.autoplay === 'wifi_only' &&\n        clientApp?.device.networkType === 'wifi'),\n  )\n\n  useEffect(() => {\n    async function togglePlay() {\n      if (!videoAutoplay || !ref.current) {\n        return\n      }\n\n      ref.current.playsInline = true\n      ref.current.muted = true\n\n      try {\n        if (isIntersecting) {\n          ref.current.play()\n        } else {\n          ref.current.pause()\n        }\n      } catch (error) {\n        if (error instanceof DOMException && error.name === 'NotAllowedError') {\n          setVideoAutoPlay(false)\n        }\n      }\n    }\n\n    togglePlay()\n  }, [isIntersecting, ref, videoAutoplay])\n\n  const { frame: imageFrame, size: imageSize } = medium\n  const size = globalSize || imageSize\n  const frame = size ? undefined : globalFrame || imageFrame\n\n  return (\n    <Frame $size={size} $height={height} $frame={frame} onClick={onClick}>\n      <Poster style={{ backgroundImage: `url(\"${medium.sizes.large.url}\")` }} />\n      <Video\n        ref={ref}\n        src={medium.video?.large.url}\n        controls={false}\n        loop\n        muted\n        playsInline\n        $isOncePlayed={isOncePlayed}\n        onTimeUpdate={isOncePlayed ? undefined : () => setIsOncePlayed(true)}\n      />\n      {!videoAutoplay && <PlayPauseButtonBase />}\n      {overlay || null}\n    </Frame>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-source/image-source.tsx",
    "content": "import { useTranslation } from '@titicaca/triple-web'\n\nexport interface ImageSourceProps {\n  sourceUrl?: string\n}\n\nexport function ImageSource({ sourceUrl }: ImageSourceProps) {\n  const t = useTranslation()\n\n  if (!sourceUrl) {\n    return null\n  }\n\n  const httpsSchemeRemovedUrl = sourceUrl.replace(/^https?:\\/\\//, '')\n\n  return (\n    <>\n      {t('출처 {{httpsSchemeRemovedUrl}}', {\n        httpsSchemeRemovedUrl,\n      })}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-source/index.ts",
    "content": "export * from './image-source'\n"
  },
  {
    "path": "packages/tds-widget/src/image-viewer/detail-viewer/image.tsx",
    "content": "import { useEffect, useRef } from 'react'\nimport { styled } from 'styled-components'\nimport {\n  ReactZoomPanPinchRef,\n  TransformComponent,\n  TransformWrapper,\n} from 'react-zoom-pan-pinch'\nimport { ImageMeta } from '@titicaca/type-definitions'\n\nconst StyledImage = styled.img`\n  max-width: 100%;\n  max-height: 100%;\n  margin: auto;\n`\n\nconst COMMON_WRAPPER_STYLE = { width: '100%', height: '100%', margin: 'auto' }\n\nexport default function Image({\n  medium,\n  visible,\n  onImageIntersecting,\n}: {\n  medium: ImageMeta\n  visible: boolean\n  onImageIntersecting: (image: ImageMeta) => void\n}) {\n  const transformComponentRef = useRef<ReactZoomPanPinchRef | null>(null)\n\n  const resetImage = () => {\n    if (transformComponentRef.current) {\n      const { resetTransform } = transformComponentRef.current\n      resetTransform()\n    }\n  }\n\n  useEffect(() => {\n    if (visible) {\n      onImageIntersecting(medium)\n    } else {\n      resetImage()\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible])\n\n  return (\n    <TransformWrapper\n      wheel={{ wheelDisabled: true }}\n      panning={{ disabled: true }}\n      doubleClick={{ disabled: true }}\n      ref={transformComponentRef}\n    >\n      <TransformComponent\n        wrapperStyle={COMMON_WRAPPER_STYLE}\n        contentStyle={COMMON_WRAPPER_STYLE}\n      >\n        <StyledImage src={medium.sizes.large.url} alt={medium.id} />\n      </TransformComponent>\n    </TransformWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-viewer/detail-viewer/index.tsx",
    "content": "import { styled, css } from 'styled-components'\nimport Flicking from '@egjs/react-flicking'\nimport { useRef } from 'react'\nimport { ImageMeta } from '@titicaca/type-definitions'\nimport { Container } from '@titicaca/tds-ui'\nimport { useUserAgent } from '@titicaca/triple-web'\n\nimport { Video } from './video'\nimport Image from './image'\n\nconst SourceUrl = styled.p`\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  background-color: var(--color-white900);\n  padding: 20px;\n  font-size: 12px;\n  line-height: 12px;\n  height: 54px;\n  color: var(--color-gray700);\n  position: relative;\n  z-index: 9999;\n`\n\nconst Button = styled.button<{ $direction: 'next' | 'prev' }>`\n  z-index: 9999;\n  position: absolute;\n  background-image: url('https://assets.triple.guide/images/ico-arrow-right-black@3x.png');\n  background-size: 30px;\n  background-repeat: no-repeat;\n  background-position: center;\n  width: 44px;\n  height: 44px;\n  top: 50%;\n  border: 1px solid var(--color-gray100);\n  ${({ $direction }) =>\n    $direction === 'next'\n      ? css`\n          transform: translateY(-50%);\n          right: 20px;\n        `\n      : css`\n          transform: rotate(180deg) translateY(50%);\n          left: 20px;\n        `}\n  background-color: white;\n  border-radius: 50%;\n`\nconst SOURCE_HEIGHT = 54\n\nexport interface DetailViewerProp {\n  images: ImageMeta[]\n  totalCount: number\n  fetchNext?: (cb?: () => void) => Promise<void>\n  imageIndex: number\n  changeImageIndex: (idx: number) => void\n  onImageMetadataIntersecting?: (\n    imageMetadata: ImageMeta,\n    index?: number,\n  ) => void\n}\n\nexport default function DetailViewer({\n  images,\n  totalCount,\n  fetchNext,\n  imageIndex,\n  changeImageIndex,\n  onImageMetadataIntersecting,\n}: DetailViewerProp) {\n  const { isMobile } = useUserAgent()\n  const flickingRef = useRef<Flicking>(null)\n\n  function onNextImageShow() {\n    if (flickingRef.current) {\n      flickingRef.current.next()\n    }\n  }\n\n  function onPrevImageShow() {\n    if (flickingRef.current) {\n      flickingRef.current.prev()\n    }\n  }\n\n  async function fetchNewImages(index: number) {\n    if (index > images.length - 5 && fetchNext) {\n      await fetchNext()\n    }\n  }\n\n  const handleImageMetadataIntersecting =\n    (index: number) => (imageMetadata: ImageMeta) => {\n      onImageMetadataIntersecting?.(imageMetadata, index)\n    }\n\n  return (\n    <Container\n      css={{\n        position: 'relative',\n        width: '100%',\n        height: '100%',\n        maxWidth: 768,\n        margin: 'auto',\n      }}\n    >\n      <Flicking\n        ref={flickingRef}\n        css={{\n          position: 'relative',\n          width: '100%',\n          height: '100%',\n        }}\n        defaultIndex={imageIndex}\n        onMoveEnd={({ index }) => {\n          changeImageIndex(index)\n          fetchNewImages(index)\n        }}\n        autoResize\n      >\n        {images.map((image, index) => (\n          <Container key={image.id} css={{ width: '100%', height: '100%' }}>\n            <Container\n              css={{\n                width: '100%',\n                height: `calc(100% - ${image.sourceUrl ? SOURCE_HEIGHT : 0}px)`,\n                display: 'flex',\n              }}\n            >\n              {'video' in image ? (\n                <Video\n                  videoMetadata={image}\n                  visible={imageIndex === index}\n                  onVideoIntersecting={handleImageMetadataIntersecting(index)}\n                />\n              ) : (\n                <Image\n                  medium={image}\n                  visible={imageIndex === index}\n                  onImageIntersecting={handleImageMetadataIntersecting(index)}\n                />\n              )}\n            </Container>\n            {image.sourceUrl ? <SourceUrl>{image.sourceUrl}</SourceUrl> : null}\n          </Container>\n        ))}\n      </Flicking>\n      {!isMobile ? (\n        <>\n          {imageIndex > 0 ? (\n            <Button $direction=\"prev\" onClick={onPrevImageShow} />\n          ) : null}\n          {imageIndex < totalCount - 1 ? (\n            <Button $direction=\"next\" onClick={onNextImageShow} />\n          ) : null}\n        </>\n      ) : null}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-viewer/detail-viewer/video.tsx",
    "content": "import { useEffect, useRef } from 'react'\nimport { ImageMeta } from '@titicaca/type-definitions'\nimport { Container } from '@titicaca/tds-ui'\n\ninterface VideoProps {\n  visible: boolean\n  videoMetadata: ImageMeta\n  onVideoIntersecting: (video: ImageMeta) => void\n}\n\nexport function Video({\n  videoMetadata,\n  visible,\n  onVideoIntersecting,\n}: VideoProps) {\n  const videoRef = useRef<HTMLVideoElement | null>(null)\n\n  useEffect(() => {\n    async function stopVideoOnNonIntersection() {\n      if (!videoRef.current) {\n        return\n      }\n\n      try {\n        if (!visible) {\n          videoRef.current.pause()\n          videoRef.current.currentTime = 0\n        }\n      } catch (error) {\n        if (error instanceof DOMException && error.name === 'NotAllowedError') {\n          // do nothing\n        }\n      }\n    }\n\n    stopVideoOnNonIntersection()\n  }, [visible, videoRef])\n\n  useEffect(() => {\n    if (visible) {\n      onVideoIntersecting(videoMetadata)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible])\n\n  return (\n    <Container\n      css={{\n        display: 'flex',\n        justifyContent: 'center',\n        margin: 'auto',\n        maxWidth: '100%',\n        maxHeight: '100%',\n      }}\n    >\n      <video\n        ref={videoRef}\n        src={`${videoMetadata.video?.large.url}#t=0.001`} // HACK: ios에서 썸네일이 노출되지 않는 문제 우회\n        poster={videoMetadata.sizes.large.url}\n        preload=\"metadata\"\n        controls\n        loop={false}\n        autoPlay={false}\n        playsInline\n        controlsList=\"nodownload\"\n      >\n        <track kind=\"captions\" />\n      </video>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-viewer/image-viewer.stories.tsx",
    "content": "import { ImageViewerPopup, ImageViewerPopupProps } from './image-viewer'\n\nexport default {\n  title: 'tds-widget / image-viewer / ImageViewer',\n}\n\nexport const ImageViewer = {\n  render: (args: ImageViewerPopupProps) => <ImageViewerPopup {...args} />,\n  args: {\n    open: true,\n    onClose: () => {},\n    images: [\n      {\n        id: 'test image',\n        sourceUrl: '출처가 있는 이미지입니다. 아주 긴 출처를 가지고 있습니다.',\n        sizes: {\n          large: {\n            url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n          },\n          full: {\n            url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n          },\n          small_square: {\n            url: 'https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg',\n          },\n        },\n      },\n      {\n        id: 'test image2',\n        sizes: {\n          full: {\n            url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n          },\n          large: {\n            url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n          },\n          smallSquare: {\n            url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n          },\n        },\n      },\n      {\n        id: 'image3',\n        sizes: {\n          large: {\n            url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/7095aed4-65f7-4157-9b11-55bc871aac6d.jpeg',\n          },\n          full: {\n            url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/7095aed4-65f7-4157-9b11-55bc871aac6d.jpeg',\n          },\n          small_square: {\n            url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/7095aed4-65f7-4157-9b11-55bc871aac6d.jpeg',\n          },\n        },\n      },\n      {\n        id: '3f66edb6-b97d-420c-bfd4-54e3deb64e0d',\n        type: 'video',\n        sizes: {\n          full: {\n            url: 'https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg',\n          },\n          large: {\n            url: 'https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg',\n          },\n          small_square: {\n            url: 'https://media.triple.guide/triple-dev/video/upload/c_fill,f_auto,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg',\n          },\n        },\n        video: {\n          full: {\n            url: 'https://media.triple.guide/triple-dev/video/upload/c_limit,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4',\n          },\n          large: {\n            url: 'https://media.triple.guide/triple-dev/video/upload/c_limit,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4',\n          },\n          small_square: {\n            url: 'https://media.triple.guide/triple-dev/video/upload/c_fill,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4',\n          },\n        },\n      },\n    ],\n    totalCount: 4,\n    defaultImageIndex: 0,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-viewer/image-viewer.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { styled } from 'styled-components'\nimport { Container, Navbar, Popup } from '@titicaca/tds-ui'\n\nimport DetailViewer, { DetailViewerProp } from './detail-viewer'\n\nconst NAVBAR_HEIGHT = 52\n\nconst Text = styled.span`\n  font-size: 16px;\n  height: 34px;\n  line-height: 34px;\n`\n\nexport interface ImageViewerPopupProps\n  extends Pick<\n    DetailViewerProp,\n    'images' | 'totalCount' | 'fetchNext' | 'onImageMetadataIntersecting'\n  > {\n  open: boolean\n  onClose?: () => void\n  defaultImageIndex?: number | null\n}\n/**\n *\n * @param defaultImageIndex: 이미지 확대뷰로 띄울 이미지의 index. TODD: null인 경우에는 격자뷰가 뜨게 됩니다.\n */\nexport function ImageViewerPopup({\n  open,\n  onClose,\n  images,\n  totalCount,\n  fetchNext,\n  defaultImageIndex,\n  onImageMetadataIntersecting,\n}: ImageViewerPopupProps) {\n  const [imageIndex, setImageIndex] = useState<null | number>(\n    defaultImageIndex ?? null,\n  )\n\n  useEffect(() => {\n    setImageIndex(defaultImageIndex ?? null)\n  }, [defaultImageIndex])\n\n  function changeImageIndex(idx: number) {\n    setImageIndex(idx)\n  }\n\n  function handleClose() {\n    setImageIndex(null)\n    onClose?.()\n  }\n  return (\n    <Popup open={open} onClose={handleClose} noNavbar>\n      {imageIndex != null ? (\n        <DetailViewerContainer\n          onClose={handleClose}\n          imageIndex={imageIndex}\n          changeImageIndex={changeImageIndex}\n          onImageMetadataIntersecting={onImageMetadataIntersecting}\n          images={images}\n          totalCount={totalCount}\n          fetchNext={fetchNext}\n        />\n      ) : null}\n    </Popup>\n  )\n}\n\nexport interface DetailViewerContainerProp\n  extends Pick<\n    DetailViewerProp,\n    | 'images'\n    | 'totalCount'\n    | 'fetchNext'\n    | 'imageIndex'\n    | 'onImageMetadataIntersecting'\n  > {\n  onClose?: () => void\n  changeImageIndex: (index: number) => void\n}\n\nexport function DetailViewerContainer({\n  onClose,\n  imageIndex,\n  changeImageIndex,\n  images,\n  totalCount,\n  fetchNext,\n  onImageMetadataIntersecting,\n}: DetailViewerContainerProp) {\n  return (\n    <>\n      <Navbar\n        css={{\n          height: NAVBAR_HEIGHT,\n          display: 'flex',\n          zIndex: 9999,\n        }}\n      >\n        <Navbar.Item\n          icon=\"close\"\n          onClick={onClose}\n          css={{ position: 'absolute' }}\n        />\n        <Navbar.Item\n          css={{\n            float: 'none',\n            margin: 0,\n            flexGrow: 1,\n            textAlign: 'center',\n          }}\n        >\n          <Text>{imageIndex + 1}</Text>\n          <Text css={{ color: 'var(--color-gray300)' }}>\n            &nbsp;/ {totalCount}\n          </Text>\n        </Navbar.Item>\n      </Navbar>\n      <Container\n        css={{ height: `calc(100vh - ${NAVBAR_HEIGHT}px)`, width: '100vw' }}\n      >\n        <DetailViewer\n          images={images}\n          totalCount={totalCount}\n          fetchNext={fetchNext}\n          imageIndex={imageIndex}\n          changeImageIndex={changeImageIndex}\n          onImageMetadataIntersecting={onImageMetadataIntersecting}\n        />\n      </Container>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/image-viewer/index.ts",
    "content": "export * from './image-viewer'\n"
  },
  {
    "path": "packages/tds-widget/src/index.ts",
    "content": "'use client'\n\nexport * from './ad-banners'\nexport * from './app-banner'\nexport * from './app-installation-cta'\nexport * from './author'\nexport * from './booking-completion'\nexport * from './chat'\nexport * from './content-sharing'\nexport * from './date-picker'\nexport * from './directions-finder'\nexport * from './flicking-carousel'\nexport * from './footer'\nexport * from './hub-form'\nexport * from './image-carousel'\nexport * from './image-source'\nexport * from './image-viewer'\nexport * from './listing-filter'\nexport * from './location-properties'\nexport * from './map'\nexport * from './media'\nexport * from './nearby-pois'\nexport * from './poi-detail'\nexport * from './poi-list-elements'\nexport * from './pricing'\nexport * from './public-header'\nexport * from './recommended-contents'\nexport * from './replies'\nexport * from './resource-list-elements'\nexport * from './review'\nexport * from './scrap'\nexport * from './scrap-button'\nexport * from './search'\nexport * from './social-reviews'\nexport * from './static-map'\nexport * from './user-verification'\n"
  },
  {
    "path": "packages/tds-widget/src/listing-filter/index.ts",
    "content": "export * from './listing-filter'\n"
  },
  {
    "path": "packages/tds-widget/src/listing-filter/listing-filter-expanding-filter-entry.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { ListingFilter } from './listing-filter'\n\nexport default {\n  title: 'tds-widget / listing-filter / ListingFilter.ExpandingFilterEntry',\n  component: ListingFilter.ExpandingFilterEntry,\n} as Meta\n\nexport const Basic: StoryObj<typeof ListingFilter.ExpandingFilterEntry> = {\n  name: '기본 Expanding',\n  args: {\n    active: true,\n    disabled: false,\n    badge: '0',\n    children: '성급 및 필터',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/listing-filter/listing-filter-filter-entry.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { ListingFilter } from './listing-filter'\n\nexport default {\n  title: 'tds-widget / listing-filter / ListingFilter.FilterEntry',\n  component: ListingFilter.FilterEntry,\n  decorators: [\n    (Story) => (\n      <ListingFilter>\n        <Story />\n      </ListingFilter>\n    ),\n  ],\n} as Meta\n\nexport const Basic: StoryObj<typeof ListingFilter.FilterEntry> = {\n  name: '기본 FilterEntry',\n  args: {\n    active: true,\n    disabled: false,\n    children: '부티크 호텔',\n  },\n}\n\nexport const Underline: StoryObj<typeof ListingFilter.FilterEntry> = {\n  name: 'Underline FilterEntry',\n  args: {\n    underline: true,\n    active: true,\n    activeIconImage: '/ico-category-food-on.svg',\n    inactiveIconImage: '/ico-category-food.svg',\n    disabled: false,\n    children: '음식점',\n  },\n}\n\nexport const WithIconImage: StoryObj<typeof ListingFilter.FilterEntry> = {\n  name: 'FilterEntry (with Icon Image)',\n  args: {\n    active: true,\n    activeIconImage: '/ico-category-food-on.svg',\n    inactiveIconImage: '/ico-category-food.svg',\n    disabled: false,\n    children: '음식점',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/listing-filter/listing-filter-primary-filter-entry.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { ListingFilter } from './listing-filter'\n\nexport default {\n  title: 'tds-widget / listing-filter / ListingFilter.PrimaryFilterEntry',\n  component: ListingFilter.PrimaryFilterEntry,\n  decorators: [\n    (Story) => (\n      <ListingFilter>\n        <Story />\n      </ListingFilter>\n    ),\n  ],\n} as Meta\n\nexport const Basic: StoryObj<typeof ListingFilter.PrimaryFilterEntry> = {\n  name: '기본 Primary',\n  args: {\n    disabled: false,\n    children: '5.17-5.20, 3명',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/listing-filter/listing-filter.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { ListingFilter } from './listing-filter'\n\nexport default {\n  title: 'tds-widget / listing-filter / ListingFilter',\n  component: ListingFilter,\n  subcomponents: {\n    FilterEntry: ListingFilter.FilterEntry,\n    ExpandingFilterEntry: ListingFilter.ExpandingFilterEntry,\n    PrimaryFilterEntry: ListingFilter.PrimaryFilterEntry,\n  },\n} as Meta<typeof ListingFilter>\n\nexport const Basic: StoryObj<typeof ListingFilter> = {\n  render: (args) => {\n    return (\n      <ListingFilter {...args}>\n        <ListingFilter.PrimaryFilterEntry>\n          5.17-5.20, 3명\n        </ListingFilter.PrimaryFilterEntry>\n        <ListingFilter.ExpandingFilterEntry>\n          침대타입\n        </ListingFilter.ExpandingFilterEntry>\n        <ListingFilter.FilterEntry>무료취소</ListingFilter.FilterEntry>\n        <ListingFilter.FilterEntry\n          active={false}\n          activeIconImage=\"/ico-category-food-on.svg\"\n          inactiveIconImage=\"/ico-category-food.svg\"\n        >\n          음식점\n        </ListingFilter.FilterEntry>\n      </ListingFilter>\n    )\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/listing-filter/listing-filter.tsx",
    "content": "import { styled, css } from 'styled-components'\nimport { MarginPadding, paddingMixin } from '@titicaca/tds-ui'\nimport { HTMLAttributes, ReactNode, PureComponent } from 'react'\n\nconst FilterEntryBase = styled.div<{ active?: boolean; disabled?: boolean }>`\n  display: inline-block;\n  font-size: 13px;\n  line-height: 15px;\n  border: 1px solid\n    ${({ active }) => (active ? 'var(--color-blue)' : 'var(--color-gray200)')};\n  color: ${({ active }) =>\n    active ? 'var(--color-blue)' : 'var(--color-gray200)'};\n  background-repeat: no-repeat;\n  border-radius: 2px;\n  margin-right: 6px;\n  vertical-align: top;\n  ${({ disabled }) =>\n    disabled &&\n    css`\n      opacity: 0.3;\n    `};\n`\n\nconst ACTIVE_EXPANDER_ICON_URL =\n  'https://assets.triple.guide/images/ico-category-select-on.svg'\nconst INACTIVE_EXPANDER_ICON_URL =\n  'https://assets.triple.guide/images/ico-category-select.svg'\nconst PRIMARY_ICON_URL = 'https://assets.triple.guide/images/ico-filter-cal.svg'\n\nconst ExpandingFilterEntryFrame = styled(FilterEntryBase)<{\n  active?: boolean\n}>`\n  padding: 9px 24px 9px 11px;\n  background-image: ${({ active }) =>\n    active\n      ? `url(${ACTIVE_EXPANDER_ICON_URL}) `\n      : `url(${INACTIVE_EXPANDER_ICON_URL}) `};\n  background-size: 10px 24px;\n  background-position: top 4px right 10px;\n`\n\nconst ExpandingFilterEntryBadge = styled.div`\n  position: absolute;\n  top: 50%;\n  transform: translateY(-50%);\n  right: 0;\n  height: 18px;\n  width: 18px;\n  line-height: 18px;\n  background-color: var(--color-blue);\n  color: var(--color-white);\n  border-radius: 9px;\n  font-size: 12px;\n  font-weight: bold;\n  text-align: center;\n`\n\nconst ExpandingFilterEntryContainer = styled.div<{\n  $withBadge?: boolean\n}>`\n  position: relative;\n\n  ${({ $withBadge }) =>\n    $withBadge &&\n    css`\n      padding-right: 20px;\n    `}\n`\n\ninterface ExpandingFilterEntryProps extends HTMLAttributes<HTMLElement> {\n  disabled?: boolean\n  active?: boolean\n  badge?: ReactNode\n}\nfunction ExpandingFilterEntry({\n  badge,\n  children,\n  ...props\n}: ExpandingFilterEntryProps) {\n  return (\n    <ExpandingFilterEntryFrame {...props}>\n      <ExpandingFilterEntryContainer $withBadge={!!badge}>\n        {children}\n        {badge ? (\n          <ExpandingFilterEntryBadge>{badge}</ExpandingFilterEntryBadge>\n        ) : null}\n      </ExpandingFilterEntryContainer>\n    </ExpandingFilterEntryFrame>\n  )\n}\n\nconst RegularFilterEntry = styled(FilterEntryBase)<{\n  active?: boolean\n  withIcon?: boolean\n  iconImage?: string\n}>`\n  ${({ withIcon, iconImage }) =>\n    withIcon\n      ? css`\n          padding: 9px 10px 9px 32px;\n          background-size: 24px 24px;\n          background-position: top 5px left 8px;\n          background-image: url(${iconImage});\n        `\n      : css`\n          padding: 9px 16px;\n        `};\n  ${({ active }) =>\n    active\n      ? css`\n          color: var(--color-white);\n          background-color: var(--color-blue);\n          font-weight: bold;\n        `\n      : css`\n          color: var(--color-gray200);\n          border: solid 1px var(--color-gray200);\n        `};\n  border-radius: 2px;\n`\n\nconst UnderlineRegularFilterEntry = styled(FilterEntryBase)<{\n  active?: boolean\n}>`\n  position: relative;\n  border: 0;\n  border-radius: 0;\n  padding: 10px;\n  font-size: 14px;\n  font-weight: bold;\n  color: var(--color-gray300);\n  ${({ active }) =>\n    active &&\n    css`\n      color: var(--color-gray);\n\n      &::before {\n        content: '';\n        position: absolute;\n        bottom: 0;\n        left: 10px;\n        right: 10px;\n        height: 2px;\n        background: var(--color-blue);\n      }\n    `};\n`\n\nconst PrimaryFilterEntry = styled(FilterEntryBase)`\n  padding: 10px 14px 9px 38px;\n  background-image: url(${PRIMARY_ICON_URL});\n  background-size: 24px 24px;\n  background-position: top 5px left 10px;\n  border: none;\n  border-radius: 2px;\n  background-color: var(--color-blue);\n  font-size: 13px;\n  font-weight: bold;\n  color: var(--color-white);\n`\n\ninterface FilterEntryProps extends HTMLAttributes<HTMLElement> {\n  disabled?: boolean\n  active?: boolean\n  activeIconImage?: string\n  inactiveIconImage?: string\n  underline?: boolean\n}\n\nfunction FilterEntry({\n  active,\n  activeIconImage,\n  inactiveIconImage,\n  underline,\n  ...props\n}: FilterEntryProps) {\n  if (underline) {\n    return <UnderlineRegularFilterEntry active={active} {...props} />\n  }\n  return (\n    <RegularFilterEntry\n      active={active}\n      iconImage={active ? activeIconImage : inactiveIconImage}\n      withIcon={!!(activeIconImage && inactiveIconImage)}\n      {...props}\n    />\n  )\n}\n\nconst ListingFilterBase = styled.div<{ padding?: MarginPadding }>`\n  white-space: nowrap;\n  overflow-x: scroll;\n  -webkit-overflow-scrolling: touch;\n  cursor: pointer;\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n\n  ${paddingMixin}\n`\n\nexport class ListingFilter extends PureComponent<{\n  children?: ReactNode\n  padding?: MarginPadding\n}> {\n  public static FilterEntry = FilterEntry\n\n  public static ExpandingFilterEntry = ExpandingFilterEntry\n\n  public static PrimaryFilterEntry = PrimaryFilterEntry\n\n  public render() {\n    const {\n      props: {\n        children,\n        padding = { top: 0, right: 20, bottom: 10, left: 20 },\n      },\n    } = this\n\n    return <ListingFilterBase padding={padding}>{children}</ListingFilterBase>\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/location-properties/index.tsx",
    "content": "export * from './location-properties'\n"
  },
  {
    "path": "packages/tds-widget/src/location-properties/location-properties.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { LocationProperties } from './location-properties'\n\nexport default {\n  title: 'tds-widget / location-properties / LocationProperties',\n  component: LocationProperties,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta<typeof LocationProperties>\n\nexport const Basic: StoryObj<typeof LocationProperties> = {\n  args: {\n    addresses: {\n      primary: null,\n      ko: null,\n      en: '1-1 Maihama, Urayasu, Chiba Prefecture 279-0031',\n      local: '〒279-0031 東京都千葉県浦安市舞浜11',\n    },\n    phoneNumber: '+81453305211',\n    officialSiteUrl: 'http://www.tokyodisneyresort.jp/tdl/index.html',\n    extraProperties: [\n      {\n        description: '내비게이션용 맵코드',\n        value: '349 569 814*88',\n      },\n    ],\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/location-properties/location-properties.tsx",
    "content": "import { useCallback, useMemo } from 'react'\nimport { Segment, List, ActionSheet, ActionSheetItem } from '@titicaca/tds-ui'\nimport { useHashRouter, useTranslation } from '@titicaca/triple-web'\nimport { TranslatedProperty } from '@titicaca/type-definitions'\n\nimport {\n  ACTION_SHEET_PREFIX,\n  PropertyItemProps,\n  PropertyItem,\n} from './property-item'\n\ninterface ExtraProperty {\n  description: string\n  value: string\n}\n\nexport function LocationProperties({\n  addresses,\n  onAddressesClick,\n  phoneNumber,\n  onPhoneNumberClick,\n  officialSiteUrl,\n  onOfficialSiteUrlClick,\n  extraProperties,\n  onExtraPropertyClick,\n  onCopy,\n  ...props\n}: {\n  addresses?: TranslatedProperty\n  onAddressesClick?: () => void\n  phoneNumber?: string\n  onPhoneNumberClick?: () => void\n  officialSiteUrl?: string\n  onOfficialSiteUrlClick?: () => void\n  extraProperties?: ExtraProperty[]\n  onExtraPropertyClick?: (extraProperty: ExtraProperty) => void\n  onCopy: (value: string) => void\n} & Omit<Parameters<typeof Segment>['0'], 'onCopy'>) {\n  const t = useTranslation()\n\n  const { uriHash, removeUriHash } = useHashRouter()\n\n  const properties: Map<\n    string,\n    Omit<PropertyItemProps, 'identifier'>\n  > = useMemo(() => {\n    const allValues = new Map<string, Omit<PropertyItemProps, 'identifier'>>()\n    const addressValue =\n      addresses?.primary || addresses?.ko || addresses?.en || addresses?.local\n\n    addressValue &&\n      allValues.set('addresses', {\n        title: t('주소'),\n        value: addressValue,\n        onClick: onAddressesClick,\n        eventActionFragment: '기본정보_주소',\n      })\n\n    phoneNumber &&\n      allValues.set('phoneNumber', {\n        title: t('전화'),\n        value: phoneNumber,\n        onClick: onPhoneNumberClick,\n        eventActionFragment: '기본정보_전화번호',\n      })\n\n    officialSiteUrl &&\n      allValues.set('officialSiteUrl', {\n        title: t('홈페이지'),\n        value: officialSiteUrl,\n        singleLine: true,\n        onClick: onOfficialSiteUrlClick,\n        eventActionFragment: '기본정보_홈페이지',\n      })\n\n    if (extraProperties) {\n      extraProperties.forEach(({ description, value, ...rest }, i) => {\n        allValues.set(`extraProperties.${i}`, {\n          title: description,\n          value,\n          onClick: onExtraPropertyClick\n            ? () => onExtraPropertyClick({ description, value, ...rest })\n            : undefined,\n        })\n      })\n    }\n\n    return allValues\n  }, [\n    t,\n    addresses,\n    phoneNumber,\n    officialSiteUrl,\n    extraProperties,\n    onAddressesClick,\n    onPhoneNumberClick,\n    onOfficialSiteUrlClick,\n    onExtraPropertyClick,\n  ])\n\n  const isActionSheetOpen = (uriHash || '').startsWith(ACTION_SHEET_PREFIX)\n  const value =\n    isActionSheetOpen &&\n    properties.get(uriHash.replace(`${ACTION_SHEET_PREFIX}.`, ''))?.value\n  const handleClick = useCallback(() => value && onCopy(value), [onCopy, value])\n\n  return (\n    <>\n      <Segment {...props}>\n        <List verticalGap={15}>\n          {Array.from(properties.entries()).map(([key, props]) => (\n            <PropertyItem key={key} identifier={key} {...props} />\n          ))}\n        </List>\n      </Segment>\n      <ActionSheet\n        title={t('복사하기')}\n        open={isActionSheetOpen}\n        onClose={removeUriHash}\n      >\n        <ActionSheetItem buttonLabel={t('복사')} onClick={handleClick}>\n          {value}\n        </ActionSheetItem>\n      </ActionSheet>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/location-properties/property-item.tsx",
    "content": "import { useCallback } from 'react'\nimport {\n  List,\n  Text,\n  longClickable,\n  FlexBox,\n  LongClickableComponentProps,\n  FlexBoxProps,\n} from '@titicaca/tds-ui'\nimport {\n  useTrackEvent,\n  useHashRouter,\n  useClientApp,\n} from '@titicaca/triple-web'\n\nexport const ACTION_SHEET_PREFIX = 'location-properties.copy-action-sheet'\n\nexport interface PropertyItemProps {\n  title: string\n  value?: string\n  singleLine?: boolean\n  identifier: string\n  eventActionFragment?: string\n  onClick?: () => void\n}\n\ntype LongClickableItemContainerProps = LongClickableComponentProps &\n  FlexBoxProps\n\nconst LongClickableItemContainer =\n  longClickable<LongClickableItemContainerProps>(FlexBox)\n\nexport function PropertyItem({\n  identifier,\n  title,\n  value,\n  singleLine,\n  onClick,\n  eventActionFragment,\n}: PropertyItemProps) {\n  const app = useClientApp()\n  const trackEvent = useTrackEvent()\n  const { addUriHash } = useHashRouter()\n\n  const handleLongClick = useCallback(() => {\n    if (eventActionFragment) {\n      trackEvent({\n        ga: [`${eventActionFragment}_복사하기_실행`],\n        fa: {\n          action: `${eventActionFragment}_복사하기_실행`,\n        },\n      })\n    }\n\n    addUriHash(`${ACTION_SHEET_PREFIX}.${identifier}`)\n  }, [addUriHash, identifier, trackEvent, eventActionFragment])\n\n  return (\n    <List.Item>\n      <LongClickableItemContainer\n        flex\n        alignItems=\"flex-start\"\n        onLongClick={app ? handleLongClick : undefined}\n        onClick={onClick}\n      >\n        <Text bold size=\"small\" css={{ flexShrink: 1, lineHeight: 1.43 }}>\n          {title}\n        </Text>\n        <Text\n          size=\"small\"\n          alpha={0.7}\n          ellipsis={singleLine}\n          css={{\n            marginLeft: 10,\n            flex: 1,\n            lineHeight: 1.43,\n            wordBreak: 'break-all',\n          }}\n        >\n          {value}\n        </Text>\n      </LongClickableItemContainer>\n    </List.Item>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/focus-tracker.tsx",
    "content": "import { useGoogleMap } from '@react-google-maps/api'\nimport { useEffect } from 'react'\nimport { PointGeoJson } from '@titicaca/type-definitions'\n\nconst AUTO_ZOOM_THRESHOLD = 10\n\ninterface FocusTrackerProps {\n  focusGeolocation?: PointGeoJson\n  activeAutoZoom?: boolean\n  autoZoomThreshold?: number\n  disabled?: boolean\n}\n\nexport function FocusTracker({\n  focusGeolocation,\n  activeAutoZoom = false,\n  autoZoomThreshold = AUTO_ZOOM_THRESHOLD,\n  disabled = false,\n}: FocusTrackerProps) {\n  const map = useGoogleMap()\n\n  useEffect(() => {\n    if (!focusGeolocation || !map || disabled) {\n      return\n    }\n\n    const [lng, lat] = focusGeolocation.coordinates\n    const zoomLevel = map.getZoom() ?? 0\n\n    if (activeAutoZoom && zoomLevel < autoZoomThreshold) {\n      map.setZoom(autoZoomThreshold)\n    }\n\n    map.panTo({ lat, lng })\n  }, [activeAutoZoom, focusGeolocation, map, autoZoomThreshold, disabled])\n\n  return null\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/index.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { MapView, WithGoogleMapProps } from './map-view'\n\nconst MOCK_MAP_VIEW: WithGoogleMapProps = {\n  coordinates: [\n    [114.17816, 22.29867],\n    [135.50260942822263, 34.6685201467736],\n  ],\n  googleMapLoadOptions: {\n    googleMapsApiKey: '',\n  },\n}\n\njest.mock('@react-google-maps/api', () => ({\n  useLoadScript: jest.fn().mockImplementation(() => ({\n    isLoaded: true,\n    loadError: undefined,\n  })),\n  useGoogleMap: jest.fn(),\n  GoogleMap: () => <div data-testid=\"google-map\" />,\n}))\n\ntest('MapView의 로드 여부를 체크합니다.', () => {\n  render(<MapView {...MOCK_MAP_VIEW} />)\n\n  expect(screen.getByTestId('google-map')).toBeInTheDocument()\n})\n"
  },
  {
    "path": "packages/tds-widget/src/map/index.ts",
    "content": "export * from './map-view'\nexport * from './focus-tracker'\nexport * from './overlay'\nexport * from './utilities'\n"
  },
  {
    "path": "packages/tds-widget/src/map/map-view.tsx",
    "content": "import {\n  CSSProperties,\n  useEffect,\n  useState,\n  useMemo,\n  PropsWithChildren,\n  useCallback,\n} from 'react'\nimport {\n  GoogleMap,\n  GoogleMapProps,\n  useLoadScript,\n} from '@react-google-maps/api'\n\nimport { getGeometry, literalToString } from './utilities'\n\nconst MAX_LAT = (Math.atan(Math.sinh(Math.PI)) * 180) / Math.PI\n\nconst DEFAULT_BOUNDS_PADDING = {\n  top: 20,\n  right: 22,\n  left: 22,\n  bottom: 20,\n}\n\nconst DEFAULT_MAP_OPTIONS: google.maps.MapOptions = {\n  noClear: true,\n  disableDefaultUI: true,\n  clickableIcons: false,\n  gestureHandling: 'greedy',\n  // 지도 뷰를 제외한 회색 영역으로 움직임을 방지\n  restriction: {\n    latLngBounds: {\n      north: MAX_LAT,\n      south: -MAX_LAT,\n      west: -180,\n      east: 180,\n    },\n    strictBounds: true,\n  },\n}\n\nconst DEFAULT_MAP_CONTAINER_STYLE: CSSProperties = {\n  width: '100%',\n  height: '100%',\n}\n\nexport interface WithGoogleMapProps extends GoogleMapProps {\n  /**\n   * 중앙좌표 값과 bounds를 계산하기 위한 좌표값\n   */\n  coordinates?: [number, number][]\n  /**\n   * Google Map SDK 로드를 위해 필요한 configuration\n   *\n   * 여러 옵션이 있지만 googleMapApiKey 정도만 설정하면 되고 기본값을 갖습니다.\n   */\n  googleMapLoadOptions: {\n    /** goole map api key */\n    googleMapsApiKey: string\n    /** region default: kr - https://developers.google.com/maps/faq#languagesupport */\n    region?: string\n    language?: string\n  }\n  /**\n   * Map SDK loaded 콜백 핸들러\n   */\n  padding?:\n    | number\n    | {\n        top?: number\n        right?: number\n        bottom?: number\n        left?: number\n      }\n  /**\n   * Map SDK loaded 콜백 핸들러\n   */\n  onLoad?: (map: google.maps.Map) => void\n  /**\n   * map의 fitBounds를 비활성화합니다.\n   */\n  disableFitBounds?: boolean\n  /**\n   * 기본 줌 레벨 설정 (coordinates가 1개 이상일 때만 적용)\n   */\n  defaultZoomLevel?: number\n}\n\nconst GOOGLE_MAP_LIBRARIES = ['geometry' as const]\n\n/**\n * 기본 Map 컴포넌트입니다.\n *\n * - 참고: https://tomchentw.github.io/react-google-maps/#googlemap\n */\nexport function MapView({\n  coordinates = [],\n  options: originOptions,\n  mapContainerStyle: originMapContainerStyle,\n  googleMapLoadOptions: { googleMapsApiKey, region = 'kr', language },\n  padding = DEFAULT_BOUNDS_PADDING,\n  children,\n  onLoad,\n  disableFitBounds = false,\n  defaultZoomLevel = 17,\n  ...props\n}: PropsWithChildren<WithGoogleMapProps>) {\n  const { isLoaded, loadError } = useLoadScript({\n    googleMapsApiKey,\n    region,\n    language,\n    libraries: GOOGLE_MAP_LIBRARIES,\n  })\n\n  const [map, setMap] = useState<google.maps.Map>()\n  const [center, setCenter] = useState<google.maps.LatLngLiteral | null>(null)\n  const [bounds, setBounds] = useState<google.maps.LatLngBoundsLiteral | null>(\n    null,\n  )\n\n  const coordinateLength = coordinates.length\n\n  useEffect(() => {\n    if (disableFitBounds || coordinates.length === 0) {\n      if (!center || !bounds) {\n        return\n      }\n      setCenter(null)\n      setBounds(null)\n      return\n    }\n\n    const { center: newCenter, bounds: newBounds } = getGeometry(coordinates)\n    setCenter(newCenter)\n    setBounds(newBounds)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [coordinates, disableFitBounds])\n\n  const options = useMemo(() => {\n    return {\n      ...DEFAULT_MAP_OPTIONS,\n      center,\n      ...(coordinateLength === 1 && { zoom: defaultZoomLevel }),\n      ...originOptions,\n    }\n  }, [center, coordinateLength, originOptions, defaultZoomLevel])\n\n  const mapContainerStyle: CSSProperties = useMemo(\n    () => ({\n      ...DEFAULT_MAP_CONTAINER_STYLE,\n      ...originMapContainerStyle,\n    }),\n    [originMapContainerStyle],\n  )\n\n  const handleOnLoad = useCallback(\n    (map: google.maps.Map) => {\n      onLoad && onLoad(map)\n      setMap(map)\n    },\n    [onLoad],\n  )\n\n  useEffect(() => {\n    if (!bounds || coordinateLength === 0 || disableFitBounds) {\n      return\n    }\n    map?.fitBounds(bounds, padding)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, literalToString(bounds), padding, coordinateLength])\n\n  return loadError ? (\n    <div>Map cannot be loaded right now, sorry.</div>\n  ) : isLoaded ? (\n    <GoogleMap\n      options={options}\n      onLoad={handleOnLoad}\n      mapContainerStyle={mapContainerStyle}\n      {...(props as GoogleMapProps)}\n    >\n      {children}\n    </GoogleMap>\n  ) : null\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/map.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { Container } from '@titicaca/tds-ui'\n\nimport { MapView } from './map-view'\nimport {\n  coordinates,\n  polygonLinePath,\n  polygonPaths,\n  polylinePaths,\n} from './mock'\nimport { DotPolyline, HotelCircleMarker, Polygon, Polyline } from './overlay'\n\n/**\n * 구글 맵 SDK API 키 설정은 아래에서 진행할 수 있습니다.\n * 아래 키는 triple-frontend 적용키로 서비스에서 사용하는 키와 다르고\n * 사용 범위가 *.triple-corp.com/* 으로 제한되어 있습니다.\n *\n * https://console.cloud.google.com/apis/credentials/key/e2f05131-1fe0-48d7-a231-f5f05336a007?folder=&organizationId=&project=titicaca-ci\n */\nconst GOOGLE_MAPS_API_KEY = 'AIzaSyDuSWU_yBwuQzeyRFcTqhyifqNX_8oaXI4'\n\nexport default {\n  title: 'tds-widget / map / Map',\n  component: MapView,\n} as Meta\n\nexport const Basic: StoryObj<typeof MapView> = {\n  render: (args) => {\n    return (\n      <Container\n        css={{\n          width: '100vw',\n          height: '100vh',\n        }}\n      >\n        <MapView {...args} />\n      </Container>\n    )\n  },\n\n  name: '기본 맵',\n\n  args: {\n    coordinates,\n    options: {\n      zoom: 10,\n    },\n    googleMapLoadOptions: { googleMapsApiKey: GOOGLE_MAPS_API_KEY },\n  },\n\n  parameters: {\n    chromatic: {\n      disableSnapshot: true,\n    },\n  },\n}\n\nexport const WithProps: StoryObj<typeof MapView> = {\n  render: (args) => {\n    return (\n      <Container\n        css={{\n          width: '50%',\n          height: 200,\n        }}\n      >\n        <MapView {...args} />\n      </Container>\n    )\n  },\n\n  name: '사이즈 설정',\n\n  args: {\n    coordinates,\n    googleMapLoadOptions: { googleMapsApiKey: GOOGLE_MAPS_API_KEY },\n    options: {\n      zoom: 9,\n    },\n    padding: {\n      top: 10,\n      left: 10,\n      right: 10,\n      bottom: 10,\n    },\n  },\n\n  parameters: {\n    chromatic: {\n      disableSnapshot: true,\n    },\n  },\n}\n\nexport const WithPolyline: StoryObj<typeof MapView> = {\n  render: (args) => {\n    return (\n      <Container\n        css={{\n          width: '100vw',\n          height: 200,\n        }}\n      >\n        <MapView {...args}>\n          <Polyline path={polylinePaths} strokeColor=\"#000000\" />\n        </MapView>\n      </Container>\n    )\n  },\n\n  name: 'Polyline',\n\n  args: {\n    coordinates: polylinePaths.map((path) => [path.lng, path.lat]),\n    googleMapLoadOptions: {\n      googleMapsApiKey: GOOGLE_MAPS_API_KEY,\n    },\n  },\n\n  parameters: {\n    chromatic: {\n      disableSnapshot: true,\n    },\n  },\n}\n\nexport const WithMarker: StoryObj<typeof MapView> = {\n  render: (args) => {\n    return (\n      <Container\n        css={{\n          width: '100vw',\n          height: 200,\n        }}\n      >\n        <MapView {...args}>\n          {polylinePaths.map((path, i) => (\n            <HotelCircleMarker\n              key={i}\n              zIndex={polylinePaths.length - i}\n              active={false}\n              position={{ ...path }}\n              onClick={() => {}}\n            >\n              {i + 1}\n            </HotelCircleMarker>\n          ))}\n\n          <Polyline path={polylinePaths} strokeColor=\"#000000\" />\n        </MapView>\n      </Container>\n    )\n  },\n\n  name: 'Polyline with CircleMarker',\n\n  args: {\n    coordinates: polylinePaths.map((path) => [path.lng, path.lat]),\n    googleMapLoadOptions: {\n      googleMapsApiKey: GOOGLE_MAPS_API_KEY,\n    },\n  },\n\n  parameters: {\n    chromatic: {\n      disableSnapshot: true,\n    },\n  },\n}\n\nexport const WithCircleMarker: StoryObj<typeof MapView> = {\n  render: (args) => {\n    return (\n      <Container\n        css={{\n          width: '50%',\n          height: 300,\n        }}\n      >\n        <MapView {...args}>\n          <Polygon paths={polygonPaths} strokeColor=\"#000000\" />\n        </MapView>\n      </Container>\n    )\n  },\n\n  name: 'Polygon with CircleMarker',\n\n  args: {\n    coordinates: polygonPaths.map((path) => [path.lng, path.lat]),\n    googleMapLoadOptions: {\n      googleMapsApiKey: GOOGLE_MAPS_API_KEY,\n    },\n  },\n\n  parameters: {\n    chromatic: {\n      disableSnapshot: true,\n    },\n  },\n}\n\nexport const WithWithPolyline: StoryObj<typeof MapView> = {\n  render: (args) => {\n    return (\n      <Container\n        css={{\n          height: 300,\n        }}\n      >\n        <MapView {...args}>\n          <DotPolyline path={polygonLinePath} strokeColor=\"#000000\" />\n          <Polygon paths={polygonPaths} fillColor=\"#000000\" fillOpacity={0.2} />\n        </MapView>\n      </Container>\n    )\n  },\n\n  name: 'Polygon with Polyline',\n\n  args: {\n    coordinates: polygonLinePath.map(({ lat, lng }) => [lng, lat]),\n    googleMapLoadOptions: {\n      googleMapsApiKey: GOOGLE_MAPS_API_KEY,\n    },\n  },\n\n  parameters: {\n    chromatic: {\n      disableSnapshot: true,\n    },\n  },\n}\n\nexport const WithPolylineAndMarker: StoryObj<typeof MapView> = {\n  render: (args) => {\n    return (\n      <Container\n        css={{\n          height: 300,\n        }}\n      >\n        <MapView {...args}>\n          {polygonPaths.map((path, i) => (\n            <HotelCircleMarker\n              key={i}\n              zIndex={polygonPaths.length - i}\n              active={false}\n              position={{ ...path }}\n              onClick={() => {}}\n            >\n              {i + 1}\n            </HotelCircleMarker>\n          ))}\n\n          <DotPolyline path={polygonLinePath} strokeColor=\"#000000\" />\n          <Polygon paths={polygonPaths} fillColor=\"#000000\" fillOpacity={0.2} />\n        </MapView>\n      </Container>\n    )\n  },\n\n  name: 'Polygon with Polyline, Marker',\n\n  args: {\n    coordinates: polygonPaths.map(({ lat, lng }) => [lng, lat]),\n    googleMapLoadOptions: {\n      googleMapsApiKey: GOOGLE_MAPS_API_KEY,\n    },\n  },\n\n  parameters: {\n    chromatic: {\n      disableSnapshot: true,\n    },\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/mock.ts",
    "content": "import HOTELS from './mocks/hotel-recommandations.json'\nimport { RecommendationHotelResourceType } from './types'\nimport { getGeometry } from './utilities'\n\nexport const coordinates: [number, number][] = (\n  HOTELS as unknown as RecommendationHotelResourceType[]\n)\n  .map(({ hotel }) => hotel)\n  .map(\n    ({\n      source: {\n        pointGeolocation: { coordinates },\n      },\n    }) => coordinates as [number, number],\n  )\n\nexport const { center, bounds } = getGeometry(coordinates)\nexport const polylinePaths = [\n  { lat: 16.0563348, lng: 108.2025533 },\n  { lat: 16.0131183, lng: 108.2637083 },\n  { lat: 16.0349407, lng: 108.2294803 },\n  { lat: 16.0347492, lng: 108.2291364 },\n  { lat: 16.0616944, lng: 108.2469346 },\n  { lat: 16.0691917, lng: 108.2429779 },\n  { lat: 16.0694295, lng: 108.2422102 },\n  { lat: 16.0131183, lng: 108.2637083 },\n]\n\nexport const polygonPaths = [\n  { lat: 33.22410952604817, lng: 126.57855753182952 },\n  { lat: 33.22632510895313, lng: 126.5581921360212 },\n  { lat: 33.24427632056917, lng: 126.54479946762507 },\n  { lat: 33.305750445906945, lng: 126.57134640583645 },\n  { lat: 33.322840067236065, lng: 126.57120470222935 },\n  { lat: 33.33836676375764, lng: 126.5880743229918 },\n  { lat: 33.33960003282098, lng: 126.62690501730869 },\n  { lat: 33.37007992590719, lng: 126.7131194159885 },\n  { lat: 33.39914760502427, lng: 126.69252568949138 },\n  { lat: 33.44739620069631, lng: 126.70250225444475 },\n  { lat: 33.465340650834754, lng: 126.74537358522166 },\n  { lat: 33.49700571297366, lng: 126.76545761455868 },\n  { lat: 33.5216201962451, lng: 126.80416859793247 },\n  { lat: 33.540307944842574, lng: 126.77230622539585 },\n  { lat: 33.56819155550412, lng: 126.77372385924352 },\n  { lat: 33.57703704881572, lng: 126.78767650939497 },\n  { lat: 33.57593413883613, lng: 126.80982987822304 },\n  { lat: 33.54456318846464, lng: 126.85899229672327 },\n  { lat: 33.524683309505726, lng: 126.86591555447332 },\n  { lat: 33.49776210381547, lng: 126.85043322888976 },\n  { lat: 33.46792578574098, lng: 126.80701906667119 },\n  { lat: 33.43365643310854, lng: 126.785951212079 },\n  { lat: 33.41659022878652, lng: 126.75051350384832 },\n  { lat: 33.40624179699998, lng: 126.74666287460637 },\n  { lat: 33.37635925690542, lng: 126.78473980004432 },\n  { lat: 33.35156244305432, lng: 126.77942085409856 },\n  { lat: 33.32362060755753, lng: 126.73513047883476 },\n  { lat: 33.29106315546635, lng: 126.62567078422525 },\n  { lat: 33.23500687433823, lng: 126.6001628251625 },\n  { lat: 33.22410952604817, lng: 126.57855753182952 },\n]\n\nexport const polygonLinePath = [\n  { lat: 33.24577929502035, lng: 126.57157193028415 },\n  { lat: 33.24588206713943, lng: 126.57193023026494 },\n  { lat: 33.246184827717805, lng: 126.57295235712667 },\n  { lat: 33.246195938219586, lng: 126.57299124239687 },\n  { lat: 33.24628759961703, lng: 126.5732967694218 },\n  { lat: 33.2463792610586, lng: 126.57360507398386 },\n  { lat: 33.24654869742276, lng: 126.57427445662532 },\n  { lat: 33.24664035978979, lng: 126.57464108946704 },\n  { lat: 33.24668757970897, lng: 126.5748244058567 },\n  { lat: 33.24672368855357, lng: 126.5749327289935 },\n  { lat: 33.24675701966259, lng: 126.57502438696993 },\n  { lat: 33.24684867951848, lng: 126.57523270019534 },\n  { lat: 33.24706810714978, lng: 126.57569932150318 },\n  { lat: 33.24714310156139, lng: 126.57586874958315 },\n  { lat: 33.24718754251144, lng: 126.5759576297728 },\n  { lat: 33.24737364176571, lng: 126.57650480040604 },\n  { lat: 33.24754307654519, lng: 126.57707419171071 },\n  { lat: 33.24780972757792, lng: 126.577943554841 },\n  { lat: 33.24784861415845, lng: 126.5780685431381 },\n  { lat: 33.24784861415845, lng: 126.5780685431381 },\n  { lat: 33.24829023169156, lng: 126.57804353538047 },\n  { lat: 33.249037370841826, lng: 126.5780046330715 },\n  { lat: 33.24994560310332, lng: 126.57795183945613 },\n  { lat: 33.25004281442072, lng: 126.57794072712302 },\n  { lat: 33.25024834685235, lng: 126.57791294713287 },\n  { lat: 33.250370555146525, lng: 126.57788516901525 },\n  { lat: 33.25046221116883, lng: 126.57785183650996 },\n  { lat: 33.25059552864285, lng: 126.5777796175487 },\n  { lat: 33.2507455104155, lng: 126.57767406776746 },\n  { lat: 33.25093437586008, lng: 126.57751018883282 },\n  { lat: 33.251459310829965, lng: 126.57706299355934 },\n  { lat: 33.25161206998549, lng: 126.57695188864155 },\n  { lat: 33.251637066962246, lng: 126.57693522285706 },\n  { lat: 33.25188148196354, lng: 126.57677967528555 },\n  { lat: 33.252028687190034, lng: 126.5767324538463 },\n  { lat: 33.252112011018696, lng: 126.57671300921388 },\n  { lat: 33.25219811236266, lng: 126.57669634205614 },\n  { lat: 33.25237031540309, lng: 126.57668522803759 },\n  { lat: 33.252481414340686, lng: 126.57669078061495 },\n  { lat: 33.252645285540225, lng: 126.5767157747661 },\n  { lat: 33.25327021846295, lng: 126.57683519481677 },\n  { lat: 33.25371183766693, lng: 126.57691573346742 },\n  { lat: 33.25393125868745, lng: 126.57696572420375 },\n  { lat: 33.25455896934479, lng: 126.57710180941346 },\n  { lat: 33.25480616503328, lng: 126.5771490219881 },\n  { lat: 33.25480616503328, lng: 126.5771490219881 },\n  { lat: 33.25515335034693, lng: 126.57723789537175 },\n  { lat: 33.25542276677464, lng: 126.5773462132631 },\n  { lat: 33.25567829575801, lng: 126.57744897639223 },\n  { lat: 33.256017149715404, lng: 126.57760451085326 },\n  { lat: 33.25621157428766, lng: 126.57770449781863 },\n  { lat: 33.256430996894224, lng: 126.5778544798899 },\n  { lat: 33.2573947922338, lng: 126.57866272152106 },\n  { lat: 33.25781141851442, lng: 126.57901545936684 },\n  { lat: 33.25807528185044, lng: 126.57924043394 },\n  { lat: 33.25835303267475, lng: 126.57947374081206 },\n  { lat: 33.258647448370425, lng: 126.57970982484635 },\n  { lat: 33.2592640552232, lng: 126.58022921042054 },\n  { lat: 33.25995565481251, lng: 126.58081247766086 },\n  { lat: 33.260433386208916, lng: 126.58121243226077 },\n  { lat: 33.26101666296603, lng: 126.5817040432109 },\n  { lat: 33.26107776812617, lng: 126.58175403750455 },\n  { lat: 33.261397181591505, lng: 126.58202345141999 },\n  { lat: 33.26144162170395, lng: 126.58205955840286 },\n  { lat: 33.261797142603555, lng: 126.58234841426567 },\n  { lat: 33.26195546049036, lng: 126.58247617741164 },\n  { lat: 33.26225820877566, lng: 126.58272337140443 },\n  { lat: 33.26307201819958, lng: 126.58337607431855 },\n  { lat: 33.263116458312005, lng: 126.58341218130124 },\n  { lat: 33.2635941894434, lng: 126.58379547067503 },\n  { lat: 33.26432467367271, lng: 126.58438151457074 },\n  { lat: 33.26435244873745, lng: 126.58440373424271 },\n  { lat: 33.2644718815423, lng: 126.5845009453544 },\n  { lat: 33.26449132410526, lng: 126.58451761013961 },\n  { lat: 33.26476074225088, lng: 126.58473425197238 },\n  { lat: 33.26497183231073, lng: 126.58487590161556 },\n  { lat: 33.265074599935595, lng: 126.58495089280524 },\n  { lat: 33.26542178758327, lng: 126.58518697564773 },\n  { lat: 33.26582452377271, lng: 126.58536750649836 },\n  { lat: 33.26624392324559, lng: 126.58545082317553 },\n  { lat: 33.26648556368012, lng: 126.58547859310995 },\n  { lat: 33.26701883836878, lng: 126.58549246879736 },\n  { lat: 33.26743268139913, lng: 126.58548134933777 },\n  { lat: 33.267507673077034, lng: 126.58547857011341 },\n  { lat: 33.267546557673626, lng: 126.58547856923852 },\n  { lat: 33.26757433238547, lng: 126.5854785686136 },\n  { lat: 33.26792151615119, lng: 126.58547022819072 },\n  { lat: 33.26805761219506, lng: 126.58546744759144 },\n  { lat: 33.269490787279835, lng: 126.58546463780638 },\n  { lat: 33.269560224059255, lng: 126.58546463624393 },\n  { lat: 33.27074342677953, lng: 126.58546460961878 },\n  { lat: 33.27097673435793, lng: 126.58546460436857 },\n  { lat: 33.27147945659559, lng: 126.58546181551834 },\n  { lat: 33.271529451076624, lng: 126.58546181439327 },\n  { lat: 33.2726515496043, lng: 126.585472899289 },\n  { lat: 33.27483741914231, lng: 126.58545618487005 },\n  { lat: 33.2763011467042, lng: 126.58547281714594 },\n  { lat: 33.27654556456231, lng: 126.58549780947777 },\n  { lat: 33.2768010928784, lng: 126.58555890954128 },\n  { lat: 33.27700107177149, lng: 126.58562001085511 },\n  { lat: 33.27716494358374, lng: 126.58568389051872 },\n  { lat: 33.27769544463101, lng: 126.58593941198636 },\n  { lat: 33.27773988456635, lng: 126.5859644088195 },\n  { lat: 33.27778154707467, lng: 126.58599218325224 },\n  { lat: 33.27927584106329, lng: 126.58690595930436 },\n  { lat: 33.27933416853075, lng: 126.58694206597293 },\n  { lat: 33.279939663106695, lng: 126.58731146476917 },\n  { lat: 33.28006465049909, lng: 126.58738645545561 },\n  { lat: 33.280959004896474, lng: 126.58793361011834 },\n  { lat: 33.281295082345146, lng: 126.58815025044116 },\n  { lat: 33.28160616228881, lng: 126.58835022610431 },\n  { lat: 33.28178114499978, lng: 126.58847798886853 },\n  { lat: 33.28180614250471, lng: 126.58849465352795 },\n  { lat: 33.282095003252884, lng: 126.58873073767282 },\n  { lat: 33.282250543665924, lng: 126.58885850087468 },\n  { lat: 33.282292206218244, lng: 126.58888905284407 },\n  { lat: 33.28282271176439, lng: 126.58942788308556 },\n  { lat: 33.28307824352092, lng: 126.58970563103615 },\n  { lat: 33.28363096940981, lng: 126.59028056875786 },\n  { lat: 33.28421702548183, lng: 126.59088883617271 },\n  { lat: 33.28501417297817, lng: 126.5917248568701 },\n  { lat: 33.28563911413149, lng: 126.59236367631452 },\n  { lat: 33.2857279946197, lng: 126.59245255549797 },\n  { lat: 33.28630294049845, lng: 126.59304138040066 },\n  { lat: 33.28635849067116, lng: 126.59308859727908 },\n  { lat: 33.286539028456595, lng: 126.59322469252731 },\n  { lat: 33.28665012849302, lng: 126.59329968352473 },\n  { lat: 33.28678067069484, lng: 126.59336634147297 },\n  { lat: 33.28682511058581, lng: 126.59338856076818 },\n  { lat: 33.28699453755977, lng: 126.59346632798852 },\n  { lat: 33.287066752117745, lng: 126.59348576912092 },\n  { lat: 33.28708897197494, lng: 126.59349132369441 },\n  { lat: 33.28723062361975, lng: 126.59353020602175 },\n  { lat: 33.288052757629245, lng: 126.59369128464772 },\n  { lat: 33.288274955980214, lng: 126.59373294269709 },\n  { lat: 33.28899154590998, lng: 126.59388291355172 },\n  { lat: 33.28938594861702, lng: 126.59399678368376 },\n  { lat: 33.289449830761455, lng: 126.59401622500364 },\n  { lat: 33.28990256084707, lng: 126.59416064672855 },\n  { lat: 33.2899497781207, lng: 126.59417731088685 },\n  { lat: 33.29041361862022, lng: 126.59435506280552 },\n  { lat: 33.2910496633431, lng: 126.59459669419513 },\n  { lat: 33.29125241983705, lng: 126.594666128052 },\n  { lat: 33.29144962090312, lng: 126.59470500912654 },\n  { lat: 33.291594049709246, lng: 126.59472444863103 },\n  { lat: 33.29235229923016, lng: 126.59471887646896 },\n  { lat: 33.29239396125242, lng: 126.59471609799301 },\n  { lat: 33.292602271319566, lng: 126.59469942807618 },\n  { lat: 33.29319109435225, lng: 126.59464664160215 },\n  { lat: 33.29328552823689, lng: 126.59463830686275 },\n  { lat: 33.29361882459107, lng: 126.5946271892029 },\n  { lat: 33.29366604155543, lng: 126.59462441060168 },\n  { lat: 33.29390490389244, lng: 126.59461329507008 },\n  { lat: 33.29406321987689, lng: 126.59462162411302 },\n  { lat: 33.294179873927455, lng: 126.59463828670599 },\n  { lat: 33.2943131933785, lng: 126.59469105690476 },\n  { lat: 33.29439096305459, lng: 126.59472160805927 },\n  { lat: 33.29451317293032, lng: 126.59479382126761 },\n  { lat: 33.294582610369744, lng: 126.59483548275801 },\n  { lat: 33.29482703012116, lng: 126.59497990917454 },\n  { lat: 33.29493257494959, lng: 126.59503823507325 },\n  { lat: 33.29524365444849, lng: 126.59521043535737 },\n  { lat: 33.29550196142274, lng: 126.59534652884946 },\n  { lat: 33.295560288889476, lng: 126.59538263551609 },\n  { lat: 33.2956741663077, lng: 126.59545207137492 },\n  { lat: 33.295721383713484, lng: 126.59547706814385 },\n  { lat: 33.29621855584988, lng: 126.59577980847295 },\n  { lat: 33.296271328418584, lng: 126.59581869280176 },\n  { lat: 33.29642409193319, lng: 126.59598256404277 },\n  { lat: 33.29651852798111, lng: 126.59611032861723 },\n  { lat: 33.29662407484064, lng: 126.59629642121877 },\n  { lat: 33.29669073639858, lng: 126.59643807410443 },\n  { lat: 33.297151811964966, lng: 126.59740464659575 },\n  { lat: 33.297174032528474, lng: 126.5974546417613 },\n  { lat: 33.29719903056307, lng: 126.59750463686423 },\n  { lat: 33.29732402091269, lng: 126.59776572252699 },\n  { lat: 33.29777954140541, lng: 126.5987239625317 },\n  { lat: 33.29811562666764, lng: 126.59943222689627 },\n  { lat: 33.29814340221755, lng: 126.59948499947353 },\n  { lat: 33.29817673288627, lng: 126.59954888207366 },\n  { lat: 33.29823506141301, lng: 126.59965164962856 },\n  { lat: 33.29831283281145, lng: 126.5997905247264 },\n  { lat: 33.29834616317102, lng: 126.59983496456734 },\n  { lat: 33.29850170490793, lng: 126.60004605387428 },\n  { lat: 33.298579475511396, lng: 126.60013493330558 },\n  { lat: 33.29863780319898, lng: 126.60018492765687 },\n  { lat: 33.29884611617966, lng: 126.60035157518129 },\n  { lat: 33.299084980856286, lng: 126.60048766910938 },\n  { lat: 33.2991988580095, lng: 126.600540439745 },\n  { lat: 33.299307179999715, lng: 126.60057932282072 },\n  { lat: 33.29949604873935, lng: 126.60062375915413 },\n  { lat: 33.29961270274522, lng: 126.60063764420866 },\n  { lat: 33.299718246822614, lng: 126.6006487519767 },\n  { lat: 33.29980712585272, lng: 126.60064597243539 },\n  { lat: 33.2998932273234, lng: 126.60063763788266 },\n  { lat: 33.3000154356088, lng: 126.60060985975649 },\n  { lat: 33.30007931717839, lng: 126.60059319309376 },\n  { lat: 33.300201525419574, lng: 126.60056263743056 },\n  { lat: 33.30044316385659, lng: 126.60046541818546 },\n  { lat: 33.300648695434674, lng: 126.60038486497677 },\n  { lat: 33.30187355156083, lng: 126.59984321763488 },\n  { lat: 33.30297342237033, lng: 126.5993571238519 },\n  { lat: 33.303942752965916, lng: 126.59892936129019 },\n  { lat: 33.304192723637875, lng: 126.59882103170898 },\n  { lat: 33.304487134553256, lng: 126.59875714171761 },\n  { lat: 33.304739884374555, lng: 126.59875435848002 },\n  { lat: 33.30489820053351, lng: 126.59877379766836 },\n  { lat: 33.305284270731796, lng: 126.59888211290394 },\n  { lat: 33.30533148800462, lng: 126.598898777061 },\n  { lat: 33.30568145138833, lng: 126.59902653586978 },\n  { lat: 33.30580921576317, lng: 126.59907097358007 },\n  { lat: 33.306067522028016, lng: 126.59916262647492 },\n  { lat: 33.30625361492916, lng: 126.59930983173868 },\n  { lat: 33.30656191783723, lng: 126.59953758281881 },\n  { lat: 33.30671190255458, lng: 126.59961812800873 },\n  { lat: 33.30680355940818, lng: 126.59963756870023 },\n  { lat: 33.3068841063776, lng: 126.59965700964231 },\n  { lat: 33.3070507545075, lng: 126.5996486732721 },\n  { lat: 33.307181294939554, lng: 126.5996042297353 },\n  { lat: 33.30732016716623, lng: 126.59952090049251 },\n  { lat: 33.30755624889127, lng: 126.59931257989173 },\n  { lat: 33.307709007589516, lng: 126.59917369959568 },\n  { lat: 33.30800896911614, lng: 126.59883761085196 },\n  { lat: 33.30826727052083, lng: 126.59862373467615 },\n  { lat: 33.30844224973815, lng: 126.59853207200796 },\n  { lat: 33.308517241014286, lng: 126.59850429494628 },\n  { lat: 33.30861722975058, lng: 126.5984904050057 },\n  { lat: 33.308714441060005, lng: 126.59847929266478 },\n  { lat: 33.308872757173866, lng: 126.59849595431537 },\n  { lat: 33.309025518831575, lng: 126.5985431689982 },\n  { lat: 33.30920050118521, lng: 126.59864871145665 },\n  { lat: 33.30931715660302, lng: 126.59875147769388 },\n  { lat: 33.309464364820634, lng: 126.59889312875976 },\n  { lat: 33.309678232210416, lng: 126.59902644571075 },\n  { lat: 33.309855991328156, lng: 126.59908754751437 },\n  { lat: 33.31003930459269, lng: 126.59909865352671 },\n  { lat: 33.31026427921572, lng: 126.59906531800716 },\n  { lat: 33.31040315188345, lng: 126.5990097641342 },\n  { lat: 33.31057535358494, lng: 126.59891532399135 },\n  { lat: 33.31118639151102, lng: 126.59855700793355 },\n  { lat: 33.31130026711605, lng: 126.5985125647725 },\n  { lat: 33.311469692053116, lng: 126.59846256528418 },\n  { lat: 33.311544683682314, lng: 126.59845700851827 },\n  { lat: 33.31161412032528, lng: 126.59844867434069 },\n  { lat: 33.31172521925444, lng: 126.59845422690809 },\n  { lat: 33.31183076341826, lng: 126.59847088974874 },\n  { lat: 33.31192519791888, lng: 126.59850144052498 },\n  { lat: 33.31202240989051, lng: 126.59853199123854 },\n  { lat: 33.312891764901785, lng: 126.59894582463457 },\n  { lat: 33.31303341649792, lng: 126.59898192941921 },\n  { lat: 33.31309452112534, lng: 126.59899859326238 },\n  { lat: 33.31319173269922, lng: 126.59900414614286 },\n  { lat: 33.313294498950015, lng: 126.59899303367608 },\n  { lat: 33.313444481633645, lng: 126.59894581216308 },\n  { lat: 33.31358057638781, lng: 126.59886248298263 },\n  { lat: 33.31375277667454, lng: 126.59867916165604 },\n  { lat: 33.31386942820372, lng: 126.59853750463766 },\n  { lat: 33.314927624157654, lng: 126.5972487036008 },\n  { lat: 33.31514426331738, lng: 126.59702371821723 },\n  { lat: 33.31532201965021, lng: 126.5969098351902 },\n  { lat: 33.315444227402374, lng: 126.59684872661916 },\n  { lat: 33.315535883768725, lng: 126.59683761440306 },\n  { lat: 33.31564698238791, lng: 126.59682372421129 },\n  { lat: 33.315755303756966, lng: 126.59682372176705 },\n  { lat: 33.31588029012874, lng: 126.59683482909466 },\n  { lat: 33.3159802797033, lng: 126.59687371235606 },\n  { lat: 33.316113599725114, lng: 126.59696259053088 },\n  { lat: 33.31618026004529, lng: 126.5970264723771 },\n  { lat: 33.31624969783647, lng: 126.59709035416063 },\n  { lat: 33.31642468076273, lng: 126.59723200459771 },\n  { lat: 33.316666324230056, lng: 126.59745142456575 },\n  { lat: 33.31699962552164, lng: 126.59775139103745 },\n  { lat: 33.31703017832118, lng: 126.5977902758656 },\n  { lat: 33.317188497260936, lng: 126.59798469988095 },\n  { lat: 33.31736348168946, lng: 126.59822078657473 },\n  { lat: 33.31756346225233, lng: 126.59838743427997 },\n  { lat: 33.31756346225233, lng: 126.59838743427997 },\n  { lat: 33.31768845047969, lng: 126.59851519815997 },\n  { lat: 33.31775233434517, lng: 126.59864296341897 },\n  { lat: 33.31778844411933, lng: 126.59880961482243 },\n  { lat: 33.317799557008044, lng: 126.59899848708585 },\n  { lat: 33.31728576817943, lng: 126.60172048491964 },\n  { lat: 33.3170052672959, lng: 126.60320925107321 },\n  { lat: 33.31690806509175, lng: 126.60379253603395 },\n  { lat: 33.31683586348581, lng: 126.60458691324155 },\n  { lat: 33.31681642918855, lng: 126.6050896478748 },\n  { lat: 33.31681365684456, lng: 126.6054118422279 },\n  { lat: 33.3168192150131, lng: 126.60561460230251 },\n  { lat: 33.316827550652675, lng: 126.60581736231447 },\n  { lat: 33.31686921872899, lng: 126.60619510640421 },\n  { lat: 33.316902551961334, lng: 126.60642008614779 },\n  { lat: 33.316969218514544, lng: 126.60687560070897 },\n  { lat: 33.31705532727657, lng: 126.60732555975729 },\n  { lat: 33.31812750566477, lng: 126.61201124045421 },\n  { lat: 33.3183830513024, lng: 126.61316113499932 },\n  { lat: 33.31848304905778, lng: 126.61371386260323 },\n  { lat: 33.31854694070729, lng: 126.61433047437363 },\n  { lat: 33.31856361035316, lng: 126.6146332255299 },\n  { lat: 33.3185719497086, lng: 126.6150692986503 },\n  { lat: 33.31856362366323, lng: 126.61546926416622 },\n  { lat: 33.31855529514161, lng: 126.61571368761031 },\n  { lat: 33.31852197313973, lng: 126.61619420226401 },\n  { lat: 33.31842199488504, lng: 126.61686636847661 },\n  { lat: 33.31821647910032, lng: 126.6179385024021 },\n  { lat: 33.31817759769029, lng: 126.61813848594471 },\n  { lat: 33.31734998014609, lng: 126.62246035222796 },\n  { lat: 33.317249999460735, lng: 126.62297975390857 },\n  { lat: 33.31724166771116, lng: 126.62302141715203 },\n  { lat: 33.31723055875596, lng: 126.62307974568033 },\n  { lat: 33.31701393322304, lng: 126.62416021247417 },\n  { lat: 33.31694450219797, lng: 126.62452129385527 },\n  { lat: 33.316266855980516, lng: 126.62808211163008 },\n  { lat: 33.31619464797176, lng: 126.62847374598256 },\n  { lat: 33.31608911486773, lng: 126.62915146740224 },\n  { lat: 33.3160363521651, lng: 126.62973197383374 },\n  { lat: 33.31602246936717, lng: 126.63001806046161 },\n  { lat: 33.31600581082385, lng: 126.63041247109648 },\n  { lat: 33.31600304003406, lng: 126.63083187925092 },\n  { lat: 33.316008596613294, lng: 126.63093464799555 },\n  { lat: 33.31602248905697, lng: 126.63125406444034 },\n  { lat: 33.31604749151764, lng: 126.6315818132452 },\n  { lat: 33.31610304770855, lng: 126.6320067751558 },\n  { lat: 33.316189158249344, lng: 126.6325678356906 },\n  { lat: 33.3163030455364, lng: 126.63325666230132 },\n  { lat: 33.31637248713596, lng: 126.63355941226871 },\n  { lat: 33.316486369024574, lng: 126.63390937936127 },\n  { lat: 33.31677246013653, lng: 126.6346370875983 },\n  { lat: 33.31704743990426, lng: 126.63527313736411 },\n  { lat: 33.317355748749435, lng: 126.63587307839484 },\n  { lat: 33.31820290047612, lng: 126.63732015604008 },\n  { lat: 33.31910282543767, lng: 126.6388477810642 },\n  { lat: 33.31910282543767, lng: 126.6388477810642 },\n  { lat: 33.3190778285082, lng: 126.63886722438865 },\n  { lat: 33.31905005428476, lng: 126.63889777792403 },\n  { lat: 33.31902783522472, lng: 126.638942219019 },\n  { lat: 33.31901394862207, lng: 126.63898943746263 },\n  { lat: 33.31900839456532, lng: 126.63904498832896 },\n  { lat: 33.31901672777523, lng: 126.63909498380723 },\n  { lat: 33.3190417257671, lng: 126.63914220137166 },\n  { lat: 33.319075055995015, lng: 126.63917830859954 },\n  { lat: 33.319122273312516, lng: 126.6391977502912 },\n  { lat: 33.31917504539502, lng: 126.6392060817091 },\n  { lat: 33.319225039785145, lng: 126.63920052550462 },\n  { lat: 33.31926392411406, lng: 126.6391838594032 },\n  { lat: 33.31929169855882, lng: 126.63916719355298 },\n  { lat: 33.31929169855882, lng: 126.63916719355298 },\n  { lat: 33.319711107929315, lng: 126.63987267847706 },\n  { lat: 33.32061658810123, lng: 126.64141696859407 },\n  { lat: 33.32088878765618, lng: 126.64188081112358 },\n  { lat: 33.32094711631885, lng: 126.64199191128596 },\n  { lat: 33.32101933282371, lng: 126.64213356404167 },\n  { lat: 33.32110543703795, lng: 126.64229743677957 },\n  { lat: 33.32118320861773, lng: 126.64244742202072 },\n  { lat: 33.321341530222476, lng: 126.64280849825495 },\n  { lat: 33.3214859643836, lng: 126.64316401972914 },\n  { lat: 33.321597068006874, lng: 126.6434639912164 },\n  { lat: 33.32181649906672, lng: 126.64414448282768 },\n  { lat: 33.321985935958345, lng: 126.64484441832903 },\n  { lat: 33.322038711627606, lng: 126.64507773024657 },\n  { lat: 33.322038711627606, lng: 126.64507773024657 },\n  { lat: 33.32200538210807, lng: 126.64508606361159 },\n  { lat: 33.32198593998796, lng: 126.64509717419952 },\n  { lat: 33.32196094301436, lng: 126.64511383998719 },\n  { lat: 33.32194427849814, lng: 126.64513328312341 },\n  { lat: 33.321930391585795, lng: 126.64516105880791 },\n  { lat: 33.3219192823659, lng: 126.6452027221148 },\n  { lat: 33.321930392958535, lng: 126.64524716245612 },\n  { lat: 33.321947058360394, lng: 126.64528327006067 },\n  { lat: 33.32197483346926, lng: 126.64530826726579 },\n  { lat: 33.32200260844528, lng: 126.64532493185976 },\n  { lat: 33.32203038333274, lng: 126.64533604137965 },\n  { lat: 33.32203038333274, lng: 126.64533604137965 },\n  { lat: 33.32222759387255, lng: 126.64596931536387 },\n  { lat: 33.32227759251383, lng: 126.64623040271468 },\n  { lat: 33.32228314781016, lng: 126.64625262288536 },\n  { lat: 33.32258591646572, lng: 126.64777748387202 },\n  { lat: 33.32307201167611, lng: 126.65014671197285 },\n  { lat: 33.32388031895908, lng: 126.65411023904895 },\n  { lat: 33.323885874344164, lng: 126.65413801429372 },\n  { lat: 33.32397476028409, lng: 126.65456853052488 },\n  { lat: 33.324141421970126, lng: 126.65541012047939 },\n  { lat: 33.32434141893607, lng: 126.65660445688513 },\n  { lat: 33.3244330863356, lng: 126.65728495138832 },\n  { lat: 33.324541426315996, lng: 126.65845151449881 },\n  { lat: 33.32457476310253, lng: 126.65889869721012 },\n  { lat: 33.32472478003972, lng: 126.66099851182945 },\n  { lat: 33.325022035832454, lng: 126.66515925561814 },\n  { lat: 33.325049815329926, lng: 126.66545922899216 },\n  { lat: 33.325049815329926, lng: 126.66545922899216 },\n  { lat: 33.32503592824066, lng: 126.665475894529 },\n  { lat: 33.32501648634227, lng: 126.66550089280283 },\n  { lat: 33.325002599430306, lng: 126.66552866848792 },\n  { lat: 33.32499426750474, lng: 126.6655592215843 },\n  { lat: 33.32499149056561, lng: 126.66559255209197 },\n  { lat: 33.32499704612824, lng: 126.6656314374851 },\n  { lat: 33.32500537916192, lng: 126.66567032281533 },\n  { lat: 33.325022044475766, lng: 126.66570087534569 },\n  { lat: 33.32503593209694, lng: 126.66571754025362 },\n  { lat: 33.3250525971005, lng: 126.66572865002455 },\n  { lat: 33.32507759438422, lng: 126.66573142699558 },\n  { lat: 33.32510536905033, lng: 126.66572864882961 },\n  { lat: 33.32512759449238, lng: 126.66608417307077 },\n  { lat: 33.325152594081054, lng: 126.66623138196921 },\n  { lat: 33.32518870408586, lng: 126.66641192106086 },\n  { lat: 33.325247035942425, lng: 126.66672300389145 },\n  { lat: 33.325397028909364, lng: 126.66732017096432 },\n  { lat: 33.32556368555027, lng: 126.66784512169609 },\n  { lat: 33.32577755942082, lng: 126.66838395904382 },\n  { lat: 33.32600809780753, lng: 126.66890335325458 },\n  { lat: 33.32624696736623, lng: 126.66934497623856 },\n  { lat: 33.32636084669371, lng: 126.66953384618003 },\n  { lat: 33.326680263755215, lng: 126.67002824054377 },\n  { lat: 33.326860803363175, lng: 126.67027821479077 },\n  { lat: 33.32713577849403, lng: 126.67062262315899 },\n  { lat: 33.32779682843993, lng: 126.67136421058252 },\n  { lat: 33.3292522493808, lng: 126.67300014694443 },\n  { lat: 33.33022715923034, lng: 126.67409725199761 },\n  { lat: 33.33056601752588, lng: 126.6745249850279 },\n  { lat: 33.330657675888226, lng: 126.67463886197075 },\n  { lat: 33.330988202436025, lng: 126.67510825824542 },\n  { lat: 33.331349283072406, lng: 126.67569708792122 },\n  { lat: 33.33159370766377, lng: 126.67614426584991 },\n  { lat: 33.33177147193044, lng: 126.67652756193657 },\n  { lat: 33.332004787658136, lng: 126.67703862346923 },\n  { lat: 33.332102003535354, lng: 126.67731359743559 },\n  { lat: 33.33254919690846, lng: 126.67859958696229 },\n  { lat: 33.33270474340385, lng: 126.67910787272011 },\n  { lat: 33.33277696159867, lng: 126.67935507188226 },\n  { lat: 33.33283806911091, lng: 126.67955227562902 },\n  { lat: 33.33332414992009, lng: 126.68101602664606 },\n  { lat: 33.335012937230495, lng: 126.68633219431261 },\n  { lat: 33.33521014956666, lng: 126.68707656977642 },\n  { lat: 33.335262925244, lng: 126.68730988169388 },\n  { lat: 33.335346255543584, lng: 126.68769595745755 },\n  { lat: 33.335510140136655, lng: 126.68855976777141 },\n  { lat: 33.33568236069413, lng: 126.68964578086249 },\n  { lat: 33.33572124994889, lng: 126.68993742137359 },\n  { lat: 33.33572124994889, lng: 126.68993742137359 },\n  { lat: 33.33570458521149, lng: 126.68994297682569 },\n  { lat: 33.335679588149766, lng: 126.68995408754091 },\n  { lat: 33.33565459126558, lng: 126.68997630840444 },\n  { lat: 33.33563792679448, lng: 126.689998529079 },\n  { lat: 33.33562403992757, lng: 126.6900290823018 },\n  { lat: 33.33562126294478, lng: 126.69005963527263 },\n  { lat: 33.335624040859656, lng: 126.6900874105804 },\n  { lat: 33.33563237367221, lng: 126.69011240822512 },\n  { lat: 33.33564626142682, lng: 126.69013740574385 },\n  { lat: 33.33566014904827, lng: 126.69015407065136 },\n  { lat: 33.335682369127206, lng: 126.69017351290697 },\n  { lat: 33.335704589072975, lng: 126.69018462255133 },\n  { lat: 33.335735141343065, lng: 126.69019017693257 },\n  { lat: 33.335754583684654, lng: 126.6901929540287 },\n  { lat: 33.335754583684654, lng: 126.6901929540287 },\n  { lat: 33.335824026674366, lng: 126.69058180764463 },\n  { lat: 33.33589069214879, lng: 126.6909678837865 },\n  { lat: 33.33615180017095, lng: 126.69257884937025 },\n  { lat: 33.33624346545996, lng: 126.69312602209594 },\n  { lat: 33.33636012701183, lng: 126.69361208843888 },\n  { lat: 33.33656567092201, lng: 126.69430369050968 },\n  { lat: 33.33659622461276, lng: 126.69439812607737 },\n  { lat: 33.33661566793104, lng: 126.69446200898919 },\n  { lat: 33.33672677312073, lng: 126.69485919427157 },\n  { lat: 33.33689898258442, lng: 126.69525082309355 },\n  { lat: 33.33719618037565, lng: 126.69577577085988 },\n  { lat: 33.33738783133765, lng: 126.69611740357308 },\n  { lat: 33.33761836733812, lng: 126.6964868107748 },\n  { lat: 33.33778501941821, lng: 126.6967256751829 },\n  { lat: 33.33797666904841, lng: 126.69698398178335 },\n  { lat: 33.33811554521945, lng: 126.69714785332026 },\n  { lat: 33.338173873221066, lng: 126.69721729042391 },\n  { lat: 33.33937376241749, lng: 126.69857547883082 },\n  { lat: 33.34000981482751, lng: 126.69929762403822 },\n  { lat: 33.34016257844261, lng: 126.6994670503334 },\n  { lat: 33.34121525910005, lng: 126.70066414492457 },\n  { lat: 33.34231793503568, lng: 126.70191123404547 },\n  { lat: 33.34268734667565, lng: 126.7024111823344 },\n  { lat: 33.343262298766426, lng: 126.70338608479751 },\n  { lat: 33.34345117199248, lng: 126.70371105234808 },\n  { lat: 33.343634490321, lng: 126.70403879756172 },\n  { lat: 33.344006682719915, lng: 126.70474428353003 },\n  { lat: 33.344156673040224, lng: 126.70517479837201 },\n  { lat: 33.34421500406295, lng: 126.70543310799609 },\n  { lat: 33.34427889322638, lng: 126.70589140016374 },\n  { lat: 33.34428723319186, lng: 126.70636358127811 },\n  { lat: 33.34407617191831, lng: 126.70802177570894 },\n  { lat: 33.34405117805582, lng: 126.70823286909491 },\n  { lat: 33.34385400236182, lng: 126.70977996172948 },\n  { lat: 33.343812345495195, lng: 126.71010493451479 },\n  { lat: 33.34373736346423, lng: 126.71071043930301 },\n  { lat: 33.34358739478156, lng: 126.71163258502291 },\n  { lat: 33.34356517625759, lng: 126.71171035656606 },\n  { lat: 33.34341797904738, lng: 126.71225753471619 },\n  { lat: 33.34322356363003, lng: 126.71272972043741 },\n  { lat: 33.342656976738276, lng: 126.71380464016126 },\n  { lat: 33.34254310558106, lng: 126.71412683705081 },\n  { lat: 33.34242923775688, lng: 126.7146573492229 },\n  { lat: 33.34239591365962, lng: 126.71500454211733 },\n  { lat: 33.34239036569487, lng: 126.71544061556823 },\n  { lat: 33.34241537075602, lng: 126.71592946153028 },\n  { lat: 33.34245704717685, lng: 126.71682660506798 },\n  { lat: 33.34245982549234, lng: 126.71687937820985 },\n  { lat: 33.34249316594498, lng: 126.71755431896894 },\n  { lat: 33.342534841700164, lng: 126.7184097994508 },\n  { lat: 33.34255151101563, lng: 126.71869033031992 },\n  { lat: 33.34256540192669, lng: 126.71891253297292 },\n  { lat: 33.34266263754949, lng: 126.7204207334138 },\n  { lat: 33.34270152578907, lng: 126.72064849057334 },\n  { lat: 33.34276263384144, lng: 126.7208790247652 },\n  { lat: 33.342834851289, lng: 126.72107900579647 },\n  { lat: 33.34284318396883, lng: 126.72109567082983 },\n  { lat: 33.342940398566846, lng: 126.72129009621898 },\n  { lat: 33.343112605949756, lng: 126.72155118079498 },\n  { lat: 33.34331814231831, lng: 126.72177060155704 },\n  { lat: 33.343543120139444, lng: 126.7219372486722 },\n  { lat: 33.343740322183265, lng: 126.72203723552771 },\n  { lat: 33.34401807062026, lng: 126.72212055533043 },\n  { lat: 33.34499574224577, lng: 126.72223441213802 },\n  { lat: 33.34568733382086, lng: 126.7223149449996 },\n  { lat: 33.3459095322587, lng: 126.7223621580814 },\n  { lat: 33.346226165822806, lng: 126.72247880744483 },\n  { lat: 33.34626227321288, lng: 126.72249547184697 },\n  { lat: 33.346431700368036, lng: 126.72258434918426 },\n  { lat: 33.346528912698595, lng: 126.72263712018018 },\n  { lat: 33.34745937705087, lng: 126.72335925868205 },\n  { lat: 33.347681577533635, lng: 126.72353423846971 },\n  { lat: 33.34828707390799, lng: 126.72401473862716 },\n  { lat: 33.34978414977264, lng: 126.72520071294184 },\n  { lat: 33.35015356066589, lng: 126.72565344309186 },\n  { lat: 33.350322992579095, lng: 126.72603951689703 },\n  { lat: 33.35039798963306, lng: 126.72637281964396 },\n  { lat: 33.350434102715575, lng: 126.72674500879421 },\n  { lat: 33.350461911493824, lng: 126.72887260158086 },\n  { lat: 33.35047302555878, lng: 126.7291336898158 },\n  { lat: 33.35049802635616, lng: 126.72935589221561 },\n  { lat: 33.35053691397491, lng: 126.72954476385449 },\n  { lat: 33.350611909650944, lng: 126.72979196295162 },\n  { lat: 33.35073412326836, lng: 126.73009748925436 },\n  { lat: 33.35077578653454, lng: 126.73017248180889 },\n  { lat: 33.352236778724816, lng: 126.73282221898532 },\n  { lat: 33.35281450991216, lng: 126.7338971127062 },\n  { lat: 33.35301727010085, lng: 126.7341970821022 },\n  { lat: 33.35319780900882, lng: 126.73440261574191 },\n  { lat: 33.353400567150956, lng: 126.73457481843097 },\n  { lat: 33.35362276701175, lng: 126.7347109126954 },\n  { lat: 33.35385885347116, lng: 126.73479978851316 },\n  { lat: 33.35410327198759, lng: 126.73486644384445 },\n  { lat: 33.354617105057926, lng: 126.73492476043654 },\n  { lat: 33.35524759106458, lng: 126.73493030117001 },\n  { lat: 33.355939182500535, lng: 126.73500250140341 },\n  { lat: 33.35628081259022, lng: 126.73507470959689 },\n  { lat: 33.35657800430073, lng: 126.73521913476554 },\n  { lat: 33.35690575117346, lng: 126.73554965422436 },\n  { lat: 33.357380706812364, lng: 126.736055155171 },\n  { lat: 33.357380706812364, lng: 126.736055155171 },\n  { lat: 33.35735015507642, lng: 126.736082931237 },\n  { lat: 33.35733071326895, lng: 126.73611348458734 },\n  { lat: 33.35731682644797, lng: 126.73614681534839 },\n  { lat: 33.35730849470248, lng: 126.73618847859436 },\n  { lat: 33.357311272840974, lng: 126.73623014158758 },\n  { lat: 33.35732238330345, lng: 126.73626624931707 },\n  { lat: 33.35733627110342, lng: 126.73629402437209 },\n  { lat: 33.35736404621387, lng: 126.73631902157408 },\n  { lat: 33.3573973761773, lng: 126.7363384635755 },\n  { lat: 33.35742792853624, lng: 126.73634957302883 },\n  { lat: 33.357458480761686, lng: 126.73635234987087 },\n  { lat: 33.3574973652666, lng: 126.73634679391206 },\n  { lat: 33.357527917269536, lng: 126.73633568306863 },\n  { lat: 33.35755569166797, lng: 126.7363162396771 },\n  { lat: 33.357586243314906, lng: 126.73628290853691 },\n  { lat: 33.35818618857518, lng: 126.73700227699536 },\n  { lat: 33.358236183987586, lng: 126.73706060413689 },\n  { lat: 33.35931941836838, lng: 126.73835213423921 },\n  { lat: 33.35952495504912, lng: 126.73859099775245 },\n  { lat: 33.35956939543056, lng: 126.73864376994602 },\n  { lat: 33.359827705128275, lng: 126.73894929314852 },\n  { lat: 33.35985825788774, lng: 126.73898540043551 },\n  { lat: 33.360038797730226, lng: 126.73924926235112 },\n  { lat: 33.36020267323642, lng: 126.73954367755388 },\n  { lat: 33.360294333695855, lng: 126.73978809873225 },\n  { lat: 33.3603637746545, lng: 126.7400491856388 },\n  { lat: 33.360416551722025, lng: 126.74036860120354 },\n  { lat: 33.360488781680566, lng: 126.74134907015421 },\n  { lat: 33.36065268901627, lng: 126.74362942438056 },\n  { lat: 33.36071657770488, lng: 126.74405716363965 },\n  { lat: 33.36082212863656, lng: 126.74449601209892 },\n  { lat: 33.360858237807356, lng: 126.7446237779836 },\n  { lat: 33.36088879199166, lng: 126.74474876645763 },\n  { lat: 33.36092212355793, lng: 126.74486819979425 },\n  { lat: 33.361008229877854, lng: 126.7451626167669 },\n  { lat: 33.36173596792791, lng: 126.74769849157431 },\n  { lat: 33.36176930007333, lng: 126.74785403289324 },\n  { lat: 33.36178874361703, lng: 126.74793180348946 },\n  { lat: 33.36180263221876, lng: 126.74800957421216 },\n  { lat: 33.36184151952883, lng: 126.74817900309013 },\n  { lat: 33.36191651667806, lng: 126.74851786090932 },\n  { lat: 33.362066513915806, lng: 126.74937889399656 },\n  { lat: 33.36214151226771, lng: 126.74979274531762 },\n  { lat: 33.36217762255236, lng: 126.74998994962992 },\n  { lat: 33.362213733015174, lng: 126.75019826409064 },\n  { lat: 33.36229984276545, lng: 126.75070655142038 },\n  { lat: 33.36258872606901, lng: 126.75234806927276 },\n  { lat: 33.36263872486701, lng: 126.75261748923371 },\n  { lat: 33.36270261066371, lng: 126.75286468858158 },\n  { lat: 33.362777606388974, lng: 126.75311466521356 },\n  { lat: 33.36285815678903, lng: 126.75334797649639 },\n  { lat: 33.36303036676062, lng: 126.75377015821526 },\n  { lat: 33.3630942506423, lng: 126.75389792346743 },\n  { lat: 33.36321924062291, lng: 126.75413401127526 },\n  { lat: 33.36367753625376, lng: 126.75493948659954 },\n  { lat: 33.364763557924675, lng: 126.75684207478362 },\n  { lat: 33.36540794919202, lng: 126.75796418509725 },\n  { lat: 33.36561904228869, lng: 126.75829470720454 },\n  { lat: 33.365660705289095, lng: 126.75835303453474 },\n  { lat: 33.36573847630337, lng: 126.75846691178454 },\n  { lat: 33.3658523547078, lng: 126.75859745343442 },\n  { lat: 33.36599123048476, lng: 126.75873632712612 },\n  { lat: 33.36613288346538, lng: 126.75885853553184 },\n  { lat: 33.3662717587077, lng: 126.75896407877813 },\n  { lat: 33.36662450122075, lng: 126.75919460632184 },\n  { lat: 33.36715222534128, lng: 126.75948345815786 },\n  { lat: 33.367402200002, lng: 126.75962510685494 },\n  { lat: 33.36774660987905, lng: 126.75984174690213 },\n  { lat: 33.36774660987905, lng: 126.75984174690213 },\n  { lat: 33.36802157531757, lng: 126.75958065214915 },\n  { lat: 33.36923808813328, lng: 126.75836684071724 },\n  { lat: 33.37033517161096, lng: 126.75727246610379 },\n  { lat: 33.37056847476107, lng: 126.7569974846156 },\n  { lat: 33.37098786499033, lng: 126.75650585099429 },\n  { lat: 33.371468357575495, lng: 126.7558892268121 },\n  { lat: 33.37171832408068, lng: 126.75552258622088 },\n  { lat: 33.37195995701452, lng: 126.75508372985546 },\n  { lat: 33.37272651084098, lng: 126.75332830895269 },\n  { lat: 33.37351250772153, lng: 126.75162010574114 },\n  { lat: 33.373623603396446, lng: 126.7514228980776 },\n  { lat: 33.37403188178023, lng: 126.75080905308222 },\n  { lat: 33.37424851956903, lng: 126.75050074153147 },\n  { lat: 33.37465402101614, lng: 126.74992022704501 },\n  { lat: 33.37514284480933, lng: 126.749228609178 },\n  { lat: 33.37543169595124, lng: 126.7488647452414 },\n  { lat: 33.3756983278601, lng: 126.74853421225598 },\n  { lat: 33.37617326697064, lng: 126.7480092469305 },\n  { lat: 33.377228687215016, lng: 126.74684265731979 },\n  { lat: 33.37733700671029, lng: 126.74672599829555 },\n  { lat: 33.379053453272086, lng: 126.74482612384551 },\n  { lat: 33.37923676310096, lng: 126.74462335946463 },\n  { lat: 33.379811689424635, lng: 126.74399006791938 },\n  { lat: 33.38069768692195, lng: 126.74300957715731 },\n  { lat: 33.38220582195489, lng: 126.74103471396364 },\n  { lat: 33.383086259296384, lng: 126.73972925149992 },\n  { lat: 33.38375561250129, lng: 126.73865155188366 },\n  { lat: 33.3840861220956, lng: 126.73806270650464 },\n  { lat: 33.384538833951424, lng: 126.73707111547391 },\n  { lat: 33.38490544599118, lng: 126.7361906278866 },\n  { lat: 33.38610804448119, lng: 126.73329640692153 },\n  { lat: 33.386163592117555, lng: 126.73318530417576 },\n  { lat: 33.386527429402875, lng: 126.73247424641342 },\n  { lat: 33.38680516918757, lng: 126.73201872401756 },\n  { lat: 33.38768282949984, lng: 126.73074103700354 },\n  { lat: 33.388143877730606, lng: 126.7299966465853 },\n  { lat: 33.38832163003313, lng: 126.72963278518952 },\n  { lat: 33.388624364428225, lng: 126.7290133875433 },\n  { lat: 33.38979363691829, lng: 126.72634692539718 },\n  { lat: 33.390165807016125, lng: 126.72566086528444 },\n  { lat: 33.39049354100815, lng: 126.72518867653227 },\n  { lat: 33.39054631188582, lng: 126.72512201444272 },\n  { lat: 33.390721289076765, lng: 126.72490536257261 },\n  { lat: 33.39213222026992, lng: 126.72340823800434 },\n  { lat: 33.393020995623964, lng: 126.72245274504175 },\n  { lat: 33.3934265002669, lng: 126.72207221324061 },\n  { lat: 33.39347927158948, lng: 126.72203332652106 },\n  { lat: 33.393751460436725, lng: 126.72182778258572 },\n  { lat: 33.39395421340345, lng: 126.72167779097136 },\n  { lat: 33.39447915111636, lng: 126.72141113546603 },\n  { lat: 33.39514018503266, lng: 126.72115280947175 },\n  { lat: 33.399498011631245, lng: 126.71958617936063 },\n  { lat: 33.39977298008325, lng: 126.71951395713593 },\n  { lat: 33.40013127292626, lng: 126.71945839823468 },\n  { lat: 33.40022015176928, lng: 126.71944450852516 },\n  { lat: 33.40051178593611, lng: 126.71942783665963 },\n  { lat: 33.40438913676305, lng: 126.71952773964884 },\n  { lat: 33.40442802139854, lng: 126.71953051629974 },\n  { lat: 33.40989409028974, lng: 126.7199303570495 },\n  { lat: 33.41054957503669, lng: 126.72003311097514 },\n  { lat: 33.411252278210945, lng: 126.72022474500527 },\n  { lat: 33.411577244070614, lng: 126.72033583907455 },\n  { lat: 33.41449360822043, lng: 126.72157455405424 },\n  { lat: 33.41528241525612, lng: 126.72190784049887 },\n  { lat: 33.416337860862846, lng: 126.72232722450663 },\n  { lat: 33.417471076228054, lng: 126.72278271471869 },\n  { lat: 33.41889870429107, lng: 126.7232881938783 },\n  { lat: 33.419920819354175, lng: 126.7236520279013 },\n  { lat: 33.42082905762654, lng: 126.723985311614 },\n  { lat: 33.42115680113182, lng: 126.72410751576199 },\n  { lat: 33.422031708685125, lng: 126.72437413934743 },\n  { lat: 33.42334267726817, lng: 126.72452409643452 },\n  { lat: 33.42582018247524, lng: 126.72460736601981 },\n  { lat: 33.42798938824839, lng: 126.724679532485 },\n  { lat: 33.43490251646729, lng: 126.7249099102751 },\n  { lat: 33.43490251646729, lng: 126.7249099102751 },\n  { lat: 33.434891417905256, lng: 126.7256154048917 },\n  { lat: 33.434819252386134, lng: 126.72865125433411 },\n  { lat: 33.43474153652386, lng: 126.73197319020736 },\n  { lat: 33.43472210822823, lng: 126.73284533722992 },\n  { lat: 33.43470823193182, lng: 126.73353416669227 },\n  { lat: 33.43471379030625, lng: 126.73374803690494 },\n  { lat: 33.43475545664664, lng: 126.73401467949381 },\n  { lat: 33.434861004991, lng: 126.73429243077146 },\n  { lat: 33.43496655235448, lng: 126.73450907623779 },\n  { lat: 33.435185979270656, lng: 126.73492847929565 },\n  { lat: 33.435352632995624, lng: 126.73527011252597 },\n  { lat: 33.43552761926647, lng: 126.73562007817654 },\n  { lat: 33.43570538323105, lng: 126.735983931448 },\n  { lat: 33.43574982418877, lng: 126.7360728116132 },\n  { lat: 33.435994248475275, lng: 126.73650054671033 },\n  { lat: 33.43604146681451, lng: 126.7365838717383 },\n  { lat: 33.436105350468324, lng: 126.73669774929132 },\n  { lat: 33.43618867766548, lng: 126.73688939743286 },\n  { lat: 33.43632755721934, lng: 126.73726436173963 },\n  { lat: 33.436505323101976, lng: 126.7377476490965 },\n  { lat: 33.43889683422828, lng: 126.7445275619914 },\n  { lat: 33.43900238284266, lng: 126.74482197848938 },\n  { lat: 33.439202369073605, lng: 126.74534137331705 },\n  { lat: 33.43921903456684, lng: 126.7453830359895 },\n  { lat: 33.439549565845695, lng: 126.74614685107977 },\n  { lat: 33.44040505670602, lng: 126.74800778124222 },\n  { lat: 33.44135776230006, lng: 126.7500714693734 },\n  { lat: 33.442374354542835, lng: 126.7524351300256 },\n  { lat: 33.44267988510859, lng: 126.75298229780753 },\n  { lat: 33.443168732800174, lng: 126.75378221725472 },\n  { lat: 33.4451796751269, lng: 126.75711521553713 },\n  { lat: 33.44530466496741, lng: 126.7573429707035 },\n  { lat: 33.446812902304636, lng: 126.7617453321765 },\n  { lat: 33.44727120599632, lng: 126.76305354156815 },\n  { lat: 33.447421194770854, lng: 126.76338684256383 },\n  { lat: 33.44758784752089, lng: 126.76366736997724 },\n  { lat: 33.447751722219756, lng: 126.76391178947452 },\n  { lat: 33.447998923092776, lng: 126.7642839737623 },\n  { lat: 33.44811557989733, lng: 126.76447284360191 },\n  { lat: 33.44841833197011, lng: 126.76495612809408 },\n  { lat: 33.45009318862699, lng: 126.76755864183629 },\n  { lat: 33.45016818189806, lng: 126.76765585391126 },\n  { lat: 33.45040149349785, lng: 126.76790860442891 },\n  { lat: 33.450840339980736, lng: 126.7682891169394 },\n  { lat: 33.45206244279133, lng: 126.76926678195315 },\n  { lat: 33.454006697216975, lng: 126.77081938056733 },\n  { lat: 33.45433721979765, lng: 126.77104157595147 },\n  { lat: 33.45464829954761, lng: 126.77123044133786 },\n  { lat: 33.45528156817025, lng: 126.77156095372865 },\n  { lat: 33.456437005695506, lng: 126.77216643031406 },\n  { lat: 33.45790629999257, lng: 126.77293299684868 },\n  { lat: 33.45808128166994, lng: 126.77299687618986 },\n  { lat: 33.4581951585466, lng: 126.77303298156151 },\n  { lat: 33.45852290119771, lng: 126.77310241247807 },\n  { lat: 33.459336702280304, lng: 126.77323849314811 },\n  { lat: 33.45947835363955, lng: 126.77326071019907 },\n  { lat: 33.45948946352204, lng: 126.77326070994464 },\n  { lat: 33.459717216023584, lng: 126.7732551496549 },\n  { lat: 33.460783762148886, lng: 126.77309402808953 },\n  { lat: 33.463708431639446, lng: 126.77265511027609 },\n  { lat: 33.464125050888995, lng: 126.77257177462705 },\n  { lat: 33.46482496994931, lng: 126.7723523331822 },\n  { lat: 33.4651360455389, lng: 126.77228288763487 },\n  { lat: 33.46524714472035, lng: 126.77230510538475 },\n  { lat: 33.4653554664312, lng: 126.77232732319824 },\n  { lat: 33.46547767585258, lng: 126.77237176098845 },\n  { lat: 33.4655221156503, lng: 126.77238842519158 },\n  { lat: 33.46572487221059, lng: 126.772463414042 },\n  { lat: 33.46685531051954, lng: 126.7729466795578 },\n  { lat: 33.46693863557597, lng: 126.7730050059228 },\n  { lat: 33.46711084152399, lng: 126.77317720926253 },\n  { lat: 33.46766079044559, lng: 126.77378269969695 },\n  { lat: 33.468216292699125, lng: 126.77428819867663 },\n  { lat: 33.46992445932752, lng: 126.7756685953507 },\n  { lat: 33.47015499238127, lng: 126.77585468503675 },\n  { lat: 33.47051051322067, lng: 126.77614076318496 },\n  { lat: 33.47069938372386, lng: 126.77629630092015 },\n  { lat: 33.4707132712556, lng: 126.7763074107493 },\n  { lat: 33.470816039008355, lng: 126.7763907344996 },\n  { lat: 33.47101879655144, lng: 126.77652682915844 },\n  { lat: 33.471068791379544, lng: 126.77654904830746 },\n  { lat: 33.47121044340739, lng: 126.77661292840857 },\n  { lat: 33.47127432567779, lng: 126.77664070231296 },\n  { lat: 33.47136042771282, lng: 126.7766684757081 },\n  { lat: 33.47229366261045, lng: 126.7769656507617 },\n  { lat: 33.47251308475315, lng: 126.77708785735373 },\n  { lat: 33.472757504443976, lng: 126.77722950613065 },\n  { lat: 33.473171352298955, lng: 126.7775239155504 },\n  { lat: 33.473351890255984, lng: 126.77767112086497 },\n  { lat: 33.47461010079022, lng: 126.7786876705078 },\n  { lat: 33.47540169014235, lng: 126.77932370829751 },\n  { lat: 33.475582228322864, lng: 126.77948480129533 },\n  { lat: 33.47619606085584, lng: 126.78020139172719 },\n  { lat: 33.476482145735375, lng: 126.78053746712564 },\n  { lat: 33.47657380387401, lng: 126.78063745635035 },\n  { lat: 33.47689044177957, lng: 126.78102630424796 },\n  { lat: 33.47747649956262, lng: 126.78174011777774 },\n  { lat: 33.478287537113026, lng: 126.7827427899787 },\n  { lat: 33.478512516746015, lng: 126.78302331604011 },\n  { lat: 33.478512516746015, lng: 126.78302331604011 },\n  { lat: 33.47850418464748, lng: 126.78304275898907 },\n  { lat: 33.4784958526384, lng: 126.78306775701171 },\n  { lat: 33.478493075525705, lng: 126.78308997737012 },\n  { lat: 33.478495853488326, lng: 126.78312053021166 },\n  { lat: 33.47850418621313, lng: 126.78313997277844 },\n  { lat: 33.47851529645322, lng: 126.78316219281837 },\n  { lat: 33.478531961589674, lng: 126.78318163519405 },\n  { lat: 33.47855418157779, lng: 126.78319552236869 },\n  { lat: 33.47857084649059, lng: 126.78320107706018 },\n  { lat: 33.47859584377032, lng: 126.78320385402375 },\n  { lat: 33.4786097311231, lng: 126.78320385370527 },\n  { lat: 33.4786263959017, lng: 126.78320107578624 },\n  { lat: 33.47864028316501, lng: 126.78319552039407 },\n  { lat: 33.47865417033885, lng: 126.78318440992824 },\n  { lat: 33.47866805742323, lng: 126.78316774438872 },\n  { lat: 33.47867638943229, lng: 126.78314274636608 },\n  { lat: 33.478681943970805, lng: 126.78311774840714 },\n  { lat: 33.478681943389276, lng: 126.78308164042824 },\n  { lat: 33.47877360014124, lng: 126.78309552601044 },\n  { lat: 33.47917078008558, lng: 126.78319828576463 },\n  { lat: 33.47961795490226, lng: 126.78332604220348 },\n  { lat: 33.47968461455337, lng: 126.78334826096936 },\n  { lat: 33.479751274472875, lng: 126.78338714495624 },\n  { lat: 33.481498328858635, lng: 126.78496474580749 },\n  { lat: 33.48163442742082, lng: 126.78512028474832 },\n  { lat: 33.48191495794116, lng: 126.78549246824906 },\n  { lat: 33.48207327698359, lng: 126.78569244726937 },\n  { lat: 33.482131605073064, lng: 126.78576743942587 },\n  { lat: 33.48233158693431, lng: 126.78601463561651 },\n  { lat: 33.48382588895036, lng: 126.78743392265615 },\n  { lat: 33.48416196803157, lng: 126.78775333168092 },\n  { lat: 33.484300844288136, lng: 126.78792275824125 },\n  { lat: 33.48462581782798, lng: 126.78851158859314 },\n  { lat: 33.48469803412048, lng: 126.78863935363037 },\n  { lat: 33.48540075466283, lng: 126.78991144937444 },\n  { lat: 33.48540908725345, lng: 126.78992255933056 },\n  { lat: 33.4854813033223, lng: 126.79003643668348 },\n  { lat: 33.48548685844239, lng: 126.79004754670333 },\n  { lat: 33.485522966678225, lng: 126.79011698429554 },\n  { lat: 33.48612569519441, lng: 126.79119743229136 },\n  { lat: 33.48615902578073, lng: 126.79125575979992 },\n  { lat: 33.486267350085576, lng: 126.7914390747448 },\n  { lat: 33.486336787923115, lng: 126.79150573403517 },\n  { lat: 33.486411780791244, lng: 126.7915779482717 },\n  { lat: 33.48658398544134, lng: 126.79166960303492 },\n  { lat: 33.48704504899614, lng: 126.79188346278896 },\n  { lat: 33.487389457893265, lng: 126.79204177448347 },\n  { lat: 33.487478338114, lng: 126.79211398840104 },\n  { lat: 33.4875394436742, lng: 126.79218898049295 },\n  { lat: 33.48798940889531, lng: 126.79311944500304 },\n  { lat: 33.48832271650506, lng: 126.79381104402302 },\n  { lat: 33.48837549135485, lng: 126.79399158270559 },\n  { lat: 33.48863103816187, lng: 126.79520258289796 },\n  { lat: 33.48870881123155, lng: 126.79544422681697 },\n  { lat: 33.48883935700238, lng: 126.79573308765059 },\n  { lat: 33.4889310157683, lng: 126.79587196238796 },\n  { lat: 33.489600399283326, lng: 126.7966857653118 },\n  { lat: 33.49081141198383, lng: 126.79889110175061 },\n  { lat: 33.491150273149515, lng: 126.79949659699874 },\n  { lat: 33.492094644295506, lng: 126.80142974094672 },\n  { lat: 33.49234184736762, lng: 126.80193802450967 },\n  { lat: 33.49247517034168, lng: 126.8022102200572 },\n  { lat: 33.492794590021376, lng: 126.80286571141336 },\n  { lat: 33.493039015533945, lng: 126.80336843996598 },\n  { lat: 33.493105676618, lng: 126.80347953990814 },\n  { lat: 33.493258440094415, lng: 126.8036406335355 },\n  { lat: 33.49428333871969, lng: 126.80438498986341 },\n  { lat: 33.49441943692505, lng: 126.80451830850484 },\n  { lat: 33.49449443064468, lng: 126.80464329593947 },\n  { lat: 33.494505541109056, lng: 126.80467940366306 },\n  { lat: 33.49458053639649, lng: 126.80490160488678 },\n  { lat: 33.49464442269781, lng: 126.80517935710236 },\n  { lat: 33.4946777558828, lng: 126.80539878174636 },\n  { lat: 33.49463887909002, lng: 126.80588207404855 },\n  { lat: 33.494641658800354, lng: 126.8060209508264 },\n  { lat: 33.49466110369217, lng: 126.80618204751605 },\n  { lat: 33.494722211627305, lng: 126.80640424905886 },\n  { lat: 33.49474998879633, lng: 126.8065570129466 },\n  { lat: 33.49476387829921, lng: 126.80669033439557 },\n  { lat: 33.49474167269145, lng: 126.80756803654585 },\n  { lat: 33.49474167269145, lng: 126.80756803654585 },\n  { lat: 33.49477222782394, lng: 126.80775135327502 },\n  { lat: 33.494858334830845, lng: 126.80808743325385 },\n  { lat: 33.49488611137286, lng: 126.80820131162596 },\n  { lat: 33.49518054193035, lng: 126.809359537722 },\n  { lat: 33.49524442738139, lng: 126.80958451673793 },\n  { lat: 33.49531664439273, lng: 126.80975672236245 },\n  { lat: 33.49654988010577, lng: 126.81216204092425 },\n  { lat: 33.49654988010577, lng: 126.81216204092425 },\n  { lat: 33.496644314865094, lng: 126.81220925688015 },\n  { lat: 33.49686095926813, lng: 126.81231479830087 },\n  { lat: 33.49716648299645, lng: 126.81243700289977 },\n  { lat: 33.497413679575025, lng: 126.81254254361808 },\n  { lat: 33.49754977679529, lng: 126.81261475644776 },\n  { lat: 33.49885797867078, lng: 126.81343687728165 },\n  { lat: 33.49898574379308, lng: 126.81352853306029 },\n  { lat: 33.49918294760518, lng: 126.8137396213266 },\n  { lat: 33.499938433297224, lng: 126.8145895302307 },\n  { lat: 33.50071058977386, lng: 126.81580885114892 },\n  { lat: 33.501388309788986, lng: 126.81687540971099 },\n  { lat: 33.501554962232724, lng: 126.8171364943415 },\n  { lat: 33.50176327789953, lng: 126.81746979397164 },\n  { lat: 33.50208269476233, lng: 126.81795030049909 },\n  { lat: 33.50324648188954, lng: 126.81962235091011 },\n  { lat: 33.50351312510982, lng: 126.81999731225032 },\n  { lat: 33.503632560062435, lng: 126.82022784506064 },\n  { lat: 33.50369366634126, lng: 126.8203472777391 },\n  { lat: 33.503735330192114, lng: 126.8204583782542 },\n  { lat: 33.50384087855454, lng: 126.82073612951001 },\n  { lat: 33.503876986881565, lng: 126.82081112217404 },\n  { lat: 33.50398253443701, lng: 126.82103887776682 },\n  { lat: 33.504124189064115, lng: 126.82126385499227 },\n  { lat: 33.50538240857726, lng: 126.82283591189845 },\n  { lat: 33.50547684499548, lng: 126.82298589671505 },\n  { lat: 33.50680728721869, lng: 126.82508290641637 },\n  { lat: 33.506921166244496, lng: 126.82525233354309 },\n  { lat: 33.50722669441167, lng: 126.8256495142806 },\n  { lat: 33.5074516737371, lng: 126.82591059756638 },\n  { lat: 33.50751555645533, lng: 126.82596614683315 },\n  { lat: 33.5076516545275, lng: 126.8260911328591 },\n  { lat: 33.50784608006296, lng: 126.82625222552174 },\n  { lat: 33.508190491022994, lng: 126.8265383038905 },\n  { lat: 33.508296036336716, lng: 126.82662718264048 },\n  { lat: 33.50844602131277, lng: 126.8267243929783 },\n  { lat: 33.50860989346181, lng: 126.8268104928492 },\n  { lat: 33.508893197424726, lng: 126.82693269795057 },\n  { lat: 33.51062357104325, lng: 126.82752149593742 },\n  { lat: 33.51076522311414, lng: 126.82758815356124 },\n  { lat: 33.51084854834986, lng: 126.8276575900642 },\n  { lat: 33.51089576593111, lng: 126.82769369695625 },\n  { lat: 33.5109624267477, lng: 126.82778813167415 },\n  { lat: 33.51134295105869, lng: 126.82846028682813 },\n  { lat: 33.51155682238625, lng: 126.82883802691397 },\n  { lat: 33.51172069713687, lng: 126.82908522391962 },\n  { lat: 33.51197345062867, lng: 126.82931297612168 },\n  { lat: 33.51307056362305, lng: 126.8300656633474 },\n  { lat: 33.51309278356627, lng: 126.8300767729832 },\n  { lat: 33.513559404168426, lng: 126.83042117680776 },\n  { lat: 33.51523701602694, lng: 126.83164047685075 },\n  { lat: 33.51563419901452, lng: 126.8319321090722 },\n  { lat: 33.515814737690675, lng: 126.83212375495597 },\n  { lat: 33.516448014686894, lng: 126.83297366664132 },\n  { lat: 33.516667439250924, lng: 126.83324586019705 },\n  { lat: 33.516753542091664, lng: 126.83332362924533 },\n  { lat: 33.51687852965345, lng: 126.83340973000863 },\n  { lat: 33.51690630453754, lng: 126.83342083951634 },\n  { lat: 33.51734237049585, lng: 126.83361247951531 },\n  { lat: 33.51736181296856, lng: 126.83362358921485 },\n  { lat: 33.51749513276215, lng: 126.83369857963886 },\n  { lat: 33.51817006423102, lng: 126.83407908664005 },\n  { lat: 33.51884221854326, lng: 126.83447903646196 },\n  { lat: 33.51944493501678, lng: 126.83481232699862 },\n  { lat: 33.51957547693578, lng: 126.8348623196547 },\n  { lat: 33.52004764834822, lng: 126.83495118995772 },\n  { lat: 33.520680913180854, lng: 126.83504838916038 },\n  { lat: 33.52078645737194, lng: 126.83506782948703 },\n  { lat: 33.52107809271113, lng: 126.83512615104272 },\n  { lat: 33.521364173558155, lng: 126.83521224809428 },\n  { lat: 33.52154471048344, lng: 126.83529557004006 },\n  { lat: 33.5221085423243, lng: 126.83562608393257 },\n  { lat: 33.52237240452968, lng: 126.83578161991561 },\n  { lat: 33.52254738651393, lng: 126.83586494198897 },\n  { lat: 33.522780695152896, lng: 126.83593437503461 },\n  { lat: 33.52292512388496, lng: 126.83595103692834 },\n  { lat: 33.52317231897825, lng: 126.83596491891782 },\n  { lat: 33.52330563796315, lng: 126.8359899136778 },\n  { lat: 33.52346395503483, lng: 126.8360676810612 },\n  { lat: 33.52418332562298, lng: 126.83642318919942 },\n  { lat: 33.52432775552194, lng: 126.83651206704968 },\n  { lat: 33.52459439596039, lng: 126.83671482109322 },\n  { lat: 33.52503046456412, lng: 126.83707033575715 },\n  { lat: 33.52520544654794, lng: 126.83715365782959 },\n  { lat: 33.52593314927703, lng: 126.83749250055244 },\n  { lat: 33.52675251149277, lng: 126.83801465859094 },\n  { lat: 33.527013597124714, lng: 126.83822574537143 },\n  { lat: 33.52732745613139, lng: 126.8385257121132 },\n  { lat: 33.52738022865308, lng: 126.83856181887549 },\n  { lat: 33.52748577378626, lng: 126.83863958747362 },\n  { lat: 33.52767742045758, lng: 126.83871457655076 },\n  { lat: 33.52793572619445, lng: 126.83877567640769 },\n  { lat: 33.52841067547696, lng: 126.83888954447097 },\n  { lat: 33.528482890381326, lng: 126.83893120585867 },\n  { lat: 33.52864398626977, lng: 126.83909229928022 },\n  { lat: 33.52879952685818, lng: 126.83923117253538 },\n  { lat: 33.529066168104194, lng: 126.83948392223854 },\n  { lat: 33.529066168104194, lng: 126.83948392223854 },\n  { lat: 33.52909949712056, lng: 126.83944503595528 },\n  { lat: 33.529280029374824, lng: 126.83923949407172 },\n  { lat: 33.53002437743626, lng: 126.8383728854374 },\n  { lat: 33.53009659027501, lng: 126.83828678013259 },\n  { lat: 33.53025212569943, lng: 126.8381062366566 },\n  { lat: 33.530610412602165, lng: 126.83768682034402 },\n  { lat: 33.530929815682335, lng: 126.83731462305323 },\n  { lat: 33.53102424792503, lng: 126.83720629694218 },\n  { lat: 33.53123810906033, lng: 126.83695353616584 },\n  { lat: 33.53128810258472, lng: 126.83689520674116 },\n  { lat: 33.532126882661956, lng: 126.8359063843151 },\n  { lat: 33.53300176922472, lng: 126.83487867554346 },\n  { lat: 33.53374889448301, lng: 126.8339954016301 },\n  { lat: 33.5339377588778, lng: 126.83377319433555 },\n  { lat: 33.534854306820534, lng: 126.8327038215555 },\n  { lat: 33.53529869310281, lng: 126.83214830396041 },\n  { lat: 33.5355958763728, lng: 126.83177332964803 },\n  { lat: 33.53580973656386, lng: 126.8314622406017 },\n  { lat: 33.53616802184747, lng: 126.8309428329699 },\n  { lat: 33.53674016678304, lng: 126.83007900585166 },\n  { lat: 33.53688181431535, lng: 126.82986513225684 },\n  { lat: 33.53735675007697, lng: 126.82914296175474 },\n  { lat: 33.53742896255582, lng: 126.82903463615709 },\n  { lat: 33.53793167239234, lng: 126.82827357950123 },\n  { lat: 33.538298289457686, lng: 126.82771528616529 },\n  { lat: 33.53905096552132, lng: 126.82657647875273 },\n  { lat: 33.53905374294679, lng: 126.82657370115197 },\n  { lat: 33.5391676163138, lng: 126.82639315863923 },\n  { lat: 33.539456466004125, lng: 126.82594596856671 },\n  { lat: 33.53959255864017, lng: 126.82573487263791 },\n  { lat: 33.53975642400473, lng: 126.8254015644525 },\n  { lat: 33.539950839074415, lng: 126.82491549104249 },\n  { lat: 33.54007859431704, lng: 126.82439608872804 },\n  { lat: 33.54012580695917, lng: 126.82412666657636 },\n  { lat: 33.54013136140613, lng: 126.82409611354423 },\n  { lat: 33.54016468669655, lng: 126.82382669171265 },\n  { lat: 33.54040907329781, lng: 126.82192129588019 },\n  { lat: 33.540453507392826, lng: 126.82158521291153 },\n  { lat: 33.540567369225826, lng: 126.82069084346006 },\n  { lat: 33.540628468188025, lng: 126.8203575376445 },\n  { lat: 33.540706231882595, lng: 126.82001867637163 },\n  { lat: 33.540858984853166, lng: 126.81952982638768 },\n  { lat: 33.54102840385753, lng: 126.81911596951167 },\n  { lat: 33.541220042624644, lng: 126.81870211212393 },\n  { lat: 33.54166719995733, lng: 126.81774940672474 },\n  { lat: 33.54219212372216, lng: 126.81662726979512 },\n  { lat: 33.542219897528064, lng: 126.81657171842082 },\n  { lat: 33.54225322587077, lng: 126.8164911690881 },\n  { lat: 33.542375429898776, lng: 126.8162023024538 },\n  { lat: 33.54251429798597, lng: 126.81586621731148 },\n  { lat: 33.54266982963876, lng: 126.81545236075732 },\n  { lat: 33.542925344255764, lng: 126.8146690895168 },\n  { lat: 33.543419709520784, lng: 126.81315532061923 },\n  { lat: 33.54379187174524, lng: 126.81199152416454 },\n  { lat: 33.54466395323523, lng: 126.8092556304218 },\n  { lat: 33.544780599945334, lng: 126.80881955447293 },\n  { lat: 33.54480837294379, lng: 126.8087140074386 },\n  { lat: 33.54495001549616, lng: 126.80819182727659 },\n  { lat: 33.54500000704595, lng: 126.8080112862397 },\n  { lat: 33.54519997279664, lng: 126.80726134672561 },\n  { lat: 33.54556379956198, lng: 126.80590867797547 },\n  { lat: 33.5457693200296, lng: 126.80514485065164 },\n  { lat: 33.545966508579895, lng: 126.80441157642393 },\n  { lat: 33.54608593424123, lng: 126.80406715912402 },\n  { lat: 33.54623868941169, lng: 126.80371440844657 },\n  { lat: 33.54681083196791, lng: 126.80270337191617 },\n  { lat: 33.54714134223251, lng: 126.80216452218788 },\n  { lat: 33.54747740725818, lng: 126.80161456218532 },\n  { lat: 33.547757924313025, lng: 126.8011534846362 },\n  { lat: 33.548646691261155, lng: 126.79969525741303 },\n  { lat: 33.548663355634424, lng: 126.79966748166251 },\n  { lat: 33.54889943370251, lng: 126.79923973557811 },\n  { lat: 33.54909940523983, lng: 126.79884809830219 },\n  { lat: 33.5493132636801, lng: 126.79842868533999 },\n  { lat: 33.54953823052159, lng: 126.79791761341212 },\n  { lat: 33.55014369671069, lng: 126.79653438620936 },\n  { lat: 33.55014369671069, lng: 126.79653438620936 },\n  { lat: 33.550357559904256, lng: 126.79640939213459 },\n  { lat: 33.550446438549585, lng: 126.79638439225789 },\n  { lat: 33.55052698545672, lng: 126.79640105562301 },\n  { lat: 33.55062697501453, lng: 126.79643993883367 },\n  { lat: 33.55063253116587, lng: 126.79651493219562 },\n  { lat: 33.5506825359899, lng: 126.79715654201327 },\n  { lat: 33.550740869771936, lng: 126.79758428131612 },\n  { lat: 33.550840865204684, lng: 126.79798702182957 },\n  { lat: 33.55093808173216, lng: 126.79830088123373 },\n  { lat: 33.55095196966658, lng: 126.79833698889053 },\n  { lat: 33.551163066018596, lng: 126.79887027106842 },\n  { lat: 33.55145193526123, lng: 126.79963408699814 },\n  { lat: 33.551532485442515, lng: 126.79985351053945 },\n  { lat: 33.55165469924783, lng: 126.80017014690439 },\n  { lat: 33.55201578585826, lng: 126.80112838873697 },\n  { lat: 33.55204911689208, lng: 126.80121449160588 },\n  { lat: 33.55204911689208, lng: 126.80121449160588 },\n  { lat: 33.55280735995289, lng: 126.8008228414697 },\n  { lat: 33.55327397101154, lng: 126.80057840749467 },\n  { lat: 33.55359060046943, lng: 126.80044507844104 },\n  { lat: 33.5541738656824, lng: 126.80022563960866 },\n  { lat: 33.55431551576894, lng: 126.80017008561234 },\n  { lat: 33.55474602025121, lng: 126.79995898290892 },\n  { lat: 33.555098756059195, lng: 126.79977843490028 },\n  { lat: 33.555098756059195, lng: 126.79977843490028 },\n  { lat: 33.55501820533975, lng: 126.79952568092024 },\n  { lat: 33.554962650507115, lng: 126.79918960026471 },\n  { lat: 33.55494598241121, lng: 126.79898684047286 },\n  { lat: 33.55494042634935, lng: 126.79891740218447 },\n  { lat: 33.55494042576625, lng: 126.79888129420797 },\n  { lat: 33.55494319713652, lng: 126.79850354915912 },\n  { lat: 33.554973744241096, lng: 126.79818968681356 },\n  { lat: 33.55500984731784, lng: 126.79793970768311 },\n  { lat: 33.55512093971457, lng: 126.79754251738278 },\n  { lat: 33.55529591370307, lng: 126.79713143792787 },\n  { lat: 33.55537645729078, lng: 126.79694256358034 },\n  { lat: 33.5557152955662, lng: 126.79613151507387 },\n]\n"
  },
  {
    "path": "packages/tds-widget/src/map/mocks/hotel-recommandations.json",
    "content": "[\n  {\n    \"id\": \"a90731f7-30d9-428b-a838-0bbac113f4eb\",\n    \"hotel\": {\n      \"id\": \"a90731f7-30d9-428b-a838-0bbac113f4eb\",\n      \"source\": {\n        \"image\": {\n          \"sourceUrl\": \"https://www.expedia.com/\",\n          \"sizes\": {\n            \"large\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/53d05667-b8dc-4300-8594-766f41310f3e.jpeg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/53d05667-b8dc-4300-8594-766f41310f3e.jpeg\"\n            },\n            \"full\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/53d05667-b8dc-4300-8594-766f41310f3e.jpeg\"\n            }\n          },\n          \"description\": null,\n          \"id\": \"53d05667-b8dc-4300-8594-766f41310f3e\",\n          \"title\": null\n        },\n        \"comment\": \"전용 해변에 맞닿아 있는 메인풀과 다낭 최초의 씨푸드 레스토랑을 보유한 리조트\",\n        \"names\": {\n          \"ko\": \"사쿠라 테라스 더 갤러리\",\n          \"en\": \"Sakura Terrace The Gallery\",\n          \"local\": \"Sakura Terrace The Gallery (サクラテラス ザ ギャラリー)\"\n        },\n        \"accommodations\": [\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"Wi-Fi (공공장소)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/580a5567-ff28-4ef2-92f0-2ccca87d8da4-1508743354.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_S_8\",\n              \"id\": \"580a5567-ff28-4ef2-92f0-2ccca87d8da4\"\n            },\n            \"id\": \"d6f9aff5-a386-4328-8976-1f94a2586951\",\n            \"priority\": 104,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"레스토랑\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/de2aa17b-cad6-44ab-932a-a98d2629abea-1508486864.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_9\",\n              \"id\": \"de2aa17b-cad6-44ab-932a-a98d2629abea\"\n            },\n            \"id\": \"92785515-d973-41b2-829e-9c70e6da19f3\",\n            \"priority\": 509,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": \"502d1d69-2d26-4a06-a8f4-7e8aae02569c\",\n            \"name\": \"아침 식사 가능(요금 별도)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/b066cd01-364d-4c8c-b626-1c505484c9a1-1508230544.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenityFilterBreakfast_S_4\",\n              \"id\": \"b066cd01-364d-4c8c-b626-1c505484c9a1\"\n            },\n            \"id\": \"46ed9af9-0dbd-4b57-8633-be3767bef1f7\",\n            \"priority\": 104,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"바/라운지\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/2b698a57-ec2d-4734-aca8-4f8f19fe2309-1508380168.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_25\",\n              \"id\": \"2b698a57-ec2d-4734-aca8-4f8f19fe2309\"\n            },\n            \"id\": \"23f06900-4854-4cc2-9e15-750bdbd2fe65\",\n            \"priority\": 525,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"커피숍 또는 카페\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/0f810e15-2b1c-4c54-9610-a4d6715e9a24-1508484594.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_5\",\n              \"id\": \"0f810e15-2b1c-4c54-9610-a4d6715e9a24\"\n            },\n            \"id\": \"f0f7d569-a921-48ad-bcf0-e8516bc82ce7\",\n            \"priority\": 505,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"공용 구역에서의 커피/티\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/0f810e15-2b1c-4c54-9610-a4d6715e9a24-1508484594.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_5\",\n              \"id\": \"0f810e15-2b1c-4c54-9610-a4d6715e9a24\"\n            },\n            \"id\": \"b791821d-ba28-493e-a369-de2fb8e7701b\",\n            \"priority\": 505,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"짐 보관 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/a76c7845-7eb6-463a-886f-9e7f77b50f6c-1508317594.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_A_1\",\n              \"id\": \"a76c7845-7eb6-463a-886f-9e7f77b50f6c\"\n            },\n            \"id\": \"ac598d63-1d6b-4bb7-8a93-fdfe54f3db10\",\n            \"priority\": 301,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"24시간 운영 프런트 데스크\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4219f884-8ff8-4440-a778-0ed13612e0f5-1508485560.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_8\",\n              \"id\": \"4219f884-8ff8-4440-a778-0ed13612e0f5\"\n            },\n            \"id\": \"19db72a7-f4a1-4ad4-a032-aede1bf2d91d\",\n            \"priority\": 508,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"콘시어지 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4219f884-8ff8-4440-a778-0ed13612e0f5-1508485560.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_8\",\n              \"id\": \"4219f884-8ff8-4440-a778-0ed13612e0f5\"\n            },\n            \"id\": \"76d03631-52b2-401c-a3e5-2baeb624ca60\",\n            \"priority\": 508,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"프런트 데스크의 안전 금고\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/3c155191-32f3-4442-ae86-aa881475ae43-1508742587.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_C_3\",\n              \"id\": \"3c155191-32f3-4442-ae86-aa881475ae43\"\n            },\n            \"id\": \"8d1321e9-5f76-4d04-825b-c74843e569ca\",\n            \"priority\": 703,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": \"265d2bc0-c12b-468a-b60c-a7d224d8e2d1\",\n            \"name\": \"시설 내 스파 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/5eaaad1d-0a0b-47ab-b21e-2361cc1f429c-1508308876.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenityFilterSpa_A_6\",\n              \"id\": \"5eaaad1d-0a0b-47ab-b21e-2361cc1f429c\"\n            },\n            \"id\": \"b8856f5f-9355-4909-a8bc-0598b055787c\",\n            \"priority\": 306,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": \"265d2bc0-c12b-468a-b60c-a7d224d8e2d1\",\n            \"name\": \"사우나\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/5eaaad1d-0a0b-47ab-b21e-2361cc1f429c-1508308876.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenityFilterSpa_A_6\",\n              \"id\": \"5eaaad1d-0a0b-47ab-b21e-2361cc1f429c\"\n            },\n            \"id\": \"56ed28a5-438a-4594-9301-d53e6de5b9e4\",\n            \"priority\": 306,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"피트니스 시설\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/e177c005-28f8-4098-9338-c41d4a5a4dd6-1508378686.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_13\",\n              \"id\": \"e177c005-28f8-4098-9338-c41d4a5a4dd6\"\n            },\n            \"id\": \"d3e61a64-f70f-4cef-916f-08cbaf504554\",\n            \"priority\": 513,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"투어/티켓 안내\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/8ca2fead-6869-4e31-81ed-3a042ee70853-1508379864.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_22\",\n              \"id\": \"8ca2fead-6869-4e31-81ed-3a042ee70853\"\n            },\n            \"id\": \"edb76f7c-a770-40d4-8f49-2a05f5fb2cdf\",\n            \"priority\": 522,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"팩스•복사 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/427b1e0f-4d8c-4e39-acf3-2a1d398c43b9-1509447056.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_5\",\n              \"id\": \"427b1e0f-4d8c-4e39-acf3-2a1d398c43b9\"\n            },\n            \"id\": \"e03130d5-438e-46ed-a635-034507b4c783\",\n            \"priority\": 305,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"세탁 시설\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/aecdb9da-ded4-4e6b-a41a-3e99ec5d0076-1508378645.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_11\",\n              \"id\": \"aecdb9da-ded4-4e6b-a41a-3e99ec5d0076\"\n            },\n            \"id\": \"a06edb33-7d30-4b76-96a7-1f1ef70f922c\",\n            \"priority\": 511,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"드라이클리닝/세탁 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/aecdb9da-ded4-4e6b-a41a-3e99ec5d0076-1508378645.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_11\",\n              \"id\": \"aecdb9da-ded4-4e6b-a41a-3e99ec5d0076\"\n            },\n            \"id\": \"47bed422-8452-4e93-9630-e291b4b54ed5\",\n            \"priority\": 511,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"지정 흡연구역(벌금 부과)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/1bdc56ea-f11c-4510-9652-b75111516b3a-1508378426.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_10\",\n              \"id\": \"1bdc56ea-f11c-4510-9652-b75111516b3a\"\n            },\n            \"id\": \"7087bc9c-ad47-4e54-af79-30f8828fb442\",\n            \"priority\": 510,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"테라스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/e3549a55-e596-46b9-a48b-065a99a1a56e-1508807091.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_1\",\n              \"id\": \"e3549a55-e596-46b9-a48b-065a99a1a56e\"\n            },\n            \"id\": \"69538392-b5fb-4143-8072-e73b83373f83\",\n            \"priority\": 101,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"엘리베이터\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/24a76017-d850-4cf6-999d-f7c0a0b7709f-1509602760.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_C_6\",\n              \"id\": \"24a76017-d850-4cf6-999d-f7c0a0b7709f\"\n            },\n            \"id\": \"3671af2d-38b2-41a6-a451-0833958b7e12\",\n            \"priority\": 706,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"별도의 좌석 공간\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/bd15e108-24db-4bb2-97a4-ee9a74d8556d-1509447726.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_10\",\n              \"id\": \"bd15e108-24db-4bb2-97a4-ee9a74d8556d\"\n            },\n            \"id\": \"5b898477-0a76-4568-a62a-338400b6d7b3\",\n            \"priority\": -1,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"발코니\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/e3549a55-e596-46b9-a48b-065a99a1a56e-1508807091.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_1\",\n              \"id\": \"e3549a55-e596-46b9-a48b-065a99a1a56e\"\n            },\n            \"id\": \"4b32b4b4-5770-49c4-8b10-9e53a0b09e6b\",\n            \"priority\": 101,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"각각 다르게 가구가 비치된 객실\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/3c04cc39-6842-4eb6-8445-02b07d5dd02a-1508826704.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_8\",\n              \"id\": \"3c04cc39-6842-4eb6-8445-02b07d5dd02a\"\n            },\n            \"id\": \"aa2c8544-d261-46ee-80d6-3f3d9b9e5b9e\",\n            \"priority\": 108,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"각각 다른 스타일의 객실\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/3c04cc39-6842-4eb6-8445-02b07d5dd02a-1508826704.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_8\",\n              \"id\": \"3c04cc39-6842-4eb6-8445-02b07d5dd02a\"\n            },\n            \"id\": \"10527a22-f0d1-4ea1-ae8a-3f0a3448a3e6\",\n            \"priority\": 108,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"욕실\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/51fa9b9c-39d9-404c-8731-f649ae1d9b40-1508807465.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_7\",\n              \"id\": \"51fa9b9c-39d9-404c-8731-f649ae1d9b40\"\n            },\n            \"id\": \"5949f0a8-125f-455f-b39a-bd64b249474a\",\n            \"priority\": 107,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"전용 욕실\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/51fa9b9c-39d9-404c-8731-f649ae1d9b40-1508807465.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_7\",\n              \"id\": \"51fa9b9c-39d9-404c-8731-f649ae1d9b40\"\n            },\n            \"id\": \"7499f081-7e1c-4f38-8b0d-c7bb2baed9f8\",\n            \"priority\": 107,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"욕조만\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c979fe89-0862-4545-b53f-fe2fe28cc304-1509417400.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_6\",\n              \"id\": \"c979fe89-0862-4545-b53f-fe2fe28cc304\"\n            },\n            \"id\": \"f1417400-33a0-4e4e-a6b8-3422f0628b9e\",\n            \"priority\": 506,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"샤워기가 있는 욕조\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c979fe89-0862-4545-b53f-fe2fe28cc304-1509417400.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_6\",\n              \"id\": \"c979fe89-0862-4545-b53f-fe2fe28cc304\"\n            },\n            \"id\": \"e7d4d2e5-ec7d-44c7-a4c7-c5295080e7ae\",\n            \"priority\": 506,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"샤워만\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c979fe89-0862-4545-b53f-fe2fe28cc304-1509417400.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_6\",\n              \"id\": \"c979fe89-0862-4545-b53f-fe2fe28cc304\"\n            },\n            \"id\": \"ea881b1f-db8f-4ea6-a0e4-72f5446cf4d7\",\n            \"priority\": 506,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"무료 세면용품\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/ec53d9b2-89d8-407d-a2de-b9244eb3acbd-1508808301.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_5\",\n              \"id\": \"ec53d9b2-89d8-407d-a2de-b9244eb3acbd\"\n            },\n            \"id\": \"a483459a-4162-4da2-99bf-14f4917a97e2\",\n            \"priority\": 505,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"타월/시트(요금 별도)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/7c5cc07b-0c90-48cf-a55d-4ac901cae6a3-1509442427.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_18\",\n              \"id\": \"7c5cc07b-0c90-48cf-a55d-4ac901cae6a3\"\n            },\n            \"id\": \"a6d41884-4646-4a33-87b3-bc1894e48d16\",\n            \"priority\": 718,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"헤어드라이어\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/9696da2e-ff1c-4d7b-bc61-563e41dbe75a-1509604461.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_8\",\n              \"id\": \"9696da2e-ff1c-4d7b-bc61-563e41dbe75a\"\n            },\n            \"id\": \"0e04e64c-b320-4371-9868-5062e4596dbc\",\n            \"priority\": 708,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"슬리퍼\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/f079a23e-f581-4d5a-91aa-027fe41f7b78-1509442356.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_17\",\n              \"id\": \"f079a23e-f581-4d5a-91aa-027fe41f7b78\"\n            },\n            \"id\": \"ff96194b-bdde-4d7a-988f-2923cb5465fd\",\n            \"priority\": 717,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"목욕가운\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/9821f67c-24e5-4db5-8f2d-2fa3aa7adb6f-1509604436.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_7\",\n              \"id\": \"9821f67c-24e5-4db5-8f2d-2fa3aa7adb6f\"\n            },\n            \"id\": \"d9b2a208-6d80-4f0a-8925-19de0d97825a\",\n            \"priority\": 707,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"비데\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/50dca65a-928d-4fcd-9213-2ef9610989ae-1509418003.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_9\",\n              \"id\": \"50dca65a-928d-4fcd-9213-2ef9610989ae\"\n            },\n            \"id\": \"9925b84a-b040-44fb-aa17-e86e17e06bf4\",\n            \"priority\": 509,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"냉장고\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/b103f0db-d296-49c1-a1dd-0c4a2e7785cc-1509414680.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_1\",\n              \"id\": \"b103f0db-d296-49c1-a1dd-0c4a2e7785cc\"\n            },\n            \"id\": \"076b3643-611c-4bdc-9952-edb144df8e3a\",\n            \"priority\": 501,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"전기 주전자\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/d015b26b-f776-4bb4-b0b4-b2306f525acf-1509442289.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_16\",\n              \"id\": \"d015b26b-f776-4bb4-b0b4-b2306f525acf\"\n            },\n            \"id\": \"79e158be-9f8f-4088-8795-58e785285c79\",\n            \"priority\": 716,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"무료 WiFi\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c4efeba5-5422-47fd-af64-7bd95d6c44a8-1509448015.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_4\",\n              \"id\": \"c4efeba5-5422-47fd-af64-7bd95d6c44a8\"\n            },\n            \"id\": \"32837370-0a16-4546-ae54-70a815ecdd66\",\n            \"priority\": 104,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"TV\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_4\",\n              \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n            },\n            \"id\": \"3e2701bf-8dc0-4049-8fac-5483f9ea2133\",\n            \"priority\": 704,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"평면 TV\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_4\",\n              \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n            },\n            \"id\": \"730f9fe6-fb4a-41ad-80b0-ea95749c810a\",\n            \"priority\": 704,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"위성 TV 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_4\",\n              \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n            },\n            \"id\": \"0b61f89e-8c1a-4b14-b66b-6a55de385511\",\n            \"priority\": 704,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"케이블 TV 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_4\",\n              \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n            },\n            \"id\": \"06b0ad07-4948-489d-bef9-2ae77261edc6\",\n            \"priority\": 704,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"전화\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/d5ca424a-e299-4555-860d-ff33086dc3a8-1509604329.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_6\",\n              \"id\": \"d5ca424a-e299-4555-860d-ff33086dc3a8\"\n            },\n            \"id\": \"589e25fd-2b24-4bec-ae68-232dc304dd54\",\n            \"priority\": 706,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"매일 하우스키핑\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/2a3d912e-73ae-43f5-90db-04d020c22f3d-1509447539.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_9\",\n              \"id\": \"2a3d912e-73ae-43f5-90db-04d020c22f3d\"\n            },\n            \"id\": \"920a8b45-29c1-4cf9-ba81-7d26f3dc0d00\",\n            \"priority\": 109,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"다리미/다리미판(요청 시)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/7c9377fa-5e3a-49f4-b841-bd0b3170a96a-1509604552.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_9\",\n              \"id\": \"7c9377fa-5e3a-49f4-b841-bd0b3170a96a\"\n            },\n            \"id\": \"a09c91a0-9153-4cc1-9309-971295f17e1f\",\n            \"priority\": 709,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"에어컨\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/74a4d9a3-e07b-48fa-a224-64bbb0f33df7-1509418072.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_1\",\n              \"id\": \"74a4d9a3-e07b-48fa-a224-64bbb0f33df7\"\n            },\n            \"id\": \"e4ac8134-adca-4242-ad7f-d0206952293e\",\n            \"priority\": 701,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"객실 금고\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/dc1fe7a5-45b9-4665-8d19-740911a02817-1509604576.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_10\",\n              \"id\": \"dc1fe7a5-45b9-4665-8d19-740911a02817\"\n            },\n            \"id\": \"cad1b4b3-e72e-4081-b783-82dfd8fab2a2\",\n            \"priority\": 710,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"객실 내 온도 조절기\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/74a4d9a3-e07b-48fa-a224-64bbb0f33df7-1509418072.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_1\",\n              \"id\": \"74a4d9a3-e07b-48fa-a224-64bbb0f33df7\"\n            },\n            \"id\": \"f26b428f-31d9-4bc2-ab59-de7275aa44b6\",\n            \"priority\": 701,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"리넨 제공됨\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/7c5cc07b-0c90-48cf-a55d-4ac901cae6a3-1509442427.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_18\",\n              \"id\": \"7c5cc07b-0c90-48cf-a55d-4ac901cae6a3\"\n            },\n            \"id\": \"b197678d-25f5-4d88-a510-2decec28479b\",\n            \"priority\": 718,\n            \"value\": true\n          }\n        ],\n        \"grade\": 10,\n        \"id\": \"a90731f7-30d9-428b-a838-0bbac113f4eb\",\n        \"categories\": [],\n        \"type\": \"hotel\",\n        \"pointGeolocation\": {\n          \"coordinates\": [135.7584, 34.98311],\n          \"type\": \"Point\"\n        },\n        \"pricing\": {\n          \"promoText\": \"최대 12%\",\n          \"nightlyPrice\": 116580,\n          \"clubPromotionTarget\": true,\n          \"nightlyPriceHotelPromotionApplied\": 116580,\n          \"clubPromotionRate\": 0,\n          \"clubMemberOnly\": false,\n          \"nightlyBasePrice\": 116580,\n          \"clubPromotionType\": \"STATIC\"\n        },\n        \"tags\": [\n          {\n            \"name\": \"지하철역 5분거리\",\n            \"id\": \"4328808b-b7c9-49f1-81ce-b88e99bf1f03\"\n          },\n          {\n            \"name\": \"룸 컨디션이 좋은\",\n            \"id\": \"96682de7-527b-4a8d-9cc9-501c23ecd2b1\"\n          },\n          {\n            \"name\": \"쇼핑하기 편한\",\n            \"id\": \"df93d846-5a6c-42a5-a067-6301b851e7ca\"\n          },\n          {\n            \"name\": \"리무진버스이용\",\n            \"id\": \"efdc27bf-57a1-4af6-8710-11e49d790d2f\"\n          }\n        ],\n        \"reviewsCount\": 6,\n        \"reviewsRating\": \"3.5\",\n        \"scrapsCount\": 4\n      }\n    },\n    \"price\": {\n      \"promoText\": \"최대 12%\",\n      \"nightlyPrice\": 116580,\n      \"clubPromotionTarget\": true,\n      \"nightlyPriceHotelPromotionApplied\": 116580,\n      \"clubPromotionRate\": 0,\n      \"clubMemberOnly\": false,\n      \"nightlyBasePrice\": 116580,\n      \"clubPromotionType\": \"STATIC\"\n    },\n    \"reasons\": []\n  },\n  {\n    \"id\": \"5b700a4e-4b0f-4266-81db-eb42f834bdd9\",\n    \"hotel\": {\n      \"id\": \"5b700a4e-4b0f-4266-81db-eb42f834bdd9\",\n      \"source\": {\n        \"image\": {\n          \"sourceUrl\": \"https://www.expedia.com/\",\n          \"sizes\": {\n            \"large\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/ea219444-8ccc-401c-bd6c-cc07d0a027d6.jpeg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/ea219444-8ccc-401c-bd6c-cc07d0a027d6.jpeg\"\n            },\n            \"full\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/ea219444-8ccc-401c-bd6c-cc07d0a027d6.jpeg\"\n            }\n          },\n          \"description\": null,\n          \"id\": \"ea219444-8ccc-401c-bd6c-cc07d0a027d6\",\n          \"title\": null\n        },\n        \"names\": {\n          \"ko\": \"소테츠 그랜드 프레사 오사카-난바\",\n          \"en\": \"Sotetsu Grand Fresa Osaka-Namba\",\n          \"local\": \"相鉄グランドフレッサ 大阪なんば\"\n        },\n        \"accommodations\": [\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"Wi-Fi (공공장소)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/580a5567-ff28-4ef2-92f0-2ccca87d8da4-1508743354.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_S_8\",\n              \"id\": \"580a5567-ff28-4ef2-92f0-2ccca87d8da4\"\n            },\n            \"id\": \"d6f9aff5-a386-4328-8976-1f94a2586951\",\n            \"priority\": 104,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"무료 유선 인터넷\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/580a5567-ff28-4ef2-92f0-2ccca87d8da4-1508743354.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_S_8\",\n              \"id\": \"580a5567-ff28-4ef2-92f0-2ccca87d8da4\"\n            },\n            \"id\": \"f59dceb1-c20b-474f-aecc-6867718aa291\",\n            \"priority\": 108,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"레스토랑\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/de2aa17b-cad6-44ab-932a-a98d2629abea-1508486864.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_9\",\n              \"id\": \"de2aa17b-cad6-44ab-932a-a98d2629abea\"\n            },\n            \"id\": \"92785515-d973-41b2-829e-9c70e6da19f3\",\n            \"priority\": 509,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": \"502d1d69-2d26-4a06-a8f4-7e8aae02569c\",\n            \"name\": \"아침 식사 가능(요금 별도)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/b066cd01-364d-4c8c-b626-1c505484c9a1-1508230544.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenityFilterBreakfast_S_4\",\n              \"id\": \"b066cd01-364d-4c8c-b626-1c505484c9a1\"\n            },\n            \"id\": \"46ed9af9-0dbd-4b57-8633-be3767bef1f7\",\n            \"priority\": 104,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"짐 보관 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/a76c7845-7eb6-463a-886f-9e7f77b50f6c-1508317594.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_A_1\",\n              \"id\": \"a76c7845-7eb6-463a-886f-9e7f77b50f6c\"\n            },\n            \"id\": \"ac598d63-1d6b-4bb7-8a93-fdfe54f3db10\",\n            \"priority\": 301,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"24시간 운영 프런트 데스크\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4219f884-8ff8-4440-a778-0ed13612e0f5-1508485560.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_8\",\n              \"id\": \"4219f884-8ff8-4440-a778-0ed13612e0f5\"\n            },\n            \"id\": \"19db72a7-f4a1-4ad4-a032-aede1bf2d91d\",\n            \"priority\": 508,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"다국어 구사 가능 직원\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4219f884-8ff8-4440-a778-0ed13612e0f5-1508485560.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_8\",\n              \"id\": \"4219f884-8ff8-4440-a778-0ed13612e0f5\"\n            },\n            \"id\": \"f1ccbdb8-ed31-4a0d-8acc-34da40904e9f\",\n            \"priority\": 508,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"프런트 데스크의 안전 금고\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/3c155191-32f3-4442-ae86-aa881475ae43-1508742587.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_C_3\",\n              \"id\": \"3c155191-32f3-4442-ae86-aa881475ae43\"\n            },\n            \"id\": \"8d1321e9-5f76-4d04-825b-c74843e569ca\",\n            \"priority\": 703,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"팩스•복사 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/427b1e0f-4d8c-4e39-acf3-2a1d398c43b9-1509447056.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_5\",\n              \"id\": \"427b1e0f-4d8c-4e39-acf3-2a1d398c43b9\"\n            },\n            \"id\": \"e03130d5-438e-46ed-a635-034507b4c783\",\n            \"priority\": 305,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"세탁 시설\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/aecdb9da-ded4-4e6b-a41a-3e99ec5d0076-1508378645.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_11\",\n              \"id\": \"aecdb9da-ded4-4e6b-a41a-3e99ec5d0076\"\n            },\n            \"id\": \"a06edb33-7d30-4b76-96a7-1f1ef70f922c\",\n            \"priority\": 511,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"드라이클리닝/세탁 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/aecdb9da-ded4-4e6b-a41a-3e99ec5d0076-1508378645.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_11\",\n              \"id\": \"aecdb9da-ded4-4e6b-a41a-3e99ec5d0076\"\n            },\n            \"id\": \"47bed422-8452-4e93-9630-e291b4b54ed5\",\n            \"priority\": 511,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"금연 숙박 시설\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/1bdc56ea-f11c-4510-9652-b75111516b3a-1508378426.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_10\",\n              \"id\": \"1bdc56ea-f11c-4510-9652-b75111516b3a\"\n            },\n            \"id\": \"d6166a83-0a4f-41ff-a60f-b59506b0d9a1\",\n            \"priority\": 510,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"지정 흡연구역(벌금 부과)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/1bdc56ea-f11c-4510-9652-b75111516b3a-1508378426.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_10\",\n              \"id\": \"1bdc56ea-f11c-4510-9652-b75111516b3a\"\n            },\n            \"id\": \"7087bc9c-ad47-4e54-af79-30f8828fb442\",\n            \"priority\": 510,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"엘리베이터\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/24a76017-d850-4cf6-999d-f7c0a0b7709f-1509602760.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_C_6\",\n              \"id\": \"24a76017-d850-4cf6-999d-f7c0a0b7709f\"\n            },\n            \"id\": \"3671af2d-38b2-41a6-a451-0833958b7e12\",\n            \"priority\": 706,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"방음 객실\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/fb8c7e18-ab48-4f6a-8adc-3cbf34018236-1508746456.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_A_3\",\n              \"id\": \"fb8c7e18-ab48-4f6a-8adc-3cbf34018236\"\n            },\n            \"id\": \"c58b32aa-6e7f-4578-81a2-2d12e11354e1\",\n            \"priority\": 303,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"욕실\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/51fa9b9c-39d9-404c-8731-f649ae1d9b40-1508807465.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_7\",\n              \"id\": \"51fa9b9c-39d9-404c-8731-f649ae1d9b40\"\n            },\n            \"id\": \"5949f0a8-125f-455f-b39a-bd64b249474a\",\n            \"priority\": 107,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"전용 욕실\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/51fa9b9c-39d9-404c-8731-f649ae1d9b40-1508807465.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_7\",\n              \"id\": \"51fa9b9c-39d9-404c-8731-f649ae1d9b40\"\n            },\n            \"id\": \"7499f081-7e1c-4f38-8b0d-c7bb2baed9f8\",\n            \"priority\": 107,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"욕조만\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c979fe89-0862-4545-b53f-fe2fe28cc304-1509417400.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_6\",\n              \"id\": \"c979fe89-0862-4545-b53f-fe2fe28cc304\"\n            },\n            \"id\": \"f1417400-33a0-4e4e-a6b8-3422f0628b9e\",\n            \"priority\": 506,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"전신 욕조\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/96d5ccdc-f0e2-44ca-8174-fb4de1a3bb5c-1509442831.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_23\",\n              \"id\": \"96d5ccdc-f0e2-44ca-8174-fb4de1a3bb5c\"\n            },\n            \"id\": \"2bd183d0-d385-4262-b707-2c8d98af3844\",\n            \"priority\": 723,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"샤워기가 있는 욕조\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c979fe89-0862-4545-b53f-fe2fe28cc304-1509417400.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_6\",\n              \"id\": \"c979fe89-0862-4545-b53f-fe2fe28cc304\"\n            },\n            \"id\": \"e7d4d2e5-ec7d-44c7-a4c7-c5295080e7ae\",\n            \"priority\": 506,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"샤워만\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c979fe89-0862-4545-b53f-fe2fe28cc304-1509417400.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_6\",\n              \"id\": \"c979fe89-0862-4545-b53f-fe2fe28cc304\"\n            },\n            \"id\": \"ea881b1f-db8f-4ea6-a0e4-72f5446cf4d7\",\n            \"priority\": 506,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"무료 세면용품\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/ec53d9b2-89d8-407d-a2de-b9244eb3acbd-1508808301.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_5\",\n              \"id\": \"ec53d9b2-89d8-407d-a2de-b9244eb3acbd\"\n            },\n            \"id\": \"a483459a-4162-4da2-99bf-14f4917a97e2\",\n            \"priority\": 505,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"타월/시트(요금 별도)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/7c5cc07b-0c90-48cf-a55d-4ac901cae6a3-1509442427.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_18\",\n              \"id\": \"7c5cc07b-0c90-48cf-a55d-4ac901cae6a3\"\n            },\n            \"id\": \"a6d41884-4646-4a33-87b3-bc1894e48d16\",\n            \"priority\": 718,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"헤어드라이어\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/9696da2e-ff1c-4d7b-bc61-563e41dbe75a-1509604461.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_8\",\n              \"id\": \"9696da2e-ff1c-4d7b-bc61-563e41dbe75a\"\n            },\n            \"id\": \"0e04e64c-b320-4371-9868-5062e4596dbc\",\n            \"priority\": 708,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"슬리퍼\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/f079a23e-f581-4d5a-91aa-027fe41f7b78-1509442356.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_17\",\n              \"id\": \"f079a23e-f581-4d5a-91aa-027fe41f7b78\"\n            },\n            \"id\": \"ff96194b-bdde-4d7a-988f-2923cb5465fd\",\n            \"priority\": 717,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"목욕가운\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/9821f67c-24e5-4db5-8f2d-2fa3aa7adb6f-1509604436.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_7\",\n              \"id\": \"9821f67c-24e5-4db5-8f2d-2fa3aa7adb6f\"\n            },\n            \"id\": \"d9b2a208-6d80-4f0a-8925-19de0d97825a\",\n            \"priority\": 707,\n            \"value\": \"무료\"\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"비데\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/50dca65a-928d-4fcd-9213-2ef9610989ae-1509418003.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_9\",\n              \"id\": \"50dca65a-928d-4fcd-9213-2ef9610989ae\"\n            },\n            \"id\": \"9925b84a-b040-44fb-aa17-e86e17e06bf4\",\n            \"priority\": 509,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"냉장고\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/b103f0db-d296-49c1-a1dd-0c4a2e7785cc-1509414680.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_1\",\n              \"id\": \"b103f0db-d296-49c1-a1dd-0c4a2e7785cc\"\n            },\n            \"id\": \"076b3643-611c-4bdc-9952-edb144df8e3a\",\n            \"priority\": 501,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"전기 주전자\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/d015b26b-f776-4bb4-b0b4-b2306f525acf-1509442289.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_16\",\n              \"id\": \"d015b26b-f776-4bb4-b0b4-b2306f525acf\"\n            },\n            \"id\": \"79e158be-9f8f-4088-8795-58e785285c79\",\n            \"priority\": 716,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"무료 유선 인터넷\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c4efeba5-5422-47fd-af64-7bd95d6c44a8-1509448015.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_4\",\n              \"id\": \"c4efeba5-5422-47fd-af64-7bd95d6c44a8\"\n            },\n            \"id\": \"d1d2b055-443a-419f-ad1c-49adcc0bf526\",\n            \"priority\": 104,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"TV\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_4\",\n              \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n            },\n            \"id\": \"3e2701bf-8dc0-4049-8fac-5483f9ea2133\",\n            \"priority\": 704,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"평면 TV\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_4\",\n              \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n            },\n            \"id\": \"730f9fe6-fb4a-41ad-80b0-ea95749c810a\",\n            \"priority\": 704,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"위성 TV 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_4\",\n              \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n            },\n            \"id\": \"0b61f89e-8c1a-4b14-b66b-6a55de385511\",\n            \"priority\": 704,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"유료 영화\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/8a3141ab-531c-4e3c-8cc3-9f542a26e900-1509418335.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_13\",\n              \"id\": \"8a3141ab-531c-4e3c-8cc3-9f542a26e900\"\n            },\n            \"id\": \"0c179bb8-2fed-4f9c-bc91-c82abe013bb7\",\n            \"priority\": 713,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"전화\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/d5ca424a-e299-4555-860d-ff33086dc3a8-1509604329.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_6\",\n              \"id\": \"d5ca424a-e299-4555-860d-ff33086dc3a8\"\n            },\n            \"id\": \"589e25fd-2b24-4bec-ae68-232dc304dd54\",\n            \"priority\": 706,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"매일 하우스키핑\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/2a3d912e-73ae-43f5-90db-04d020c22f3d-1509447539.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_9\",\n              \"id\": \"2a3d912e-73ae-43f5-90db-04d020c22f3d\"\n            },\n            \"id\": \"920a8b45-29c1-4cf9-ba81-7d26f3dc0d00\",\n            \"priority\": 109,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"에어컨\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/74a4d9a3-e07b-48fa-a224-64bbb0f33df7-1509418072.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_1\",\n              \"id\": \"74a4d9a3-e07b-48fa-a224-64bbb0f33df7\"\n            },\n            \"id\": \"e4ac8134-adca-4242-ad7f-d0206952293e\",\n            \"priority\": 701,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"난방\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/f64b68ed-74bb-4ba6-a2d7-cc7d40d87c41-1509423090.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_3\",\n              \"id\": \"f64b68ed-74bb-4ba6-a2d7-cc7d40d87c41\"\n            },\n            \"id\": \"0e983f0d-0887-4156-90bd-ce0c343fa7fc\",\n            \"priority\": 703,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"객실 금고\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/dc1fe7a5-45b9-4665-8d19-740911a02817-1509604576.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_10\",\n              \"id\": \"dc1fe7a5-45b9-4665-8d19-740911a02817\"\n            },\n            \"id\": \"cad1b4b3-e72e-4081-b783-82dfd8fab2a2\",\n            \"priority\": 710,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"책상\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/03eb887f-0d38-496b-b1a5-bedeab475da8-1509447296.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_11\",\n              \"id\": \"03eb887f-0d38-496b-b1a5-bedeab475da8\"\n            },\n            \"id\": \"ca50aee8-9992-4785-b934-9e86573e0d6b\",\n            \"priority\": 711,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"침구 (담요/이불)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/aa88d9b6-f95d-4fb7-b2df-3b328e814f93-1508819875.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_6\",\n              \"id\": \"aa88d9b6-f95d-4fb7-b2df-3b328e814f93\"\n            },\n            \"id\": \"6aafe02f-5ee6-44c9-834d-8ccdcaeca08b\",\n            \"priority\": 106,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"리넨 제공됨\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/7c5cc07b-0c90-48cf-a55d-4ac901cae6a3-1509442427.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_18\",\n              \"id\": \"7c5cc07b-0c90-48cf-a55d-4ac901cae6a3\"\n            },\n            \"id\": \"b197678d-25f5-4d88-a510-2decec28479b\",\n            \"priority\": 718,\n            \"value\": true\n          }\n        ],\n        \"scrapsCount\": \"5\",\n        \"grade\": 10,\n        \"id\": \"5b700a4e-4b0f-4266-81db-eb42f834bdd9\",\n        \"categories\": [],\n        \"type\": \"hotel\",\n        \"pointGeolocation\": {\n          \"coordinates\": [135.5062759, 34.6683088],\n          \"type\": \"Point\"\n        },\n        \"pricing\": {\n          \"promoText\": \"최대 8%\",\n          \"nightlyPrice\": 79228,\n          \"clubPromotionTarget\": true,\n          \"nightlyPriceHotelPromotionApplied\": 79228,\n          \"clubPromotionRate\": 0,\n          \"clubMemberOnly\": false,\n          \"nightlyBasePrice\": 79228,\n          \"clubPromotionType\": \"STATIC\"\n        },\n        \"tags\": [\n          {\n            \"name\": \"지하철역 5분거리\",\n            \"id\": \"4328808b-b7c9-49f1-81ce-b88e99bf1f03\"\n          },\n          {\n            \"name\": \"룸 컨디션이 좋은\",\n            \"id\": \"96682de7-527b-4a8d-9cc9-501c23ecd2b1\"\n          },\n          {\n            \"name\": \"쇼핑하기 편한\",\n            \"id\": \"df93d846-5a6c-42a5-a067-6301b851e7ca\"\n          }\n        ]\n      }\n    },\n    \"price\": {\n      \"promoText\": \"최대 8%\",\n      \"nightlyPrice\": 79228,\n      \"clubPromotionTarget\": true,\n      \"nightlyPriceHotelPromotionApplied\": 79228,\n      \"clubPromotionRate\": 0,\n      \"clubMemberOnly\": false,\n      \"nightlyBasePrice\": 79228,\n      \"clubPromotionType\": \"STATIC\"\n    },\n    \"reasons\": []\n  },\n  {\n    \"id\": \"f481a7fb-3f14-4c55-903b-92d225d450b7\",\n    \"hotel\": {\n      \"id\": \"f481a7fb-3f14-4c55-903b-92d225d450b7\",\n      \"source\": {\n        \"image\": {\n          \"sourceUrl\": \"https://www.expedia.com/\",\n          \"sizes\": {\n            \"large\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/4b0fac6d-2f53-4bf1-90cc-27cf2ec3c03b.jpeg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/4b0fac6d-2f53-4bf1-90cc-27cf2ec3c03b.jpeg\"\n            },\n            \"full\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/4b0fac6d-2f53-4bf1-90cc-27cf2ec3c03b.jpeg\"\n            }\n          },\n          \"description\": null,\n          \"id\": \"4b0fac6d-2f53-4bf1-90cc-27cf2ec3c03b\",\n          \"title\": null\n        },\n        \"names\": {\n          \"ko\": \"칸데오 호텔스 오사카 남바\",\n          \"en\": \"Candeo Hotels Osaka Namba\",\n          \"local\": \"カンデオホテルズ大阪なんば\"\n        },\n        \"accommodations\": [\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"Wi-Fi (공공장소)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/580a5567-ff28-4ef2-92f0-2ccca87d8da4-1508743354.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_S_8\",\n              \"id\": \"580a5567-ff28-4ef2-92f0-2ccca87d8da4\"\n            },\n            \"id\": \"d6f9aff5-a386-4328-8976-1f94a2586951\",\n            \"priority\": 104,\n            \"value\": \"무료\"\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": \"e3eafbef-80f7-4347-a046-dd0725fec786\",\n            \"name\": \"시설 내 주차\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/6e72760c-cfc7-4c72-9878-e3b8bf1d6e86-1508224443.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenityFilterParking_S_9\",\n              \"id\": \"6e72760c-cfc7-4c72-9878-e3b8bf1d6e86\"\n            },\n            \"id\": \"487195a7-3fdc-43fe-94c9-4e14e8967df1\",\n            \"priority\": 109,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": \"e3eafbef-80f7-4347-a046-dd0725fec786\",\n            \"name\": \"셀프 주차(요금 별도)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/6e72760c-cfc7-4c72-9878-e3b8bf1d6e86-1508224443.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenityFilterParking_S_9\",\n              \"id\": \"6e72760c-cfc7-4c72-9878-e3b8bf1d6e86\"\n            },\n            \"id\": \"36059029-586c-442a-b3a8-0e5237458927\",\n            \"priority\": 109,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"레스토랑\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/de2aa17b-cad6-44ab-932a-a98d2629abea-1508486864.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_9\",\n              \"id\": \"de2aa17b-cad6-44ab-932a-a98d2629abea\"\n            },\n            \"id\": \"92785515-d973-41b2-829e-9c70e6da19f3\",\n            \"priority\": 509,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": \"502d1d69-2d26-4a06-a8f4-7e8aae02569c\",\n            \"name\": \"아침 식사 가능(요금 별도)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/b066cd01-364d-4c8c-b626-1c505484c9a1-1508230544.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenityFilterBreakfast_S_4\",\n              \"id\": \"b066cd01-364d-4c8c-b626-1c505484c9a1\"\n            },\n            \"id\": \"46ed9af9-0dbd-4b57-8633-be3767bef1f7\",\n            \"priority\": 104,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"짐 보관 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/a76c7845-7eb6-463a-886f-9e7f77b50f6c-1508317594.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_A_1\",\n              \"id\": \"a76c7845-7eb6-463a-886f-9e7f77b50f6c\"\n            },\n            \"id\": \"ac598d63-1d6b-4bb7-8a93-fdfe54f3db10\",\n            \"priority\": 301,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"24시간 운영 프런트 데스크\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4219f884-8ff8-4440-a778-0ed13612e0f5-1508485560.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_8\",\n              \"id\": \"4219f884-8ff8-4440-a778-0ed13612e0f5\"\n            },\n            \"id\": \"19db72a7-f4a1-4ad4-a032-aede1bf2d91d\",\n            \"priority\": 508,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"간편 체크인\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/a76c7845-7eb6-463a-886f-9e7f77b50f6c-1508317594.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_A_1\",\n              \"id\": \"a76c7845-7eb6-463a-886f-9e7f77b50f6c\"\n            },\n            \"id\": \"06d11a1a-83eb-4ba4-a7e8-a5a4c308051b\",\n            \"priority\": 301,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"다국어 구사 가능 직원\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4219f884-8ff8-4440-a778-0ed13612e0f5-1508485560.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_8\",\n              \"id\": \"4219f884-8ff8-4440-a778-0ed13612e0f5\"\n            },\n            \"id\": \"f1ccbdb8-ed31-4a0d-8acc-34da40904e9f\",\n            \"priority\": 508,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": \"265d2bc0-c12b-468a-b60c-a7d224d8e2d1\",\n            \"name\": \"시설 내 스파 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/5eaaad1d-0a0b-47ab-b21e-2361cc1f429c-1508308876.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenityFilterSpa_A_6\",\n              \"id\": \"5eaaad1d-0a0b-47ab-b21e-2361cc1f429c\"\n            },\n            \"id\": \"b8856f5f-9355-4909-a8bc-0598b055787c\",\n            \"priority\": 306,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": \"265d2bc0-c12b-468a-b60c-a7d224d8e2d1\",\n            \"name\": \"사우나\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/5eaaad1d-0a0b-47ab-b21e-2361cc1f429c-1508308876.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenityFilterSpa_A_6\",\n              \"id\": \"5eaaad1d-0a0b-47ab-b21e-2361cc1f429c\"\n            },\n            \"id\": \"56ed28a5-438a-4594-9301-d53e6de5b9e4\",\n            \"priority\": 306,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"투어/티켓 안내\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/8ca2fead-6869-4e31-81ed-3a042ee70853-1508379864.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_22\",\n              \"id\": \"8ca2fead-6869-4e31-81ed-3a042ee70853\"\n            },\n            \"id\": \"edb76f7c-a770-40d4-8f49-2a05f5fb2cdf\",\n            \"priority\": 522,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"팩스•복사 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/427b1e0f-4d8c-4e39-acf3-2a1d398c43b9-1509447056.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_5\",\n              \"id\": \"427b1e0f-4d8c-4e39-acf3-2a1d398c43b9\"\n            },\n            \"id\": \"e03130d5-438e-46ed-a635-034507b4c783\",\n            \"priority\": 305,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"세탁 시설\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/aecdb9da-ded4-4e6b-a41a-3e99ec5d0076-1508378645.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_11\",\n              \"id\": \"aecdb9da-ded4-4e6b-a41a-3e99ec5d0076\"\n            },\n            \"id\": \"a06edb33-7d30-4b76-96a7-1f1ef70f922c\",\n            \"priority\": 511,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"드라이클리닝/세탁 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/aecdb9da-ded4-4e6b-a41a-3e99ec5d0076-1508378645.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_11\",\n              \"id\": \"aecdb9da-ded4-4e6b-a41a-3e99ec5d0076\"\n            },\n            \"id\": \"47bed422-8452-4e93-9630-e291b4b54ed5\",\n            \"priority\": 511,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"금연 숙박 시설\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/1bdc56ea-f11c-4510-9652-b75111516b3a-1508378426.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_10\",\n              \"id\": \"1bdc56ea-f11c-4510-9652-b75111516b3a\"\n            },\n            \"id\": \"d6166a83-0a4f-41ff-a60f-b59506b0d9a1\",\n            \"priority\": 510,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"지정 흡연구역\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/1bdc56ea-f11c-4510-9652-b75111516b3a-1508378426.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_B_10\",\n              \"id\": \"1bdc56ea-f11c-4510-9652-b75111516b3a\"\n            },\n            \"id\": \"80089278-769f-426b-a609-6d4ee1fd0f29\",\n            \"priority\": 510,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelProperty\",\n            \"groupId\": null,\n            \"name\": \"엘리베이터\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/24a76017-d850-4cf6-999d-f7c0a0b7709f-1509602760.png\"\n                }\n              },\n              \"name\": \"icoHotelAmenity_C_6\",\n              \"id\": \"24a76017-d850-4cf6-999d-f7c0a0b7709f\"\n            },\n            \"id\": \"3671af2d-38b2-41a6-a451-0833958b7e12\",\n            \"priority\": 706,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"별도의 좌석 공간\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/bd15e108-24db-4bb2-97a4-ee9a74d8556d-1509447726.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_10\",\n              \"id\": \"bd15e108-24db-4bb2-97a4-ee9a74d8556d\"\n            },\n            \"id\": \"5b898477-0a76-4568-a62a-338400b6d7b3\",\n            \"priority\": -1,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"욕실\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/51fa9b9c-39d9-404c-8731-f649ae1d9b40-1508807465.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_7\",\n              \"id\": \"51fa9b9c-39d9-404c-8731-f649ae1d9b40\"\n            },\n            \"id\": \"5949f0a8-125f-455f-b39a-bd64b249474a\",\n            \"priority\": 107,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"전용 욕실\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/51fa9b9c-39d9-404c-8731-f649ae1d9b40-1508807465.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_7\",\n              \"id\": \"51fa9b9c-39d9-404c-8731-f649ae1d9b40\"\n            },\n            \"id\": \"7499f081-7e1c-4f38-8b0d-c7bb2baed9f8\",\n            \"priority\": 107,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"욕조만\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c979fe89-0862-4545-b53f-fe2fe28cc304-1509417400.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_6\",\n              \"id\": \"c979fe89-0862-4545-b53f-fe2fe28cc304\"\n            },\n            \"id\": \"f1417400-33a0-4e4e-a6b8-3422f0628b9e\",\n            \"priority\": 506,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"전신 욕조\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/96d5ccdc-f0e2-44ca-8174-fb4de1a3bb5c-1509442831.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_23\",\n              \"id\": \"96d5ccdc-f0e2-44ca-8174-fb4de1a3bb5c\"\n            },\n            \"id\": \"2bd183d0-d385-4262-b707-2c8d98af3844\",\n            \"priority\": 723,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"샤워기가 있는 욕조\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c979fe89-0862-4545-b53f-fe2fe28cc304-1509417400.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_6\",\n              \"id\": \"c979fe89-0862-4545-b53f-fe2fe28cc304\"\n            },\n            \"id\": \"e7d4d2e5-ec7d-44c7-a4c7-c5295080e7ae\",\n            \"priority\": 506,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"샤워만\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/c979fe89-0862-4545-b53f-fe2fe28cc304-1509417400.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_6\",\n              \"id\": \"c979fe89-0862-4545-b53f-fe2fe28cc304\"\n            },\n            \"id\": \"ea881b1f-db8f-4ea6-a0e4-72f5446cf4d7\",\n            \"priority\": 506,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"무료 세면용품\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/ec53d9b2-89d8-407d-a2de-b9244eb3acbd-1508808301.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_5\",\n              \"id\": \"ec53d9b2-89d8-407d-a2de-b9244eb3acbd\"\n            },\n            \"id\": \"a483459a-4162-4da2-99bf-14f4917a97e2\",\n            \"priority\": 505,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"타월/시트(요금 별도)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/7c5cc07b-0c90-48cf-a55d-4ac901cae6a3-1509442427.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_18\",\n              \"id\": \"7c5cc07b-0c90-48cf-a55d-4ac901cae6a3\"\n            },\n            \"id\": \"a6d41884-4646-4a33-87b3-bc1894e48d16\",\n            \"priority\": 718,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"헤어드라이어\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/9696da2e-ff1c-4d7b-bc61-563e41dbe75a-1509604461.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_8\",\n              \"id\": \"9696da2e-ff1c-4d7b-bc61-563e41dbe75a\"\n            },\n            \"id\": \"0e04e64c-b320-4371-9868-5062e4596dbc\",\n            \"priority\": 708,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"목욕가운\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/9821f67c-24e5-4db5-8f2d-2fa3aa7adb6f-1509604436.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_7\",\n              \"id\": \"9821f67c-24e5-4db5-8f2d-2fa3aa7adb6f\"\n            },\n            \"id\": \"d9b2a208-6d80-4f0a-8925-19de0d97825a\",\n            \"priority\": 707,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"슬리퍼\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/f079a23e-f581-4d5a-91aa-027fe41f7b78-1509442356.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_17\",\n              \"id\": \"f079a23e-f581-4d5a-91aa-027fe41f7b78\"\n            },\n            \"id\": \"ff96194b-bdde-4d7a-988f-2923cb5465fd\",\n            \"priority\": 717,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"비데\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/50dca65a-928d-4fcd-9213-2ef9610989ae-1509418003.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_9\",\n              \"id\": \"50dca65a-928d-4fcd-9213-2ef9610989ae\"\n            },\n            \"id\": \"9925b84a-b040-44fb-aa17-e86e17e06bf4\",\n            \"priority\": 509,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"냉장고\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/b103f0db-d296-49c1-a1dd-0c4a2e7785cc-1509414680.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_B_1\",\n              \"id\": \"b103f0db-d296-49c1-a1dd-0c4a2e7785cc\"\n            },\n            \"id\": \"076b3643-611c-4bdc-9952-edb144df8e3a\",\n            \"priority\": 501,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"전기 주전자\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/d015b26b-f776-4bb4-b0b4-b2306f525acf-1509442289.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_16\",\n              \"id\": \"d015b26b-f776-4bb4-b0b4-b2306f525acf\"\n            },\n            \"id\": \"79e158be-9f8f-4088-8795-58e785285c79\",\n            \"priority\": 716,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"LCD TV\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_4\",\n              \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n            },\n            \"id\": \"5947a643-32f6-462b-8e16-91c37b196650\",\n            \"priority\": 704,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"평면 TV\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_4\",\n              \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n            },\n            \"id\": \"730f9fe6-fb4a-41ad-80b0-ea95749c810a\",\n            \"priority\": 704,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"위성 TV 서비스\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_4\",\n              \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n            },\n            \"id\": \"0b61f89e-8c1a-4b14-b66b-6a55de385511\",\n            \"priority\": 704,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"유료 영화\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/8a3141ab-531c-4e3c-8cc3-9f542a26e900-1509418335.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_13\",\n              \"id\": \"8a3141ab-531c-4e3c-8cc3-9f542a26e900\"\n            },\n            \"id\": \"0c179bb8-2fed-4f9c-bc91-c82abe013bb7\",\n            \"priority\": 713,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"매일 하우스키핑\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/2a3d912e-73ae-43f5-90db-04d020c22f3d-1509447539.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_9\",\n              \"id\": \"2a3d912e-73ae-43f5-90db-04d020c22f3d\"\n            },\n            \"id\": \"920a8b45-29c1-4cf9-ba81-7d26f3dc0d00\",\n            \"priority\": 109,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"다리미/다리미판(요청 시)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/7c9377fa-5e3a-49f4-b841-bd0b3170a96a-1509604552.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_9\",\n              \"id\": \"7c9377fa-5e3a-49f4-b841-bd0b3170a96a\"\n            },\n            \"id\": \"a09c91a0-9153-4cc1-9309-971295f17e1f\",\n            \"priority\": 709,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"에어컨\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/74a4d9a3-e07b-48fa-a224-64bbb0f33df7-1509418072.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_1\",\n              \"id\": \"74a4d9a3-e07b-48fa-a224-64bbb0f33df7\"\n            },\n            \"id\": \"e4ac8134-adca-4242-ad7f-d0206952293e\",\n            \"priority\": 701,\n            \"value\": \"무료\"\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"소파베드\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/b6ae0774-3ac8-4549-9e85-b3aa35c445a4-1509418261.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_12\",\n              \"id\": \"b6ae0774-3ac8-4549-9e85-b3aa35c445a4\"\n            },\n            \"id\": \"78e45595-e418-4625-bb80-d3f492b9c024\",\n            \"priority\": 712,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"객실 내 온도 조절기\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/74a4d9a3-e07b-48fa-a224-64bbb0f33df7-1509418072.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_1\",\n              \"id\": \"74a4d9a3-e07b-48fa-a224-64bbb0f33df7\"\n            },\n            \"id\": \"f26b428f-31d9-4bc2-ab59-de7275aa44b6\",\n            \"priority\": 701,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"침구 (담요/이불)\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/aa88d9b6-f95d-4fb7-b2df-3b328e814f93-1508819875.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_6\",\n              \"id\": \"aa88d9b6-f95d-4fb7-b2df-3b328e814f93\"\n            },\n            \"id\": \"6aafe02f-5ee6-44c9-834d-8ccdcaeca08b\",\n            \"priority\": 106,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"고급 침구\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/aa88d9b6-f95d-4fb7-b2df-3b328e814f93-1508819875.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_S_6\",\n              \"id\": \"aa88d9b6-f95d-4fb7-b2df-3b328e814f93\"\n            },\n            \"id\": \"b15cc85e-a625-4495-a972-a4d854443239\",\n            \"priority\": 106,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"리넨 제공됨\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/7c5cc07b-0c90-48cf-a55d-4ac901cae6a3-1509442427.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_18\",\n              \"id\": \"7c5cc07b-0c90-48cf-a55d-4ac901cae6a3\"\n            },\n            \"id\": \"b197678d-25f5-4d88-a510-2decec28479b\",\n            \"priority\": 718,\n            \"value\": true\n          },\n          {\n            \"subtype\": \"HotelRoom\",\n            \"groupId\": null,\n            \"name\": \"암막 커튼\",\n            \"icon\": {\n              \"sizes\": {\n                \"small\": {\n                  \"url\": null\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/icons/large_image/789fccf7-cdf1-4368-820b-934da79fe145-1509423446.png\"\n                }\n              },\n              \"name\": \"icoHotelRoomAmenity_C_15\",\n              \"id\": \"789fccf7-cdf1-4368-820b-934da79fe145\"\n            },\n            \"id\": \"822d399b-ea8c-4ac1-9914-1c42206e88d6\",\n            \"priority\": 715,\n            \"value\": true\n          }\n        ],\n        \"grade\": 30,\n        \"id\": \"f481a7fb-3f14-4c55-903b-92d225d450b7\",\n        \"categories\": [],\n        \"type\": \"hotel\",\n        \"pointGeolocation\": {\n          \"coordinates\": [135.50618, 34.67066],\n          \"type\": \"Point\"\n        },\n        \"pricing\": {\n          \"promoText\": \"최대 23%\",\n          \"nightlyPrice\": 111220,\n          \"clubPromotionTarget\": true,\n          \"nightlyPriceHotelPromotionApplied\": 111220,\n          \"clubPromotionRate\": 0,\n          \"clubMemberOnly\": false,\n          \"nightlyBasePrice\": 111220,\n          \"clubPromotionType\": \"STATIC\"\n        },\n        \"tags\": [\n          {\n            \"name\": \"지하철역 5분거리\",\n            \"id\": \"4328808b-b7c9-49f1-81ce-b88e99bf1f03\"\n          },\n          {\n            \"name\": \"룸 컨디션이 좋은\",\n            \"id\": \"96682de7-527b-4a8d-9cc9-501c23ecd2b1\"\n          }\n        ],\n        \"scrapsCount\": \"0\"\n      }\n    },\n    \"price\": {\n      \"promoText\": \"최대 23%\",\n      \"nightlyPrice\": 111220,\n      \"clubPromotionTarget\": true,\n      \"nightlyPriceHotelPromotionApplied\": 111220,\n      \"clubPromotionRate\": 0,\n      \"clubMemberOnly\": false,\n      \"nightlyBasePrice\": 111220,\n      \"clubPromotionType\": \"STATIC\"\n    },\n    \"reasons\": []\n  }\n]\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/index.ts",
    "content": "export * from './polyline'\nexport * from './polygon'\nexport * from './markers'\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/markers/flexible-marker.tsx",
    "content": "import { ReactNode } from 'react'\n\nimport { CircleMarker, CircleMarkerProps } from './primary-marker'\n\n/**\n * PinWithCircleMarker와 다르게\n * 각 프로젝트에서 active 및 default 시 필요한 마커를 렌더링 하기위한 컴포넌트\n */\nexport function FlexibleMarker({\n  active,\n  activeContent,\n  defaultContent,\n  ...props\n}: CircleMarkerProps & {\n  active: boolean\n  activeContent: ReactNode\n  defaultContent: ReactNode\n}) {\n  return (\n    <CircleMarker active={active} {...props}>\n      {active ? activeContent : defaultContent}\n    </CircleMarker>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/markers/index.ts",
    "content": "export * from './primary-marker'\nexport * from './poi-dot-marker'\nexport * from './flexible-marker'\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/markers/poi-dot-marker.tsx",
    "content": "import {\n  MouseEvent,\n  useRef,\n  useCallback,\n  useEffect,\n  MouseEventHandler,\n  PropsWithChildren,\n  ReactNode,\n  RefObject,\n} from 'react'\nimport { OverlayView, OverlayViewProps } from '@react-google-maps/api'\n\nimport {\n  BubbleMarker,\n  CIRCLE_MARKER,\n  CircleType,\n  DotMarker,\n} from './primary-marker'\nimport { MarkerBaseProps } from './primary-marker/circle-marker/circle-marker-base'\n\nexport interface DotWithPopOverMarkerProps\n  extends Pick<MarkerBaseProps, 'zIndex'>,\n    Omit<OverlayViewProps, 'mapPaneName'> {\n  type: CircleType\n  active: boolean\n  dotSize?: { width: number; height: number }\n  bubbleContent: ReactNode\n  activeContent?: ReactNode\n  inactiveContent?: ReactNode\n  withDot?: boolean\n  onClick?: (e: MouseEvent) => void\n  onBubbleClick?: (e: MouseEvent) => void\n}\n\n/**\n * 말풍선 텍스트 및 poi dot marker를 렌더링하기 위한 컴포넌트\n */\nexport function PoiDotMarker({\n  children,\n  ...props\n}: PropsWithChildren<DotWithPopOverMarkerProps>) {\n  return <DotWithPopOverMarker {...props}>{children}</DotWithPopOverMarker>\n}\n\nfunction DotWithPopOverMarker({\n  type,\n  active,\n  dotSize,\n  withDot,\n  activeContent,\n  inactiveContent,\n  bubbleContent,\n  zIndex,\n  onClick,\n  onBubbleClick,\n  onLoad,\n  children,\n  ...overlayViewProps\n}: PropsWithChildren<DotWithPopOverMarkerProps>) {\n  const overlayViewRef = useRef() as RefObject<OverlayView>\n  const adjustZindex = useCallback(() => {\n    const containerEl = overlayViewRef?.current?.containerRef?.current\n\n    if (!containerEl) {\n      return\n    }\n\n    const weight = active ? 100 : 0\n    const applyingZindex = (zIndex || 0) + weight\n    containerEl.style.zIndex = String(applyingZindex)\n  }, [active, overlayViewRef, zIndex])\n\n  const handleLoad = useCallback(\n    (overlay: google.maps.OverlayView) => {\n      adjustZindex()\n\n      onLoad && onLoad(overlay)\n    },\n    [adjustZindex, onLoad],\n  )\n\n  useEffect(() => {\n    adjustZindex()\n  }, [zIndex, overlayViewRef, active, adjustZindex])\n\n  const handleClick = useCallback<MouseEventHandler<HTMLDivElement>>(\n    (e) => {\n      onClick && onClick(e)\n    },\n    [onClick],\n  )\n\n  const handleBubbleClick = useCallback<MouseEventHandler<HTMLDivElement>>(\n    (e) => {\n      onBubbleClick && onBubbleClick(e)\n    },\n    [onBubbleClick],\n  )\n\n  const color = CIRCLE_MARKER[type].color\n\n  return (\n    <OverlayView\n      {...overlayViewProps}\n      mapPaneName={OverlayView.OVERLAY_MOUSE_TARGET}\n      ref={overlayViewRef}\n      onLoad={handleLoad}\n    >\n      <>\n        {active ? (\n          <BubbleMarker onClick={handleBubbleClick}>\n            {bubbleContent}\n          </BubbleMarker>\n        ) : null}\n\n        {withDot ? (\n          <DotMarker\n            active={active}\n            size={dotSize}\n            color={color}\n            onClick={handleClick}\n          >\n            {children}\n          </DotMarker>\n        ) : null}\n\n        {active && activeContent ? activeContent : null}\n\n        {!active && inactiveContent ? inactiveContent : null}\n      </>\n    </OverlayView>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/markers/primary-marker/bubble-marker.tsx",
    "content": "import { MouseEventHandler, PropsWithChildren } from 'react'\nimport { styled } from 'styled-components'\n\ninterface BubbleMarkerProps {\n  onClick: MouseEventHandler<HTMLDivElement>\n}\n\nconst BUBBLE_HEIGHT = 32\n\nconst LinkContainer = styled.div`\n  position: relative;\n  background: #fff;\n  height: ${BUBBLE_HEIGHT}px;\n  top: -${13 + BUBBLE_HEIGHT}px;\n  left: calc(-50% + 1px);\n  border-radius: 16px;\n  line-height: ${BUBBLE_HEIGHT}px;\n  font-size: 13px;\n  font-weight: bold;\n  text-align: center;\n  padding-left: 16px;\n  padding-right: 16px;\n  box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.1);\n\n  &::after {\n    top: 100%;\n    left: 50%;\n    border: solid transparent;\n    content: ' ';\n    height: 0;\n    width: 0;\n    position: absolute;\n    pointer-events: none;\n    border-color: #fff0;\n    border-top-color: #fff;\n    border-width: 7px;\n    margin-left: -7px;\n  }\n`\n/**\n * 말풍선 마커 컴포넌트\n */\nexport function BubbleMarker({\n  onClick,\n  children,\n}: PropsWithChildren<BubbleMarkerProps>) {\n  return <LinkContainer onClick={onClick}>{children}</LinkContainer>\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/markers/primary-marker/circle-marker/circle-marker-base.tsx",
    "content": "import { styled, css, keyframes } from 'styled-components'\nimport { Required } from 'utility-types'\n\nexport interface MarkerBaseProps {\n  /** 마커 사이즈, 단위 px */\n  width?: number\n  height?: number\n  /** 마커 선택 상태값 */\n  active?: boolean\n  /** active 시 image 클릭 가능 여부값 */\n  alwaysClickable?: boolean\n  /** 마커 기본 색상 */\n  color?: string\n  /** 활성화 마커 백그라운드 이미지 */\n  src?: string\n  /** 마커 zIndex */\n  zIndex?: number\n}\n\n/** 맵 상에 표시해야할 마커의 총 개수 */\nconst MAX_ZINDEX_WEIGHT = 100\n\nconst bounce = keyframes`\n  from {\n    transform: scale(0.01);\n  }\n\n  to {\n    transform: scale(1);\n  }\n`\n\nconst bounceAnimationMixin = css<{\n  active: boolean\n  animationDuration?: number\n}>`\n  will-change: transform;\n  ${({ active, animationDuration = 400 }) => {\n    return active\n      ? css`\n          animation: ${bounce} ${animationDuration}ms\n            cubic-bezier(0.68, -0.55, 0.265, 1.55);\n        `\n      : ''\n  }};\n`\n\nfunction withActive({\n  color,\n  width,\n  height,\n  active,\n}: Required<Omit<MarkerBaseProps, 'color' | 'src'>> & {\n  color?: string\n  src?: string\n}) {\n  return active\n    ? css`\n        left: 6px;\n        top: 5px;\n        width: 30px;\n        height: 30px;\n        line-height: 30px;\n        background-color: transparent;\n      `\n    : css`\n        left: 0;\n        top: 0;\n        width: ${width}px;\n        height: ${height}px;\n        line-height: ${height}px;\n        background-color: ${color};\n        box-shadow: 0 2px 1px 0 rgba(0, 0, 0, 0.15);\n      `\n}\n\nexport const Circle = styled.div`\n  position: absolute;\n  z-index: 1;\n  color: var(--color-white);\n  font-size: 12px;\n  text-align: center;\n  border-radius: 50%;\n`\n\nexport const CirclePin = styled.div<\n  Required<Omit<MarkerBaseProps, 'color' | 'src'>> & {\n    color?: string\n    src?: string\n    animationDuration?: number\n  }\n>`\n  position: absolute;\n  transform-origin: 21px 50px;\n\n  ${({ active, alwaysClickable }) =>\n    active ? (alwaysClickable ? '' : 'pointer-events: none;') : ''}\n\n  ${({ src, width, height, active, zIndex }) => {\n    return active\n      ? css`\n          left: -21px;\n          top: -50px;\n          background: url(${src}) no-repeat 0 0;\n          background-size: 42px 50px;\n          width: 42px;\n          height: 50px;\n          z-index: ${MAX_ZINDEX_WEIGHT + zIndex};\n        `\n      : css`\n          left: -${width / 2}px;\n          top: -${height / 2}px;\n          width: ${width}px;\n          height: ${height}px;\n          z-index: ${zIndex};\n        `\n  }}\n\n  ${bounceAnimationMixin}\n\n  ${Circle} {\n    ${(props) => withActive(props)}\n  }\n`\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/markers/primary-marker/circle-marker/index.tsx",
    "content": "import {\n  MouseEvent,\n  MouseEventHandler,\n  PropsWithChildren,\n  useCallback,\n} from 'react'\nimport { OverlayViewProps, OverlayView } from '@react-google-maps/api'\n\nimport { Circle, CirclePin, MarkerBaseProps } from './circle-marker-base'\n\n// tna 추가예정\nexport type CircleType = 'attraction' | 'restaurant' | 'hotel'\n\nexport interface CircleMarkerProps\n  extends MarkerBaseProps,\n    Omit<OverlayViewProps, 'mapPaneName'> {\n  onClick?: (e: MouseEvent) => void\n}\n\n/** 마커 종류의 따른 css 값 */\nexport const CIRCLE_MARKER: {\n  [key in CircleType]: {\n    color: string\n    imageUrl: string\n  }\n} = {\n  attraction: {\n    color: 'var(--color-purple)',\n    imageUrl:\n      'https://assets.triple.guide/images/img-map-pin-attraction-on@3x.png',\n  },\n  restaurant: {\n    color: 'var(--color-vermilion)',\n    imageUrl:\n      'https://assets.triple.guide/images/img-map-pin-restaurant-on@3x.png',\n  },\n  hotel: {\n    color: 'var(--color-purple)',\n    imageUrl: 'https://assets.triple.guide/images/img-map-pin-hotel-on@3x.png',\n  },\n}\n\nexport function CircleMarker({\n  position,\n  color,\n  src,\n  zIndex = 1,\n  active = false,\n  alwaysClickable = false,\n  width = 28,\n  height = 28,\n  onLoad,\n  onClick,\n  children,\n  ...overlayViewProps\n}: PropsWithChildren<CircleMarkerProps>) {\n  const handleClick: MouseEventHandler = useCallback(\n    (e) => {\n      onClick && onClick(e)\n    },\n    [onClick],\n  )\n\n  return (\n    <OverlayView\n      {...overlayViewProps}\n      mapPaneName={OverlayView.OVERLAY_MOUSE_TARGET}\n      position={position}\n      onLoad={onLoad}\n    >\n      <CirclePin\n        zIndex={zIndex}\n        width={width}\n        height={height}\n        alwaysClickable={alwaysClickable}\n        active={active}\n        color={color}\n        src={src}\n        onClick={handleClick}\n      >\n        <Circle>{children}</Circle>\n      </CirclePin>\n    </OverlayView>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/markers/primary-marker/dot-marker.tsx",
    "content": "import { styled, css } from 'styled-components'\nimport { MouseEventHandler, PropsWithChildren } from 'react'\n\ninterface DotMarkerProps {\n  active: boolean\n  color: string\n  size?: { width: number; height: number }\n  onClick?: MouseEventHandler<HTMLDivElement>\n}\n\nconst BUBBLE_HEIGHT = 32\n\nconst DotMarkerContainer = styled.div<{\n  $size?: { width: number; height: number }\n  $active: boolean\n}>`\n  position: relative;\n  ${({ $size: { width = 16, height = 16 } = {}, $active }) => css`\n    left: -8px;\n    top: -${8 + ($active ? BUBBLE_HEIGHT : 0)}px;\n    width: ${width * 2}px;\n    height: ${height * 2}px;\n    pointer-events: ${$active ? 'none' : 'auto'};\n  `}\n`\n\nconst DotCircle = styled.div<{\n  $size?: { width: number; height: number }\n  $color: string\n}>`\n  position: absolute;\n  z-index: 1;\n  color: #fff;\n  text-align: center;\n  border-radius: 50%;\n  box-shadow: 0 2px 1px 0 rgba(0, 0, 0, 0.15);\n  left: 3px;\n  top: 3px;\n\n  ${({ $size: { width = 13, height = 13 } = {}, $color }) => css`\n    width: ${width}px;\n    height: ${height}px;\n    background-color: ${$color};\n  `}\n  > svg {\n    padding-top: 4px;\n  }\n`\n\n/**\n * Poi를 나타내는 작은 점 마커 컴포넌트\n */\nexport function DotMarker({\n  active,\n  size,\n  color,\n  onClick,\n  children,\n}: PropsWithChildren<DotMarkerProps>) {\n  return (\n    <DotMarkerContainer $size={size} onClick={onClick} $active={active}>\n      <DotCircle $size={size} $color={color}>\n        {children}\n      </DotCircle>\n    </DotMarkerContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/markers/primary-marker/index.ts",
    "content": "export * from './pin-marker'\nexport * from './circle-marker'\nexport * from './bubble-marker'\nexport * from './dot-marker'\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/markers/primary-marker/pin-marker/index.ts",
    "content": "import { PinWithCircleMarker } from './pin-marker'\n\nexport * from './pin-marker'\n\n/** 공통으로 사용되는 poi 마커 */\nexport const HotelCircleMarker = PinWithCircleMarker('hotel')\nexport const AttractionCircleMarker = PinWithCircleMarker('attraction')\nexport const RestaurantCircleMarker = PinWithCircleMarker('restaurant')\nexport const TnaCircleMarker = PinWithCircleMarker('tna')\nexport const FestaCircleMarker = PinWithCircleMarker('festa')\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/markers/primary-marker/pin-marker/pin-marker.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { CircleMarker } from '../circle-marker'\n\nexport type PinMarkerType =\n  | 'attraction'\n  | 'restaurant'\n  | 'hotel'\n  | 'tna'\n  | 'festa'\n\nfunction getColorOfType(type: PinMarkerType) {\n  switch (type) {\n    case 'hotel':\n      return 'var(--color-mint)'\n    case 'restaurant':\n      return 'var(--color-vermilion)'\n    case 'attraction':\n      return 'var(--color-purple)'\n    case 'tna':\n      return 'var(--color-orange)'\n    case 'festa':\n      return '#EB147B'\n  }\n\n  throw new Error('Unknown color of poi color type')\n}\n\nfunction getActivePinImageUrl(type: PinMarkerType) {\n  return `https://assets.triple.guide/images/img_map_${type}_timetable_pick@3x.png`\n}\n\n/**\n * CircleMarker 에 color 와 pin background-image 를 결정하는 로직을 분리하고\n * poi.type 을 기준으로 컴포넌트의 색상과 pin background-image 를 자유롭게 설정하기 위한\n * High Order Component\n */\n\ntype HocProps = Omit<Parameters<typeof CircleMarker>[0], 'color' | 'src'>\n\nexport function PinWithCircleMarker(type: PinMarkerType) {\n  const color = getColorOfType(type)\n  const src = getActivePinImageUrl(type)\n\n  return function ColorMarkerComponent({\n    children,\n    ...rest\n  }: PropsWithChildren<HocProps>) {\n    return (\n      <CircleMarker {...rest} color={color} src={src}>\n        {children}\n      </CircleMarker>\n    )\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/polygon.tsx",
    "content": "import { useMemo } from 'react'\nimport { Polygon as GoogleMapPolygon } from '@react-google-maps/api'\n\nconst defaultPolygonStyle: google.maps.PolygonOptions = {\n  fillColor: '#ff5b2f',\n  fillOpacity: 0.2,\n  strokeOpacity: 0,\n  clickable: false,\n  draggable: false,\n  editable: false,\n  geodesic: false,\n  zIndex: 1,\n}\n\ntype PolygonProps = {\n  paths?: google.maps.LatLngLiteral[]\n} & google.maps.PolygonOptions\n\nexport function Polygon({ paths, ...rest }: PolygonProps) {\n  const options = useMemo(() => ({ ...defaultPolygonStyle, ...rest }), [rest])\n\n  return <GoogleMapPolygon paths={paths} options={options} />\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/overlay/polyline.tsx",
    "content": "import { useMemo } from 'react'\nimport { Polyline as GoogleMapPolyline } from '@react-google-maps/api'\n\nconst defaultPolylineStyle: google.maps.PolylineOptions = {\n  strokeColor: '#6d6d6d',\n  strokeOpacity: 0,\n  strokeWeight: 2,\n  clickable: false,\n  draggable: false,\n  editable: false,\n  visible: true,\n  zIndex: 1,\n}\n\nexport function PolylineBase({ path, ...rest }: google.maps.PolylineOptions) {\n  const options = useMemo(() => ({ ...defaultPolylineStyle, ...rest }), [rest])\n\n  // eslint-disable-next-line @typescript-eslint/ban-ts-comment\n  // @ts-ignore\n  return <GoogleMapPolyline path={path} options={options} />\n}\n\n/**\n * 커스텀 스타일을 적용한 polyline 컴포넌트를 위한 HoC\n * @param options\n *\n * const DotPolyline = withCustomOptions({\n *  strokeColor: '#FF0000',\n *  strokeOpacity: 0.8\n * })\n */\nexport function withCustomOptions(options: google.maps.PolylineOptions) {\n  return function PolylineComponent({\n    path,\n    ...rest\n  }: google.maps.PolylineOptions) {\n    const style: google.maps.PolylineOptions = { ...options, ...rest }\n\n    return <PolylineBase path={path} {...style} />\n  }\n}\n\nexport const DotPolyline = withCustomOptions({\n  icons: [\n    {\n      icon: {\n        path: 'M 0,-1 0,1',\n        strokeOpacity: 1,\n        strokeWeight: 3,\n        scale: 1,\n      },\n      offset: '2px 2px',\n      repeat: '8px',\n    },\n  ],\n})\n\nexport const Polyline = withCustomOptions({\n  strokeColor: '#FF0000',\n  strokeOpacity: 0.8,\n})\n"
  },
  {
    "path": "packages/tds-widget/src/map/types.ts",
    "content": "import type {\n  TranslatedProperty,\n  PointGeoJson,\n  ImageMeta,\n} from '@titicaca/type-definitions'\n\ninterface BaseResourceType {\n  id: string\n  name: string\n}\n\nenum PoiType {\n  Attraction = 'attraction',\n  Restaurant = 'restaurant',\n  Article = 'article',\n  Hotel = 'hotel',\n  Tna = 'tna',\n  Air = 'air',\n}\n\ninterface Price {\n  promoText: string // \"최대 8%\",\n  nightlyPrice: number // 79228,\n  clubPromotionTarget: boolean // true,\n  nightlyPriceHotelPromotionApplied: number // 79228,\n  clubPromotionRate: number // 0,\n  clubMemberOnly: boolean // false,\n  nightlyBasePrice: number // 79228,\n  clubPromotionType: 'STATIC'\n}\n\nexport interface HotelResourceType {\n  id: string\n  source: {\n    id: string\n    regionId: string\n    names: TranslatedProperty\n    comment: string\n    pointGeolocation: PointGeoJson\n    grade: number\n    areas: BaseResourceType[]\n    image: ImageMeta\n    tags: BaseResourceType[]\n    starRating: number // 3\n    reviewsCount: number // 1\n    scrapsCount: number // '5'\n    reviewsRating: number // 5\n  }\n  scraped: boolean\n}\n\nexport interface RecommendationHotelResourceType {\n  id: string\n  hotel: HotelResourceType\n  price: Price\n  reasons: string[]\n}\n\nexport interface RecommendationItineraryCardResourceType {\n  cardType: string\n  content: {\n    pois?: RecommendationItineraryPoiCard[]\n    poi?: RecommendationItineraryPoiCard\n    flight?: unknown\n  }\n  options: {\n    isRecommended: boolean\n    isHidden?: boolean // 일정추천뷰에서는 보이지 않고, 일정 import 에서는 보여야 하는 카드\n    needFlight?: boolean\n  }\n  memo: string\n}\n\nexport interface RecommendationItineraryResourceType {\n  day: number\n  date: string\n  cards: RecommendationItineraryCardResourceType[]\n}\n\nexport interface RecommendationItineraryPoiCard {\n  id?: string\n  source: {\n    type: PoiType\n    id?: string\n    regionId: string\n    names: TranslatedProperty\n    addresses: TranslatedProperty\n    location: number[]\n    comment: string\n    pointGeolocation: PointGeoJson\n    grade: number\n    areas: BaseResourceType[]\n    categories?: BaseResourceType[] // type: [attraction, restaurant]\n    image: ImageMeta\n    hasTnaProducts: false\n    scrapsCount: number // '0'\n    starRating?: number // type: [hotel]\n  }\n  customPoiId?: number\n  planId?: number // 나만의장소로 등록한 숙소의 경우, 상세랜딩시 필요\n}\n"
  },
  {
    "path": "packages/tds-widget/src/map/utilities.ts",
    "content": "export function getGeometry(coordinates: [number, number][]) {\n  const longitude = { west: 180, east: -180 }\n  const latitude = { south: 90, north: -90 }\n  const coordinatesLength = coordinates.length\n\n  coordinates.forEach(([lng, lat]) => {\n    longitude.west = Math.min(longitude.west, lng)\n    longitude.east = Math.max(longitude.east, lng)\n    latitude.south = Math.min(latitude.south, lat)\n    latitude.north = Math.max(latitude.north, lat)\n  })\n\n  const { south, north } = latitude\n  const { west, east } = longitude\n\n  return {\n    center: {\n      lat: (north + south) / 2,\n      lng: (west + east) / 2,\n    } as google.maps.LatLngLiteral,\n    bounds:\n      coordinatesLength !== 1\n        ? ({\n            south,\n            west,\n            north,\n            east,\n          } as google.maps.LatLngBoundsLiteral)\n        : null,\n  }\n}\n\nexport function literalToString(\n  latLngOrBounds:\n    | google.maps.LatLngLiteral\n    | google.maps.LatLngBoundsLiteral\n    | null,\n) {\n  if (!latLngOrBounds) {\n    return ''\n  } else if ('south' in latLngOrBounds) {\n    const { south, west, north, east } =\n      latLngOrBounds as google.maps.LatLngBoundsLiteral\n\n    return `((${south}, ${west}), (${north}, ${east}))`\n  } else if ('lat' in latLngOrBounds) {\n    const { lat, lng } = latLngOrBounds as google.maps.LatLngLiteral\n\n    return `(${lat}, ${lng})`\n  }\n}\n\nexport function getShiftLatLng(\n  map: google.maps.Map,\n  latLng: google.maps.LatLng,\n  offset: google.maps.Point | { x: number; y: number },\n) {\n  const projection = map.getProjection() as google.maps.Projection\n\n  if (!projection) {\n    return latLng\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n  const zoom = map.getZoom()!\n  const scale = Math.pow(2, zoom)\n  const { x, y } = offset\n  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n  const centerPoint = projection.fromLatLngToPoint(latLng)!\n  const offsetPoint = new google.maps.Point(x / scale || 0, y / scale || 0)\n\n  centerPoint.x += offsetPoint.x\n  centerPoint.y += offsetPoint.y\n\n  return projection.fromPointToLatLng(centerPoint)\n}\n"
  },
  {
    "path": "packages/tds-widget/src/media/index.ts",
    "content": "export * from './media'\n"
  },
  {
    "path": "packages/tds-widget/src/media/media.tsx",
    "content": "import { SyntheticEvent } from 'react'\nimport {\n  Video,\n  Image,\n  MarginPadding,\n  OptimizedImgProps,\n} from '@titicaca/tds-ui'\nimport { ImageMeta, FrameRatioAndSizes } from '@titicaca/type-definitions'\n\nimport { ImageSource } from '../image-source'\n\nexport type MediaMeta = ImageMeta\n\nexport type MediaProps = {\n  optimized?: boolean\n  media: ImageMeta\n  autoPlay?: boolean\n  loop?: boolean\n  hideControls?: boolean\n  showNativeControls?: boolean\n  ImageSource?: typeof ImageSource\n  borderRadius?: number\n  margin?: MarginPadding\n  frame?: FrameRatioAndSizes\n  onClick?: (e: SyntheticEvent, media: ImageMeta) => void\n} & Omit<OptimizedImgProps, 'cloudinaryBucket' | 'cloudinaryId'>\n\nexport function Media({\n  optimized = false,\n  media,\n  autoPlay,\n  loop = true,\n  hideControls,\n  showNativeControls,\n  ImageSource,\n  borderRadius,\n  margin,\n  frame,\n  onClick,\n  ...props\n}: MediaProps) {\n  const {\n    id,\n    type,\n    sizes,\n    cloudinaryBucket,\n    cloudinaryId,\n    video,\n    frame: mediaFrame,\n    sourceUrl,\n    title,\n    description,\n    videoInitiallyMuted,\n  } = media\n\n  if (type && type === 'video' && video) {\n    return (\n      <Video\n        borderRadius={borderRadius}\n        frame={mediaFrame || frame || 'large'}\n        fallbackImageUrl={sizes.large.url}\n        src={video.large.url}\n        cloudinaryBucket={cloudinaryBucket}\n        cloudinaryId={cloudinaryId}\n        autoPlay={autoPlay}\n        loop={loop}\n        muted={videoInitiallyMuted}\n        hideControls={!!hideControls}\n        showNativeControls={showNativeControls}\n      />\n    )\n  }\n\n  return (\n    <Image borderRadius={borderRadius}>\n      <Image.FixedRatioFrame\n        margin={margin}\n        frame={mediaFrame || frame}\n        onClick={onClick && ((e: SyntheticEvent) => onClick(e, media))}\n      >\n        {sourceUrl ? (\n          <Image.SourceUrl>\n            {ImageSource ? <ImageSource sourceUrl={sourceUrl} /> : sourceUrl}\n          </Image.SourceUrl>\n        ) : null}\n\n        {media && optimized ? (\n          <Image.OptimizedImg\n            cloudinaryId={cloudinaryId || id}\n            cloudinaryBucket={cloudinaryBucket}\n            alt={title || description || undefined}\n            {...props}\n          />\n        ) : (\n          <Image.Img\n            src={sizes.large.url}\n            alt={title || description || undefined}\n            css={{ display: 'block' }}\n          />\n        )}\n      </Image.FixedRatioFrame>\n    </Image>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/nearby-pois/index.ts",
    "content": "export * from './nearby-pois'\n"
  },
  {
    "path": "packages/tds-widget/src/nearby-pois/nearby-pois.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { NearbyPois } from './nearby-pois'\n\nexport default {\n  title: 'tds-widget / nearby-pois / NearbyPois',\n  component: NearbyPois,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta<typeof NearbyPois>\n\n// TODO: 서버에 데이터가 없어서 mocking 해야 할 듯\nexport const Basic: StoryObj<typeof NearbyPois> = {\n  args: {\n    poiId: 'a86a3f55-9f89-4540-a124-f8c4db07ab34',\n    geolocation: {\n      type: 'Point',\n      coordinates: [125.50129726256557, 34.668727308992935],\n    },\n    regionId: '71476976-cf9a-4ae8-a60f-76e6fb26900d',\n  },\n}\n\nexport const InitialTab: StoryObj<typeof NearbyPois> = {\n  args: {\n    ...Basic.args,\n    initialTab: 'restaurant',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/nearby-pois/nearby-pois.tsx",
    "content": "import { useReducer, useCallback, useEffect } from 'react'\nimport {\n  Section,\n  Button,\n  List,\n  Tabs,\n  TabList,\n  Tab,\n  TabPanel,\n  H1,\n  Paragraph,\n} from '@titicaca/tds-ui'\nimport { useTrackEvent, useTranslation } from '@titicaca/triple-web'\nimport { PointGeoJson } from '@titicaca/type-definitions'\n\nimport { NearByPoiType } from './types'\nimport nearbyPoisReducer, {\n  NearbyPoisState,\n  setCurrentTab,\n  appendPois,\n  setFetchingStatus,\n} from './reducer'\nimport { fetchPois } from './service'\nimport { PoiEntry } from './poi-entry'\n\nconst INITIAL_STATE: NearbyPoisState = {\n  attraction: {\n    pois: [],\n    hasMore: undefined,\n    fetching: false,\n  },\n  restaurant: {\n    pois: [],\n    hasMore: undefined,\n    fetching: false,\n  },\n  currentTab: 'attraction',\n}\n\nconst EVENT_LABELS: { [key in NearByPoiType]: string } = {\n  attraction: '관광',\n  restaurant: '맛집',\n}\n\nconst DEFAULT_PAGE_SIZE = 3\nconst SUBSEQUENT_PAGE_SIZE = 10\n\nexport function NearbyPois({\n  poiId,\n  regionId,\n  initialTab,\n  geolocation: {\n    coordinates: [lon, lat],\n  },\n  optimized,\n  ...props\n}: {\n  poiId: string\n  regionId?: string\n  initialTab?: NearByPoiType\n  geolocation: PointGeoJson\n  optimized?: boolean\n} & Parameters<typeof Section>['0']) {\n  const t = useTranslation()\n\n  const [{ currentTab, ...state }, dispatch] = useReducer(nearbyPoisReducer, {\n    ...INITIAL_STATE,\n    ...(initialTab && { currentTab: initialTab }),\n  })\n  const { pois, hasMore, fetching } = state[currentTab]\n  const trackEvent = useTrackEvent()\n\n  useEffect(() => {\n    async function fetchAndSetPois() {\n      const [attractions, restaurants] = await Promise.all(\n        (['attraction', 'restaurant'] as NearByPoiType[]).map((type) => {\n          setFetchingStatus({ type })\n\n          return fetchPois({\n            type,\n            excludedIds: [poiId],\n            regionId,\n            lon,\n            lat,\n            size: DEFAULT_PAGE_SIZE,\n          })\n        }),\n      )\n\n      dispatch(\n        appendPois({\n          type: 'attraction',\n          pois: attractions,\n          hasMore: attractions.length === DEFAULT_PAGE_SIZE,\n        }),\n      )\n\n      dispatch(\n        appendPois({\n          type: 'restaurant',\n          pois: restaurants,\n          hasMore: restaurants.length === DEFAULT_PAGE_SIZE,\n        }),\n      )\n    }\n\n    fetchAndSetPois()\n  }, []) // eslint-disable-line react-hooks/exhaustive-deps\n\n  const handleLoadMore = useCallback(async () => {\n    trackEvent({\n      ga: ['근처추천장소_장소더보기', EVENT_LABELS[currentTab]],\n      fa: {\n        action: '근처추천장소_장소더보기',\n        label: EVENT_LABELS[currentTab],\n      },\n    })\n\n    setFetchingStatus({ type: currentTab })\n\n    const additionalPois = await fetchPois({\n      regionId,\n      lat,\n      lon,\n      type: currentTab,\n      excludedIds: [poiId],\n      from: pois.length,\n      size: SUBSEQUENT_PAGE_SIZE,\n    })\n\n    dispatch(\n      appendPois({\n        type: currentTab,\n        pois: additionalPois,\n        hasMore: additionalPois.length === SUBSEQUENT_PAGE_SIZE,\n      }),\n    )\n  }, [poiId, regionId, lat, lon, currentTab, pois, dispatch, trackEvent])\n\n  const handleTabChange = useCallback(\n    (newTab: string) => {\n      trackEvent({\n        ga: ['근처추천장소_탭선택', EVENT_LABELS[newTab as NearByPoiType]],\n        fa: {\n          action: '근처추천장소_탭선택',\n          label: EVENT_LABELS[newTab as NearByPoiType],\n          tab_name: EVENT_LABELS[newTab as NearByPoiType],\n        },\n      })\n\n      dispatch(setCurrentTab({ type: newTab as NearByPoiType }))\n    },\n    [trackEvent, dispatch],\n  )\n\n  return (\n    <Section\n      anchor=\"nearby-pois\"\n      {...props}\n      css={{\n        minHeight: 404,\n      }}\n    >\n      <H1\n        css={{\n          margin: '0 0 20px',\n        }}\n      >\n        {t('근처의 추천 장소')}\n      </H1>\n\n      <Tabs variant=\"basic\" value={currentTab} onChange={handleTabChange}>\n        <TabList>\n          <Tab value=\"attraction\">{t('관광')}</Tab>\n          <Tab value=\"restaurant\">{t('맛집')}</Tab>\n        </TabList>\n        <TabPanel value={currentTab}>\n          {pois.length === 0 && hasMore === false ? (\n            <Paragraph center margin={{ top: 70 }}>\n              {t('장소가 없습니다.')}\n            </Paragraph>\n          ) : (\n            <>\n              <List divided margin={{ top: 10 }}>\n                {pois.map((poi, i) => (\n                  <PoiEntry\n                    key={poi.id}\n                    index={i}\n                    poi={poi}\n                    eventLabel={EVENT_LABELS[currentTab]}\n                    optimized={optimized}\n                  />\n                ))}\n              </List>\n              {hasMore && (\n                <Button\n                  basic\n                  fluid\n                  compact\n                  color=\"gray\"\n                  size=\"small\"\n                  margin={{ top: 10 }}\n                  disabled={fetching}\n                  onClick={handleLoadMore}\n                >\n                  {t('더 많은 장소 보기')}\n                </Button>\n              )}\n            </>\n          )}\n        </TabPanel>\n      </Tabs>\n    </Section>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/nearby-pois/poi-entry.tsx",
    "content": "import { useCallback } from 'react'\nimport { List } from '@titicaca/tds-ui'\nimport { StaticIntersectionObserver as IntersectionObserver } from '@titicaca/intersection-observer'\nimport { useTrackEvent } from '@titicaca/triple-web'\nimport { useNavigate } from '@titicaca/router'\n\nimport { PoiListElement } from '../poi-list-elements'\n\nimport { ListingPoi } from './types'\n\nexport function PoiEntry({\n  index,\n  poi,\n  poi: {\n    id,\n    type,\n    source: { regionId },\n  },\n  eventLabel,\n  optimized,\n}: {\n  index: number\n  poi: ListingPoi\n  eventLabel: string\n  optimized?: boolean\n}) {\n  const trackEvent = useTrackEvent()\n  const { navigate } = useNavigate()\n\n  const handleIntersectionChange = useCallback(\n    ({ isIntersecting }: { isIntersecting: boolean }) => {\n      if (isIntersecting) {\n        trackEvent({\n          fa: {\n            action: '근처추천장소_POI노출',\n            label: `${eventLabel}_${index + 1}_${id}`,\n            item_id: id,\n          },\n        })\n      }\n    },\n    [trackEvent, eventLabel, index, id],\n  )\n\n  const handleClick = useCallback(() => {\n    trackEvent({\n      ga: ['근처추천장소_POI선택', `${eventLabel}_${index + 1}_${id}`],\n      fa: {\n        action: '근처추천장소_POI선택',\n        label: `${eventLabel}_${index + 1}_${id}`,\n      },\n    })\n\n    navigate(\n      regionId ? `/regions/${regionId}/${type}s/${id}` : `/${type}s/${id}`,\n    )\n  }, [eventLabel, id, index, navigate, regionId, trackEvent, type])\n\n  return (\n    <IntersectionObserver key={id} onChange={handleIntersectionChange}>\n      <List.Item>\n        <PoiListElement\n          as=\"div\"\n          poi={poi}\n          onClick={handleClick}\n          optimized={optimized}\n        />\n      </List.Item>\n    </IntersectionObserver>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/nearby-pois/reducer.ts",
    "content": "import { NearByPoiType, ListingPoi } from './types'\n\nconst FETCH_POIS = 'nearby-pois/FETCH_POIS'\nconst APPEND_POIS = 'nearby-pois/APPEND_POIS'\nconst SET_CURRENT_TAB = 'nearby-pois/SET_CURRENT_TAB'\n\ntype NearbyPoisAction = ReturnType<\n  typeof setFetchingStatus | typeof appendPois | typeof setCurrentTab\n>\n\nexport interface NearbyPoisState {\n  attraction: {\n    pois: ListingPoi[]\n    fetching: boolean\n    hasMore: boolean | undefined\n  }\n  restaurant: {\n    pois: ListingPoi[]\n    fetching: boolean\n    hasMore: boolean | undefined\n  }\n  currentTab: NearByPoiType\n}\n\nexport function setFetchingStatus({ type }: { type: NearByPoiType }) {\n  return {\n    type: FETCH_POIS,\n    payload: { type },\n  } as const\n}\n\nexport function appendPois({\n  type,\n  pois,\n  hasMore,\n}: {\n  type: NearByPoiType\n  pois: ListingPoi[]\n  hasMore: boolean\n}) {\n  return {\n    type: APPEND_POIS,\n    payload: {\n      type,\n      pois,\n      hasMore,\n    },\n  } as const\n}\n\nexport function setCurrentTab({ type }: { type: NearByPoiType }) {\n  return {\n    type: SET_CURRENT_TAB,\n    payload: { type },\n  } as const\n}\n\nexport default function nearbyPoisReducer(\n  state: NearbyPoisState,\n  action: NearbyPoisAction,\n) {\n  switch (action.type) {\n    case FETCH_POIS:\n      return {\n        ...state,\n        [action.payload.type]: {\n          ...state[action.payload.type],\n          fetching: true,\n        },\n      }\n    case APPEND_POIS:\n      return {\n        ...state,\n        [action.payload.type]: {\n          ...state[action.payload.type],\n          fetching: false,\n          pois: deduplicateAndMergePoiList(\n            state[action.payload.type].pois,\n            action.payload.pois,\n          ),\n          hasMore: action.payload.hasMore,\n        },\n      }\n    case SET_CURRENT_TAB:\n      return {\n        ...state,\n        currentTab: action.payload.type,\n      }\n  }\n}\n\nfunction deduplicateAndMergePoiList(\n  prevPoiList: ListingPoi[],\n  nextPoiList: ListingPoi[],\n): ListingPoi[] {\n  const poiMap = new Map([\n    ...prevPoiList.map<[string, ListingPoi]>((poi) => [poi.id, poi]),\n    ...nextPoiList.map<[string, ListingPoi]>((poi) => [poi.id, poi]),\n  ])\n\n  return Array.from(poiMap.values())\n}\n"
  },
  {
    "path": "packages/tds-widget/src/nearby-pois/service.ts",
    "content": "import { measureDistance } from '@titicaca/view-utilities'\nimport { authGuardedFetchers, NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\n\nimport { NearByPoiType, ListingPoi } from './types'\n\nexport async function fetchPois({\n  type,\n  excludedIds = [],\n  regionId = null,\n  lat,\n  lon,\n  distance = 1000,\n  from = 0,\n  size = 3,\n}: {\n  type: NearByPoiType\n  excludedIds?: string[]\n  regionId?: string | null\n  lat: number\n  lon: number\n  distance?: number | string\n  from?: number\n  size?: number\n}): Promise<ListingPoi[]> {\n  const response = await authGuardedFetchers.post<ListingPoi[]>(\n    '/api/content/pois',\n    {\n      headers: {\n        'content-type': 'application/json',\n      },\n      credentials: 'same-origin',\n      body: {\n        types: [type],\n        lat,\n        lon,\n        distance,\n        from,\n        size,\n        excludedIds,\n        regionId,\n      },\n    },\n  )\n  if (response === NEED_LOGIN_IDENTIFIER || !response.ok) {\n    throw new Error(`Failed to fetch nearby POIs: ${type}`)\n  }\n  const { parsedBody: pois } = response\n  return pois.map((poi) => ({\n    ...poi,\n    distance: measureDistance(poi.source.pointGeolocation, {\n      type: 'Point',\n      coordinates: [lon, lat],\n    }),\n  }))\n}\n"
  },
  {
    "path": "packages/tds-widget/src/nearby-pois/types.ts",
    "content": "import {\n  ListingAttraction,\n  ListingRestaurant,\n} from '@titicaca/type-definitions'\n\nexport type NearByPoiType = 'attraction' | 'restaurant'\nexport type ListingPoi = ListingAttraction | ListingRestaurant\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/actions/actions.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { PoiDetailActions } from './actions'\n\nexport default {\n  title: 'tds-widget / poi-detail / Actions',\n  component: PoiDetailActions,\n  args: {\n    poiId: 'e889ae22-0336-4cf9-8fbb-742b95fd09d0',\n  },\n  decorators: [\n    (Story) => {\n      localStorage.setItem('REVIEW_TOOLTIP_EXPOSED', 'false')\n      return Story()\n    },\n  ],\n} as Meta<typeof PoiDetailActions>\n\nexport const Basic: StoryObj<typeof PoiDetailActions> = {\n  args: {\n    onContentShare: () => {},\n    onReviewEdit: () => {},\n    onScheduleAdd: () => {},\n    onScrapedChange: () => {},\n  },\n}\n\nexport const NoDivider: StoryObj<typeof PoiDetailActions> = {\n  args: {\n    noDivider: true,\n    onContentShare: () => {},\n    onReviewEdit: () => {},\n    onScheduleAdd: () => {},\n    onScrapedChange: () => {},\n  },\n}\n\nexport const GlobalHotel: StoryObj<typeof PoiDetailActions> = {\n  args: {\n    onContentShare: () => {},\n    onReviewEdit: () => {},\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/actions/actions.tsx",
    "content": "import { styled, css } from 'styled-components'\nimport {\n  Section,\n  HR1,\n  Button,\n  MarginPadding,\n  ButtonGroup,\n} from '@titicaca/tds-ui'\nimport { useEffect, useState } from 'react'\nimport { useTrackEvent, useTranslation } from '@titicaca/triple-web'\n\nimport Tooltip, { useLocalStorageTooltip } from './tooltip/tooltip'\n\nconst ActionButton = styled(Button)`\n  position: relative;\n  padding-left: 0;\n  padding-right: 0;\n`\n\nexport type TOOLTIP_TYPE = 'SCRAPE' | 'REVIEW'\n\nconst REVIEW_TOOLTIP_EXPOSED = 'REVIEW_TOOLTIP_EXPOSED'\nconst SCRAPE_TOOLTIP_EXPOSED = 'SCRAPE_TOOLTIP_EXPOSED'\n\nexport function PoiDetailActions({\n  scraped,\n  reviewed,\n  onScheduleAdd,\n  onScrapedChange,\n  onContentShare,\n  onReviewEdit,\n  noDivider = false,\n  tooltips = ['REVIEW'],\n  ...props\n}: {\n  poiId: string\n  scraped: boolean\n  reviewed: boolean\n  onScheduleAdd?: () => void\n  onScrapedChange?: () => void\n  onContentShare: () => void\n  onReviewEdit: () => void\n  margin?: MarginPadding\n  padding?: MarginPadding\n  noDivider?: boolean\n  tooltips?: Array<TOOLTIP_TYPE>\n}) {\n  const t = useTranslation()\n  const trackEvent = useTrackEvent()\n\n  const hasScrapeTooltip = tooltips.includes('SCRAPE')\n  const hasReviewTooltip = tooltips.includes('REVIEW')\n\n  const isReviewTooltipShownBefore = useLocalStorageTooltip(\n    REVIEW_TOOLTIP_EXPOSED,\n  )\n\n  const [showScrapeTooltip, setShowScrapeTooltip] = useState(\n    hasScrapeTooltip && !scraped,\n  )\n\n  const [showReviewTooltip, setShowReviewTooltip] = useState(false)\n\n  useEffect(() => {\n    setShowReviewTooltip(hasReviewTooltip && !isReviewTooltipShownBefore)\n  }, [isReviewTooltipShownBefore, hasReviewTooltip])\n\n  return (\n    <Section {...props}>\n      <ButtonGroup\n        horizontalGap={22}\n        buttonCount={\n          [onScheduleAdd, onScrapedChange, onContentShare, onReviewEdit].filter(\n            Boolean,\n          ).length\n        }\n      >\n        {onScrapedChange ? (\n          <ActionButton\n            icon={scraped ? 'saveFilled' : 'saveEmpty'}\n            onClick={onScrapedChange}\n            css={{ transform: 'rotate(0deg)' }}\n          >\n            {showScrapeTooltip ? (\n              <Tooltip\n                localStorageKey={SCRAPE_TOOLTIP_EXPOSED}\n                label=\"이 장소를 저장할 수 있어요!\"\n                position=\"bottom\"\n                onClick={(e) => {\n                  e.stopPropagation()\n                  trackEvent({\n                    fa: {\n                      action: '저장유도툴팁_닫기',\n                    },\n                  })\n                  setShowScrapeTooltip(false)\n                }}\n                css={css`\n                  transform: initial;\n                  left: 0;\n\n                  &::before {\n                    position: fixed;\n                    left: 50%;\n                    top: initial;\n                    bottom: 0;\n                  }\n                `}\n              />\n            ) : null}\n            {scraped ? t('저장취소') : t('저장하기')}\n          </ActionButton>\n        ) : null}\n        {onScheduleAdd ? (\n          <ActionButton icon=\"schedule\" onClick={onScheduleAdd}>\n            {t('일정추가')}\n          </ActionButton>\n        ) : null}\n        <ActionButton\n          icon={reviewed ? 'starFilled' : 'starEmpty'}\n          onClick={onReviewEdit}\n        >\n          {showReviewTooltip && !showScrapeTooltip ? (\n            <Tooltip\n              localStorageKey={REVIEW_TOOLTIP_EXPOSED}\n              label=\"이제 영상도 올릴 수 있어요!\"\n              rule=\"once\"\n              onClick={(e) => {\n                e.stopPropagation()\n                setShowReviewTooltip(false)\n              }}\n            />\n          ) : null}\n          {reviewed ? t('리뷰수정') : t('리뷰쓰기')}\n        </ActionButton>\n        <ActionButton icon=\"share\" onClick={onContentShare}>\n          {t('공유하기')}\n        </ActionButton>\n      </ButtonGroup>\n      {!noDivider && <HR1 css={{ margin: '8px 0 0' }} />}\n    </Section>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/actions/index.ts",
    "content": "export * from './actions'\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/actions/tooltip/tooltip.tsx",
    "content": "import { Tooltip as CoreTooltip } from '@titicaca/tds-ui'\nimport { styled, css } from 'styled-components'\nimport { ComponentProps, MouseEventHandler, useEffect, useState } from 'react'\n\ntype Position = 'top' | 'bottom'\n\nconst StyledTooltip = styled(CoreTooltip)<{ $position: Position }>`\n  width: max-content;\n  padding: 9px 15px 8px;\n  transform: translateX(-50%);\n  left: 50%;\n  cursor: default;\n  ${({ $position }) =>\n    $position === 'bottom' &&\n    css`\n      top: 100%;\n    `}\n\n  &::before {\n    transform: translateX(-50%);\n    left: 50%;\n  }\n\n  &::after {\n    transform: translateX(-50%);\n    left: 50%;\n  }\n`\n\ntype CoreTooltipProps = ComponentProps<typeof CoreTooltip>\n\nfunction getPointing(position: Position): CoreTooltipProps['pointing'] {\n  if (position === 'top') {\n    return {\n      vertical: 'bottom',\n      horizontal: 'right',\n    }\n  }\n  if (position === 'bottom') {\n    return {\n      vertical: 'top',\n      horizontal: 'right',\n    }\n  }\n}\n\nexport default function Tooltip({\n  localStorageKey,\n  label,\n  position = 'top',\n  rule = 'always',\n  onClick,\n  ...props\n}: {\n  localStorageKey: string\n  label: string\n  position?: Position\n  rule?: 'always' | 'once'\n  onClick?: MouseEventHandler<HTMLDivElement>\n}) {\n  useEffect(() => {\n    if (rule === 'once') {\n      localStorage.setItem(localStorageKey, 'true')\n    }\n  }, [])\n\n  return (\n    <div onClick={onClick} role=\"presentation\">\n      <StyledTooltip\n        label={label}\n        pointing={getPointing(position)}\n        nowrap={false}\n        borderRadius=\"16.17\"\n        backgroundColor=\"var(--color-blue)\"\n        $position={position}\n        positioning={{ top: -26 }}\n        {...props}\n      />\n    </div>\n  )\n}\n\nexport function useLocalStorageTooltip(key: string) {\n  const [value, setValue] = useState(true)\n\n  useEffect(() => {\n    setValue(JSON.parse(localStorage.getItem(key) || 'false') as boolean)\n  }, [])\n\n  return value\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/area-names.tsx",
    "content": "import { ReactNode } from 'react'\nimport { styled } from 'styled-components'\nimport { Container, Text } from '@titicaca/tds-ui'\n\nconst AreaContainer = styled(Container)`\n  padding-left: 20px;\n  background-image: url('https://assets.triple.guide/images/ico-end-location@3x.png');\n  background-size: 16px 16px;\n  background-repeat: no-repeat;\n  background-position: left top;\n`\n\ninterface Area {\n  id: number | string\n  name: string\n}\n\nexport function AreaNames({\n  areaName,\n  areas = [],\n  vicinity,\n  arrowAction,\n}: {\n  areaName?: string\n  areas?: Area[]\n  vicinity?: string\n  arrowAction?: ReactNode\n}) {\n  const names = areaName || areas[0]?.name || vicinity\n\n  return names ? (\n    <AreaContainer>\n      <Text size=\"tiny\" bold margin={{ top: 10 }} alpha={0.8} lineHeight={1.38}>\n        {names}\n        {arrowAction}\n      </Text>\n    </AreaContainer>\n  ) : null\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/constants.ts",
    "content": "export const HASH_COPY_ACTION_SHEET =\n  'poi-detail.detail-header.copy-action-sheet'\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/copy-action-sheet-item.tsx",
    "content": "import { useCallback } from 'react'\nimport { ActionSheetItem } from '@titicaca/tds-ui'\nimport { useTranslation } from '@titicaca/triple-web'\n\nexport function CopyActionSheetItem({\n  value,\n  onCopy,\n}: {\n  value?: string | null\n  onCopy: (value: string) => void\n}) {\n  const t = useTranslation()\n\n  const handleClick = useCallback(() => value && onCopy(value), [value, onCopy])\n\n  return value ? (\n    <ActionSheetItem onClick={handleClick} buttonLabel={t('복사')}>\n      {value}\n    </ActionSheetItem>\n  ) : null\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/copy-action-sheet.tsx",
    "content": "import { ActionSheet } from '@titicaca/tds-ui'\nimport { TranslatedProperty } from '@titicaca/type-definitions'\n\nimport { CopyActionSheetItem } from './copy-action-sheet-item'\n\nexport function CopyActionSheet({\n  open,\n  names: { primary, ko, en, local },\n  onCopy,\n  onClose,\n}: {\n  open: boolean\n  names: TranslatedProperty\n  onCopy: (value: string) => void\n  onClose: () => void\n}) {\n  return (\n    <ActionSheet open={open} onClose={onClose}>\n      <CopyActionSheetItem value={primary || ko} onCopy={onCopy} />\n      <CopyActionSheetItem value={en} onCopy={onCopy} />\n      <CopyActionSheetItem value={local} onCopy={onCopy} />\n    </ActionSheet>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/detail-header/business-hours-icons.tsx",
    "content": "export function TimeIcon() {\n  return (\n    <svg\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <rect\n        x=\"0.888916\"\n        y=\"0.888916\"\n        width=\"14.2222\"\n        height=\"14.2222\"\n        rx=\"7.11111\"\n        fill=\"#FD2E69\"\n      />\n      <path\n        d=\"M10.6667 8.00004H8L8 5.33337\"\n        stroke=\"white\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n\nexport function RightArrowIcon() {\n  return (\n    <svg\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M6.44664 12L10.3999 8.02351L6.3999 4\"\n        stroke=\"#FF213C\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/detail-header/business-hours-note.tsx",
    "content": "import { styled } from 'styled-components'\nimport { useTranslation } from '@titicaca/triple-web'\nimport { Container, FlexBox, Text } from '@titicaca/tds-ui'\n\nimport { TimeIcon, RightArrowIcon } from './business-hours-icons'\n\nconst IconBox = styled.div`\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  width: 16px;\n  height: 16px;\n`\n\nexport function BusinessHoursNote({\n  todayBusinessHours,\n  onClick,\n}: {\n  todayBusinessHours?: string\n  onClick: () => void\n}) {\n  const t = useTranslation()\n\n  return (\n    <Container\n      css={{\n        margin: '10px 0 0',\n      }}\n    >\n      <FlexBox flex alignItems=\"center\">\n        <IconBox>\n          <TimeIcon />\n        </IconBox>\n\n        <Text\n          size={15}\n          bold\n          lineHeight=\"16px\"\n          color=\"red\"\n          margin={{ left: 4 }}\n          ellipsis\n          onClick={onClick}\n        >\n          {todayBusinessHours\n            ? t('영업준비중 {{todayBusinessHours}}', { todayBusinessHours })\n            : t('휴무일')}\n        </Text>\n\n        <IconBox>\n          <RightArrowIcon />\n        </IconBox>\n\n        <Container />\n      </FlexBox>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/detail-header/index.test.tsx",
    "content": "import { FC, MouseEventHandler, PropsWithChildren, ReactNode } from 'react'\nimport '@titicaca/tds-ui'\nimport { render, screen } from '@testing-library/react'\nimport { ClientAppName } from '@titicaca/triple-web'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport { PoiDetailHeader } from './index'\n\nconst addUriHashMockFn = jest.fn()\nconst removeUriHashMockFn = jest.fn()\n\njest.mock('@titicaca/triple-web', () => ({\n  ...jest.requireActual('@titicaca/triple-web'),\n  useTrackEvent: jest.fn().mockImplementation(() => jest.fn()),\n  useHashRouter: jest.fn().mockImplementation(() => ({\n    addUriHash: addUriHashMockFn,\n    removeUriHash: removeUriHashMockFn,\n  })),\n  useSessionAvailability: jest.fn(),\n}))\njest.mock('@titicaca/tds-ui', () => ({\n  ...jest.requireActual('@titicaca/tds-ui'),\n  longClickable: (Component: FC<{ children?: ReactNode; onClick: unknown }>) =>\n    function WrappedLongClickable({\n      onLongClick,\n      children,\n    }: PropsWithChildren<{\n      onLongClick?: unknown\n    }>) {\n      return (\n        <Component\n          onClick={onLongClick as MouseEventHandler<HTMLDivElement>}\n          data-testid=\"mock-clickable-section\"\n        >\n          {children}\n        </Component>\n      )\n    },\n}))\n\nbeforeEach(() => {\n  jest.clearAllMocks()\n})\n\ndescribe('when user is on app', () => {\n  test('attaches long-click handler to the outermost section', () => {\n    render(\n      <PoiDetailHeader\n        names={{ ko: 'test', en: 'test', local: 'test' }}\n        areaName=\"테스트 지역\"\n        scrapsCount={1}\n        reviewsCount={0}\n        reviewsRating={0}\n        onReviewsRatingClick={jest.fn()}\n        onCopy={jest.fn()}\n      />,\n      {\n        wrapper: createTestWrapper({\n          clientAppProvider: {\n            device: { autoplay: 'always', networkType: 'unknown' },\n            metadata: { name: ClientAppName.iOS, version: '6.5.0' },\n          },\n        }),\n      },\n    )\n\n    screen.getByTestId('mock-clickable-section').click()\n\n    expect(addUriHashMockFn).toHaveBeenCalled()\n  })\n})\n\ndescribe('when user is on web', () => {\n  test('attaches long-click handler to the outermost section', () => {\n    render(\n      <PoiDetailHeader\n        names={{ ko: 'test', en: 'test', local: 'test' }}\n        areaName=\"테스트 지역\"\n        scrapsCount={1}\n        reviewsCount={0}\n        reviewsRating={0}\n        onReviewsRatingClick={jest.fn()}\n        onCopy={jest.fn()}\n      />,\n      {\n        wrapper: createTestWrapper(),\n      },\n    )\n\n    screen.getByTestId('mock-clickable-section').click()\n\n    expect(addUriHashMockFn).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/detail-header/index.tsx",
    "content": "import { useCallback } from 'react'\nimport {\n  Section,\n  Container,\n  longClickable,\n  Text,\n  Rating,\n  Icon,\n  TextTitle,\n} from '@titicaca/tds-ui'\nimport {\n  useTrackEvent,\n  useHashRouter,\n  useClientApp,\n} from '@titicaca/triple-web'\nimport { TranslatedProperty } from '@titicaca/type-definitions'\nimport { formatNumber } from '@titicaca/view-utilities'\n\nimport { CopyActionSheet } from '../copy-action-sheet'\nimport { AreaNames } from '../area-names'\nimport { HASH_COPY_ACTION_SHEET } from '../constants'\n\nimport { BusinessHoursNote } from './business-hours-note'\n\nconst LongClickableSection = longClickable(Section)\n\ninterface Area {\n  id: number | string\n  name: string\n}\n\nexport function PoiDetailHeader({\n  names,\n  areaName,\n  areas = [],\n  scrapsCount,\n  reviewsCount,\n  reviewsRating,\n  onReviewsRatingClick,\n  onCopy,\n  vicinity,\n  currentBusinessHours,\n  todayBusinessHours,\n  permanentlyClosed,\n  onBusinessHoursClick,\n  ...props\n}: {\n  names: TranslatedProperty\n  areaName?: string\n  /**\n   * @deprecated areaName 으로 통합됩니다.\n   */\n  areas?: Area[]\n  scrapsCount: number\n  reviewsCount: number\n  reviewsRating: number\n  onReviewsRatingClick: () => void\n  onCopy: (value: string) => void\n  /**\n   * @deprecated areaName 으로 통합됩니다.\n   */\n  vicinity?: string\n  currentBusinessHours?: null | { from: number; to: number; dayOfWeek: number }\n  todayBusinessHours?: string\n  permanentlyClosed?: boolean\n  onBusinessHoursClick?: () => void\n} & Parameters<typeof Section>['0']) {\n  const app = useClientApp()\n  const { uriHash, addUriHash, removeUriHash } = useHashRouter()\n  const trackEvent = useTrackEvent()\n\n  const handleLongClick = useCallback(() => {\n    trackEvent({ fa: { action: '장소명_복사하기_노출' } })\n    addUriHash(HASH_COPY_ACTION_SHEET)\n  }, [addUriHash, trackEvent])\n\n  return (\n    <>\n      <LongClickableSection\n        onLongClick={app ? handleLongClick : undefined}\n        {...props}\n      >\n        <TextTitle>{names.primary || names.ko || names.en}</TextTitle>\n        <Text size=\"tiny\" alpha={0.5}>\n          {names.local || names.en}\n        </Text>\n\n        {!permanentlyClosed && onBusinessHoursClick && !currentBusinessHours ? (\n          <BusinessHoursNote\n            todayBusinessHours={todayBusinessHours}\n            onClick={onBusinessHoursClick}\n          />\n        ) : null}\n\n        {(reviewsCount > 0 || scrapsCount > 0) && (\n          <Container\n            css={{\n              margin: '10px 0 0',\n            }}\n          >\n            {reviewsCount > 0 && (\n              <Text\n                inline\n                bold\n                size=\"mini\"\n                alpha={1}\n                margin={{ right: 10 }}\n                onClick={onReviewsRatingClick}\n              >\n                <Rating score={reviewsRating} />\n                {` ${formatNumber(reviewsCount)}`}\n              </Text>\n            )}\n            {scrapsCount > 0 && (\n              <Text inline bold size=\"mini\" alpha={1}>\n                <Icon name=\"save\" size=\"tiny\" />\n                {` ${formatNumber(scrapsCount)}`}\n              </Text>\n            )}\n          </Container>\n        )}\n        <AreaNames areaName={areaName} areas={areas} vicinity={vicinity} />\n      </LongClickableSection>\n      <CopyActionSheet\n        open={uriHash === HASH_COPY_ACTION_SHEET}\n        names={names}\n        onCopy={onCopy}\n        onClose={removeUriHash}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/detail-header-v2/index.test.tsx",
    "content": "import { defaultTheme } from '@titicaca/tds-theme'\nimport { ThemeProvider } from 'styled-components'\nimport { FC, MouseEventHandler, PropsWithChildren, ReactNode } from 'react'\nimport '@titicaca/tds-ui'\nimport { render, screen } from '@testing-library/react'\nimport { ClientAppName } from '@titicaca/triple-web'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport { PoiDetailHeaderV2 } from './index'\n\nconst addUriHashMockFn = jest.fn()\nconst removeUriHashMockFn = jest.fn()\n\njest.mock('@titicaca/triple-web', () => ({\n  ...jest.requireActual('@titicaca/triple-web'),\n  useTrackEvent: jest.fn().mockImplementation(() => jest.fn()),\n  useHashRouter: jest.fn().mockImplementation(() => ({\n    addUriHash: addUriHashMockFn,\n    removeUriHash: removeUriHashMockFn,\n  })),\n  useSessionAvailability: jest.fn(),\n}))\n\njest.mock('@titicaca/triple-web')\njest.mock('@titicaca/tds-ui', () => ({\n  ...jest.requireActual('@titicaca/tds-ui'),\n  longClickable: (Component: FC<{ children?: ReactNode; onClick: unknown }>) =>\n    function WrappedLongClickable({\n      onLongClick,\n      children,\n    }: PropsWithChildren<{\n      onLongClick?: unknown\n    }>) {\n      return (\n        <Component\n          onClick={onLongClick as MouseEventHandler<HTMLDivElement>}\n          data-testid=\"mock-clickable-section\"\n        >\n          {children}\n        </Component>\n      )\n    },\n}))\n\nbeforeEach(() => {\n  jest.clearAllMocks()\n})\n\ndescribe('when user is on app', () => {\n  it('attaches long-click handler to the outermost section', () => {\n    render(\n      <ThemeProvider theme={defaultTheme}>\n        <PoiDetailHeaderV2\n          names={{ ko: 'test', en: 'test', local: 'test' }}\n          areaName=\"테스트 지역\"\n          scrapsCount={1}\n          reviewsCount={0}\n          reviewsRating={0}\n          onReviewsRatingClick={jest.fn()}\n          onCopy={jest.fn()}\n        />\n      </ThemeProvider>,\n      {\n        wrapper: createTestWrapper({\n          clientAppProvider: {\n            device: { autoplay: 'always', networkType: 'unknown' },\n            metadata: { name: ClientAppName.iOS, version: '6.5.0' },\n          },\n        }),\n      },\n    )\n\n    screen.getByTestId('mock-clickable-section').click()\n\n    expect(addUriHashMockFn).toHaveBeenCalled()\n  })\n})\n\ndescribe('when user is on web', () => {\n  it('attaches long-click handler to the outermost section', () => {\n    render(\n      <ThemeProvider theme={defaultTheme}>\n        <PoiDetailHeaderV2\n          names={{ ko: 'test', en: 'test', local: 'test' }}\n          areaName=\"테스트 지역\"\n          scrapsCount={1}\n          reviewsCount={0}\n          reviewsRating={0}\n          onReviewsRatingClick={jest.fn()}\n          onCopy={jest.fn()}\n        />\n      </ThemeProvider>,\n      {\n        wrapper: createTestWrapper(),\n      },\n    )\n\n    screen.getByTestId('mock-clickable-section').click()\n\n    expect(addUriHashMockFn).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/detail-header-v2/index.tsx",
    "content": "import { useCallback } from 'react'\nimport {\n  useTranslation,\n  useTrackEvent,\n  useHashRouter,\n  useClientApp,\n} from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport {\n  Section,\n  Container,\n  longClickable,\n  Text,\n  Icon,\n  Rating,\n  TextTitle,\n} from '@titicaca/tds-ui'\nimport { formatNumber } from '@titicaca/view-utilities'\nimport { TranslatedProperty } from '@titicaca/type-definitions'\n\nimport { CopyActionSheet } from '../copy-action-sheet'\nimport { AreaNames } from '../area-names'\nimport { HASH_COPY_ACTION_SHEET } from '../constants'\n\nconst ArrowButton = styled.button`\n  display: inline-block;\n  color: var(--color-blue);\n  outline: 0;\n  font-size: 12px;\n  font-weight: bold;\n  padding: 0 14px 0 6px;\n  background-image: url('https://assets.triple.guide/images/ico-arrow-right-blue.png');\n  background-size: 14px 14px;\n  background-position: right center;\n  background-repeat: no-repeat;\n  height: 14px;\n`\n\nconst LongClickableSection = longClickable(Section)\n\ninterface Area {\n  id: number | string\n  name: string\n}\n\nexport function PoiDetailHeaderV2({\n  names,\n  areaName,\n  areas = [],\n  scrapsCount,\n  reviewsCount,\n  reviewsRating,\n  onReviewsRatingClick,\n  onCopy,\n  onAreaClick,\n  vicinity,\n  ...props\n}: {\n  names: TranslatedProperty\n  areaName?: string\n  /**\n   * @deprecated areaName 으로 통합됩니다.\n   */\n  areas?: Area[]\n  scrapsCount: number\n  reviewsCount: number\n  reviewsRating: number\n  onReviewsRatingClick: () => void\n  onAreaClick?: () => void\n  onCopy: (value: string) => void\n  /**\n   * @deprecated areaName 으로 통합됩니다.\n   */\n  vicinity?: string\n} & Parameters<typeof Section>['0']) {\n  const t = useTranslation()\n\n  const app = useClientApp()\n  const { uriHash, addUriHash, removeUriHash } = useHashRouter()\n  const trackEvent = useTrackEvent()\n\n  const handleLongClick = useCallback(() => {\n    trackEvent({ fa: { action: '장소명_복사하기_노출' } })\n    addUriHash(HASH_COPY_ACTION_SHEET)\n  }, [addUriHash, trackEvent])\n\n  return (\n    <>\n      <LongClickableSection\n        onLongClick={app ? handleLongClick : undefined}\n        {...props}\n      >\n        <TextTitle margin={{ bottom: 6 }}>\n          {names.primary || names.ko || names.en}\n        </TextTitle>\n        <Text size=\"tiny\" alpha={0.5}>\n          {names.local || names.en}\n        </Text>\n        {(reviewsRating || scrapsCount > 0 || reviewsCount > 0) && (\n          <Container\n            css={{\n              margin: '14px 0 0',\n            }}\n          >\n            {scrapsCount > 0 ? (\n              <Text inline bold size=\"mini\" alpha={1} margin={{ right: 10 }}>\n                <Icon name=\"save\" size=\"tiny\" />\n                {` ${formatNumber(scrapsCount)}`}\n              </Text>\n            ) : null}\n            {reviewsRating > 0 ? (\n              <Text inline bold size=\"mini\" alpha={1}>\n                <Rating score={reviewsRating} />\n                {reviewsCount > 0 && ` ${formatNumber(reviewsCount)}`}\n                <ArrowButton onClick={onReviewsRatingClick}>\n                  {t('리뷰보기')}\n                </ArrowButton>\n              </Text>\n            ) : reviewsCount > 0 ? (\n              <Text inline bold size=\"mini\" alpha={1}>\n                <ArrowButton\n                  onClick={onReviewsRatingClick}\n                  style={{ paddingLeft: 0, marginLeft: '-4px' }}\n                >\n                  {t('리뷰보기')}\n                </ArrowButton>\n              </Text>\n            ) : null}\n          </Container>\n        )}\n        <AreaNames\n          areaName={areaName}\n          areas={areas}\n          vicinity={vicinity}\n          arrowAction={\n            onAreaClick ? (\n              <ArrowButton onClick={onAreaClick}>{t('지도보기')}</ArrowButton>\n            ) : null\n          }\n        />\n      </LongClickableSection>\n      <CopyActionSheet\n        open={uriHash === HASH_COPY_ACTION_SHEET}\n        names={names}\n        onCopy={onCopy}\n        onClose={removeUriHash}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/detail-header-v2.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { PoiDetailHeaderV2 } from './detail-header-v2'\n\nexport default {\n  title: 'tds-widget / poi-detail / DetailHeader V2',\n  component: PoiDetailHeaderV2,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta<typeof PoiDetailHeaderV2>\n\nexport const Basic: StoryObj<typeof PoiDetailHeaderV2> = {\n  name: 'V2',\n  args: {\n    names: {\n      primary: '도쿄 디즈니 랜드',\n      ko: '도쿄 디즈니 랜드',\n      en: 'Tokyo Disney land',\n      local: '東京ディズニーランド',\n    },\n    areaName: '도쿄',\n    scrapsCount: 682,\n    reviewsCount: 13859,\n    reviewsRating: 4.45,\n    onAreaClick: () => {},\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/detail-header.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { PoiDetailHeader } from './detail-header'\n\nexport default {\n  title: 'tds-widget / poi-detail / DetailHeader',\n  component: PoiDetailHeader,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta\n\nexport const Basic: StoryObj<typeof PoiDetailHeader> = {\n  name: '기본',\n  args: {\n    names: {\n      primary: '도쿄 디즈니 랜드',\n      ko: '도쿄 디즈니 랜드',\n      en: 'Tokyo Disney land',\n      local: '東京ディズニーランド',\n    },\n    areaName: '도쿄',\n    scrapsCount: 682,\n    reviewsCount: 13859,\n    reviewsRating: 4.45,\n    onBusinessHoursClick: () => {},\n  },\n}\n\nexport const WithBusinessHoursNote: StoryObj<typeof PoiDetailHeader> = {\n  name: '영업시간 추가',\n  args: {\n    ...Basic.args,\n    todayBusinessHours: '11:00 - 18:00',\n    permanentlyClosed: false,\n    onBusinessHoursClick: () => {},\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/image-carousel/carousel-section.tsx",
    "content": "import { Section, Container } from '@titicaca/tds-ui'\nimport { GuestModeType } from '@titicaca/type-definitions'\n\nimport { CarouselProps, Carousel } from './carousel'\nimport { ResponsiveImagePlaceholder as Placeholder } from './placeholder'\nimport { BusinessHoursNote, PermanentlyClosedNote } from './note'\n\nexport type PoiType = 'attraction' | 'hotel' | 'restaurant'\n\nexport interface CarouselSectionProps extends CarouselProps {\n  loading: boolean\n  currentBusinessHours?:\n    | string\n    | {\n        from: number\n        to: number\n        dayOfWeek: number\n      }\n  todayBusinessHours?: string\n  permanentlyClosed?: boolean\n  onBusinessHoursClick?: () => void\n  onPlaceholderClick: () => void\n  height?: number\n  guestMode?: GuestModeType\n  type?: PoiType\n}\n\nexport function CarouselSection({\n  images,\n  loading,\n  currentBusinessHours,\n  todayBusinessHours,\n  permanentlyClosed,\n  onPlaceholderClick,\n  onBusinessHoursClick,\n  borderRadius,\n  guestMode,\n  type,\n  ...props\n}: CarouselSectionProps) {\n  return (\n    <Section\n      css={{\n        minWidth: 320,\n        maxWidth: 768,\n        paddingLeft: 20,\n        paddingRight: 20,\n      }}\n      {...props}\n    >\n      <Container position=\"relative\">\n        {images.length > 0 ? (\n          <Carousel\n            images={images}\n            borderRadius={borderRadius}\n            guestMode={guestMode}\n            {...props}\n          />\n        ) : (\n          <Placeholder\n            onClick={onPlaceholderClick}\n            noContent={loading}\n            guestMode={guestMode}\n            type={type}\n          />\n        )}\n        {!permanentlyClosed && onBusinessHoursClick ? (\n          <BusinessHoursNote\n            bottomBorderRadius={borderRadius}\n            currentBusinessHours={currentBusinessHours}\n            todayBusinessHours={todayBusinessHours}\n            onClick={onBusinessHoursClick}\n          />\n        ) : null}\n        {permanentlyClosed ? (\n          <PermanentlyClosedNote bottomBorderRadius={borderRadius} />\n        ) : null}\n      </Container>\n    </Section>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/image-carousel/carousel.tsx",
    "content": "import { useState, useCallback, PropsWithChildren, MouseEvent } from 'react'\nimport { styled } from 'styled-components'\nimport { Container, Responsive } from '@titicaca/tds-ui'\nimport {\n  useClientApp,\n  useSessionAvailability,\n  useTrackEvent,\n} from '@titicaca/triple-web'\nimport { GuestModeType, ImageMeta } from '@titicaca/type-definitions'\n\nimport { ImageCarousel, CarouselImageMeta } from '../../image-carousel'\nimport { ImageSource } from '../../image-source'\n\nimport { CtaOverlay } from './cta-overlay'\n\nconst SHOW_CTA_FROM_INDEX = 5\n\nconst FixedRatioContainer = styled.div<{ $ratio: number }>`\n  padding-top: ${({ $ratio }) => 100 * $ratio}%;\n  width: 100%;\n`\n\nconst FixedRatioContent = styled.div<{ $ratio: number }>`\n  margin-top: -${({ $ratio }) => 100 * $ratio}%;\n`\n\nfunction FixedRatio({ ratio, children }: PropsWithChildren<{ ratio: number }>) {\n  return (\n    <FixedRatioContainer $ratio={ratio}>\n      <FixedRatioContent $ratio={ratio}>{children}</FixedRatioContent>\n    </FixedRatioContainer>\n  )\n}\n\nexport interface CarouselProps {\n  images: CarouselImageMeta[]\n  totalImagesCount: number\n  onImageClick: (image: ImageMeta) => void\n  onCtaClick: () => void\n  onImagesFetch: () => void\n  optimized?: boolean\n  borderRadius?: number\n  height?: number\n  guestMode?: GuestModeType\n}\n\nexport function Carousel({\n  images,\n  totalImagesCount,\n  onImageClick,\n  onCtaClick,\n  onImagesFetch,\n  optimized,\n  borderRadius = 6,\n  height,\n  guestMode,\n}: CarouselProps) {\n  const app = useClientApp()\n  const sessionAvailable = useSessionAvailability()\n  const trackEvent = useTrackEvent()\n  const [currentPage, setCurrentPage] = useState(0)\n\n  const loginRequired = !app && !sessionAvailable\n  const visibleImages =\n    guestMode || loginRequired\n      ? images.slice(\n          0,\n          guestMode ? SHOW_CTA_FROM_INDEX : SHOW_CTA_FROM_INDEX + 1,\n        )\n      : images\n\n  const handleImageClick = useCallback(\n    (event?: MouseEvent, media?: ImageMeta) => {\n      if (loginRequired && currentPage === SHOW_CTA_FROM_INDEX) {\n        return onCtaClick()\n      }\n\n      onImageClick(images[currentPage])\n\n      const action = '대표사진선택'\n      const label = '선택'\n\n      trackEvent({\n        fa: {\n          action,\n          label,\n          media_id: media?.id,\n          type: media?.type === 'video' ? '비디오' : '이미지',\n        },\n      })\n    },\n    [currentPage, onImageClick, images, trackEvent, onCtaClick, loginRequired],\n  )\n\n  const handlePageChange = useCallback(\n    ({ index }: { index: number }) => {\n      if (index === currentPage) {\n        return\n      }\n\n      setCurrentPage(index)\n\n      if (loginRequired && index === SHOW_CTA_FROM_INDEX) {\n        return trackEvent({ fa: { action: '대표사진_더보기_노출' } })\n      }\n\n      const currentImage = images[index]\n\n      if (currentImage) {\n        const { attachmentId, id, type } = currentImage\n        const action = '대표사진선택'\n        const label = `스와이프${attachmentId ? '_사용자등록' : ''}`\n\n        trackEvent({\n          fa: {\n            action,\n            label,\n            ...(attachmentId ? { attachment_id: attachmentId } : {}),\n            media_id: id,\n            type: type === 'video' ? '비디오' : '이미지',\n          },\n          ga: ['대표사진_스와이프'],\n        })\n      }\n\n      if (index > images.length - 5) {\n        onImagesFetch()\n      }\n    },\n    [\n      setCurrentPage,\n      currentPage,\n      images,\n      onImagesFetch,\n      trackEvent,\n      loginRequired,\n    ],\n  )\n\n  const CTA = ({ currentIndex }: { currentIndex: number }) =>\n    loginRequired && currentIndex === SHOW_CTA_FROM_INDEX ? (\n      <CtaOverlay />\n    ) : null\n\n  return (\n    <>\n      <Responsive maxWidth={706}>\n        <FixedRatio ratio={0.6}>\n          <ImageCarousel\n            images={visibleImages}\n            currentPage={currentPage}\n            displayedTotalCount={\n              guestMode ? visibleImages.length : totalImagesCount\n            }\n            borderRadius={borderRadius}\n            onImageClick={handleImageClick}\n            onMoveEnd={handlePageChange}\n            ImageSource={ImageSource}\n            showMoreRenderer={CTA}\n            optimized={optimized}\n            height={height}\n          />\n        </FixedRatio>\n      </Responsive>\n      <Responsive minWidth={707}>\n        <Container\n          css={{\n            minHeight: 400,\n          }}\n        >\n          <ImageCarousel\n            images={visibleImages}\n            currentPage={currentPage}\n            displayedTotalCount={totalImagesCount}\n            borderRadius={borderRadius}\n            size=\"large\"\n            onImageClick={handleImageClick}\n            onMoveEnd={handlePageChange}\n            ImageSource={ImageSource}\n            showMoreRenderer={CTA}\n            optimized={optimized}\n          />\n        </Container>\n      </Responsive>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/image-carousel/cta-overlay.tsx",
    "content": "import { styled } from 'styled-components'\n\nconst MoreImageOverlayLink = styled.a`\n  display: block;\n  width: 100%;\n  text-align: center;\n  color: white;\n  top: 50%;\n  position: absolute;\n  transform: translateY(-50%);\n  text-decoration: none;\n  font-size: 16px;\n`\n\nconst MoreImageOverlayLinkIcon = styled.img`\n  width: 20px;\n  height: 20px;\n  vertical-align: sub;\n`\n\nexport function CtaOverlay() {\n  return (\n    <MoreImageOverlayLink>\n      더 많은 이미지 보기\n      <MoreImageOverlayLinkIcon src=\"https://assets.triple.guide/images/ico-arrow@4x.png\" />\n    </MoreImageOverlayLink>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/image-carousel/index.tsx",
    "content": "import { usePoiDetailImages } from '../images-provider'\n\nimport { CarouselSectionProps, CarouselSection } from './carousel-section'\n\ntype ImageCarouselProps = Pick<\n  CarouselSectionProps,\n  | 'permanentlyClosed'\n  | 'currentBusinessHours'\n  | 'todayBusinessHours'\n  | 'onBusinessHoursClick'\n  | 'onImageClick'\n  | 'onCtaClick'\n  | 'onPlaceholderClick'\n  | 'optimized'\n  | 'borderRadius'\n  | 'height'\n  | 'guestMode'\n  | 'type'\n>\n\nexport function PoiDetailImageCarousel(props: ImageCarouselProps) {\n  const {\n    images,\n    loading,\n    total,\n    actions: { fetch },\n  } = usePoiDetailImages()\n\n  return (\n    <CarouselSection\n      images={images}\n      loading={loading}\n      totalImagesCount={total}\n      onImagesFetch={fetch}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/image-carousel/note.tsx",
    "content": "import { useTranslation } from '@titicaca/triple-web'\nimport { styled, css } from 'styled-components'\nimport { Text } from '@titicaca/tds-ui'\n\nconst NoteContainer = styled.div<{\n  $warning?: boolean\n  $bottomBorderRadius?: number\n}>`\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  height: 40px;\n  background-color: ${({ $warning }) => ($warning ? '#ff3636' : '#2987f0')};\n  z-index: 2;\n\n  & > div {\n    margin: 0 13px;\n    line-height: 40px;\n  }\n\n  ${({ $bottomBorderRadius }) =>\n    $bottomBorderRadius\n      ? css`\n          border-bottom-left-radius: ${$bottomBorderRadius}px;\n          border-bottom-right-radius: ${$bottomBorderRadius}px;\n        `\n      : null}\n`\n\ninterface PermanetlyCloseNoteProps {\n  bottomBorderRadius?: number\n}\n\nexport function PermanentlyClosedNote({\n  bottomBorderRadius = 6,\n}: PermanetlyCloseNoteProps) {\n  const t = useTranslation()\n\n  return (\n    <NoteContainer $warning $bottomBorderRadius={bottomBorderRadius}>\n      <Text bold size=\"small\" color=\"white\">\n        {t('더이상 운영하지 않습니다.')}\n      </Text>\n    </NoteContainer>\n  )\n}\n\ninterface BusinessHourNoteProps {\n  bottomBorderRadius?: number\n  currentBusinessHours?:\n    | string\n    | {\n        from: number\n        to: number\n        dayOfWeek: number\n      }\n  todayBusinessHours?: string\n  onClick: () => void\n}\n\nexport function BusinessHoursNote({\n  bottomBorderRadius = 6,\n  currentBusinessHours,\n  todayBusinessHours,\n  onClick,\n}: BusinessHourNoteProps) {\n  const t = useTranslation()\n\n  return (\n    <NoteContainer\n      onClick={onClick}\n      $warning={!currentBusinessHours}\n      $bottomBorderRadius={bottomBorderRadius}\n    >\n      <Text bold size=\"small\" color=\"white\">\n        {currentBusinessHours\n          ? t('영업중 {{todayBusinessHours}}', { todayBusinessHours })\n          : todayBusinessHours\n            ? t('영업준비중 {{todayBusinessHours}}', { todayBusinessHours })\n            : t('휴무일')}\n      </Text>\n    </NoteContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/image-carousel/placeholder.tsx",
    "content": "import { useTranslation } from '@titicaca/triple-web'\nimport { styled, css } from 'styled-components'\nimport { Text, Responsive } from '@titicaca/tds-ui'\nimport { GuestModeType } from '@titicaca/type-definitions'\n\nimport { PoiType } from './carousel-section'\n\nconst DEFAULT_ICON_URLS: Record<PoiType, string> = {\n  attraction: 'https://assets.triple.guide/images/seoulcon/default/ic_spot.svg',\n  hotel: 'https://assets.triple.guide/images/seoulcon/default/ic_hotel.svg',\n  restaurant:\n    'https://assets.triple.guide/images/seoulcon/default/ic_restaurant.svg',\n}\n\nconst ImagePlaceholderContainer = styled.div<{ $large?: boolean }>`\n  width: 100%;\n  overflow: hidden;\n  border-radius: 6px;\n  background-color: #efefef;\n  ${({ $large }) =>\n    $large\n      ? css`\n          height: 400px;\n        `\n      : css`\n          padding-top: 60%;\n        `};\n`\n\nconst PlaceholderIcon = styled.img`\n  vertical-align: baseline;\n`\n\nconst ImagePlaceholderContent = styled.div<{ $large?: boolean }>`\n  text-align: center;\n  transform: translate(0, -50%);\n  ${({ $large }) =>\n    $large\n      ? css`\n          margin-top: 200px;\n        `\n      : css`\n          margin-top: -30%;\n        `};\n`\n\ninterface ImagePlaceholderProps {\n  large?: boolean\n  noContent?: boolean\n  guestMode?: GuestModeType\n  type?: PoiType\n  onClick: () => void\n}\n\nfunction ImagePlaceholder({\n  large,\n  noContent,\n  guestMode,\n  type,\n  onClick,\n}: ImagePlaceholderProps) {\n  const t = useTranslation()\n\n  return (\n    <ImagePlaceholderContainer $large={large} onClick={onClick}>\n      <ImagePlaceholderContent $large={large}>\n        {noContent ? null : guestMode ? (\n          <PlaceholderIcon\n            src={DEFAULT_ICON_URLS[type || 'attraction']}\n            width={40}\n            css={{ opacity: 0.3 }}\n          />\n        ) : (\n          <>\n            <PlaceholderIcon\n              src=\"https://assets.triple.guide/images/img-empty-photo-m@4x.png\"\n              width={60}\n            />\n            <Text size=\"small\" color=\"gray\" alpha={0.3}>\n              {t('이곳의 첫 번째 사진을 올려주세요.')}\n            </Text>\n          </>\n        )}\n      </ImagePlaceholderContent>\n    </ImagePlaceholderContainer>\n  )\n}\n\nexport function ResponsiveImagePlaceholder({\n  noContent,\n  guestMode,\n  type,\n  onClick,\n}: Omit<ImagePlaceholderProps, 'large'>) {\n  return (\n    <>\n      <Responsive maxWidth={706}>\n        <ImagePlaceholder\n          noContent={noContent}\n          guestMode={guestMode}\n          type={type}\n          onClick={onClick}\n        />\n      </Responsive>\n      <Responsive minWidth={707}>\n        <ImagePlaceholder\n          noContent={noContent}\n          guestMode={guestMode}\n          type={type}\n          large\n          onClick={onClick}\n        />\n      </Responsive>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/image-carousel.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { PoiDetailImageCarousel } from './image-carousel'\nimport { PoiDetailImagesProvider } from './images-provider'\n\nexport default {\n  title: 'tds-widget / poi-detail / ImageCarousel',\n  component: PoiDetailImageCarousel,\n} as Meta<typeof PoiDetailImageCarousel>\n\nexport const AttractionOrRestaurant: StoryObj<typeof PoiDetailImageCarousel> = {\n  args: {\n    onBusinessHoursClick: undefined,\n  },\n  decorators: [\n    (Story) => (\n      <PoiDetailImagesProvider\n        source={{\n          id: 'e889ae22-0336-4cf9-8fbb-742b95fd09d0',\n          type: 'attraction',\n        }}\n      >\n        <Story />\n      </PoiDetailImagesProvider>\n    ),\n  ],\n}\n\nexport const Hotel: StoryObj<typeof PoiDetailImageCarousel> = {\n  args: {\n    onBusinessHoursClick: undefined,\n  },\n  decorators: [\n    (Story) => (\n      <PoiDetailImagesProvider\n        source={{\n          id: '1ff98b6f-ca34-4961-ae29-fa52c8ca2e21',\n          type: 'hotel',\n        }}\n      >\n        <Story />\n      </PoiDetailImagesProvider>\n    ),\n  ],\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/images-provider.tsx",
    "content": "import {\n  createContext,\n  useContext,\n  PropsWithChildren,\n  useMemo,\n  useCallback,\n  useReducer,\n  useEffect,\n  useState,\n} from 'react'\nimport { ImageMeta } from '@titicaca/type-definitions'\n\nimport reducer, {\n  loadImagesRequest,\n  loadImagesSuccess,\n  loadImagesFail,\n  reinitializeImages,\n} from './images-reducer'\nimport { ImageCategoryOrder } from './types'\nimport useFetchImages from './use-fetch-images'\n\ninterface PoiDetailImagesContext {\n  images: ImageMeta[]\n  total: number\n  loading: boolean\n  actions: {\n    fetch: (cb?: () => void) => Promise<void>\n    reFetch: (cb?: () => void) => Promise<void>\n    indexOf: (target: { id: string }) => Promise<number>\n  }\n}\n\ninterface PoiDetailImagesProviderProps {\n  source: {\n    id: string\n    type: 'attraction' | 'restaurant' | 'hotel'\n  }\n  categoryOrder?: Array<ImageCategoryOrder>\n  images?: ImageMeta[]\n  total?: number\n}\n\nconst Context = createContext<PoiDetailImagesContext>({\n  images: [],\n  total: 0,\n  loading: false,\n  actions: {\n    fetch: () => Promise.resolve(),\n    reFetch: () => Promise.resolve(),\n    indexOf: () => Promise.resolve(-1),\n  },\n})\n\nexport function PoiDetailImagesProvider({\n  images: defaultImages,\n  total: initialTotal,\n  categoryOrder = [\n    'recommendation',\n    'menuBoard',\n    'menuItem',\n    'featuredContent',\n    'images',\n  ],\n  source: { id, type },\n  children,\n}: PropsWithChildren<PoiDetailImagesProviderProps>) {\n  const [uniqueDefaultImages, setUniqueDefaultImages] = useState(\n    defaultImages || [],\n  )\n  const [{ loading, images, total, hasMore }, dispatch] = useReducer(reducer, {\n    loading: !defaultImages,\n    images: defaultImages || [],\n    total: initialTotal || 0,\n    hasMore: true,\n  })\n\n  const fetchImages = useFetchImages()\n\n  const sendFetchRequest = useCallback(\n    async (size = 15) => {\n      const response = await fetchImages({\n        target: { type, id },\n        currentImageLength: images.length - uniqueDefaultImages.length,\n        size,\n        categoryOrder,\n      })\n\n      return response\n    },\n    [\n      categoryOrder,\n      fetchImages,\n      id,\n      images.length,\n      type,\n      uniqueDefaultImages.length,\n    ],\n  )\n\n  const reFetch = useCallback(async () => {\n    if (loading) {\n      return\n    }\n\n    dispatch(loadImagesRequest())\n\n    try {\n      const {\n        data: fetchedImages,\n        total,\n        next,\n      } = await fetchImages({\n        target: { type, id },\n        currentImageLength: 0,\n        size: 15,\n        categoryOrder,\n      })\n\n      const filteredDefaultImages = filterDefaultImages(\n        uniqueDefaultImages,\n        fetchedImages,\n      )\n\n      dispatch(\n        reinitializeImages({\n          images: [...filteredDefaultImages, ...fetchedImages],\n          total: total + filteredDefaultImages.length,\n          hasMore: !!next,\n        }),\n      )\n      setUniqueDefaultImages(filteredDefaultImages)\n    } catch (error) {\n      dispatch(loadImagesFail(error))\n    }\n  }, [loading, fetchImages, type, id, categoryOrder, uniqueDefaultImages])\n\n  const fetch = useCallback(\n    async (onFetchAfter?: () => void, force?: boolean) => {\n      if (!force && (loading || !hasMore)) {\n        return\n      }\n\n      dispatch(loadImagesRequest())\n\n      try {\n        const { data: fetchedImages, total, next } = await sendFetchRequest()\n\n        const filteredDefaultImages =\n          uniqueDefaultImages.length === images.length // 이미지 순서가 바뀌는 경우를 방지하기 위해 첫 fetch 시에만 필터링을 진행합니다.\n            ? filterDefaultImages(uniqueDefaultImages, fetchedImages)\n            : uniqueDefaultImages\n\n        const shouldReInitImages =\n          filteredDefaultImages.length !== uniqueDefaultImages.length\n\n        if (shouldReInitImages) {\n          dispatch(\n            reinitializeImages({\n              images: [...filteredDefaultImages, ...fetchedImages],\n              total: total + filteredDefaultImages.length,\n              hasMore: !!next,\n            }),\n          )\n          setUniqueDefaultImages(filteredDefaultImages)\n        } else {\n          dispatch(\n            loadImagesSuccess({\n              images: fetchedImages,\n              total: total + filteredDefaultImages.length,\n              hasMore: !!next,\n            }),\n          )\n        }\n      } catch (error) {\n        dispatch(loadImagesFail(error))\n      }\n\n      onFetchAfter && onFetchAfter()\n    },\n    [hasMore, images.length, loading, sendFetchRequest, uniqueDefaultImages],\n  )\n\n  const indexOf = useCallback(\n    async ({ id: targetId }: { id: string }) => {\n      const index = images.findIndex(({ id }) => id === targetId)\n      if (index >= 0) {\n        return index\n      }\n\n      // Just fetch 30 more images to check index of clicked image.\n      // Ignore the case of unfindable image in these 45(15 + 30) images.\n      const { data: fetchedImages } = await sendFetchRequest(30)\n\n      return fetchedImages\n        ? [...images, ...fetchedImages].findIndex(({ id }) => id === targetId)\n        : -1\n    },\n    [images, sendFetchRequest],\n  )\n\n  useEffect(() => {\n    fetch(undefined, true)\n  }, []) // eslint-disable-line react-hooks/exhaustive-deps\n\n  const value = useMemo(\n    () => ({\n      images,\n      total,\n      actions: {\n        fetch,\n        reFetch,\n        indexOf,\n      },\n      loading,\n    }),\n    [fetch, images, indexOf, total, loading, reFetch],\n  )\n\n  return <Context.Provider value={value}>{children}</Context.Provider>\n}\n\nexport function usePoiDetailImages() {\n  return useContext(Context)\n}\n\nfunction filterDefaultImages(\n  defaultImages: ImageMeta[],\n  fetchedImages: ImageMeta[],\n) {\n  return defaultImages.filter(\n    (defaultImage) =>\n      !fetchedImages.some(\n        (fetchedImage) => fetchedImage.id === defaultImage.id,\n      ),\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/images-reducer.ts",
    "content": "import { ImageMeta } from '@titicaca/type-definitions'\n\ninterface ImagesState {\n  loading: boolean\n  images: ImageMeta[]\n  total: number\n  hasMore: boolean\n}\n\nconst REINITIALIZE_IMAGES = 'INITIALIZE_IMAGES'\nconst LOAD_IMAGES_REQUEST = 'LOAD_IMAGES_REQUEST'\nconst LOAD_IMAGES_SUCCESS = 'LOAD_IMAGES_SUCCESS'\nconst LOAD_IMAGES_FAIL = 'LOAD_IMAGES_FAIL'\n\nexport function loadImagesRequest() {\n  return {\n    type: LOAD_IMAGES_REQUEST,\n  } as const\n}\n\nexport function loadImagesSuccess(payload: {\n  images: ImageMeta[]\n  total: number\n  hasMore: boolean\n}) {\n  return {\n    type: LOAD_IMAGES_SUCCESS,\n    payload,\n  } as const\n}\n\nexport function loadImagesFail(error: unknown) {\n  return {\n    type: LOAD_IMAGES_FAIL,\n    error: true,\n    payload: error,\n  } as const\n}\n\nexport function reinitializeImages(payload: {\n  images: ImageMeta[]\n  total: number\n  hasMore: boolean\n}) {\n  return {\n    type: REINITIALIZE_IMAGES,\n    payload,\n  } as const\n}\n\nexport default function reducer(\n  state: ImagesState,\n  action: ReturnType<\n    | typeof loadImagesRequest\n    | typeof loadImagesSuccess\n    | typeof loadImagesFail\n    | typeof reinitializeImages\n  >,\n): ImagesState {\n  switch (action.type) {\n    case REINITIALIZE_IMAGES:\n      return {\n        loading: !action.payload.images,\n        images: action.payload.images,\n        total: action.payload.total,\n        hasMore: action.payload.hasMore,\n      }\n    case LOAD_IMAGES_REQUEST:\n      return {\n        ...state,\n        loading: true,\n      }\n    case LOAD_IMAGES_SUCCESS:\n      return {\n        loading: false,\n        images: state.images.concat(action.payload.images),\n        total: action.payload.total,\n        hasMore: action.payload.hasMore,\n      }\n    case LOAD_IMAGES_FAIL:\n      return {\n        ...state,\n        loading: false,\n      }\n    default:\n      return state\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/index.ts",
    "content": "export * from './actions'\nexport * from './detail-header'\nexport * from './detail-header-v2'\nexport * from './image-carousel'\nexport * from './images-provider'\nexport * from './recommended-articles'\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/mocks/inventory-item.json",
    "content": "{\n  \"items\": [\n    {\n      \"bucket\": null,\n      \"desc\": \"asdf\",\n      \"detailedDesc\": \"\",\n      \"id\": \"4dbb5490-8d0e-406d-b9b3-8a897015e5a8\",\n      \"image\": \"https://media.triple.guide/triple-cms/f_auto/0aec542e-8e97-4251-9b8b-6ba5c994ccdd.jpg\",\n      \"target\": \"asdf\",\n      \"text\": null\n    }\n  ]\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/mocks/recommended-articles.json",
    "content": "[\n  {\n    \"id\": \"8cdfe337-ac60-4e3e-872c-14af0fd8fdde\",\n    \"source\": {\n      \"summary\": \"旅行前に知っておくと便利な情報\",\n      \"image\": {\n        \"sourceUrl\": \"shutterstock.com\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/556b1d3b-cd01-4cc8-9ffd-70a8984f10cf.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/556b1d3b-cd01-4cc8-9ffd-70a8984f10cf.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/556b1d3b-cd01-4cc8-9ffd-70a8984f10cf.jpeg\"\n          }\n        },\n        \"width\": 1000,\n        \"cloudinaryId\": \"556b1d3b-cd01-4cc8-9ffd-70a8984f10cf\",\n        \"id\": \"556b1d3b-cd01-4cc8-9ffd-70a8984f10cf\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 667\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": 5,\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"reviewsCount\": 12,\n      \"id\": \"8cdfe337-ac60-4e3e-872c-14af0fd8fdde\",\n      \"type\": \"article\",\n      \"title\": \"済州島ってどんなところ？\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"c48583d2-e5d2-4cd2-b84e-b0ace9d31719\",\n    \"source\": {\n      \"summary\": \"여행 가기 가장 좋은 시기는 언제? 제주의 월별 기온과 강우량\",\n      \"image\": {\n        \"sourceUrl\": \"shutterstock.com\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/f136c65c-d9d6-45b2-a7ca-751853acaef9.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/f136c65c-d9d6-45b2-a7ca-751853acaef9.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/f136c65c-d9d6-45b2-a7ca-751853acaef9.jpeg\"\n          }\n        },\n        \"width\": 1000,\n        \"cloudinaryId\": \"f136c65c-d9d6-45b2-a7ca-751853acaef9\",\n        \"id\": \"f136c65c-d9d6-45b2-a7ca-751853acaef9\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 667\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": 2,\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"reviewsCount\": 3,\n      \"id\": \"c48583d2-e5d2-4cd2-b84e-b0ace9d31719\",\n      \"type\": \"article\",\n      \"title\": \"월별로 알아보는 제주 날씨\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"807eabdb-8795-4e98-bc20-71b660b420ee\",\n    \"source\": {\n      \"summary\": \"버스 여행 팁부터 여행자들이 가장 많이 이용하는 버스 알아보기\",\n      \"image\": {\n        \"sourceUrl\": \"컴퍼스 6\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/e2f46ca8-68c8-430e-becb-3352cc3df937.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/e2f46ca8-68c8-430e-becb-3352cc3df937.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/e2f46ca8-68c8-430e-becb-3352cc3df937.jpeg\"\n          }\n        },\n        \"width\": 4032,\n        \"cloudinaryId\": \"e2f46ca8-68c8-430e-becb-3352cc3df937\",\n        \"id\": \"e2f46ca8-68c8-430e-becb-3352cc3df937\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 3024\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": \"1\",\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"reviewsCount\": \"0\",\n      \"id\": \"807eabdb-8795-4e98-bc20-71b660b420ee\",\n      \"type\": \"article\",\n      \"title\": \"제주 버스 완전 정복\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"8f7189bb-8104-4bac-959f-5e5555a9bdae\",\n    \"source\": {\n      \"summary\": \"유명 여행지는 모두 가본 여행자를 위한 추천 명소 7\",\n      \"image\": {\n        \"sourceUrl\": \"instagram.com/p/CexuCv_L39a/\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/b2b3030f-8431-44ee-a44c-3133dfaa5cb6.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/b2b3030f-8431-44ee-a44c-3133dfaa5cb6.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/b2b3030f-8431-44ee-a44c-3133dfaa5cb6.jpeg\"\n          }\n        },\n        \"width\": 1440,\n        \"cloudinaryId\": \"b2b3030f-8431-44ee-a44c-3133dfaa5cb6\",\n        \"id\": \"b2b3030f-8431-44ee-a44c-3133dfaa5cb6\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 1440\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"geotags\": [\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        },\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        }\n      ],\n      \"scrapsCount\": 1,\n      \"id\": \"8f7189bb-8104-4bac-959f-5e5555a9bdae\",\n      \"type\": \"article\",\n      \"title\": \"N회차 제주 여행자를 위한 도민 추천 여행지\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"c46b56c6-fbd6-465f-aaa3-06a7853f0efa\",\n    \"source\": {\n      \"summary\": \"여행의 질을 높여주는 꿀팁 모음\",\n      \"image\": {\n        \"sourceUrl\": \"shutterstock.com\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/88b18b7e-c7ee-49d3-a229-bb33a27a0729.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/88b18b7e-c7ee-49d3-a229-bb33a27a0729.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/88b18b7e-c7ee-49d3-a229-bb33a27a0729.jpeg\"\n          }\n        },\n        \"width\": 1000,\n        \"cloudinaryId\": \"88b18b7e-c7ee-49d3-a229-bb33a27a0729\",\n        \"id\": \"88b18b7e-c7ee-49d3-a229-bb33a27a0729\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 667\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": 1,\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"reviewsCount\": \"1\",\n      \"id\": \"c46b56c6-fbd6-465f-aaa3-06a7853f0efa\",\n      \"type\": \"article\",\n      \"title\": \"제주 여행 꿀팁 가이드\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"0f158e2c-139e-4a8d-9b7a-046627ea37c4\",\n    \"source\": {\n      \"summary\": \"제주 국제공항의 다양한 시설과 서비스 소개\",\n      \"image\": {\n        \"sourceUrl\": \"앨리스 님의 사진\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/18abcf00-bb54-4e0e-8de9-80820232b207.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/18abcf00-bb54-4e0e-8de9-80820232b207.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/18abcf00-bb54-4e0e-8de9-80820232b207.jpeg\"\n          }\n        },\n        \"width\": 4032,\n        \"cloudinaryId\": \"18abcf00-bb54-4e0e-8de9-80820232b207\",\n        \"id\": \"18abcf00-bb54-4e0e-8de9-80820232b207\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 3024\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": \"1\",\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"reviewsCount\": \"0\",\n      \"id\": \"0f158e2c-139e-4a8d-9b7a-046627ea37c4\",\n      \"type\": \"article\",\n      \"title\": \"제주 국제공항 안내\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"9e07ead8-f880-48d5-8d45-a765a523bbae\",\n    \"source\": {\n      \"summary\": \"여행 중 도움이 필요할 때 유용한 정보 모음\",\n      \"image\": {\n        \"sourceUrl\": \"shutterstock.com\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/14de5758-0022-4808-b917-6a63ef69906c.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/14de5758-0022-4808-b917-6a63ef69906c.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/14de5758-0022-4808-b917-6a63ef69906c.jpeg\"\n          }\n        },\n        \"width\": 1000,\n        \"cloudinaryId\": \"14de5758-0022-4808-b917-6a63ef69906c\",\n        \"id\": \"14de5758-0022-4808-b917-6a63ef69906c\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 667\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": 1,\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"reviewsCount\": \"1\",\n      \"id\": \"9e07ead8-f880-48d5-8d45-a765a523bbae\",\n      \"type\": \"article\",\n      \"title\": \"긴급상황 시 이렇게 대처하세요!\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"7f5691c2-291a-4f8d-b438-e66ea5a3063c\",\n    \"source\": {\n      \"summary\": \"추천 코스부터 특산물까지, 우도 여행 총정리\",\n      \"image\": {\n        \"sourceUrl\": \"shutterstock.com\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/d431bca2-9c13-47f6-84ee-0459778d7f94.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/d431bca2-9c13-47f6-84ee-0459778d7f94.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/d431bca2-9c13-47f6-84ee-0459778d7f94.jpeg\"\n          }\n        },\n        \"width\": 1000,\n        \"cloudinaryId\": \"d431bca2-9c13-47f6-84ee-0459778d7f94\",\n        \"id\": \"d431bca2-9c13-47f6-84ee-0459778d7f94\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 563\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": 1,\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"id\": \"7f5691c2-291a-4f8d-b438-e66ea5a3063c\",\n      \"type\": \"article\",\n      \"title\": \"제주 속 또 다른 섬, 우도\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"86f67dab-c40e-4af3-be12-88524060ae3f\",\n    \"source\": {\n      \"summary\": \"핫한 플리 마켓, 야시장, 오일장 추천\",\n      \"image\": {\n        \"sourceUrl\": \"https://www.facebook.com/jejudomarket/photos/a.549933625214138/1193999467474214/?type=3&theater\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/eaf37d19-3d66-464e-85a0-94c3e1d0da71.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/eaf37d19-3d66-464e-85a0-94c3e1d0da71.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/eaf37d19-3d66-464e-85a0-94c3e1d0da71.jpeg\"\n          }\n        },\n        \"width\": 637,\n        \"cloudinaryId\": \"eaf37d19-3d66-464e-85a0-94c3e1d0da71\",\n        \"id\": \"eaf37d19-3d66-464e-85a0-94c3e1d0da71\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 960\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": 1,\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"id\": \"86f67dab-c40e-4af3-be12-88524060ae3f\",\n      \"type\": \"article\",\n      \"title\": \"눈과 입을 즐겁게 해줄 제주 시장\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"f16ed6e4-5436-4f38-acac-e9bd0bc8e2b0\",\n    \"source\": {\n      \"summary\": \"우도, 마라도, 가파도, 비양도, 차귀도, 추자도 가는 방법 총정리\",\n      \"image\": {\n        \"sourceUrl\": \"트리플\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/8cd05f39-1c54-4dcc-9ed8-422ffafdb78a.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/8cd05f39-1c54-4dcc-9ed8-422ffafdb78a.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/8cd05f39-1c54-4dcc-9ed8-422ffafdb78a.jpeg\"\n          }\n        },\n        \"width\": 1260,\n        \"cloudinaryId\": \"8cd05f39-1c54-4dcc-9ed8-422ffafdb78a\",\n        \"id\": \"8cd05f39-1c54-4dcc-9ed8-422ffafdb78a\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 1260\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": \"1\",\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"reviewsCount\": \"0\",\n      \"id\": \"f16ed6e4-5436-4f38-acac-e9bd0bc8e2b0\",\n      \"type\": \"article\",\n      \"title\": \"제주 속 주요 섬 가는 방법\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"67130c43-9c89-4114-942e-a56da455b004\",\n    \"source\": {\n      \"summary\": \"렌터카 예약부터 운전 시 주의 사항, 주유하는 방법까지\",\n      \"image\": {\n        \"sourceUrl\": \"shutterstock.com\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/0d100188-ed70-447f-85b4-8c33eed6d6de.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/0d100188-ed70-447f-85b4-8c33eed6d6de.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/0d100188-ed70-447f-85b4-8c33eed6d6de.jpeg\"\n          }\n        },\n        \"width\": 4272,\n        \"cloudinaryId\": \"0d100188-ed70-447f-85b4-8c33eed6d6de\",\n        \"id\": \"0d100188-ed70-447f-85b4-8c33eed6d6de\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 2848\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": 0,\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"id\": \"67130c43-9c89-4114-942e-a56da455b004\",\n      \"type\": \"article\",\n      \"title\": \"제주에서 렌터카 이용하기\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"1d933d2d-5785-449a-b509-c47e92dd7819\",\n    \"source\": {\n      \"summary\": \"내 여행 스타일에 딱 맞는 숙소 위치 정하기\",\n      \"image\": {\n        \"sourceUrl\": \"shutterstock.com\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/bc71627b-f5cc-4f2a-8f0d-5aa55023d34e.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/bc71627b-f5cc-4f2a-8f0d-5aa55023d34e.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/bc71627b-f5cc-4f2a-8f0d-5aa55023d34e.jpeg\"\n          }\n        },\n        \"width\": 1000,\n        \"cloudinaryId\": \"bc71627b-f5cc-4f2a-8f0d-5aa55023d34e\",\n        \"id\": \"bc71627b-f5cc-4f2a-8f0d-5aa55023d34e\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 667\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": 0,\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"reviewsCount\": \"0\",\n      \"id\": \"1d933d2d-5785-449a-b509-c47e92dd7819\",\n      \"type\": \"article\",\n      \"title\": \"제주 여행, 어디서 묵을까\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"80d42ed4-2f18-43a0-90a5-5b09e6e4ee17\",\n    \"source\": {\n      \"summary\": \"대자연부터 감성 카페까지, 인생샷을 남길 수 있는 곳\",\n      \"image\": {\n        \"sourceUrl\": \"shutterstock.com\",\n        \"cloudinaryBucket\": \"triple-cms\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/54e3594d-1087-43ff-b760-7231d4103edf.jpeg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/54e3594d-1087-43ff-b760-7231d4103edf.jpeg\"\n          },\n          \"full\": {\n            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/54e3594d-1087-43ff-b760-7231d4103edf.jpeg\"\n          }\n        },\n        \"width\": 1000,\n        \"cloudinaryId\": \"54e3594d-1087-43ff-b760-7231d4103edf\",\n        \"id\": \"54e3594d-1087-43ff-b760-7231d4103edf\",\n        \"source\": {},\n        \"type\": \"image\",\n        \"height\": 668\n      },\n      \"regionId\": \"759174cc-0814-4400-a420-5668a0517edd\",\n      \"scrapsCount\": 0,\n      \"geotags\": [\n        {\n          \"id\": \"759174cc-0814-4400-a420-5668a0517edd\",\n          \"type\": \"triple-region\"\n        },\n        {\n          \"id\": \"41658134-233f-4b30-adad-ea58ace7c71b\",\n          \"type\": \"triple-zone\"\n        }\n      ],\n      \"id\": \"80d42ed4-2f18-43a0-90a5-5b09e6e4ee17\",\n      \"type\": \"article\",\n      \"title\": \"여름 제주 인생샷 명소 BEST 7\"\n    },\n    \"type\": \"article\",\n    \"reviewed\": false,\n    \"scraped\": false\n  }\n]\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/recommended-articles/api-client.ts",
    "content": "import { authGuardedFetchers, NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\nimport qs from 'qs'\n\nimport { ArticleListingData } from './types'\n\nexport async function fetchRecommendedArticles({\n  regionId,\n  zoneId,\n}: {\n  regionId?: string\n  zoneId?: string\n}): Promise<ArticleListingData[]> {\n  const response = await authGuardedFetchers.get<ArticleListingData[]>(\n    `/api/content/articles?${qs.stringify({\n      ...((regionId || zoneId) && {\n        geotags: [\n          ...(regionId ? [{ type: 'triple-region', id: regionId }] : []),\n          ...(zoneId ? [{ type: 'triple-zone', id: zoneId }] : []),\n        ],\n      }),\n      sortBy: 'scrap',\n    })}`,\n  )\n\n  if (response !== NEED_LOGIN_IDENTIFIER && response.ok === true) {\n    const { parsedBody } = response\n    return shuffle(parsedBody.filter(({ source: { image } }) => image))\n  } else {\n    throw new Error(`Failed to fetch recommended articles`)\n  }\n}\n\nfunction shuffle<T>(array: T[]): T[] {\n  for (let i = 0; i < array.length; i++) {\n    const j = Math.floor(Math.random() * array.length)\n    const tmp = array[i]\n    array[i] = array[j]\n    array[j] = tmp\n  }\n\n  return array\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/recommended-articles/article-entry.tsx",
    "content": "import { SyntheticEvent } from 'react'\nimport { Image, H3 } from '@titicaca/tds-ui'\nimport { StaticIntersectionObserver } from '@titicaca/intersection-observer'\n\nimport { ArticleListingData } from './types'\n\nexport function ArticleEntry({\n  article,\n  article: {\n    source: { title, image },\n  },\n  onClick,\n  onIntersect,\n}: {\n  article: ArticleListingData\n  onClick: (e: SyntheticEvent, article: ArticleListingData) => void\n  onIntersect: (article: ArticleListingData) => void\n}) {\n  const handleClick = (e: SyntheticEvent) => onClick(e, article)\n  const handleIntersectionChange = ({\n    isIntersecting,\n  }: {\n    isIntersecting: boolean\n  }) => isIntersecting && onIntersect(article)\n\n  return (\n    <StaticIntersectionObserver\n      threshold={0.7}\n      onChange={handleIntersectionChange}\n    >\n      <div>\n        <Image borderRadius={6}>\n          <Image.FixedRatioFrame frame=\"big\" onClick={handleClick}>\n            <Image.Img src={image && image.sizes.large.url} />\n\n            <Image.Overlay\n              padding={{ top: 16, bottom: 16, left: 16, right: 26 }}\n            >\n              <H3 lineHeight=\"25px\" color=\"white\">\n                {title}\n              </H3>\n            </Image.Overlay>\n          </Image.FixedRatioFrame>\n        </Image>\n      </div>\n    </StaticIntersectionObserver>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/recommended-articles/index.tsx",
    "content": "export * from './recommended-articles'\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/recommended-articles/more-button.tsx",
    "content": "import { styled } from 'styled-components'\nimport { Button } from '@titicaca/tds-ui'\n\nexport const MoreButton = styled(Button)`\n  width: 100%;\n  text-align: center;\n  padding: 8px 0;\n  margin-top: 20px;\n  font-size: 14px;\n  font-weight: bold;\n`\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/recommended-articles/recommended-articles.tsx",
    "content": "import { useState, useEffect, useCallback, SyntheticEvent } from 'react'\nimport {\n  Section,\n  Responsive,\n  Container,\n  H1,\n  formatMarginPadding,\n} from '@titicaca/tds-ui'\nimport {\n  useTranslation,\n  useTrackEvent,\n  useAppInstallCtaModal,\n} from '@titicaca/triple-web'\nimport { InventoryItemMeta } from '@titicaca/type-definitions'\nimport { styled } from 'styled-components'\n\nimport { ArticleCardCta, fetchInventoryItems } from '../../app-installation-cta'\nimport { FlickingCarousel } from '../../flicking-carousel/flicking-carousel'\n\nimport { fetchRecommendedArticles } from './api-client'\nimport { ArticleListingData } from './types'\nimport { ArticleEntry } from './article-entry'\nimport { MoreButton } from './more-button'\n\nconst MobileCarousel = styled(FlickingCarousel)`\n  margin-top: 20px;\n  padding: 0 30px;\n`\n\nexport function PoiDetailRecommendedArticles({\n  regionId,\n  zoneId,\n  mobilePadding,\n  deskTopPadding,\n  onArticleClick,\n  onMoreClick,\n  appInstallationCta,\n}: {\n  regionId?: string\n  zoneId?: string\n  /**\n   * mobilePadding, deskTopPadding\n   *\n   * 예시 1 (호텔)\n   * - https://triple-dev.titicaca-corp.com/hotels/375458d2-09fb-408b-b2dd-53932ed6ce89?regionId=759174cc-0814-4400-a420-5668a0517edd&cityId=KM1861798255&_triple_no_navbar=true&from=public-list\n   *\n   * 예시 2 (공유 일정)\n   * - https://triple-staging.titicaca-corp.com/trips/lounge/itineraries/797d148d-4769-4bf1-8f36-839ef2801979\n   *\n   * 위의 예시 링크들의 하단을 보면 디자인이 달라 mobile, desktop 각각 내부 padding을 커스텀할 때 사용하는 props\n   */\n  mobilePadding?: { left: number; right: number }\n  deskTopPadding?: { left: number; right: number }\n  onArticleClick: (\n    e: SyntheticEvent,\n    clickedArticle: ArticleListingData,\n  ) => void\n  onMoreClick?: () => void\n  appInstallationCta?: {\n    href?: string\n    inventoryId: string\n    onClick?: () => void\n  }\n}) {\n  const t = useTranslation()\n\n  const [recommendedArticles, setRecommendedArticles] = useState<\n    ArticleListingData[]\n  >([])\n  const [articleCardCta, setArticleCardCta] =\n    useState<InventoryItemMeta | null>(null)\n\n  const { show } = useAppInstallCtaModal()\n  const trackEvent = useTrackEvent()\n\n  useEffect(() => {\n    async function fetchAndSetRecommendedArticles() {\n      setRecommendedArticles(\n        await fetchRecommendedArticles({ regionId, zoneId }),\n      )\n    }\n    async function fetchAndSetArticleCardCta() {\n      const items = await fetchInventoryItems({\n        inventoryId: appInstallationCta?.inventoryId,\n      })\n\n      if (items && items.length > 0) {\n        setArticleCardCta(items[0])\n      }\n    }\n\n    fetchAndSetRecommendedArticles()\n\n    if (appInstallationCta?.inventoryId) {\n      fetchAndSetArticleCardCta()\n    }\n  }, [\n    appInstallationCta,\n    regionId,\n    zoneId,\n    setRecommendedArticles,\n    setArticleCardCta,\n  ])\n\n  const handleIntersect = useCallback(\n    (intersectingArticle: ArticleListingData) => {\n      trackEvent({\n        ga: ['추천가이드_노출', intersectingArticle.source.title],\n        fa: {\n          action: '추천가이드_노출',\n          item_name: intersectingArticle.source.title,\n          item_id: intersectingArticle.id,\n        },\n      })\n    },\n    [trackEvent],\n  )\n\n  const handleShowMoreClick = useCallback(() => {\n    onMoreClick\n      ? onMoreClick()\n      : show({ triggeredEventAction: '추천가이드_더보기' })\n  }, [onMoreClick, show])\n\n  if (!recommendedArticles || recommendedArticles.length === 0) {\n    return null\n  }\n\n  return (\n    <Section\n      divider=\"top\"\n      css={{\n        marginTop: 50,\n        marginBottom: 50,\n        padding: '0',\n      }}\n    >\n      <Responsive minWidth={768}>\n        <H1\n          css={{\n            margin: `0 0 0 ${deskTopPadding?.right || 110}px`,\n          }}\n        >\n          {t('놓치기 아까운 이 지역 꿀 정보 2')}\n        </H1>\n\n        <FlickingCarousel\n          margin={{ top: 20 }}\n          containerPadding={deskTopPadding || { left: 110, right: 110 }}\n        >\n          {articleCardCta && (\n            <FlickingCarousel.Item size=\"medium\">\n              <ArticleCardCta\n                cta={articleCardCta}\n                href={appInstallationCta?.href}\n                onClick={appInstallationCta?.onClick}\n              />\n            </FlickingCarousel.Item>\n          )}\n          {recommendedArticles.map((article) => (\n            <FlickingCarousel.Item key={article.id} size=\"medium\">\n              <ArticleEntry\n                article={article}\n                onClick={onArticleClick}\n                onIntersect={handleIntersect}\n              />\n            </FlickingCarousel.Item>\n          ))}\n        </FlickingCarousel>\n\n        <Container\n          css={formatMarginPadding(\n            deskTopPadding || { left: 110, right: 110 },\n            'padding',\n          )}\n        >\n          <MoreButton basic compact onClick={handleShowMoreClick}>\n            {t('여행 정보 더보기')}\n          </MoreButton>\n        </Container>\n      </Responsive>\n\n      <Responsive maxWidth={767}>\n        <H1\n          css={{\n            margin: `0 0 0 ${mobilePadding?.right || 30}px`,\n          }}\n        >\n          {t('놓치기 아까운 이 지역 꿀 정보')}\n        </H1>\n        <MobileCarousel>\n          {articleCardCta && (\n            <FlickingCarousel.Item size=\"medium\">\n              <ArticleCardCta\n                cta={articleCardCta}\n                href={appInstallationCta?.href}\n                onClick={appInstallationCta?.onClick}\n              />\n            </FlickingCarousel.Item>\n          )}\n          {recommendedArticles.map((article) => (\n            <FlickingCarousel.Item key={article.id} size=\"medium\">\n              <ArticleEntry\n                article={article}\n                onClick={onArticleClick}\n                onIntersect={handleIntersect}\n              />\n            </FlickingCarousel.Item>\n          ))}\n        </MobileCarousel>\n        <Container\n          css={formatMarginPadding(\n            mobilePadding || { left: 30, right: 30 },\n            'padding',\n          )}\n        >\n          <MoreButton basic compact onClick={handleShowMoreClick}>\n            {t('여행 정보 더보기')}\n          </MoreButton>\n        </Container>\n      </Responsive>\n    </Section>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/recommended-articles/types.ts",
    "content": "import { ImageMeta } from '@titicaca/type-definitions'\n\nexport interface ArticleListingData {\n  id: string\n  type: 'article'\n  source: {\n    title: string\n    image?: ImageMeta\n  }\n  reviewed: boolean\n  scraped: boolean\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/recommended-articles.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { http, HttpResponse } from 'msw'\n\nimport { PoiDetailRecommendedArticles } from './recommended-articles/recommended-articles'\n\nconst meta: Meta<typeof PoiDetailRecommendedArticles> = {\n  title: 'tds-widget / poi-detail / RecommendedArticles',\n  component: PoiDetailRecommendedArticles,\n  parameters: {\n    chromatic: {\n      disableSnapshot: true,\n    },\n  },\n}\n\nexport default meta\n\ntype Story = StoryObj<typeof PoiDetailRecommendedArticles>\n\nexport const Basic: Story = {\n  args: {\n    appInstallationCta: {\n      inventoryId: 'app-install-cta-poi-v1',\n      href: 'https://triple-dev.titicaca-corp.com',\n    },\n    regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n  },\n  parameters: {\n    msw: {\n      handlers: [\n        http.get(\n          '/api/content/articles?geotags%5B0%5D%5Btype%5D=triple-region&geotags%5B0%5D%5Bid%5D=edf1982d-c835-43a7-b06b-af43acbb6f38&sortBy=scrap',\n          async () => {\n            return HttpResponse.json([\n              {\n                id: '10465daa-d6c5-41c1-b8e1-09b96ee79f03',\n                source: {\n                  id: '10465daa-d6c5-41c1-b8e1-09b96ee79f03',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '여행을 더욱 풍성하게, 방콕 대표 축제',\n                  summary: '월별로 알아보는 방콕 대표 축제들',\n                  image: {\n                    cloudinaryId: '0aa2a4dd-4c8b-4285-8312-6f45f86ae512',\n                    id: '24be1f76-c25d-4ffe-a049-a94d486a8f26',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/0aa2a4dd-4c8b-4285-8312-6f45f86ae512.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/0aa2a4dd-4c8b-4285-8312-6f45f86ae512.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/0aa2a4dd-4c8b-4285-8312-6f45f86ae512.jpeg',\n                      },\n                    },\n                    source: {},\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  reviewsCount: 0,\n                  scrapsCount: 2,\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: '057b607f-81db-475f-843d-eaca121e98b0',\n                source: {\n                  id: '057b607f-81db-475f-843d-eaca121e98b0',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '월별로 알아보는 방콕 날씨',\n                  summary:\n                    '여행 가기에 가장 좋은 시기는 언제? 방콕의 월별 기온과 강우량',\n                  image: {\n                    cloudinaryId: '0e1df5e5-bfed-4d02-9f69-7e6b8167ed9e',\n                    id: 'd5c1eb97-f1cf-480a-a787-ddf135f71de9',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/0e1df5e5-bfed-4d02-9f69-7e6b8167ed9e.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/0e1df5e5-bfed-4d02-9f69-7e6b8167ed9e.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/0e1df5e5-bfed-4d02-9f69-7e6b8167ed9e.jpeg',\n                      },\n                    },\n                    source: {},\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  reviewsCount: 0,\n                  scrapsCount: 2,\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: '1c86606b-d674-4c0c-a161-c6af2f2dc5c0',\n                source: {\n                  id: '1c86606b-d674-4c0c-a161-c6af2f2dc5c0',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '방콕 기초 정보',\n                  summary: '떠나기 전 미리 알아두면 좋을 여행 정보',\n                  image: {\n                    cloudinaryId: '7e9e48c4-f4b5-4aac-9c45-7537324458cc',\n                    id: '5f68c41a-3897-4eca-98d7-749160052a67',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/7e9e48c4-f4b5-4aac-9c45-7537324458cc.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/7e9e48c4-f4b5-4aac-9c45-7537324458cc.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/7e9e48c4-f4b5-4aac-9c45-7537324458cc.jpeg',\n                      },\n                    },\n                    source: {},\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  reviewsCount: 1,\n                  scrapsCount: 2,\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: 'eff78728-f75c-4f0f-814c-5fc91384115d',\n                source: {\n                  id: 'eff78728-f75c-4f0f-814c-5fc91384115d',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '방콕 근교까지 즐기는 3박 4일 추천 코스',\n                  summary: '방콕 시내는 물론 근교까지 한 번에 다녀오기',\n                  image: {\n                    cloudinaryId: '58af5f8e-180b-4ee6-9844-dd8b95ccbbf4',\n                    id: '28b473d6-fa23-476f-890f-71bd320ad236',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/58af5f8e-180b-4ee6-9844-dd8b95ccbbf4.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/58af5f8e-180b-4ee6-9844-dd8b95ccbbf4.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/58af5f8e-180b-4ee6-9844-dd8b95ccbbf4.jpeg',\n                      },\n                    },\n                    source: {},\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                  scrapsCount: 1,\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: 'ea850d61-0010-4598-a2a4-ab088610700e',\n                source: {\n                  id: 'ea850d61-0010-4598-a2a4-ab088610700e',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '해외 여행의 특권, 면세 쇼핑하기',\n                  summary: '면세품을 구입할 수 있는 다양한 방법들 소개',\n                  image: {\n                    cloudinaryId: '5165dd01-3c43-47da-bf57-0b24a22a17e1',\n                    id: '0bdf927f-5ef7-4f61-965f-bec2d974847d',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/5165dd01-3c43-47da-bf57-0b24a22a17e1.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/5165dd01-3c43-47da-bf57-0b24a22a17e1.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/5165dd01-3c43-47da-bf57-0b24a22a17e1.jpeg',\n                      },\n                    },\n                    source: {},\n                    width: 1000,\n                    height: 667,\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  reviewsCount: 1,\n                  scrapsCount: 0,\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: 'ef42a012-b3a0-4ccf-949b-a462ed8c26c1',\n                source: {\n                  id: 'ef42a012-b3a0-4ccf-949b-a462ed8c26c1',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '현지에서 똑똑하게 카드 사용하는 방법',\n                  summary:\n                    '사용할 수 있는 카드와 수수료 절약 방법 그리고 이용팁까지',\n                  image: {\n                    cloudinaryId: '04c4a061-fefa-43d2-b7f2-163272b87d83',\n                    id: '04c4a061-fefa-43d2-b7f2-163272b87d83',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/04c4a061-fefa-43d2-b7f2-163272b87d83.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/04c4a061-fefa-43d2-b7f2-163272b87d83.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/04c4a061-fefa-43d2-b7f2-163272b87d83.jpeg',\n                      },\n                    },\n                    source: {},\n                    width: 1000,\n                    height: 664,\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: '06bf2d91-f54b-477e-8e6c-cef08f4aa0c4',\n                source: {\n                  id: '06bf2d91-f54b-477e-8e6c-cef08f4aa0c4',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '인천 국제공항 제 2터미널 안내',\n                  summary: '인천 국제공항 제 2터미널의 대표 시설 알아보기',\n                  image: {\n                    cloudinaryId: '93440699-4c06-4847-8c5d-8122cbf7c0cc',\n                    id: '93440699-4c06-4847-8c5d-8122cbf7c0cc',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/93440699-4c06-4847-8c5d-8122cbf7c0cc.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/93440699-4c06-4847-8c5d-8122cbf7c0cc.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/93440699-4c06-4847-8c5d-8122cbf7c0cc.jpeg',\n                      },\n                    },\n                    source: {},\n                    width: 1080,\n                    height: 544,\n                    sourceUrl:\n                      'https://www.facebook.com/incheonairport/photos/a.355745784527921/2026404927461990/?type=3&theater',\n                  },\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: '7af75ad3-a4d6-405c-8ea6-232fcdaee6e5',\n                source: {\n                  id: '7af75ad3-a4d6-405c-8ea6-232fcdaee6e5',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '파타야 베스트 추천 호텔',\n                  summary: '가족, 친구, 연인과 가기 좋은 파타야 베스트 호텔',\n                  image: {\n                    cloudinaryId: 'b9b96247-d9dd-444c-9bd2-e751b0ba14d4',\n                    id: 'd1ae4961-4003-452a-bbb7-957f6ad32c7e',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/b9b96247-d9dd-444c-9bd2-e751b0ba14d4.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/b9b96247-d9dd-444c-9bd2-e751b0ba14d4.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/b9b96247-d9dd-444c-9bd2-e751b0ba14d4.jpeg',\n                      },\n                    },\n                    source: {},\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: '16ac3ab2-ab72-4830-8bc5-91044567bb6b',\n                source: {\n                  id: '16ac3ab2-ab72-4830-8bc5-91044567bb6b',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '출국 과정의 모든 것',\n                  summary: '공항 도착부터 출국까지, 이것만 알면 걱정 끝!',\n                  image: {\n                    cloudinaryId: '219ad9ac-6d9b-4826-ac66-1002810d00a6',\n                    id: '219ad9ac-6d9b-4826-ac66-1002810d00a6',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/219ad9ac-6d9b-4826-ac66-1002810d00a6.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/219ad9ac-6d9b-4826-ac66-1002810d00a6.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/219ad9ac-6d9b-4826-ac66-1002810d00a6.jpeg',\n                      },\n                    },\n                    source: {},\n                    width: 1000,\n                    height: 574,\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: '99ffd38e-8db0-450a-a753-dbde6f47a004',\n                source: {\n                  id: '99ffd38e-8db0-450a-a753-dbde6f47a004',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '대구국제공항 가는 길',\n                  summary: '대구국제공항 가는 교통편 알아보기',\n                  image: {\n                    cloudinaryId: '65dcdeac-0d62-4a79-9831-9037dc212a3d',\n                    id: '65dcdeac-0d62-4a79-9831-9037dc212a3d',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/65dcdeac-0d62-4a79-9831-9037dc212a3d.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/65dcdeac-0d62-4a79-9831-9037dc212a3d.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/65dcdeac-0d62-4a79-9831-9037dc212a3d.jpeg',\n                      },\n                    },\n                    source: {},\n                    width: 1000,\n                    height: 667,\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: '52b03281-1674-48fd-b65f-6dccd86e3af1',\n                source: {\n                  id: '52b03281-1674-48fd-b65f-6dccd86e3af1',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '방콕 교통 완전 정복',\n                  summary: '편리한 여행을 위한 교통 수단 정보 모음',\n                  image: {\n                    cloudinaryId: '242f51be-cdaa-425d-bb0c-9fda4d5b5535',\n                    id: '242f51be-cdaa-425d-bb0c-9fda4d5b5535',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/242f51be-cdaa-425d-bb0c-9fda4d5b5535.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/242f51be-cdaa-425d-bb0c-9fda4d5b5535.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/242f51be-cdaa-425d-bb0c-9fda4d5b5535.jpeg',\n                      },\n                    },\n                    source: {},\n                    width: 1000,\n                    height: 616,\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: '38dd8e08-d261-4289-a612-97ab5b3ae68f',\n                source: {\n                  id: '38dd8e08-d261-4289-a612-97ab5b3ae68f',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '출국 전 체크사항',\n                  summary: '방콕 여행을 떠나기 전, 반드시 챙겨야 할 체크리스트',\n                  image: {\n                    cloudinaryId: '5f8e905a-4c97-40e5-81f7-1fa63f6bea87',\n                    id: '5f8e905a-4c97-40e5-81f7-1fa63f6bea87',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/5f8e905a-4c97-40e5-81f7-1fa63f6bea87.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/5f8e905a-4c97-40e5-81f7-1fa63f6bea87.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/5f8e905a-4c97-40e5-81f7-1fa63f6bea87.jpeg',\n                      },\n                    },\n                    source: {},\n                    width: 1000,\n                    height: 667,\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n              {\n                id: '78548075-da47-40a1-ad0e-9d1b61d15fc6',\n                source: {\n                  id: '78548075-da47-40a1-ad0e-9d1b61d15fc6',\n                  type: 'article',\n                  regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                  title: '방콕 여행 꿀팁 가이드',\n                  summary: '알면 쓸모 있는 요모조모 방콕 필수 팁',\n                  image: {\n                    cloudinaryId: '00de8510-06b2-4837-b6c9-c30f091983af',\n                    id: 'b622576d-f5ff-4284-8861-0fa333b1921c',\n                    type: 'image',\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/00de8510-06b2-4837-b6c9-c30f091983af.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/00de8510-06b2-4837-b6c9-c30f091983af.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/00de8510-06b2-4837-b6c9-c30f091983af.jpeg',\n                      },\n                    },\n                    source: {},\n                    sourceUrl: 'shutterstock.com',\n                  },\n                  geotags: [\n                    {\n                      id: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n                      type: 'triple-region',\n                    },\n                  ],\n                },\n                type: 'article',\n                reviewed: false,\n                scraped: false,\n              },\n            ])\n          },\n        ),\n      ],\n    },\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/types.ts",
    "content": "export type ImageCategoryOrder =\n  | 'image' // 대표 이미지\n  | 'recommendation'\n  | 'menuItem' // 대표 메뉴 이미지\n  | 'menuBoard' // 메뉴판 이미지\n  | 'featuredContent'\n  | 'images' // 어드민에 등록된 이미지들 가운데 대표 이미지를 제외한 나머지 이미지\n"
  },
  {
    "path": "packages/tds-widget/src/poi-detail/use-fetch-images.tsx",
    "content": "import qs from 'qs'\nimport { useState } from 'react'\nimport { ImageMeta } from '@titicaca/type-definitions'\nimport {\n  captureHttpError,\n  authGuardedFetchers,\n  NEED_LOGIN_IDENTIFIER,\n} from '@titicaca/fetcher'\n\nimport { ImageCategoryOrder } from './types'\n\nexport default function useFetchImages() {\n  const [totalContentImagesCount, setTotalContentImagesCount] = useState(0)\n  const [totalPoiReviewImagesCount, setTotalPoiReviewImagesCount] = useState(0)\n\n  async function fetchImages({\n    target,\n    currentImageLength,\n    size,\n    categoryOrder: categoryOrderArray,\n  }: {\n    target: { type: string; id: string }\n    currentImageLength: number\n    size: number\n    categoryOrder: Array<ImageCategoryOrder>\n  }) {\n    const categoryOrder = categoryOrderArray.join(',')\n\n    if (\n      currentImageLength === 0 ||\n      currentImageLength < totalContentImagesCount\n    ) {\n      const response = await fetchContentImages(target, {\n        from: currentImageLength,\n        size,\n        categoryOrder,\n      })\n      setTotalContentImagesCount(response.total)\n\n      const needReviewImages = response.data.length < size //  content 이미지의 개수가 부족해서 리뷰 이미지도 fetch 해야할 때\n      const poiReviewsResponse =\n        needReviewImages || currentImageLength === 0\n          ? await fetchPoiReviewImages(target, {\n              from: 0,\n              size: needReviewImages ? size - response.data.length : 1, // 1은 첫 fetch에 review image total을 알아오기 위함\n            })\n          : { data: [], next: null, total: 0 }\n      return {\n        ...response,\n        data: [\n          ...response.data,\n          ...(needReviewImages ? poiReviewsResponse.data : []),\n        ],\n        total:\n          response.total +\n          (poiReviewsResponse.total || totalPoiReviewImagesCount),\n        next:\n          response.next || (needReviewImages ? poiReviewsResponse.next : true),\n      }\n    }\n    const response = await fetchPoiReviewImages(target, {\n      from: currentImageLength - totalContentImagesCount,\n      size,\n    })\n    setTotalPoiReviewImagesCount(response.total)\n    return {\n      ...response,\n      total: response.total + totalContentImagesCount,\n      hasMore: !!response.next,\n    }\n  }\n\n  return fetchImages\n}\n\n/** API 문서 : https://inpk.atlassian.net/wiki/spaces/dev/pages/480903530/API */\nasync function sendFetchImages(\n  querystring: string,\n  endpoint: 'content' | 'reviews',\n) {\n  const response = await authGuardedFetchers.get<\n    {\n      data: ImageMeta[]\n      total: number\n      next: string | null\n      prev: string | null\n      count: number\n    },\n    { message: string }\n  >(`/api/${endpoint}/v2/images?${querystring}`)\n\n  if (response !== NEED_LOGIN_IDENTIFIER && response.ok === true) {\n    const { parsedBody } = response\n    return parsedBody\n  } else {\n    if (response !== NEED_LOGIN_IDENTIFIER) {\n      captureHttpError(response)\n    }\n    throw new Error(`Fail to fetch ${endpoint} images`)\n  }\n}\n\nasync function fetchContentImages(\n  target: { type: string; id: string },\n  query: { from: number; size: number; categoryOrder: string },\n) {\n  const querystring = qs.stringify({\n    resource_type: target.type,\n    resource_id: target.id,\n    from: query.from,\n    size: query.size,\n    category_order: query.categoryOrder,\n  })\n  return sendFetchImages(querystring, 'content')\n}\n\nasync function fetchPoiReviewImages(\n  target: { id: string },\n  query: { from: number; size: number },\n) {\n  const querystring = qs.stringify({\n    resource_id: target.id,\n    from: query.from,\n    size: query.size,\n  })\n  return sendFetchImages(querystring, 'reviews')\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/carousel-element.tsx",
    "content": "import { ReactNode } from 'react'\nimport {\n  Text,\n  Container,\n  Image,\n  Carousel,\n  CarouselSizes,\n} from '@titicaca/tds-ui'\nimport { FrameRatioAndSizes, GuestModeType } from '@titicaca/type-definitions'\nimport { useTranslation } from '@titicaca/triple-web'\n\nimport { OverlayScrapButton } from '../scrap-button'\n\nimport { POI_IMAGE_PLACEHOLDERS } from './constants'\nimport { getTypeNames } from './get-type-names'\nimport {\n  PoiListElementBaseProps,\n  ActionButtonElement,\n  PoiListElementType,\n} from './types'\n\nexport function PoiCarouselElement<T extends PoiListElementType>({\n  poi,\n  poi: {\n    type,\n    region,\n    nameOverride,\n    source: { image, names, areas, vicinity },\n  },\n  onClick,\n  actionButtonElement,\n  description,\n  additionalInfo = null,\n  carouselSize,\n  titleTopSpacing = 10,\n  imageFrame,\n  onImpress,\n  optimized,\n  guestMode,\n}: PoiListElementBaseProps<T> & {\n  actionButtonElement?: ActionButtonElement\n  description?: ReactNode\n  additionalInfo?: ReactNode\n  carouselSize?: CarouselSizes\n  titleTopSpacing?: number\n  imageFrame?: FrameRatioAndSizes\n  onImpress?: () => void\n  optimized?: boolean\n  guestMode?: GuestModeType\n}) {\n  const t = useTranslation()\n\n  if (!poi) {\n    return null\n  }\n\n  const { names: regionNames } = region?.source || {}\n\n  const name =\n    nameOverride || names.primary || names.ko || names.en || names.local\n  const regionName =\n    regionNames?.primary ||\n    regionNames?.ko ||\n    regionNames?.en ||\n    regionNames?.local\n  const ActionButton = actionButtonElement || (\n    <Container\n      position=\"absolute\"\n      css={{\n        top: '3px',\n        right: '3px',\n      }}\n    >\n      <OverlayScrapButton resource={poi} size={36} />\n    </Container>\n  )\n\n  return (\n    <Carousel.Item\n      size={carouselSize || 'small'}\n      onClick={onClick}\n      onImpress={onImpress}\n    >\n      <Image>\n        <Image.FixedRatioFrame frame={imageFrame || 'large'}>\n          {image ? (\n            optimized ? (\n              <Image.OptimizedImg\n                cloudinaryId={image.cloudinaryId as string}\n                cloudinaryBucket={image.cloudinaryBucket}\n                alt={name || ''}\n              />\n            ) : (\n              <Image.Img src={image.sizes.large.url} alt={name || ''} />\n            )\n          ) : (\n            <Image.Placeholder src={POI_IMAGE_PLACEHOLDERS[type]} />\n          )}\n        </Image.FixedRatioFrame>\n      </Image>\n\n      <Text bold ellipsis alpha={1} margin={{ top: titleTopSpacing }}>\n        {name}\n      </Text>\n      <Text size=\"tiny\" alpha={0.7} margin={{ top: 2 }}>\n        {description || t(getTypeNames(type))}\n      </Text>\n      <Text size=\"tiny\" alpha={0.7} margin={{ top: 2 }}>\n        {regionName\n          ? areas?.[0]?.name\n            ? `${regionName}(${areas?.[0]?.name})`\n            : regionName\n          : vicinity}\n      </Text>\n\n      {!guestMode ? ActionButton : null}\n\n      {additionalInfo}\n    </Carousel.Item>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/compact-poi-list-element.tsx",
    "content": "import { useState, useRef, useEffect } from 'react'\nimport {\n  Text,\n  SquareImage,\n  ResourceListItem,\n  Container,\n} from '@titicaca/tds-ui'\nimport { GuestModeType } from '@titicaca/type-definitions'\nimport { useTranslation } from '@titicaca/triple-web'\nimport { WebTarget } from 'styled-components'\n\nimport { OutlineScrapButton } from '../scrap-button'\n\nimport {\n  PoiListElementBaseProps,\n  ActionButtonElement,\n  PoiListElementType,\n} from './types'\nimport { getTypeNames } from './get-type-names'\n\ninterface CompactPoiListElementBaseProps<T extends PoiListElementType>\n  extends PoiListElementBaseProps<T> {\n  actionButtonElement?: ActionButtonElement\n  guestMode?: GuestModeType\n}\n\nexport type CompactPoiListElementProps<T extends PoiListElementType> =\n  CompactPoiListElementBaseProps<T> & {\n    as?: WebTarget\n  }\n\nconst POI_IMAGE_PLACEHOLDERS_SMALL: {\n  [key in PoiListElementType['type']]: string\n} = {\n  attraction: 'https://assets.triple.guide/images/ico_blank_see_small@2x.png',\n  restaurant: 'https://assets.triple.guide/images/ico_blank_eat_small@2x.png',\n  hotel: 'https://assets.triple.guide/images/ico_blank_hotel_small@2x.png',\n}\n\nexport function CompactPoiListElement<T extends PoiListElementType>({\n  as,\n  actionButtonElement,\n  poi,\n  poi: {\n    type,\n    nameOverride,\n    region,\n    source: { names, image, areas, vicinity },\n  },\n  onClick,\n  guestMode,\n}: CompactPoiListElementProps<T>) {\n  const t = useTranslation()\n  const [actionButtonWidth, setActionButtonWidth] = useState(0)\n  const actionButtonRef = useRef<HTMLDivElement & { width?: number }>(null)\n\n  useEffect(() => {\n    if (actionButtonRef && actionButtonRef.current) {\n      setActionButtonWidth(actionButtonRef.current?.width || 0)\n    }\n  }, [actionButtonRef])\n\n  const { names: regionNames } = region?.source || {}\n\n  const name =\n    nameOverride || names.primary || names.ko || names.en || names.local\n  const regionName =\n    regionNames?.primary ||\n    regionNames?.ko ||\n    regionNames?.en ||\n    regionNames?.local\n  const ActionButton = actionButtonElement ? (\n    <div ref={actionButtonRef}>{actionButtonElement}</div>\n  ) : (\n    <Container\n      position=\"absolute\"\n      css={{\n        top: 0,\n        right: 0,\n      }}\n    >\n      <OutlineScrapButton resource={poi} size={34} />\n    </Container>\n  )\n\n  return (\n    <ResourceListItem as={as} onClick={onClick}>\n      <SquareImage\n        floated=\"left\"\n        size=\"small\"\n        src={image ? image.sizes.large.url : POI_IMAGE_PLACEHOLDERS_SMALL[type]}\n        alt={name || ''}\n      />\n      <Text\n        bold\n        ellipsis\n        alpha={1}\n        margin={{ left: 50, right: actionButtonWidth }}\n        padding={{ right: 40 }}\n      >\n        {name}\n      </Text>\n      <Text size=\"tiny\" alpha={0.7} margin={{ top: 4, left: 50 }}>\n        {[\n          t(getTypeNames(type)),\n          regionName\n            ? areas?.[0]?.name\n              ? `${regionName}(${areas?.[0]?.name})`\n              : regionName\n            : areas?.[0]?.name || vicinity,\n        ]\n          .filter(Boolean)\n          .join(' · ')}\n      </Text>\n\n      {!guestMode ? ActionButton : null}\n    </ResourceListItem>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/constants.ts",
    "content": "import { PoiListElementType } from './types'\n\nexport const POI_IMAGE_PLACEHOLDERS: {\n  [key in PoiListElementType['type']]: string\n} = {\n  attraction: 'https://assets.triple.guide/images/ico_blank_see@2x.png',\n  restaurant: 'https://assets.triple.guide/images/ico_blank_eat@2x.png',\n  hotel: 'https://assets.triple.guide/images/ico_blank_hotel@2x.png',\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/extended-poi-list-element.tsx",
    "content": "import { useTranslation } from '@titicaca/triple-web'\nimport { WebTarget } from 'styled-components'\n\nimport { useScrap } from '../scrap'\nimport { ExtendedResourceListElement } from '../resource-list-elements'\n\nimport { POI_IMAGE_PLACEHOLDERS } from './constants'\nimport { PoiListElementBaseProps, PoiListElementType } from './types'\n\ninterface ExtendedPoiListElementBaseProps<T extends PoiListElementType>\n  extends PoiListElementBaseProps<T> {\n  maxCommentLines?: number\n  distance?: string | number\n  distanceSuffix?: string\n  isAdvertisement?: boolean\n  notes?: (string | null | undefined)[]\n}\n\nexport type ExtendedPoiListElementProps<T extends PoiListElementType> =\n  ExtendedPoiListElementBaseProps<T> & {\n    as?: WebTarget\n  }\n\nexport function ExtendedPoiListElement<T extends PoiListElementType>({\n  poi,\n  poi: {\n    id,\n    type,\n    nameOverride,\n    scraped,\n    categories: categoriesWithGraphql,\n    reviewsCount: reviewsCountWithGraphql,\n    scrapsCount: scrapsCountWithGraphql,\n    reviewsRating: reviewsRatingWithGraphql,\n    source: {\n      names,\n      image,\n      areas = [],\n      categories,\n      comment,\n      reviewsCount: rawReviewsCount,\n      scrapsCount: rawScrapsCount,\n      reviewsRating: rawReviewsRating,\n      vicinity,\n    },\n    distance,\n  },\n  onClick,\n  distance: distanceOverride,\n  distanceSuffix,\n  maxCommentLines,\n  as,\n  isAdvertisement,\n  notes,\n  optimized,\n}: ExtendedPoiListElementProps<T> & { optimized?: boolean }) {\n  const t = useTranslation()\n\n  const { deriveCurrentStateAndCount } = useScrap()\n  const {\n    source: { starRating },\n  } =\n    type === 'hotel'\n      ? poi\n      : {\n          source: { starRating: undefined },\n        }\n  const [area] = areas\n\n  const [category] = (categoriesWithGraphql ?? categories) || []\n\n  const { scrapsCount } = deriveCurrentStateAndCount({\n    id,\n    scraped,\n    scrapsCount: scrapsCountWithGraphql ?? rawScrapsCount,\n  })\n  const reviewsCount = Number((reviewsCountWithGraphql ?? rawReviewsCount) || 0)\n  const note = (\n    notes || [\n      starRating\n        ? t('{{starRating}}성급', { starRating })\n        : category\n          ? category.name\n          : null,\n      area ? area.name : vicinity,\n    ]\n  )\n    .filter((v) => v)\n    .join(' · ')\n\n  return (\n    <ExtendedResourceListElement\n      as={as}\n      resource={poi}\n      image={image}\n      imagePlaceholder={POI_IMAGE_PLACEHOLDERS[type]}\n      name={nameOverride || names.ko || names.en || names.local || undefined}\n      comment={comment}\n      distance={distanceOverride || distance}\n      distanceSuffix={distanceSuffix}\n      note={note}\n      reviewsCount={reviewsCount}\n      reviewsRating={reviewsRatingWithGraphql ?? rawReviewsRating}\n      scrapsCount={scrapsCount}\n      onClick={onClick}\n      maxCommentLines={maxCommentLines}\n      isAdvertisement={isAdvertisement}\n      optimized={optimized}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/get-type-names.ts",
    "content": "import { PoiListElementType } from './types'\n\nexport function getTypeNames(type: PoiListElementType['type']) {\n  switch (type) {\n    case 'attraction': {\n      return '관광명소'\n    }\n    case 'hotel': {\n      return '호텔'\n    }\n    case 'restaurant': {\n      return '음식점'\n    }\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/index.ts",
    "content": "export * from './compact-poi-list-element'\nexport * from './extended-poi-list-element'\nexport * from './poi-list-element'\nexport * from './poi-card-element'\nexport * from './carousel-element'\nexport * from './types'\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/mocks/hotels.sample.json",
    "content": "[\n  {\n    \"id\": \"d74ad4ef-cc99-47e7-8fed-f115c41e17ea\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://blog.naver.com/kakoi77/220935596068\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/883a655b-adf0-4dd5-a350-3dd12852dd52.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/883a655b-adf0-4dd5-a350-3dd12852dd52.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/883a655b-adf0-4dd5-a350-3dd12852dd52.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"883a655b-adf0-4dd5-a350-3dd12852dd52\",\n        \"title\": null\n      },\n      \"scrapsCount\": 0,\n      \"areas\": [{ \"name\": \"셩완, 센트럴, 빅토리아피크\" }],\n      \"reviewsCount\": 1,\n      \"type\": \"hotel\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.14238, 22.28804],\n        \"type\": \"Point\"\n      },\n      \"tags\": [{ \"name\": \"야경이 멋진\" }, { \"name\": \"교통이 편리한\" }],\n      \"reviewsRating\": 5.0,\n      \"names\": {\n        \"ko\": \"아일랜드 퍼시픽 호텔\",\n        \"en\": \"Island Pacific Hotel\",\n        \"local\": \"港岛太平洋饭店\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 50,\n      \"comment\": \"아시아부터 서양 요리까지 준비된 가성비 좋은 호텔\",\n      \"location\": [114.14238, 22.28804],\n      \"id\": \"d74ad4ef-cc99-47e7-8fed-f115c41e17ea\",\n      \"starRating\": 4,\n      \"pricing\": {\n        \"promoText\": \"최대 26%\",\n        \"nightlyPrice\": 120094,\n        \"clubPromotionTarget\": true,\n        \"nightlyPriceHotelPromotionApplied\": 121443,\n        \"clubPromotionRate\": 1,\n        \"clubMemberOnly\": true,\n        \"nightlyBasePrice\": 163519,\n        \"clubPromotionType\": \"STATIC\"\n      }\n    },\n    \"type\": \"hotel\",\n    \"priceInfo\": {\n      \"nightlyBasePrice\": 355695,\n      \"nightlyPrice\": 0\n    },\n    \"prices\": {\n      \"nightlyBasePrice\": 355695,\n      \"nightlyPrice\": 355695,\n      \"promoText\": \"최대 16%\",\n      \"nightlyPriceHotelPromotionApplied\": 0,\n      \"clubPromotionRate\": 0,\n      \"clubPromotionType\": \"STATIC\",\n      \"clubMemberOnly\": false,\n      \"clubPromotionTarget\": true\n    },\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"ee94fc9a-4ebb-4a89-b2c4-1987cc2a0ba9\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/a9b2fffc-fc99-42ef-8341-27784859d7e8.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/a9b2fffc-fc99-42ef-8341-27784859d7e8.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/a9b2fffc-fc99-42ef-8341-27784859d7e8.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"a9b2fffc-fc99-42ef-8341-27784859d7e8\",\n        \"title\": null\n      },\n      \"areas\": [{ \"name\": \"침사추이\" }],\n      \"type\": \"hotel\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.17816, 22.29867],\n        \"type\": \"Point\"\n      },\n      \"tags\": [{ \"name\": \"아이와 함께\" }, { \"name\": \"야경이 멋진\" }],\n      \"names\": {\n        \"ko\": \"리갈 카오룽 호텔\",\n        \"en\": \"Regal Kowloon Hotel\",\n        \"local\": \"富豪九龙酒店\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 50,\n      \"comment\": \"무료 셔틀버스로 유명 명소를 관광할 수 있는 호텔\",\n      \"location\": [114.17816, 22.29867],\n      \"id\": \"ee94fc9a-4ebb-4a89-b2c4-1987cc2a0ba9\",\n      \"starRating\": 4,\n      \"pricing\": {\n        \"promoText\": \"최대 26%\",\n        \"nightlyPrice\": 158207,\n        \"clubPromotionTarget\": true,\n        \"nightlyPriceHotelPromotionApplied\": 159984,\n        \"clubPromotionRate\": 1,\n        \"clubMemberOnly\": false,\n        \"nightlyBasePrice\": 215534,\n        \"clubPromotionType\": \"STATIC\"\n      }\n    },\n    \"type\": \"hotel\",\n    \"prices\": {\n      \"nightlyBasePrice\": 319665,\n      \"nightlyPrice\": 244096,\n      \"promoText\": \"최대 23%\",\n      \"nightlyPriceHotelPromotionApplied\": 246839,\n      \"clubPromotionRate\": 1,\n      \"clubPromotionType\": \"STATIC\",\n      \"clubMemberOnly\": false,\n      \"clubPromotionTarget\": true\n    },\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"36354ba4-aa58-4205-a197-6250072e1441\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/214a63a6-3ead-4342-ab10-e1a2a5d84649.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/214a63a6-3ead-4342-ab10-e1a2a5d84649.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/214a63a6-3ead-4342-ab10-e1a2a5d84649.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"214a63a6-3ead-4342-ab10-e1a2a5d84649\",\n        \"title\": null\n      },\n      \"scrapsCount\": 1,\n      \"areas\": [{ \"name\": \"마카오\" }],\n      \"reviewsCount\": 0,\n      \"type\": \"hotel\",\n      \"pointGeolocation\": {\n        \"coordinates\": [113.55321, 22.19266],\n        \"type\": \"Point\"\n      },\n      \"tags\": [{ \"name\": \"아이와 함께\" }],\n      \"names\": {\n        \"ko\": \"그랜드 라파, 마카오\",\n        \"en\": \"Grand Lapa, Macau\",\n        \"local\": \"澳门金丽华酒店\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 50,\n      \"comment\": \"분위기 좋은 수영장, 교통 편의성을 갖춘 5성급 호텔\",\n      \"location\": [113.55321, 22.19266],\n      \"id\": \"36354ba4-aa58-4205-a197-6250072e1441\",\n      \"starRating\": 4,\n      \"pricing\": {\n        \"promoText\": \"최대 23%\",\n        \"nightlyPrice\": 114071,\n        \"clubPromotionTarget\": true,\n        \"nightlyPriceHotelPromotionApplied\": 115353,\n        \"clubPromotionRate\": 1,\n        \"clubMemberOnly\": false,\n        \"nightlyBasePrice\": 148672,\n        \"clubPromotionType\": \"STATIC\"\n      }\n    },\n    \"type\": \"hotel\",\n    \"prices\": {\n      \"nightlyBasePrice\": 255732,\n      \"nightlyPrice\": 235557,\n      \"promoText\": \"최대 7%\",\n      \"nightlyPriceHotelPromotionApplied\": 238203,\n      \"clubPromotionRate\": 1,\n      \"clubPromotionType\": \"STATIC\",\n      \"clubMemberOnly\": false,\n      \"clubPromotionTarget\": true\n    },\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"8e4dfc60-fba7-404b-87b6-0cf0838cbcae\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/dca48743-85a6-4423-ba05-6590c89cec60.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/dca48743-85a6-4423-ba05-6590c89cec60.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/dca48743-85a6-4423-ba05-6590c89cec60.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"dca48743-85a6-4423-ba05-6590c89cec60\",\n        \"title\": null\n      },\n      \"areas\": [{ \"name\": \"침사추이\" }],\n      \"type\": \"hotel\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.1741, 22.30076],\n        \"type\": \"Point\"\n      },\n      \"tags\": [\n        { \"name\": \"아이와 함께\" },\n        { \"name\": \"부티크 호텔\" },\n        { \"name\": \"쇼핑하기 편한\" }\n      ],\n      \"names\": {\n        \"ko\": \"럭스 매너\",\n        \"en\": \"The Luxe Manor\",\n        \"local\": \"香港帝乐文娜公馆\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 50,\n      \"comment\": \"홍콩에서 느끼는 북유럽 감성의 부티크 호텔\",\n      \"location\": [114.1741, 22.30076],\n      \"id\": \"8e4dfc60-fba7-404b-87b6-0cf0838cbcae\",\n      \"starRating\": 4,\n      \"pricing\": {\n        \"promoText\": \"최대 36%\",\n        \"nightlyPrice\": 160814,\n        \"clubPromotionTarget\": true,\n        \"nightlyPriceHotelPromotionApplied\": 162621,\n        \"clubPromotionRate\": 1,\n        \"clubMemberOnly\": true,\n        \"nightlyBasePrice\": 252601,\n        \"clubPromotionType\": \"STATIC\"\n      }\n    },\n    \"type\": \"hotel\",\n    \"prices\": {\n      \"nightlyBasePrice\": 812292,\n      \"nightlyPrice\": 715704,\n      \"promoText\": \"최대 11%\",\n      \"nightlyPriceHotelPromotionApplied\": 723746,\n      \"clubPromotionRate\": 1,\n      \"clubPromotionType\": \"STATIC\",\n      \"clubMemberOnly\": true,\n      \"clubPromotionTarget\": true\n    },\n    \"reviewed\": false,\n    \"scraped\": false\n  }\n]\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/mocks/pois.sample.json",
    "content": "[\n  {\n    \"id\": \"f72d2f50-2efb-4469-a903-47ad6b0c0740\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://www.our-thailand-vacations.com/reclining-buddha-bangkok.html\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f9ff3ed6-990e-4270-a810-d9409befa31f.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f9ff3ed6-990e-4270-a810-d9409befa31f.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f9ff3ed6-990e-4270-a810-d9409befa31f.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"f9ff3ed6-990e-4270-a810-d9409befa31f\",\n        \"title\": null\n      },\n      \"scrapsCount\": 4,\n      \"areas\": [],\n      \"reviewsCount\": 6,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.493298, 13.746523],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 3.5,\n      \"names\": {\n        \"ko\": \"노보텔 방콕 수완나폼 에어포트 호텔 - HOTEL TEST \",\n        \"en\": \"Temple of the Reclining Buddha (Wat Pho)\",\n        \"local\": \"วัดโพธิ์\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"comment\": \"아유타야 양식으로 지어진 방콕 최대 규모의 유서깊은 사원\",\n      \"location\": [100.493298, 13.746523],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"f72d2f50-2efb-4469-a903-47ad6b0c0740\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"0b436475-eead-4a82-8486-5b1c659c90ba\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": null,\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d9705590-7480-4ed5-adee-34c01463d1d2.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d9705590-7480-4ed5-adee-34c01463d1d2.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d9705590-7480-4ed5-adee-34c01463d1d2.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"d9705590-7480-4ed5-adee-34c01463d1d2\",\n        \"title\": null\n      },\n      \"scrapsCount\": 19,\n      \"areas\": [\n        {\n          \"name\": \"셩완, 센트럴, 빅토리아피크\"\n        }\n      ],\n      \"reviewsCount\": 12,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.145457, 22.275908],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 4.5,\n      \"names\": {\n        \"ko\": \"빅토리아 피크\",\n        \"en\": \"Victoria Peak (The Peak)\",\n        \"local\": \"太平山山頂\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"파노라마 뷰로 즐기는 홍콩 야경의 하이라이트\",\n      \"location\": [114.145457, 22.275908],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"0b436475-eead-4a82-8486-5b1c659c90ba\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"3d32908d-72e3-4db1-8376-dc707c3d5112\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://kindcandy.blog.me/220863061922\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/1a89e821-c22e-4ba5-ae02-b3e2059f1b67.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/1a89e821-c22e-4ba5-ae02-b3e2059f1b67.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/1a89e821-c22e-4ba5-ae02-b3e2059f1b67.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"1a89e821-c22e-4ba5-ae02-b3e2059f1b67\",\n        \"title\": null\n      },\n      \"names\": {\n        \"ko\": \"카오산 로드\",\n        \"en\": \"Khao San Road\",\n        \"local\": \"ถนนข้าวสาร\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"areas\": [\n        {\n          \"name\": \"올드시티\"\n        }\n      ],\n      \"comment\": \"방콕  배낭 여행의 중심지\",\n      \"location\": [100.497176, 13.758948],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"3d32908d-72e3-4db1-8376-dc707c3d5112\",\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.497176, 13.758948],\n        \"type\": \"Point\"\n      }\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"6ca7e0ad-acec-4d32-9107-b77f2516681e\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://www.laservision.com.au/portfolio/symphony-of-lights/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/85eb8700-d8a4-4a0b-a7ef-a78ec5104269.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/85eb8700-d8a4-4a0b-a7ef-a78ec5104269.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/85eb8700-d8a4-4a0b-a7ef-a78ec5104269.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"85eb8700-d8a4-4a0b-a7ef-a78ec5104269\",\n        \"title\": null\n      },\n      \"scrapsCount\": 9,\n      \"areas\": [\n        {\n          \"name\": \"침사추이\"\n        }\n      ],\n      \"reviewsCount\": 6,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.174771, 22.293186],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": \"4.333333333333333\",\n      \"names\": {\n        \"ko\": \"심포니 오브 라이트\",\n        \"en\": \"Symphony of Lights\",\n        \"local\": \"幻彩詠香江燈光錶演\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"매일 밤 8시에 펼쳐지는 환상의 레이저 쇼\",\n      \"location\": [114.174771, 22.293186],\n      \"categories\": [\n        {\n          \"name\": \"테마/체험\"\n        }\n      ],\n      \"id\": \"6ca7e0ad-acec-4d32-9107-b77f2516681e\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"fe700d6a-2066-4c31-ab64-75da86acb756\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://www.lifebeyondtourism.org/puntointeresse/2285/Wat-Arun-\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/65fcb462-bfdf-447b-a815-21d4f34bff5d.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/65fcb462-bfdf-447b-a815-21d4f34bff5d.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/65fcb462-bfdf-447b-a815-21d4f34bff5d.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"65fcb462-bfdf-447b-a815-21d4f34bff5d\",\n        \"title\": null\n      },\n      \"names\": {\n        \"ko\": \"왓 아룬\",\n        \"en\": \"Temple of Dawn (Wat Arun)\",\n        \"local\": \"วัดอรุณ\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"areas\": [\n        {\n          \"name\": \"올드시티\"\n        }\n      ],\n      \"comment\": \"방콕의 새벽을 밝히는 사원\",\n      \"location\": [100.489006, 13.743707],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"fe700d6a-2066-4c31-ab64-75da86acb756\",\n      \"hasTnaProducts\": true,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.489006, 13.743707],\n        \"type\": \"Point\"\n      }\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"9a21b2ac-cc5f-48c0-bd62-58f88b577a14\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://blog.naver.com/lioooolioooo/220812572460 \",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/56b8ace0-a8ef-46c8-8cf8-3ea5f80f7903.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/56b8ace0-a8ef-46c8-8cf8-3ea5f80f7903.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/56b8ace0-a8ef-46c8-8cf8-3ea5f80f7903.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"56b8ace0-a8ef-46c8-8cf8-3ea5f80f7903\",\n        \"title\": null\n      },\n      \"scrapsCount\": 7,\n      \"areas\": [\n        {\n          \"name\": \"셩완, 센트럴, 빅토리아피크\"\n        }\n      ],\n      \"reviewsCount\": 7,\n      \"hasTnaProducts\": true,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.152844, 22.281572],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 4.571428571428571,\n      \"names\": {\n        \"ko\": \"소호\",\n        \"en\": \"Soho Test\",\n        \"local\": \"蘇豪區\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"홍콩 제일의 트렌디한 거리에서 즐기는 낮과 밤\",\n      \"location\": [114.152844, 22.281572],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"9a21b2ac-cc5f-48c0-bd62-58f88b577a14\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"cd9b3a6e-7034-4d8d-9005-dfb1b1accbcd\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://blog.naver.com/yeontech/220815008594\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/54e4fbf9-239f-4294-bfba-cb72003397a2.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/54e4fbf9-239f-4294-bfba-cb72003397a2.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/54e4fbf9-239f-4294-bfba-cb72003397a2.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"54e4fbf9-239f-4294-bfba-cb72003397a2\",\n        \"title\": null\n      },\n      \"scrapsCount\": 2,\n      \"areas\": [\n        {\n          \"name\": \"올드시티\"\n        }\n      ],\n      \"reviewsCount\": 5,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.491253, 13.749977],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 3.4,\n      \"names\": {\n        \"ko\": \"왕궁\",\n        \"en\": \"The Grand Palace\",\n        \"local\": \"พระบรมมหาราชวัง\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"comment\": \"230년 짜끄리 왕조과 함께 해온 방콕의 대표 명소\",\n      \"location\": [100.491253, 13.749977],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"cd9b3a6e-7034-4d8d-9005-dfb1b1accbcd\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"c95c117f-f1ee-4261-badd-2ea8e32e088f\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://blog.naver.com/qkrtmdqja/220762393209\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/0c784694-59a4-4b3b-9a01-e0cbd3090a4e.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/0c784694-59a4-4b3b-9a01-e0cbd3090a4e.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/0c784694-59a4-4b3b-9a01-e0cbd3090a4e.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"0c784694-59a4-4b3b-9a01-e0cbd3090a4e\",\n        \"title\": null\n      },\n      \"names\": {\n        \"ko\": \"짜뚜짝 주말시장\",\n        \"en\": \"Chatuchak Weekend Market\",\n        \"local\": null\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"comment\": \"보는 재미, 먹는 재미로 활기찬 주말시장\",\n      \"location\": [100.550899, 13.799974],\n      \"categories\": [\n        {\n          \"name\": \"쇼핑\"\n        }\n      ],\n      \"id\": \"c95c117f-f1ee-4261-badd-2ea8e32e088f\",\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.550899, 13.799974],\n        \"type\": \"Point\"\n      }\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"ffec5c01-ad40-4817-8732-5e11e1216f34\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"https://utrip.com/plan-travel/china/hong-kong/hong-kong-lan-kwai-fong/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/cb3b1eb2-ca99-418d-a2ad-6cf19fdee88b.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/cb3b1eb2-ca99-418d-a2ad-6cf19fdee88b.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/cb3b1eb2-ca99-418d-a2ad-6cf19fdee88b.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"cb3b1eb2-ca99-418d-a2ad-6cf19fdee88b\",\n        \"title\": null\n      },\n      \"scrapsCount\": 3,\n      \"areas\": [\n        {\n          \"name\": \"셩완, 센트럴, 빅토리아피크\"\n        }\n      ],\n      \"reviewsCount\": 6,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.15567, 22.280837],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 4.5,\n      \"names\": {\n        \"ko\": \"란콰이퐁\",\n        \"en\": \"Lan Kwai Fong\",\n        \"local\": \"蘭桂坊\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"핫한 나이트 라이프로 유명한 거리\",\n      \"location\": [114.15567, 22.280837],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"ffec5c01-ad40-4817-8732-5e11e1216f34\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"a8f8d4e5-c4b0-4d00-829a-db4378a51e1d\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://blog.naver.com/superkbh/220957368617\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/02e405f4-e2a9-40cc-934b-0751e3e640c5.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/02e405f4-e2a9-40cc-934b-0751e3e640c5.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/02e405f4-e2a9-40cc-934b-0751e3e640c5.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"02e405f4-e2a9-40cc-934b-0751e3e640c5\",\n        \"title\": null\n      },\n      \"names\": {\n        \"ko\": \"씨암 스퀘어\",\n        \"en\": \"Siam Square\",\n        \"local\": \"สยามสแควร์\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"areas\": [\n        {\n          \"name\": \"씨암, 칫롬\"\n        }\n      ],\n      \"comment\": \"방콕 유행을 선도하는 핫플레이스\",\n      \"location\": [100.53433877546308, 13.745084118890189],\n      \"categories\": [\n        {\n          \"name\": \"쇼핑\"\n        }\n      ],\n      \"id\": \"a8f8d4e5-c4b0-4d00-829a-db4378a51e1d\",\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.53433877546308, 13.745084118890189],\n        \"type\": \"Point\"\n      }\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"07f9a261-d3c3-43a6-b014-f057b59ec879\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://www.camemberu.com/2013/06/hong-kong-disneyland-park-attractions.html\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/95cb40a3-78c3-430b-8d04-894ecbbb2a89.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/95cb40a3-78c3-430b-8d04-894ecbbb2a89.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/95cb40a3-78c3-430b-8d04-894ecbbb2a89.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"95cb40a3-78c3-430b-8d04-894ecbbb2a89\",\n        \"title\": null\n      },\n      \"scrapsCount\": 7,\n      \"areas\": [\n        {\n          \"name\": \"란타우섬\"\n        }\n      ],\n      \"reviewsCount\": 7,\n      \"hasTnaProducts\": true,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.041281, 22.312962],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 4.571428571428571,\n      \"names\": {\n        \"ko\": \"홍콩 디즈니랜드\",\n        \"en\": \"Hong Kong Disneyland\",\n        \"local\": \"香港迪士尼樂園\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"홍콩 여행에서 빼 놓을 수 없는 테마파크\",\n      \"location\": [114.041281, 22.312962],\n      \"categories\": [\n        {\n          \"name\": \"테마/체험\"\n        }\n      ],\n      \"id\": \"07f9a261-d3c3-43a6-b014-f057b59ec879\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"e43610f5-2345-4a48-a905-1eb5f66892ef\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://vietravelasia.com/en/news/top-5-things-to-do-in-bangkok-292.html\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f7ba0e55-5c7b-479f-a44b-acb3396311cc.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f7ba0e55-5c7b-479f-a44b-acb3396311cc.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f7ba0e55-5c7b-479f-a44b-acb3396311cc.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"f7ba0e55-5c7b-479f-a44b-acb3396311cc\",\n        \"title\": null\n      },\n      \"names\": {\n        \"ko\": \"짐 톰슨 하우스\",\n        \"en\": \"The Jim Thompson House\",\n        \"local\": \"บ้านจิมทอมป์สัน\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"areas\": [\n        {\n          \"name\": \"씨암, 칫롬\"\n        }\n      ],\n      \"comment\": \"태국 최고의 실크 브랜드를 만든 짐 톰슨의 집\",\n      \"location\": [100.528324, 13.749251],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"e43610f5-2345-4a48-a905-1eb5f66892ef\",\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.528324, 13.749251],\n        \"type\": \"Point\"\n      }\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"d6269a4c-c7cd-4786-89d3-756e9ae76d7c\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://cafe.naver.com/hkmyhome/19590\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/635446f3-235f-4e06-b504-b8db1244918a.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/635446f3-235f-4e06-b504-b8db1244918a.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/635446f3-235f-4e06-b504-b8db1244918a.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"635446f3-235f-4e06-b504-b8db1244918a\",\n        \"title\": null\n      },\n      \"scrapsCount\": 4,\n      \"areas\": [\n        {\n          \"name\": \"셩완, 센트럴, 빅토리아피크\"\n        }\n      ],\n      \"reviewsCount\": 4,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.158127, 22.285878],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 4.0,\n      \"names\": {\n        \"ko\": \"IFC 몰\",\n        \"en\": \"IFC mall\",\n        \"local\": \"國際金融中心商場\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"홍콩의 중심에서 홍콩의 모든것을 즐길 수 있는 쇼핑몰\",\n      \"location\": [114.158127, 22.285878],\n      \"categories\": [\n        {\n          \"name\": \"쇼핑\"\n        }\n      ],\n      \"id\": \"d6269a4c-c7cd-4786-89d3-756e9ae76d7c\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  }\n]\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/poi-card-element/direction-button.tsx",
    "content": "import { styled } from 'styled-components'\n\nexport const DIRECTION_BUTTON_WIDTH = 40\n\nconst IconWrapper = styled.div`\n  > svg {\n    vertical-align: middle;\n  }\n`\n\nexport function DirectionButton({ onClick }: { onClick: () => void }) {\n  return (\n    <IconWrapper\n      onClick={(e) => {\n        e.stopPropagation()\n        onClick()\n      }}\n    >\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width={DIRECTION_BUTTON_WIDTH}\n        height=\"40\"\n        viewBox=\"0 0 40 40\"\n      >\n        <g fill=\"none\" fillRule=\"evenodd\">\n          <rect\n            width=\"39.5\"\n            height=\"39.5\"\n            x=\".25\"\n            y=\".25\"\n            stroke=\"#222\"\n            strokeOpacity=\".1\"\n            strokeWidth=\".5\"\n            rx=\"19.75\"\n          />\n          <path\n            fill=\"#368FFF\"\n            d=\"M16.731 8.269L21.346 12.308 16.731 16.346z\"\n            transform=\"rotate(-90 19.038 12.308)\"\n          />\n          <path fill=\"#368FFF\" d=\"M25.385 15.769L30 19.808 25.385 23.846z\" />\n          <path\n            stroke=\"#368FFF\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"2.4\"\n            d=\"M18.923 27.885v-4.039c0-2.23 1.802-4.038 4.035-4.038h5.196\"\n          />\n          <path\n            stroke=\"#368FFF\"\n            strokeLinecap=\"square\"\n            strokeWidth=\"2.4\"\n            d=\"M18.891 15.192L18.891 26.731\"\n          />\n        </g>\n      </svg>\n    </IconWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/poi-card-element/index.ts",
    "content": "export * from './poi-card-element'\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/poi-card-element/poi-card-element.tsx",
    "content": "import { MouseEventHandler } from 'react'\nimport { useTranslation } from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport { Container, Text, Card as OriginalCard, Image } from '@titicaca/tds-ui'\nimport { ImageMeta, TranslatedProperty } from '@titicaca/type-definitions'\nimport { formatNumber } from '@titicaca/view-utilities'\n\nimport { useScrap } from '../../scrap'\nimport { OverlayScrapButton } from '../../scrap-button'\nimport {\n  ResourceListElementStats,\n  ReviewScrapStat,\n} from '../../resource-list-elements'\nimport type { PoiListElementType } from '../types'\n\nimport { DIRECTION_BUTTON_WIDTH, DirectionButton } from './direction-button'\n\nconst IMAGE_WIDTH = 58\n\nconst IMAGE_PLACEHOLDERS = {\n  hotel: 'https://assets.triple.guide/images/ico_blank_hotel@3x.png',\n  attraction: 'https://assets.triple.guide/images/ico_blank_see@3x.png',\n  restaurant: 'https://assets.triple.guide/images/ico_blank_eat@3x.png',\n} as const\n\nconst Card = styled(OriginalCard)`\n  background-color: white;\n`\n\nconst PoiCardBody = styled(Container)`\n  height: 100%;\n`\n\nconst ImageContainer = styled(Container)`\n  position: absolute;\n  top: 0;\n  left: 0;\n`\n\nconst DirectionButtonContainer = styled(Container)`\n  position: absolute;\n  top: 50%;\n  right: 0;\n  transform: translateY(-50%);\n`\n\nconst ScrapButtonContainer = styled(Container)`\n  position: absolute;\n  top: 0;\n  right: 0;\n`\n\nexport function PoiCardElement({\n  id,\n  type,\n  names: { ko, en, local },\n  image,\n  comment,\n  reviewsRating,\n  reviewsCount,\n  nightlyPrice,\n  priceLabelOverride,\n  scraped,\n  scrapsCount: rawScrapsCount,\n  distance,\n  categoryName,\n  areaName,\n  onClick,\n  onDirectionButtonClick,\n  optimized,\n}: {\n  id: string\n  type: PoiListElementType['type']\n  scraped: boolean\n  /**\n   * @deprecated 더이상 사용하지 않습니다.\n   */\n  regionId?: string\n  image: ImageMeta | undefined\n  names: TranslatedProperty\n  comment?: string\n  reviewsRating?: number\n  reviewsCount?: number\n  /**\n   * Scraps context를 통과하지 않은 POI의 원본 데이터를 넣어주세요.\n   */\n  scrapsCount?: number\n  nightlyPrice?: number\n  priceLabelOverride?: JSX.Element\n  distance?: string\n  categoryName?: string\n  areaName?: string\n  onClick?: MouseEventHandler<HTMLDivElement>\n  onDirectionButtonClick: Parameters<typeof DirectionButton>[0]['onClick']\n  optimized?: boolean\n}) {\n  const t = useTranslation()\n\n  const formattedNightlyPrice = formatNumber(nightlyPrice)\n  const { deriveCurrentStateAndCount } = useScrap()\n  const { scrapsCount } = deriveCurrentStateAndCount({\n    id,\n    scraped,\n    scrapsCount: rawScrapsCount,\n  })\n\n  return (\n    <Card\n      radius={6}\n      shadowValue=\"0 1px 3px 0 rgba(0, 0, 0, 0.1)\"\n      css={{ padding: 18 }}\n    >\n      <PoiCardBody\n        position=\"relative\"\n        display=\"block\"\n        onClick={onClick}\n        css={{\n          textAlign: 'left',\n        }}\n      >\n        <ImageContainer clearing>\n          <Image>\n            <Image.FixedDimensionsFrame width={IMAGE_WIDTH} height={72}>\n              {image ? (\n                optimized ? (\n                  <Image.OptimizedImg\n                    cloudinaryId={image.cloudinaryId as string}\n                    cloudinaryBucket={image.cloudinaryBucket}\n                  />\n                ) : (\n                  <Image.Img\n                    src={\n                      'smallSquare' in image.sizes\n                        ? image.sizes.smallSquare.url\n                        : image.sizes.small_square.url\n                    }\n                  />\n                )\n              ) : (\n                <Image.Placeholder src={IMAGE_PLACEHOLDERS[type]} />\n              )}\n            </Image.FixedDimensionsFrame>\n          </Image>\n\n          <ScrapButtonContainer>\n            <OverlayScrapButton resource={{ id, type, scraped }} size={30} />\n          </ScrapButtonContainer>\n        </ImageContainer>\n\n        <Container\n          css={{\n            maxWidth: 190,\n            marginLeft: IMAGE_WIDTH + 14,\n            marginRight: DIRECTION_BUTTON_WIDTH + 13,\n          }}\n        >\n          <Text size=\"large\" bold ellipsis>\n            {ko || en || local}\n          </Text>\n\n          {comment ? (\n            <Text alpha={0.7} size=\"small\" margin={{ top: 4 }} maxLines={2}>\n              {comment}\n            </Text>\n          ) : null}\n\n          <ResourceListElementStats\n            stats={[categoryName, areaName]}\n            size=\"tiny\"\n            alpha={0.4}\n            margin={{ top: 4 }}\n          />\n\n          <ReviewScrapStat\n            reviewsCount={reviewsCount}\n            scrapsCount={scrapsCount}\n            reviewsRating={reviewsRating}\n            css={{ marginTop: 4 }}\n          />\n\n          {distance ||\n          nightlyPrice !== undefined ||\n          priceLabelOverride !== undefined ? (\n            <Container\n              css={{\n                margin: '6px 0 0',\n              }}\n            >\n              {distance ? (\n                <Text\n                  inlineBlock\n                  size=\"tiny\"\n                  color=\"blue\"\n                  margin={{ right: 4 }}\n                >\n                  {t('{{distance}} 이내', { distance })}\n                </Text>\n              ) : null}\n\n              {/* TODO: pricing과 관련 로직 통합 */}\n              {priceLabelOverride ||\n                (nightlyPrice !== undefined ? (\n                  <Text inlineBlock size=\"small\">\n                    {t('{{formattedNightlyPrice}}원', {\n                      formattedNightlyPrice,\n                    })}\n                  </Text>\n                ) : null)}\n            </Container>\n          ) : null}\n        </Container>\n\n        <DirectionButtonContainer>\n          <DirectionButton onClick={onDirectionButtonClick} />\n        </DirectionButtonContainer>\n      </PoiCardBody>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/poi-card-element.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { ScrapsProvider } from '../scrap/provider'\n\nimport { PoiCardElement } from './poi-card-element'\n\nexport default {\n  title: 'tds-widget / poi-list-elements / PoiCardElement',\n  component: PoiCardElement,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <ScrapsProvider>\n          <Story />\n        </ScrapsProvider>\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta<typeof PoiCardElement>\n\nexport const Hotel: StoryObj<typeof PoiCardElement> = {\n  args: {\n    id: 'f72d2f50-2efb-4469-a903-47ad6b0c0740',\n    type: 'hotel',\n    names: {\n      ko: '아일랜드 퍼시픽 호텔',\n      en: 'Island Pacific Hotel',\n      local: '港岛太平洋饭店',\n    },\n    regionId: '84685a5a-a0ee-47b5-b84c-4d76dffad76d',\n    image: {\n      id: '',\n      title: null,\n      description: null,\n      sourceUrl: '',\n      sizes: {\n        large: {\n          url: 'https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/883a655b-adf0-4dd5-a350-3dd12852dd52.jpg',\n        },\n        smallSquare: {\n          url: 'https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/883a655b-adf0-4dd5-a350-3dd12852dd52.jpg',\n        },\n        full: {\n          url: 'https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/883a655b-adf0-4dd5-a350-3dd12852dd52.jpg',\n        },\n      },\n    },\n    comment: '아시아부터 서양 요리까지 준비된 가성비 좋은 호텔',\n    reviewsRating: 5,\n    reviewsCount: 1,\n    nightlyPrice: 120094,\n    scraped: false,\n    scrapsCount: 0,\n    distance: '300m',\n  },\n}\n\nexport const Poi: StoryObj<typeof PoiCardElement> = {\n  args: {\n    id: 'f72d2f50-2efb-4469-a903-47ad6b0c0740',\n    type: 'attraction',\n    names: {\n      ko: '왓 포 사원',\n      en: 'Temple of the Reclining Buddha (Wat Pho)',\n      local: 'วัดโพธิ์',\n    },\n    regionId: 'edf1982d-c835-43a7-b06b-af43acbb6f38',\n    image: {\n      sourceUrl:\n        'http://www.our-thailand-vacations.com/reclining-buddha-bangkok.html',\n      sizes: {\n        large: {\n          url: 'https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f9ff3ed6-990e-4270-a810-d9409befa31f.jpg',\n        },\n        smallSquare: {\n          url: 'https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f9ff3ed6-990e-4270-a810-d9409befa31f.jpg',\n        },\n        full: {\n          url: 'https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f9ff3ed6-990e-4270-a810-d9409befa31f.jpg',\n        },\n      },\n      description: null,\n      id: 'f9ff3ed6-990e-4270-a810-d9409befa31f',\n      title: null,\n    },\n    comment: '아유타야 양식으로 지어진 방콕 최대 규모의 유서깊은 사원',\n    reviewsRating: 5,\n    reviewsCount: 1,\n    scraped: false,\n    scrapsCount: 0,\n    categoryName: '관광명소',\n    areaName: '올드시티',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/poi-carousel-element.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { ScrapsProvider } from '../scrap/provider'\n\nimport { PoiCarouselElement } from './carousel-element'\nimport POIS from './mocks/pois.sample.json'\n\nexport default {\n  title: 'tds-widget / poi-list-elements / PoiCarouselElement',\n  component: PoiCarouselElement,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <ScrapsProvider>\n          <Story />\n        </ScrapsProvider>\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta<typeof PoiCarouselElement>\n\nconst [POI] = POIS\n\nexport const TripleDocument: StoryObj<typeof PoiCarouselElement> = {\n  args: {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    poi: POI as any,\n    titleTopSpacing: 10,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/poi-list-element.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { ScrapsProvider } from '../scrap/provider'\n\nimport HOTELS from './mocks/hotels.sample.json'\nimport POIS from './mocks/pois.sample.json'\nimport { PoiListElement } from './poi-list-element'\n\nexport default {\n  title: 'tds-widget / poi-list-elements / PoiList',\n  component: PoiListElement,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <ScrapsProvider>\n          <Story />\n        </ScrapsProvider>\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta<typeof PoiListElement>\n\nconst [POI] = POIS\nconst [HOTEL] = HOTELS\n\nexport const PoiList: StoryObj<typeof PoiListElement> = {\n  name: 'POI 리스트',\n  args: {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    poi: POI as any,\n  },\n}\n\nexport const HotelList: StoryObj<typeof PoiListElement> = {\n  name: '호텔 리스트',\n  args: {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    poi: HOTEL as any,\n  },\n}\n\nexport const TripleDocumentList: StoryObj<typeof PoiListElement> = {\n  name: 'TripleDocument 리스트',\n  args: {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    poi: POI as any,\n    compact: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/poi-list-element.tsx",
    "content": "import { PoiListElementType } from './types'\nimport {\n  CompactPoiListElement,\n  CompactPoiListElementProps,\n} from './compact-poi-list-element'\nimport {\n  ExtendedPoiListElement,\n  ExtendedPoiListElementProps,\n} from './extended-poi-list-element'\n\nexport type PoiListElementProps<T extends PoiListElementType> =\n  | ({ compact: true } & CompactPoiListElementProps<T>)\n  | ({ compact?: false; optimized?: boolean } & ExtendedPoiListElementProps<T>)\n\nexport function PoiListElement<T extends PoiListElementType>({\n  compact,\n  ...props\n}: PoiListElementProps<T>) {\n  return compact ? (\n    <CompactPoiListElement {...props} />\n  ) : (\n    <ExtendedPoiListElement {...props} />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/poi-list-elements/types.ts",
    "content": "import { ReactNode, MouseEventHandler } from 'react'\nimport {\n  ImageMeta,\n  PointGeoJson,\n  TranslatedProperty,\n} from '@titicaca/type-definitions'\n\nexport type ActionButtonElement = ReactNode\n\nexport interface PoiListElementBaseProps<T extends PoiListElementType> {\n  poi: T\n  onClick?: MouseEventHandler<HTMLLIElement>\n}\n\ntype PoiType = 'attraction' | 'restaurant' | 'hotel'\n\nexport interface PoiListElementType {\n  id: string\n  type: PoiType\n  categories?: { id: string; name: string }[]\n  nameOverride?: string\n  reviewed?: boolean\n  scraped?: boolean\n  distance?: number\n  scrapsCount?: number\n  reviewsCount?: number\n  reviewsRating?: number\n  region?: {\n    source: {\n      names: TranslatedProperty\n    }\n  }\n  source: {\n    areas?: { name: string }[]\n    categories?: { id: string; filter?: boolean; name: string }[]\n    comment?: string\n    scrapsCount?: number\n    reviewsCount?: number\n    reviewsRating?: number\n    grade?: number\n    id?: string\n    geolocation?: PointGeoJson\n    pointGeolocation?: PointGeoJson\n    regionId?: string\n    image?: ImageMeta\n    names: TranslatedProperty\n    starRating?: number\n    vicinity?: string\n    type?: PoiType\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/pricing/fixed-pricing-v2/index.tsx",
    "content": "import { ReactNode, SyntheticEvent } from 'react'\nimport { styled } from 'styled-components'\nimport { useTranslation } from '@titicaca/triple-web'\nimport {\n  Container,\n  Drawer,\n  Tooltip,\n  Text,\n  MarginPadding,\n  Skeleton,\n  safeAreaInsetMixin,\n  paddingMixin,\n  PaddingMixinProps,\n  SafeAreaInsetMixinProps,\n} from '@titicaca/tds-ui'\nimport { formatNumber } from '@titicaca/view-utilities'\n\nimport { PurchaseButton } from './purchase-button'\n\nexport interface FixedPricingV2Props {\n  loading: boolean\n  active?: boolean\n  label?: ReactNode\n  discountRate?: ReactNode\n  description?: ReactNode\n  buttonText?: string\n  buttonDisabled?: boolean\n  salePrice?: number\n  isSoldOut?: boolean\n  priceLabelOverride?: string\n  tooltipLabel?: string\n  onClick?: (e?: SyntheticEvent) => void\n  onTooltipClick?: (e?: SyntheticEvent) => void\n  maxWidth?: number\n  tooltipColor?: string\n  padding?: MarginPadding\n  emptyOverride?: ReactNode\n}\n\nconst FloatedFrame = styled(Container)<\n  PaddingMixinProps & SafeAreaInsetMixinProps\n>`\n  border-top: 1px solid #efefef;\n  background: #fff;\n\n  ${paddingMixin}\n  ${safeAreaInsetMixin}\n`\n\nconst FloatedPricingContainer = styled(Container)`\n  width: 50%;\n`\n\nconst PurchaseButtonContainer = styled(Container)`\n  top: 50%;\n  right: 0;\n  transform: translateY(-50%);\n  width: 41%;\n  height: 47px;\n`\n\nfunction LoadingSkeleton() {\n  return (\n    <>\n      <Container clearing>\n        <Skeleton\n          borderRadius={2}\n          floated=\"left\"\n          css={{\n            width: '40%',\n            height: 20,\n          }}\n        />\n      </Container>\n      <Container\n        clearing\n        css={{\n          margin: '5px 0 0',\n        }}\n      >\n        <Skeleton\n          borderRadius={2}\n          floated=\"left\"\n          css={{\n            width: '60%',\n            height: 22,\n          }}\n        />\n      </Container>\n      <Container\n        clearing\n        css={{\n          margin: '5px 0 0',\n        }}\n      >\n        <Skeleton\n          borderRadius={2}\n          floated=\"left\"\n          css={{\n            width: '100%',\n            height: 15,\n          }}\n        />\n      </Container>\n    </>\n  )\n}\n\nexport function FixedPricingV2({\n  emptyOverride,\n  loading,\n  active,\n  label,\n  buttonText,\n  buttonDisabled,\n  description,\n  salePrice,\n  tooltipLabel,\n  tooltipColor,\n  discountRate,\n  onClick,\n  priceLabelOverride,\n  onTooltipClick,\n  isSoldOut = false,\n  maxWidth,\n  padding = { top: 14, right: 20, bottom: 14, left: 20 },\n}: FixedPricingV2Props) {\n  const t = useTranslation()\n\n  const formattedSalePrice = formatNumber(salePrice)\n  const pricingLabel = label ? (\n    typeof label === 'string' ? (\n      <Text color=\"gray\" alpha={0.5} size=\"mini\">\n        {label}\n      </Text>\n    ) : (\n      label\n    )\n  ) : null\n\n  const pricingDescription = description ? (\n    typeof description === 'string' ? (\n      <Text size=\"mini\" alpha={0.5} margin={{ top: 1 }}>\n        {description}\n      </Text>\n    ) : (\n      description\n    )\n  ) : null\n\n  return (\n    <Drawer active={active} overflow=\"visible\">\n      <FloatedFrame padding={padding}>\n        <Container\n          position=\"relative\"\n          clearing\n          centered={!!maxWidth}\n          css={{\n            maxWidth,\n          }}\n        >\n          {emptyOverride || (\n            <>\n              {!loading && active && tooltipLabel && (\n                <Tooltip\n                  borderRadius=\"30\"\n                  backgroundColor={tooltipColor}\n                  positioning={{ top: -34 }}\n                  label={tooltipLabel}\n                  onClick={onTooltipClick}\n                />\n              )}\n              <FloatedPricingContainer floated=\"left\">\n                {loading ? (\n                  <LoadingSkeleton />\n                ) : (\n                  <>\n                    {pricingLabel}\n                    <Text\n                      size=\"huge\"\n                      bold\n                      margin={{ bottom: 3 }}\n                      color={isSoldOut ? 'gray300' : 'gray'}\n                    >\n                      {priceLabelOverride ||\n                        t('{{formattedSalePrice}}원', { formattedSalePrice })}\n                      {discountRate}\n                    </Text>\n                    {pricingDescription}\n                  </>\n                )}\n              </FloatedPricingContainer>\n\n              <PurchaseButtonContainer position=\"absolute\">\n                <PurchaseButton\n                  loading={loading}\n                  disabled={buttonDisabled}\n                  buttonText={buttonText}\n                  onClick={onClick}\n                />\n              </PurchaseButtonContainer>\n            </>\n          )}\n        </Container>\n      </FloatedFrame>\n    </Drawer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/pricing/fixed-pricing-v2/purchase-button-loading-indicator.tsx",
    "content": "import { styled, keyframes } from 'styled-components'\nimport { FlexBox } from '@titicaca/tds-ui'\n\ninterface IndicatorProps {\n  loading: boolean\n  indicatorCount?: number\n  color?: string\n  size?: number\n  margin?: number\n  speedMultiplier?: number\n}\n\nconst pulse = keyframes`\n  0% {\n    transform: translateY(5px);\n    opacity: 1;\n  }\n\n  50% {\n    transform: translateY(-5px);\n    opacity: 0.7;\n  }\n\n  100% {\n    transform: translateY(5px);\n    opacity: 1;\n  }\n`\n\nconst Indicator = styled.span<{\n  index: number\n  color: string\n  size: number\n  speedMultiplier: number\n  margin: number\n}>`\n  background-color: ${({ color }) => color};\n  width: ${({ size }) => `${size}px`};\n  height: ${({ size }) => `${size}px`};\n  margin: ${({ margin }) => `${margin}px`};\n  border-radius: 100%;\n  display: inline-block;\n  animation-duration: ${({ speedMultiplier }) => 0.75 / speedMultiplier}s;\n  animation-timing-function: cubic-bezier(0.62, 0.28, 0.23, 0.99);\n  animation-delay: ${({ speedMultiplier, index }) =>\n    (index * 0.12) / speedMultiplier}s;\n  animation-iteration-count: infinite;\n  animation-direction: normal;\n  animation-fill-mode: both;\n  animation-name: ${pulse};\n`\n\nexport function PurchaseButtonLoadingIndicator({\n  loading,\n  indicatorCount = 3,\n  color = 'var(--color-white)',\n  size = 6,\n  margin = 3,\n  speedMultiplier = 0.75,\n}: IndicatorProps) {\n  return loading ? (\n    <FlexBox flex alignItems=\"center\" justifyContent=\"center\">\n      {[...new Array(indicatorCount)].map((_, index) => {\n        return (\n          <Indicator\n            key={index}\n            index={index + 1}\n            color={color}\n            size={size}\n            margin={margin}\n            speedMultiplier={speedMultiplier}\n          />\n        )\n      })}\n    </FlexBox>\n  ) : null\n}\n"
  },
  {
    "path": "packages/tds-widget/src/pricing/fixed-pricing-v2/purchase-button.tsx",
    "content": "import { Button } from '@titicaca/tds-ui'\n\nimport { PurchaseButtonLoadingIndicator } from './purchase-button-loading-indicator'\n\nexport function PurchaseButton({\n  loading,\n  disabled,\n  buttonText,\n  onClick,\n}: {\n  loading: boolean\n  disabled?: boolean\n  buttonText?: string\n  onClick?: () => void\n}) {\n  return (\n    <Button\n      as=\"button\"\n      fluid\n      borderRadius={4}\n      size=\"small\"\n      color={loading ? 'blue500' : 'blue'}\n      disabled={disabled}\n      onClick={onClick}\n    >\n      {loading ? (\n        <PurchaseButtonLoadingIndicator loading={loading} />\n      ) : (\n        buttonText\n      )}\n    </Button>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/pricing/fixed-pricing-v2.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { FixedPricingV2 } from './fixed-pricing-v2'\n\nexport default {\n  title: 'tds-widget / pricing / FixedPricingV2',\n  component: FixedPricingV2,\n  parameters: {\n    story: {\n      inline: false,\n      iframeHeight: 300,\n    },\n  },\n} as Meta<typeof FixedPricingV2>\n\nexport const Basic: StoryObj<typeof FixedPricingV2> = {\n  args: {\n    loading: false,\n    active: true,\n    salePrice: 25000,\n    label: '1박 세금포함',\n    buttonText: '객실예약',\n    description: '쿠폰적용시 10,000원',\n    discountRate: '5%',\n    maxWidth: 720,\n  },\n}\n\nexport const Loading: StoryObj<typeof FixedPricingV2> = {\n  args: {\n    ...Basic.args,\n    loading: true,\n  },\n}\n\nexport const Soldout: StoryObj<typeof FixedPricingV2> = {\n  args: {\n    loading: false,\n    active: true,\n    salePrice: 25000,\n    label: '1박 세금포함',\n    buttonText: '객실예약',\n    description: '쿠폰적용시 10,000원',\n    discountRate: '5%',\n    isSoldOut: true,\n    buttonDisabled: true,\n    maxWidth: 720,\n  },\n}\n\nexport const WithTooltip: StoryObj<typeof FixedPricingV2> = {\n  args: {\n    loading: false,\n    active: true,\n    salePrice: 25000,\n    label: '1박 세금포함',\n    buttonText: '객실예약',\n    description: '쿠폰적용시 10,000원',\n    tooltipLabel: '쿠폰사용시 -15,000원 더 할인!',\n    discountRate: '5%',\n    maxWidth: 720,\n    onTooltipClick: () => {},\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/pricing/fixed-pricing.tsx",
    "content": "import { ReactNode, SyntheticEvent } from 'react'\nimport { useTranslation } from '@titicaca/triple-web'\nimport { styled, css } from 'styled-components'\nimport {\n  Container,\n  Drawer,\n  Tooltip,\n  Text,\n  Button,\n  MarginPadding,\n  paddingMixin,\n} from '@titicaca/tds-ui'\nimport { formatNumber } from '@titicaca/view-utilities'\n\nexport interface FixedPricingProps {\n  active?: boolean\n  label?: ReactNode\n  discountRate?: ReactNode\n  description?: ReactNode\n  buttonText?: string\n  buttonDisabled?: boolean\n  salePrice?: number\n  isSoldOut?: boolean\n  priceLabelOverride?: ReactNode\n  tooltipLabel?: string\n  onClick?: (e?: SyntheticEvent) => void\n  onTooltipClick?: (e?: SyntheticEvent) => void\n  maxWidth?: number\n  tooltipColor?: string\n  padding?: MarginPadding\n}\n\nconst FloatedFrame = styled(Container)<{ padding: MarginPadding }>`\n  border-top: 1px solid #efefef;\n  background: #fff;\n\n  ${paddingMixin}\n  @supports (padding: max(0px)) and (padding: env(safe-area-inset-bottom)) {\n    ${({ padding }) =>\n      padding?.bottom\n        ? css`\n            padding-bottom: max(\n              ${padding.bottom}px,\n              env(safe-area-inset-bottom, ${padding.bottom}px)\n            );\n          `\n        : css`\n            padding-bottom: max(14px, env(safe-area-inset-bottom, 14px));\n          `}\n  }\n`\n\nconst FloatedPricingContainer = styled(Container)`\n  width: 50%;\n`\n\nconst PurchaseButtonContainer = styled(Container)`\n  top: 50%;\n  right: 0;\n  transform: translateY(-50%);\n  width: 41%;\n  height: 47px;\n`\n\nexport function FixedPricing({\n  active,\n  label,\n  buttonText,\n  buttonDisabled,\n  description,\n  salePrice,\n  tooltipLabel,\n  tooltipColor,\n  discountRate,\n  onClick,\n  priceLabelOverride,\n  onTooltipClick,\n  isSoldOut = false,\n  maxWidth,\n  padding = { top: 14, right: 20, bottom: 14, left: 20 },\n}: FixedPricingProps) {\n  const t = useTranslation()\n\n  const formattedSalePrice = formatNumber(salePrice)\n  const pricingLabel = label ? (\n    typeof label === 'string' ? (\n      <Text color=\"gray\" alpha={0.5} size=\"mini\">\n        {label}\n      </Text>\n    ) : (\n      label\n    )\n  ) : null\n\n  const pricingDescription = description ? (\n    typeof description === 'string' ? (\n      <Text size=\"mini\" alpha={0.5} margin={{ top: 1 }}>\n        {description}\n      </Text>\n    ) : (\n      description\n    )\n  ) : null\n\n  return (\n    <Drawer active={active} overflow=\"visible\">\n      <FloatedFrame padding={padding}>\n        <Container\n          position=\"relative\"\n          clearing\n          centered={!!maxWidth}\n          css={{\n            maxWidth,\n          }}\n        >\n          {active && tooltipLabel && (\n            <Tooltip\n              borderRadius=\"30\"\n              backgroundColor={tooltipColor}\n              positioning={{ top: -34 }}\n              label={tooltipLabel}\n              onClick={onTooltipClick}\n            />\n          )}\n          <FloatedPricingContainer floated=\"left\">\n            {pricingLabel}\n            <Text\n              size=\"huge\"\n              bold\n              margin={{ bottom: 3 }}\n              color={isSoldOut ? 'gray300' : 'gray'}\n            >\n              {priceLabelOverride ||\n                t('{{formattedSalePrice}}원', {\n                  formattedSalePrice,\n                })}\n              {discountRate}\n            </Text>\n            {pricingDescription}\n          </FloatedPricingContainer>\n\n          <PurchaseButtonContainer position=\"absolute\">\n            <Button\n              as=\"button\"\n              fluid\n              borderRadius={4}\n              size=\"small\"\n              color=\"blue\"\n              disabled={buttonDisabled}\n              onClick={onClick}\n            >\n              {buttonText}\n            </Button>\n          </PurchaseButtonContainer>\n        </Container>\n      </FloatedFrame>\n    </Drawer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/pricing/index.ts",
    "content": "export * from './fixed-pricing-v2'\nexport * from './pricing'\n"
  },
  {
    "path": "packages/tds-widget/src/pricing/pricing.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { Pricing } from './pricing'\n\nexport default {\n  title: 'tds-widget / pricing / Pricing',\n  component: Pricing,\n} as Meta<typeof Pricing>\n\nexport const Basic: StoryObj<typeof Pricing> = {\n  args: {\n    basePrice: 30000,\n    salePrice: 25000,\n    isSoldOut: false,\n  },\n}\n\nexport const Rich: StoryObj<typeof Pricing> = {\n  args: {\n    rich: true,\n    basePrice: 30000,\n    basePriceUnit: '원',\n    pricingNote: '1박, 세금포함',\n    salePrice: 25000,\n    label: '트리플가',\n    hideDiscountRate: false,\n    isSoldOut: false,\n    description: '쿠폰적용시 10,000원',\n  },\n}\n\nexport const Fixed: StoryObj<typeof Pricing> = {\n  args: {\n    fixed: true,\n    active: true,\n    salePrice: 25000,\n    priceLabelOverride: '25,000원',\n    label: '1박 세금포함',\n    buttonText: '객실예약',\n    buttonDisabled: false,\n    description: '쿠폰적용시 10,000원',\n    discountRate: '5%',\n    tooltipLabel: '쿠폰사용시 -15,000원 더 할인!',\n    isSoldOut: false,\n    maxWidth: 720,\n    onTooltipClick: () => {},\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/pricing/pricing.tsx",
    "content": "import { ReactNode } from 'react'\nimport { useTranslation } from '@titicaca/triple-web'\nimport { styled, css } from 'styled-components'\nimport { formatNumber } from '@titicaca/view-utilities'\nimport { Container, Text, MarginPadding, GlobalColors } from '@titicaca/tds-ui'\nimport { GlobalSizes } from '@titicaca/type-definitions'\n\nimport { FixedPricingProps, FixedPricing } from './fixed-pricing'\n\nexport type BasePrice = number | null\n\ninterface RegularPricingProps {\n  basePrice?: BasePrice\n  salePrice?: number\n  priceLabelOverride?: ReactNode\n  isSoldOut?: boolean\n}\n\ninterface RichPricingProps {\n  basePrice?: BasePrice\n  basePriceUnit?: string\n  salePrice?: number\n  label?: ReactNode\n  pricingNote?: string\n  description?: ReactNode\n  priceLabelOverride?: ReactNode\n  hideDiscountRate?: boolean\n  isSoldOut?: boolean\n}\n\ntype PricingProps =\n  | ({ rich: true; fixed?: false } & RichPricingProps)\n  | ({ rich?: false; fixed: true } & FixedPricingProps)\n  | ({\n      rich?: false\n      fixed?: false\n    } & RegularPricingProps)\n\nconst FONT_SIZE: Partial<Record<GlobalSizes, string>> = {\n  mini: '12px',\n  tiny: '13px',\n  small: '14px',\n  large: '18px',\n  big: '20px',\n}\n\ntype PricingColors = GlobalColors | 'pink' | 'default'\n\nconst COLORS: Partial<Record<PricingColors, string>> = {\n  pink: 'rgba(253, 46, 105, 1)',\n  gray: 'rgba(58, 58, 58, 0.3)',\n  blue: 'rgba(54, 143, 255, 1)',\n  white: 'rgba(255, 255, 255, 1)',\n  default: 'rgba(58, 58, 58, 1)',\n}\n\nconst PricingContainer = styled.div<{ padding?: MarginPadding }>`\n  clear: both;\n  position: relative;\n  text-align: right;\n  font-size: ${FONT_SIZE.large};\n  font-weight: bold;\n  color: #3a3a3a;\n\n  ${({ padding }) =>\n    padding &&\n    css`\n      padding: ${padding.top || 0}px ${padding.right || 0}px\n        ${padding.bottom || 0}px ${padding.left || 0}px;\n    `};\n\n  small {\n    color: rgba(58, 58, 58, 0.3);\n    font-weight: normal;\n    font-size: ${FONT_SIZE.mini};\n    display: inline-block;\n    text-decoration: line-through;\n    margin-right: 6px;\n  }\n`\n\nconst Label = styled.div<{ size?: GlobalSizes }>`\n  position: absolute;\n  left: 0;\n  bottom: 0;\n  color: ${COLORS.blue};\n  font-size: ${({ size }) => FONT_SIZE[size || 'tiny']};\n`\n\nfunction DiscountRate({\n  basePrice,\n  salePrice,\n}: {\n  basePrice: number\n  salePrice: number\n}) {\n  const rate = Math.floor(((basePrice - salePrice) / basePrice) * 100)\n\n  return rate > 0 ? (\n    <Text color=\"red\" size={20} margin={{ right: 5 }} bold inline>\n      {rate}%\n    </Text>\n  ) : null\n}\n\nfunction RichPricing({\n  basePrice,\n  salePrice = 0,\n  label,\n  pricingNote,\n  description,\n  basePriceUnit,\n  priceLabelOverride,\n  hideDiscountRate,\n  isSoldOut,\n}: RichPricingProps) {\n  const t = useTranslation()\n\n  const formattedSalePrice = formatNumber(salePrice)\n  const pricingDescription = description ? (\n    typeof description === 'string' ? (\n      <Text size=\"tiny\" alpha={0.8} margin={{ top: 3 }}>\n        {description}\n      </Text>\n    ) : (\n      description\n    )\n  ) : null\n\n  const hasBasePrice =\n    typeof basePrice === 'number' && basePrice > 0 && basePrice > salePrice\n\n  return (\n    <Container\n      css={{\n        textAlign: 'right',\n      }}\n    >\n      <PricingContainer>\n        {label ? <Label> {label} </Label> : null}\n\n        {(pricingNote || hasBasePrice) && (\n          <Container\n            css={{\n              margin: '0 0 3px',\n            }}\n          >\n            {pricingNote && (\n              <Text alpha={0.3} size=\"mini\" inlineBlock margin={{ right: 3 }}>\n                {pricingNote}\n              </Text>\n            )}\n\n            {hasBasePrice && (\n              <Text size=\"mini\" strikethrough inline color=\"gray300\">\n                {formatNumber(basePrice)}\n                {basePriceUnit}\n              </Text>\n            )}\n          </Container>\n        )}\n\n        {!hideDiscountRate && hasBasePrice ? (\n          <DiscountRate basePrice={basePrice as number} salePrice={salePrice} /> // HACK: hasBasePrice가 true면 basePrice는 무조건 number이다.\n        ) : null}\n\n        <Text size={20} bold inline color={isSoldOut ? 'gray300' : 'gray'}>\n          {priceLabelOverride ||\n            t('{{formattedSalePrice}}원', {\n              formattedSalePrice,\n            })}\n        </Text>\n      </PricingContainer>\n      {pricingDescription}\n    </Container>\n  )\n}\n\nconst RegularPricing = ({\n  basePrice,\n  salePrice = 0,\n  priceLabelOverride,\n  isSoldOut,\n}: RegularPricingProps) => {\n  const t = useTranslation()\n\n  const formattedSalePrice = formatNumber(salePrice)\n  const hasBasePrice =\n    typeof basePrice === 'number' && basePrice > 0 && basePrice > salePrice\n\n  return (\n    <PricingContainer padding={{ top: 18 }}>\n      {hasBasePrice && (\n        <Text\n          color=\"gray300\"\n          size=\"mini\"\n          strikethrough\n          inline\n          margin={{ right: 5 }}\n        >\n          {formatNumber(basePrice)}\n        </Text>\n      )}\n      <Text size={18} bold inline color={isSoldOut ? 'gray300' : 'gray'}>\n        {priceLabelOverride ||\n          t('{{formattedSalePrice}}원', {\n            formattedSalePrice,\n          })}\n      </Text>\n    </PricingContainer>\n  )\n}\n\nexport function Pricing(props: PricingProps) {\n  const { salePrice, priceLabelOverride } = props\n\n  if (props.rich) {\n    const {\n      basePrice,\n      label,\n      pricingNote,\n      description,\n      basePriceUnit,\n      hideDiscountRate,\n      isSoldOut,\n    } = props\n\n    return (\n      <RichPricing\n        basePrice={basePrice}\n        priceLabelOverride={priceLabelOverride}\n        basePriceUnit={basePriceUnit}\n        salePrice={salePrice}\n        label={label}\n        pricingNote={pricingNote}\n        description={description}\n        hideDiscountRate={hideDiscountRate}\n        isSoldOut={isSoldOut}\n      />\n    )\n  } else if (props.fixed) {\n    const {\n      active,\n      label,\n      buttonText,\n      buttonDisabled,\n      description,\n      onClick,\n      tooltipLabel,\n      onTooltipClick,\n      isSoldOut,\n      maxWidth,\n      tooltipColor,\n      discountRate,\n      padding,\n    } = props\n\n    return (\n      <FixedPricing\n        active={active}\n        label={label}\n        padding={padding}\n        buttonText={buttonText}\n        buttonDisabled={buttonDisabled}\n        salePrice={salePrice}\n        description={description}\n        onClick={onClick}\n        priceLabelOverride={priceLabelOverride}\n        tooltipLabel={tooltipLabel}\n        tooltipColor={tooltipColor}\n        discountRate={discountRate}\n        onTooltipClick={onTooltipClick}\n        isSoldOut={isSoldOut}\n        maxWidth={maxWidth}\n      />\n    )\n  } else {\n    const { basePrice, isSoldOut } = props\n\n    return (\n      <RegularPricing\n        basePrice={basePrice}\n        salePrice={salePrice}\n        priceLabelOverride={priceLabelOverride}\n        isSoldOut={isSoldOut}\n      />\n    )\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/categories.ts",
    "content": "import type { Category } from './types'\n\nexport function getCategoryHref(category?: Category) {\n  switch (category) {\n    case 'air':\n      return '/air'\n    case 'hotels':\n      return '/hotels'\n    case 'tna':\n      return '/tna'\n    default:\n      return '/'\n  }\n}\n\nexport function getCategoryTitle(category?: Category) {\n  switch (category) {\n    case 'air':\n      return 'Triple 항공 홈'\n    case 'hotels':\n      return 'Triple 숙소 홈'\n    case 'tna':\n      return 'Triple 투어 티켓 홈'\n    default:\n      return 'Triple 홈'\n  }\n}\n\nexport function getCategoryImageProps(category: Category) {\n  switch (category) {\n    case 'air':\n      return {\n        alt: '항공',\n        src: 'https://assets.triple.guide/images/img_intro_logo_air.svg',\n      } as const\n    case 'hotels':\n      return {\n        alt: '숙소',\n        src: 'https://assets.triple.guide/images/img_intro_logo_hotels.svg',\n      } as const\n    case 'tna':\n      return {\n        alt: '투어 티켓',\n        src: 'https://assets.triple.guide/images/img_intro_logo_tna.svg',\n      } as const\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/constants.ts",
    "content": "import { MenuItem } from './side-menu/type'\n\nexport const MIN_DESKTOP_WIDTH = 1142\nexport const TRANSITION_TIME = 250\nexport const HEADER_MOBILE_HEIGHT = 50\nexport const HEADER_DESKTOP_HEIGHT = 80\nexport const HEADER_SIDE_MENU_HASH = 'open.side-menu'\n\nexport const HEADER_ACTION_ITEMS = {\n  login: {\n    label: '로그인',\n    href: '/login',\n    eventAction: '헤더_로그인_선택',\n  },\n  booking: {\n    label: '내 예약',\n    href: '/my-bookings',\n    eventAction: '헤더_내예약_선택',\n  },\n}\n\nexport const HEADER_SIDE_MENU_ITEMS: MenuItem[] = [\n  {\n    type: 'link',\n    label: '내 예약',\n    href: '/my-bookings',\n    eventAction: '내예약_선택',\n  },\n  {\n    type: 'link',\n    label: '도시별 여행정보',\n    href: '/trips/intro',\n    eventAction: '라운지홈_선택',\n  },\n  {\n    type: 'link',\n    label: 'AI 추천 맞춤일정',\n    href: '/trips/promotion/customized-schedule',\n    eventAction: '맞춤일정_선택',\n  },\n  {\n    type: 'accordion',\n    label: '여행상품',\n    defaultOpen: true,\n    subItems: [\n      {\n        type: 'link',\n        label: '항공',\n        href: '/air',\n        eventAction: '항공_선택',\n      },\n      {\n        type: 'link',\n        label: '숙소',\n        href: '/hotels',\n        eventAction: '숙소_선택',\n      },\n      {\n        type: 'link',\n        label: '투어·티켓',\n        href: '/tna',\n        eventAction: '투어티켓_선택',\n      },\n    ],\n  },\n]\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/extra-action-item.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { MIN_DESKTOP_WIDTH } from './constants'\n\nexport const ExtraActionItem = styled.a`\n  text-decoration: none;\n  display: inline-flex;\n  align-items: center;\n  color: var(--color-gray800);\n  font-size: 14px;\n  padding: 10px 8px;\n\n  @media (min-width: ${MIN_DESKTOP_WIDTH}px) {\n    font-size: 17px;\n    padding: 10px 14px;\n  }\n`\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/extra-action-separator.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { MIN_DESKTOP_WIDTH } from './constants'\n\nexport const ExtraActionSeparator = styled.div`\n  display: inline-block;\n  width: 1px;\n  margin: 0 2px;\n  height: 10px;\n  background-color: var(--color-gray100);\n\n  @media (min-width: ${MIN_DESKTOP_WIDTH}px) {\n    height: 14px;\n  }\n`\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/extra-actions-container.tsx",
    "content": "import { styled } from 'styled-components'\n\nexport const ExtraActionsContainer = styled.div`\n  display: flex;\n  align-items: center;\n  margin-left: auto;\n`\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/header-menu-button.tsx",
    "content": "import { styled, css } from 'styled-components'\n\nexport const HeaderMenuButton = styled.button.attrs({\n  id: 'header-menu-button',\n})<{ hasNewNotification?: boolean }>`\n  position: relative;\n  width: 24px;\n  height: 24px;\n  background: no-repeat center/100%\n    url('https://assets.triple.guide/images/ico_navi_menu@4x.png');\n  margin: 0 8px;\n\n  ${({ hasNewNotification }) =>\n    hasNewNotification &&\n    css`\n      &::after {\n        content: '';\n        position: absolute;\n        top: -6px;\n        right: -7px;\n        width: 8px;\n        height: 8px;\n        background-color: #fd2e69;\n        border-radius: 50%;\n      }\n    `}\n`\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/index.ts",
    "content": "export * from './public-header'\nexport * from './extra-action-item'\nexport * from './extra-action-separator'\nexport * from './constants'\nexport * from './side-menu'\nexport * from './header-menu-button'\nexport * from './side-menu/type'\n\nexport { PublicHeader as default } from './public-header'\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/public-header-deeplink.tsx",
    "content": "import { useTranslation, useTrackEventWithMetadata } from '@titicaca/triple-web'\n\nimport { ExtraActionItem } from './extra-action-item'\nimport { ExtraActionSeparator } from './extra-action-separator'\nimport { DeeplinkComponent } from './types'\nimport { useDeeplinkHref } from './use-deeplink-href'\n\ninterface Props {\n  deeplinkPath: string\n  DeeplinkComponent?: DeeplinkComponent\n}\n\nexport function PublicHeaderDeeplink({\n  deeplinkPath,\n  DeeplinkComponent,\n}: Props) {\n  const t = useTranslation()\n\n  const trackEventWithMetadata = useTrackEventWithMetadata()\n  const deeplinkHref = useDeeplinkHref(deeplinkPath)\n\n  return DeeplinkComponent ? (\n    DeeplinkComponent({ deeplinkHref })\n  ) : (\n    <>\n      <ExtraActionSeparator />\n      <ExtraActionItem\n        href={deeplinkHref}\n        onClick={() =>\n          trackEventWithMetadata({\n            ga: ['헤더_설치유도_선택', '앱에서 보기'],\n            fa: {\n              action: '헤더_설치유도_선택',\n            },\n            metaPixel: {\n              action: '헤더_설치유도_선택',\n            },\n          })\n        }\n      >\n        {t('앱에서 보기')}\n      </ExtraActionItem>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/public-header.spec.tsx",
    "content": "import { render } from '@testing-library/react'\nimport { ClientAppName } from '@titicaca/triple-web'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport { PublicHeader } from './public-header'\n\ntest('renders nothing inside triple client', () => {\n  const { container } = render(<PublicHeader />, {\n    wrapper: createTestWrapper({\n      clientAppProvider: {\n        device: { autoplay: 'always', networkType: 'unknown' },\n        metadata: { name: ClientAppName.iOS, version: '6.5.0' },\n      },\n    }),\n  })\n\n  expect(container.childNodes).toHaveLength(0)\n})\n\ntest('renders header outside triple client', () => {\n  const { container } = render(<PublicHeader />, {\n    wrapper: createTestWrapper(),\n  })\n\n  expect(container.childNodes).toHaveLength(1)\n})\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/public-header.stories.tsx",
    "content": "import type { Meta, StoryFn, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider, TripleWeb } from '@titicaca/triple-web'\n\nimport { PublicHeader } from './public-header'\n\nexport default {\n  title: 'tds-widget / public-header / PublicHeader',\n  component: PublicHeader,\n  decorators: [\n    (Story, context) => (\n      <TripleWeb\n        clientAppProvider={null}\n        envProvider={{\n          appUrlScheme: 'dev-soto',\n          basePath: '/',\n          webUrlBase: 'https://triple-dev.titicaca-corp.com',\n          facebookAppId: '',\n          defaultPageTitle: '',\n          defaultPageDescription: '',\n          googleMapsApiKey: 'AIzaSyDuSWU_yBwuQzeyRFcTqhyifqNX_8oaXI4',\n          afOnelinkId: '',\n          afOnelinkPid: '',\n          afOnelinkSubdomain: '',\n        }}\n        i18nProvider={{\n          locale: context.globals.locale,\n        }}\n        sessionProvider={{\n          user: {\n            name: '여행자',\n            provider: 'KAKAO',\n            country: 'KR',\n            lang: 'ko',\n            unregister: false,\n            photo: 'https://assets.triple.guide/images/ico-default-profile.svg',\n            mileage: { badges: [], level: 1, point: 0 },\n            uid: 'random-user',\n            email: 'triple@triple-corp.com',\n          },\n        }}\n        userAgentProvider={{\n          ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148;Triple-iOS/6.5.0',\n          browser: { name: 'WebKit', version: '605.1.15', major: '605' },\n          engine: { name: 'WebKit', version: '605.1.15' },\n          os: { name: 'iOS', version: '13.3.1' },\n          device: { vendor: 'Apple', model: 'iPhone', type: 'mobile' },\n          cpu: { architecture: undefined },\n          isMobile: false,\n        }}\n      >\n        <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n          <Story />\n        </EventTrackingProvider>\n      </TripleWeb>\n    ),\n  ],\n  argTypes: {\n    category: {\n      control: { type: 'select' },\n      options: ['air', 'hotels', 'tna'],\n    },\n  },\n} as Meta<typeof PublicHeader>\n\nexport const Basic: StoryObj<typeof PublicHeader> = {\n  args: {\n    disableAutoHide: true,\n  },\n}\n\nexport const WithSideMenu: StoryObj<typeof PublicHeader> = {\n  args: {\n    disableAutoHide: true,\n    hasSideMenu: true,\n  },\n}\n\nexport const Categories: StoryFn<typeof PublicHeader> = () => {\n  return (\n    <>\n      <PublicHeader disableAutoHide category=\"air\" />\n      <PublicHeader disableAutoHide category=\"hotels\" />\n      <PublicHeader disableAutoHide category=\"tna\" />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/public-header.tsx",
    "content": "import {\n  useTranslation,\n  useClientApp,\n  useTrackEvent,\n  useHashRouter,\n} from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport { PropsWithChildren, useCallback, useMemo } from 'react'\n\nimport {\n  HEADER_DESKTOP_HEIGHT,\n  HEADER_MOBILE_HEIGHT,\n  HEADER_SIDE_MENU_HASH,\n  HEADER_SIDE_MENU_ITEMS,\n  MIN_DESKTOP_WIDTH,\n  TRANSITION_TIME,\n} from './constants'\nimport type { Category, DeeplinkComponent } from './types'\nimport {\n  getCategoryHref,\n  getCategoryImageProps,\n  getCategoryTitle,\n} from './categories'\nimport { useAutoHide } from './use-auto-hide'\nimport { ExtraActionsContainer } from './extra-actions-container'\nimport { ExtraActionItem } from './extra-action-item'\nimport { MenuItem } from './side-menu/type'\nimport { SideMenu } from './side-menu'\nimport { HeaderMenuButton } from './header-menu-button'\nimport { PublicHeaderDeeplink } from './public-header-deeplink'\n\nconst Wrapper = styled.div<{ $visible: boolean }>`\n  transition: height ease ${TRANSITION_TIME}ms;\n  overflow: hidden;\n  top: 0;\n  height: ${({ $visible }) => ($visible ? `${HEADER_MOBILE_HEIGHT}px` : '0px')};\n\n  &:focus-within {\n    height: ${HEADER_MOBILE_HEIGHT}px;\n  }\n\n  @media (min-width: ${MIN_DESKTOP_WIDTH}px) {\n    height: ${({ $visible: visible }) =>\n      visible ? `${HEADER_DESKTOP_HEIGHT}px` : '0px'};\n\n    &:focus-within {\n      height: ${HEADER_DESKTOP_HEIGHT}px;\n    }\n  }\n`\n\nconst HeaderFrame = styled.div`\n  background-color: var(--color-white);\n  display: flex;\n  align-items: center;\n  border-bottom: 1px solid var(--color-brightGray);\n  padding: 0 6px;\n  height: ${HEADER_MOBILE_HEIGHT}px;\n\n  @media (min-width: ${MIN_DESKTOP_WIDTH}px) {\n    height: ${HEADER_DESKTOP_HEIGHT}px;\n    padding: 0 42px;\n  }\n`\n\nconst Logo = styled.a`\n  padding: 10px 8px;\n  text-decoration: none;\n  display: flex;\n`\n\nconst LogoImage = styled.img`\n  display: block;\n  width: 57px;\n  height: 20px;\n\n  @media (min-width: ${MIN_DESKTOP_WIDTH}px) {\n    width: 68px;\n    height: 24px;\n  }\n`\n\nconst LogoCategoryImage = styled.img`\n  display: block;\n  margin-left: 2px;\n  width: auto;\n  height: 20px;\n\n  @media (min-width: ${MIN_DESKTOP_WIDTH}px) {\n    height: 24px;\n  }\n`\n\nexport interface PublicHeaderProps {\n  category?: Category\n  /**\n   * 앱에서 열 수 있는 path. ex) inlink or 네이티브 딥링크\n   */\n  deeplinkPath?: string\n  DeeplinkComponent?: DeeplinkComponent\n  disableAutoHide?: boolean\n  /** @deprecated onLinkClick을 사용해주세요 */\n  onClick?: () => void\n  linkHref?: string\n  linkLabel?: string\n  onLogoClick?: () => void\n  onLinkClick?: () => void\n  sideMenuItems?: MenuItem[]\n  hasSideMenu?: boolean\n  hasNewNotification?: boolean\n}\n\nexport function PublicHeader({\n  category,\n  deeplinkPath,\n  DeeplinkComponent,\n  disableAutoHide,\n  onClick,\n  onLinkClick,\n  linkHref = '/my-bookings',\n  linkLabel,\n  onLogoClick,\n  children,\n  hasSideMenu = false,\n  sideMenuItems = HEADER_SIDE_MENU_ITEMS,\n  hasNewNotification,\n  ...props\n}: PropsWithChildren<PublicHeaderProps>) {\n  const t = useTranslation()\n\n  const app = useClientApp()\n  const visible = useAutoHide(disableAutoHide)\n  const { uriHash, addUriHash, removeUriHash } = useHashRouter()\n  const trackEvent = useTrackEvent()\n\n  const onMenuButtonClick = useCallback(() => {\n    addUriHash(HEADER_SIDE_MENU_HASH)\n    trackEvent({ fa: { action: '헤더_메뉴_선택' } })\n  }, [trackEvent, addUriHash])\n\n  const onSideMenuClose = useCallback(() => {\n    trackEvent({ fa: { category: '메인메뉴', action: '닫기_선택' } })\n    removeUriHash()\n  }, [trackEvent, removeUriHash])\n\n  const onTrackMenuEvent = useCallback(\n    (eventAction?: string) => {\n      if (eventAction) {\n        trackEvent({ fa: { category: '메인메뉴', action: eventAction } })\n      }\n    },\n    [trackEvent],\n  )\n\n  const sideMenuItemsWithEventTracking = useMemo(\n    () =>\n      sideMenuItems.map((menu) => ({\n        ...menu,\n        ...('subItems' in menu && {\n          subItems: menu.subItems.map((subItem) => ({\n            ...subItem,\n            onClick: () => {\n              onTrackMenuEvent(subItem.eventAction)\n            },\n          })),\n        }),\n        onClick: () => onTrackMenuEvent(menu.eventAction),\n      })),\n    [onTrackMenuEvent, sideMenuItems],\n  )\n\n  if (app) {\n    return null\n  }\n\n  return (\n    <>\n      <Wrapper $visible={visible}>\n        <HeaderFrame {...props}>\n          <Logo\n            href={getCategoryHref(category)}\n            title={t(getCategoryTitle(category))}\n            onClick={onLogoClick}\n          >\n            <LogoImage\n              alt=\"Triple\"\n              src=\"https://assets.triple.guide/images/img_intro_logo_dark.svg\"\n            />\n            {category && (\n              <LogoCategoryImage\n                alt={t(getCategoryImageProps(category).alt)}\n                src={getCategoryImageProps(category).src}\n              />\n            )}\n          </Logo>\n\n          <ExtraActionsContainer>\n            {children}\n            <ExtraActionItem href={linkHref} onClick={onLinkClick || onClick}>\n              {linkLabel ?? t('내 예약')}\n            </ExtraActionItem>\n\n            {deeplinkPath ? (\n              <PublicHeaderDeeplink\n                deeplinkPath={deeplinkPath}\n                DeeplinkComponent={DeeplinkComponent}\n              />\n            ) : null}\n\n            {hasSideMenu ? (\n              <HeaderMenuButton\n                onClick={onMenuButtonClick}\n                hasNewNotification={hasNewNotification}\n              />\n            ) : null}\n          </ExtraActionsContainer>\n        </HeaderFrame>\n      </Wrapper>\n\n      {sideMenuItems ? (\n        <SideMenu\n          open={uriHash === HEADER_SIDE_MENU_HASH}\n          onClose={onSideMenuClose}\n          menus={sideMenuItemsWithEventTracking}\n        />\n      ) : null}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/side-menu/auth-button.tsx",
    "content": "import { Container, Button } from '@titicaca/tds-ui'\nimport { useTrackEvent, useLogout } from '@titicaca/triple-web'\n\nexport function AuthButton() {\n  const logout = useLogout()\n  const trackEvent = useTrackEvent()\n\n  return (\n    <Container css={{ margin: '0 20px' }}>\n      <Button\n        basic\n        fluid\n        css={{\n          height: 44,\n          padding: '11.5px 16px',\n          fontSize: 15,\n          border: '1px solid var(--color-gray300)',\n          borderRadius: 8,\n        }}\n        onClick={() => {\n          trackEvent({\n            fa: { category: '메인메뉴', action: '로그아웃_선택' },\n          })\n          logout()\n        }}\n      >\n        로그아웃\n      </Button>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/side-menu/index.tsx",
    "content": "import { Navbar } from '@titicaca/tds-ui'\nimport { useSessionAvailability } from '@titicaca/triple-web'\n\nimport { SideMenuOverlay } from './overlay'\nimport { MenuList } from './menu-list'\nimport { AuthButton } from './auth-button'\nimport { Profile } from './profile'\nimport { MenuItem } from './type'\n\nexport function SideMenu({\n  open,\n  onClose,\n  menus,\n  ...props\n}: {\n  open: boolean\n  onClose: () => void\n  menus: MenuItem[]\n}) {\n  const sessionAvailable = useSessionAvailability()\n\n  return (\n    <SideMenuOverlay open={open} onClose={onClose} {...props}>\n      <Navbar borderless>\n        <Navbar.Item floated=\"left\" icon=\"close\" onClick={onClose} />\n      </Navbar>\n\n      <Profile />\n      <MenuList menus={menus} />\n\n      {sessionAvailable ? <AuthButton /> : null}\n    </SideMenuOverlay>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/side-menu/menu-list.tsx",
    "content": "import { styled, css } from 'styled-components'\nimport { Container, Text } from '@titicaca/tds-ui'\nimport { useState } from 'react'\n\nimport type { MenuItem, LinkMenuItem, AccordionMenuItem } from './type'\n\nconst MenuListContainer = styled.ul`\n  padding-bottom: 12px;\n`\n\nconst menuItemCss = css`\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin: 0 20px;\n  padding: 20px 0;\n  border-top: 1px solid var(--color-gray50);\n`\n\nconst LinkMenuItemBase = styled.a`\n  ${menuItemCss}\n`\n\nconst AccordionMenuItemContainer = styled(Container)<{ open: boolean }>`\n  display: grid;\n  grid-template-rows: ${({ open }) =>\n    open ? 'max-content 1fr' : 'max-content 0fr'};\n  transition: grid-template-rows 300ms ease;\n`\n\nconst AccordionMenuItemBase = styled(Container)<{ open: boolean }>`\n  ${menuItemCss}\n  cursor: pointer;\n\n  & > img {\n    width: 8px;\n    height: 16px;\n    transform: rotate(90deg);\n\n    ${({ open }) =>\n      open &&\n      css`\n        transform: rotate(270deg);\n      `};\n  }\n`\n\nconst AccordionSubItemContainer = styled(Container)`\n  background: var(--color-gray20);\n  overflow: hidden;\n`\n\nconst AccordionSubItem = styled.a`\n  display: block;\n  padding: 16px 28px;\n  font-size: 15px;\n  cursor: pointer;\n\n  &:first-child {\n    margin-top: 8px;\n  }\n\n  &:last-child {\n    margin-bottom: 8px;\n  }\n`\n\nconst Tooltip = styled.div`\n  position: relative;\n  display: flex;\n  align-items: center;\n  height: 32px;\n  padding: 0 12px;\n  border-radius: 16px;\n  background-color: var(--color-blue);\n  color: var(--color-white);\n  font-size: 13px;\n  font-weight: 700;\n\n  &::before {\n    content: '';\n    position: absolute;\n    top: 50%;\n    left: -5px;\n    border-top: 8px solid transparent;\n    border-bottom: 8px solid transparent;\n    border-right: 8px solid var(--color-blue);\n    transform: translateY(-50%);\n  }\n`\n\ninterface MenuListProps {\n  menus: MenuItem[]\n}\n\nexport function MenuList({ menus }: MenuListProps) {\n  return (\n    <MenuListContainer>\n      {menus.map((menu) => {\n        return (\n          <li key={menu.label}>\n            {menu.type === 'link' ? (\n              <LinkMenuItem {...menu} />\n            ) : menu.type === 'accordion' ? (\n              <AccordionMenuItem {...menu} />\n            ) : null}\n          </li>\n        )\n      })}\n    </MenuListContainer>\n  )\n}\n\nfunction LinkMenuItem({\n  label,\n  href,\n  onClick,\n  tooltipDescription,\n}: LinkMenuItem) {\n  return (\n    <LinkMenuItemBase href={href} onClick={onClick}>\n      <Container css={{ display: 'flex', alignItems: 'center', gap: 16 }}>\n        <Text color=\"gray\" size={15} bold>\n          {label}\n        </Text>\n\n        {tooltipDescription ? <Tooltip>{tooltipDescription}</Tooltip> : null}\n      </Container>\n    </LinkMenuItemBase>\n  )\n}\n\nfunction AccordionMenuItem({\n  label,\n  onClick,\n  subItems,\n  defaultOpen = true,\n}: AccordionMenuItem) {\n  const [open, setOpen] = useState<boolean>(defaultOpen)\n\n  const onMenuClick = () => {\n    setOpen((open) => !open)\n    onClick?.()\n  }\n\n  return (\n    <AccordionMenuItemContainer open={open}>\n      <AccordionMenuItemBase onClick={onMenuClick} open={open}>\n        <Container css={{ display: 'flex', alignItems: 'center', gap: 16 }}>\n          <Text color=\"gray\" size={15} bold>\n            {label}\n          </Text>\n        </Container>\n        <img\n          src=\"https://assets.triple.guide/images/ico_arrow_right_16@4x.png\"\n          alt=\"arrow icon\"\n        />\n      </AccordionMenuItemBase>\n\n      <AccordionSubItemContainer>\n        {subItems.map(\n          ({\n            label: subItemLabel,\n            href: subItemHref,\n            onClick: onSubItemClick,\n          }) => (\n            <AccordionSubItem\n              key={subItemLabel}\n              href={subItemHref}\n              onClick={onSubItemClick}\n            >\n              {subItemLabel}\n            </AccordionSubItem>\n          ),\n        )}\n      </AccordionSubItemContainer>\n    </AccordionMenuItemContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/side-menu/overlay.tsx",
    "content": "import { PropsWithChildren, useEffect } from 'react'\nimport { styled, css } from 'styled-components'\nimport { Container } from '@titicaca/tds-ui'\nimport {\n  FloatingFocusManager,\n  FloatingOverlay,\n  FloatingPortal,\n  useDismiss,\n  useFloating,\n  useInteractions,\n  useRole,\n  useTransitionStatus,\n} from '@floating-ui/react'\n\nconst TRANSITION_DURATION = 300\nconst SIDE_BAR_WIDTH = 325\n\nconst SideBarContainer = styled(Container)`\n  position: fixed;\n  top: 0;\n  bottom: 0;\n  right: 0;\n  max-width: ${SIDE_BAR_WIDTH}px;\n  width: ${SIDE_BAR_WIDTH}px;\n  background-color: #fff;\n  user-select: none;\n  z-index: 9999;\n  outline: none;\n\n  @media (max-width: ${SIDE_BAR_WIDTH}px) {\n    width: 100%;\n  }\n\n  @supports (padding: env(safe-area-inset-bottom)) {\n    padding-bottom: env(safe-area-inset-bottom);\n  }\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n\n  transition: transform ${TRANSITION_DURATION}ms ease-out;\n  transform: translateX(100%);\n\n  &[data-transition='open'] {\n    transform: translateX(0);\n  }\n`\n\nexport interface SideMenuOverlayProps extends PropsWithChildren {\n  open: boolean\n  onClose?: () => void\n  onEnter?: () => void\n  onEntered?: () => void\n  onExit?: () => void\n  onExited?: () => void\n}\n\nexport function SideMenuOverlay({\n  open = false,\n  children,\n  onClose,\n  onEnter,\n  onEntered,\n  onExit,\n  onExited,\n  ...props\n}: SideMenuOverlayProps) {\n  const { context, refs } = useFloating({\n    open,\n    onOpenChange: (open) => (open ? undefined : onClose?.()),\n  })\n\n  const dismiss = useDismiss(context)\n  const role = useRole(context, { role: 'menu' })\n\n  const { getFloatingProps } = useInteractions([dismiss, role])\n\n  const { isMounted, status } = useTransitionStatus(context, {\n    duration: TRANSITION_DURATION,\n  })\n\n  useEffect(() => {\n    if (status === 'open') {\n      onEnter?.()\n      const timeout = setTimeout(() => onEntered?.(), TRANSITION_DURATION)\n      return () => clearTimeout(timeout)\n    } else if (status === 'close') {\n      onExit?.()\n      const timeout = setTimeout(() => onExited?.(), TRANSITION_DURATION)\n      return () => clearTimeout(timeout)\n    }\n  }, [onEnter, onEntered, onExit, onExited, status])\n\n  if (!isMounted) {\n    return null\n  }\n\n  return (\n    <FloatingPortal>\n      <FloatingOverlay\n        lockScroll\n        css={css`\n          position: fixed;\n          top: 0;\n          bottom: 0;\n          left: 0;\n          right: 0;\n          background-color: rgba(58, 58, 58, 0.5);\n          z-index: 9999;\n        `}\n      />\n      <FloatingFocusManager context={context} initialFocus={refs.floating}>\n        <SideBarContainer\n          id=\"side-menu-container\"\n          ref={refs.setFloating}\n          data-transition={status}\n          aria-modal\n          {...getFloatingProps(props)}\n        >\n          {children}\n        </SideBarContainer>\n      </FloatingFocusManager>\n    </FloatingPortal>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/side-menu/profile.tsx",
    "content": "import { Container, FlexBox, Text } from '@titicaca/tds-ui'\nimport { useTrackEvent, useSession } from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\n\nconst Link = styled.a`\n  font-size: 24px;\n  font-weight: bold;\n  text-decoration-line: underline;\n  color: var(--color-gray);\n`\n\nconst SocialIcon = styled.img`\n  width: 16px;\n  height: 16px;\n  margin-right: 6px;\n`\n\nconst ProfileImage = styled.img`\n  width: 60px;\n  height: 60px;\n  border-radius: 50%;\n  box-shadow: 0 0 0 1px rgba(54, 54, 54, 0.02);\n  outline-offset: -1px;\n`\n\nconst Badge = styled.img`\n  position: absolute;\n  right: -8px;\n  bottom: -4px;\n  width: 30px;\n  height: 30px;\n`\n\nconst UserName = styled(Text)`\n  margin-bottom: 4px;\n  font-size: 24px;\n  font-weight: bold;\n`\n\nconst UserEmailOrProvider = styled(Text)`\n  display: flex;\n  align-items: flex-start;\n  word-break: break-all;\n  font-size: 13px;\n  color: var(--color-gray600);\n`\n\nconst PROVIDER_INFO = {\n  TRIPLE: {\n    label: '트리플',\n    icon: undefined,\n  },\n  APPLE: {\n    label: '애플',\n    icon: 'https://assets.triple.guide/images/header/icon_apple@4x.png',\n  },\n  KAKAO: {\n    label: '카카오',\n    icon: 'https://assets.triple.guide/images/header/icon_kakao@4x.png',\n  },\n  FACEBOOK: {\n    label: '페이스북',\n    icon: 'https://assets.triple.guide/images/header/icon_facebook@4x.png',\n  },\n  NAVER: {\n    label: '네이버',\n    icon: 'https://assets.triple.guide/images/header/icon_naver@4x.png',\n  },\n  INVALID: {\n    label: '',\n    icon: undefined,\n  },\n}\n\nconst PROFILE_EVENT_METADATA_LABEL = {\n  name: '닉네임',\n  photo: '프로필사진',\n}\n\nconst NOL_CONNECTED_LABEL = 'NOL 회원'\n\nexport function Profile() {\n  const { user } = useSession()\n  const trackEvent = useTrackEvent()\n  const returnUrl = encodeURIComponent(location.href)\n\n  const onLoginClick = () => {\n    trackEvent({ fa: { category: '메인메뉴', action: '로그인_선택' } })\n  }\n\n  if (!user) {\n    return (\n      <FlexBox flex css={{ padding: '20px 20px 30px', alignItems: 'center' }}>\n        <Link href={`/login?returnUrl=${returnUrl}`} onClick={onLoginClick}>\n          로그인\n        </Link>\n        <Text size={24} bold css={{ marginTop: -3 }}>\n          /\n        </Text>\n        <Link href={`/login?returnUrl=${returnUrl}`} onClick={onLoginClick}>\n          회원가입\n        </Link>\n      </FlexBox>\n    )\n  }\n\n  const { provider, email, nolConnected, mileage } = user\n\n  const { icon: providerIconSrc, label: providerLabel } =\n    PROVIDER_INFO[provider] || {}\n  const profileLabel = nolConnected\n    ? NOL_CONNECTED_LABEL\n    : email || providerLabel\n\n  const badgeUrl = mileage?.badges[0]?.icon.image_url\n\n  const onProfileClick = (\n    referrer: keyof typeof PROFILE_EVENT_METADATA_LABEL,\n  ) => {\n    trackEvent({\n      fa: {\n        category: '메인메뉴',\n        action: '프로필_선택',\n        label: PROFILE_EVENT_METADATA_LABEL[referrer],\n      },\n    })\n  }\n\n  return (\n    <FlexBox\n      flex\n      css={{ padding: 20, justifyContent: 'space-between', gap: 16 }}\n    >\n      <Container>\n        <UserName onClick={() => onProfileClick('name')}>{user.name}</UserName>\n        <UserEmailOrProvider>\n          {!nolConnected && providerIconSrc ? (\n            <SocialIcon src={providerIconSrc} alt=\"social login icon\" />\n          ) : null}\n          {profileLabel}\n        </UserEmailOrProvider>\n      </Container>\n\n      <Container\n        css={{ position: 'relative', minWidth: 60, height: 60 }}\n        onClick={() => onProfileClick('photo')}\n      >\n        <ProfileImage src={user.photo} alt=\"profile\" />\n        {badgeUrl ? <Badge src={badgeUrl} alt=\"badge\" /> : null}\n      </Container>\n    </FlexBox>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/side-menu/type.ts",
    "content": "interface MenuItemBase {\n  label: string\n  onClick?: () => void\n  eventAction?: string\n  tooltipDescription?: string\n}\n\nexport type LinkMenuItem = MenuItemBase & {\n  type: 'link'\n  href: string\n}\n\nexport type AccordionMenuItem = MenuItemBase & {\n  type: 'accordion'\n  subItems: LinkMenuItem[]\n  defaultOpen?: boolean\n}\n\nexport type MenuItem = LinkMenuItem | AccordionMenuItem\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/types.ts",
    "content": "import { ReactElement } from 'react'\n\nexport type Category = 'air' | 'hotels' | 'tna'\nexport type DeeplinkComponent = ({\n  deeplinkHref,\n}: {\n  deeplinkHref: string\n}) => ReactElement\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/use-auto-hide.ts",
    "content": "import { useEffect, useState } from 'react'\n\nimport {\n  HEADER_DESKTOP_HEIGHT,\n  HEADER_MOBILE_HEIGHT,\n  MIN_DESKTOP_WIDTH,\n  TRANSITION_TIME,\n} from './constants'\n\nexport function useAutoHide(disabled = false) {\n  const [publicHeaderVisible, setPublicHeaderVisible] = useState(disabled)\n\n  useEffect(() => {\n    if (disabled) {\n      return\n    }\n\n    const detectScrollDirection = createScrollDirectionDetector({\n      timeStamp: 0,\n      scrollTop: getScrollTop(),\n    })\n\n    const headerHeight: number =\n      1 +\n      (window.innerWidth >= MIN_DESKTOP_WIDTH\n        ? HEADER_DESKTOP_HEIGHT\n        : HEADER_MOBILE_HEIGHT)\n    let lastTimeStamp: number | null = null\n    let isVisible = false\n\n    const intervalId = setInterval(() => {\n      if (lastTimeStamp) {\n        const direction = detectScrollDirection({\n          scrollTop: getScrollTop(headerHeight * (isVisible ? -1 : 0)),\n          timeStamp: lastTimeStamp,\n        })\n\n        if (direction !== 'NEUTRAL') {\n          setPublicHeaderVisible(direction === 'UP')\n          isVisible = direction === 'UP'\n        }\n\n        lastTimeStamp = null\n      }\n    }, TRANSITION_TIME + 50)\n\n    const handleScroll = (evt: Event) => {\n      lastTimeStamp = evt.timeStamp\n    }\n\n    window.addEventListener('scroll', handleScroll, {\n      capture: false,\n      passive: true,\n    })\n\n    return () => {\n      window.removeEventListener('scroll', handleScroll)\n      clearInterval(intervalId)\n    }\n  }, [disabled])\n\n  return publicHeaderVisible\n}\n\n/**\n * 현재 화면 꼭대기의 상대적인 위치를 구합니다.\n *\n * overscroll 되었을 때를 무시하기 위해 scrollTop의 범위는\n * 0 이상, 페이지 길이 - 화면 높이 이하로 제한합니다.\n *\n * @param heightCompensation 동적으로 표시했다 가렸다 하는 엘리먼트의 높이를 보정하는 파라미터\n * @returns\n */\nfunction getScrollTop(heightCompensation = 0) {\n  const maxScrollTop = document.body.clientHeight - window.innerHeight\n\n  return Math.min(\n    Math.max(\n      (window.pageYOffset ?? document.documentElement.scrollTop) +\n        heightCompensation,\n      0,\n    ),\n    maxScrollTop,\n  )\n}\n\ninterface DirectionLog {\n  timeStamp: number\n  scrollTop: number\n}\ntype Direction = 'NEUTRAL' | 'UP' | 'DOWN'\n\nfunction createScrollDirectionDetector(initialLog: DirectionLog) {\n  const prevLog: DirectionLog = {\n    timeStamp: initialLog.timeStamp,\n    scrollTop: initialLog.scrollTop,\n  }\n\n  return ({ scrollTop, timeStamp }: DirectionLog): Direction => {\n    const scrollVelocity =\n      (scrollTop - prevLog.scrollTop) / (timeStamp - prevLog.timeStamp)\n\n    prevLog.scrollTop = scrollTop\n    prevLog.timeStamp = timeStamp\n\n    if (scrollVelocity === 0) {\n      return 'NEUTRAL'\n    }\n\n    return scrollVelocity > 0 ? 'DOWN' : 'UP'\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/public-header/use-deeplink-href.ts",
    "content": "import { useMemo } from 'react'\nimport { useEnv, useUtm } from '@titicaca/triple-web'\nimport {\n  injectIsSearchAd,\n  injectUTMContext,\n  makeDeepLinkGenerator,\n} from '@titicaca/view-utilities'\n\nexport function useDeeplinkHref(path: string) {\n  const {\n    appUrlScheme,\n    webUrlBase,\n    afOnelinkSubdomain,\n    afOnelinkPid,\n    afOnelinkId,\n  } = useEnv()\n  const utmContext = useUtm()\n\n  const deeplinkGenerator = useMemo(\n    () =>\n      makeDeepLinkGenerator({\n        oneLinkParams: {\n          id: afOnelinkId,\n          pid: afOnelinkPid,\n          subdomain: afOnelinkSubdomain,\n        },\n        appScheme: appUrlScheme,\n        webURLBase: webUrlBase,\n      }),\n    [afOnelinkId, afOnelinkPid, afOnelinkSubdomain, appUrlScheme, webUrlBase],\n  )\n\n  return deeplinkGenerator({\n    ...injectIsSearchAd(utmContext),\n    ...injectUTMContext(utmContext),\n    path,\n  })\n}\n"
  },
  {
    "path": "packages/tds-widget/src/recommended-contents/index.ts",
    "content": "export * from './recommended-contents'\n"
  },
  {
    "path": "packages/tds-widget/src/recommended-contents/mocks/recommended-contents.sample.json",
    "content": "{\n  \"contents\": [\n    {\n      \"title\": \"한줄테스트\",\n      \"backgroundImageUrl\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/4984852d-61df-4fab-95f9-da24d257a829.jpeg\",\n      \"url\": \"https://triple.guide\"\n    },\n    {\n      \"title\": \"두줄이ㅁㄴㅇㄹㅁㄴ\\n강제로ㅁㄴㅇㄹㅁ\",\n      \"backgroundImageUrl\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/4984852d-61df-4fab-95f9-da24d257a829.jpeg\",\n      \"url\": \"https://triple.guide\"\n    },\n    {\n      \"title\": \"한줄테스트\",\n      \"backgroundImageUrl\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/4984852d-61df-4fab-95f9-da24d257a829.jpeg\",\n      \"url\": \"https://triple.guide\"\n    },\n    {\n      \"title\": \"연말을 앞둔 당신을 위한 한해를 떠나보내기 좋은 여행지 TOP 10\",\n      \"backgroundImageUrl\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/4984852d-61df-4fab-95f9-da24d257a829.jpeg\",\n      \"url\": \"https://triple.guide\"\n    },\n    {\n      \"title\": \"한줄테스트\",\n      \"backgroundImageUrl\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/4984852d-61df-4fab-95f9-da24d257a829.jpeg\",\n      \"url\": \"https://triple.guide\"\n    },\n    {\n      \"title\": \"두줄이ㅁㄴㅇㄹㅁㄴ\\n강제로ㅁㄴㅇㄹㅁ\",\n      \"backgroundImageUrl\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/4984852d-61df-4fab-95f9-da24d257a829.jpeg\",\n      \"url\": \"https://triple.guide\"\n    },\n    {\n      \"title\": \"한줄테스트\",\n      \"backgroundImageUrl\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/4984852d-61df-4fab-95f9-da24d257a829.jpeg\",\n      \"url\": \"https://triple.guide\"\n    },\n    {\n      \"title\": \"두줄이ㅁㄴㅇㄹㅁㄴ\\n강제로ㅁㄴㅇㄹㅁ\",\n      \"backgroundImageUrl\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/4984852d-61df-4fab-95f9-da24d257a829.jpeg\",\n      \"url\": \"https://triple.guide\"\n    }\n  ]\n}\n"
  },
  {
    "path": "packages/tds-widget/src/recommended-contents/recommended-contents.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport mock from './mocks/recommended-contents.sample.json'\nimport { RecommendedContents } from './recommended-contents'\n\nexport default {\n  title: 'tds-widget / recommended-contents / RecommendedContents',\n  component: RecommendedContents,\n} as Meta<typeof RecommendedContents>\n\nexport const Basic: StoryObj<typeof RecommendedContents> = {\n  args: {\n    contents: mock.contents,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/recommended-contents/recommended-contents.tsx",
    "content": "import { SyntheticEvent } from 'react'\nimport { styled, css } from 'styled-components'\nimport { Text, MarginPadding, Responsive, Container } from '@titicaca/tds-ui'\nimport { StaticIntersectionObserver as IntersectionObserver } from '@titicaca/intersection-observer'\n\nconst RecommendedContentsContainer = styled.section<{\n  margin?: MarginPadding\n}>`\n  ${({ margin }) =>\n    margin &&\n    css`\n      margin: ${margin.top || 0}px ${margin.right || 0}px\n        ${margin.bottom || 0}px ${margin.left || 0}px;\n    `};\n`\n\nconst RecommendedContentWithDesktopResolution = styled.li<{\n  backgroundImageUrl: string\n}>`\n  display: inline-block;\n  vertical-align: top;\n  width: calc(50% - 7.5px);\n  height: 150px;\n  padding: 20px 15px 0;\n  margin-bottom: 15px;\n  border-radius: 6px;\n  ${({ backgroundImageUrl }) => css`\n    background: linear-gradient(0deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)),\n      url(${backgroundImageUrl});\n  `};\n  background-repeat: no-repeat;\n  background-size: cover;\n  background-position: center;\n  cursor: pointer;\n\n  &::before {\n    content: '';\n    width: 20px;\n    height: 20px;\n    background-size: 20px 20px;\n    background-image: url('https://assets.triple.guide/images/ico-arrow@4x.png');\n    float: right;\n    margin-right: -20px;\n    position: relative;\n    right: 20px;\n    top: 45px;\n  }\n\n  &:nth-child(odd) {\n    margin-right: 15px;\n  }\n`\n\nconst RecommendedContentWithMobileResolution = styled.li`\n  display: inline-block;\n  vertical-align: top;\n  width: 50%;\n  height: 0;\n  padding-top: 60%;\n  margin-bottom: 15px;\n  border-radius: 6px;\n  position: relative;\n  cursor: pointer;\n  overflow: hidden;\n  background-repeat: no-repeat;\n  background-size: cover;\n  background-position: center;\n\n  & > * {\n    position: absolute;\n    top: 0;\n  }\n\n  &:nth-child(even) {\n    left: 15px;\n  }\n`\n\nconst Image = styled.img`\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n`\n\nconst ImageColorOverlay = styled.div`\n  width: 100%;\n  height: 100%;\n  background-color: rgba(0, 0, 0, 0.3);\n`\n\ninterface ContentElementProps {\n  backgroundImageUrl: string\n  title: string\n}\n\nexport function RecommendedContents<T extends ContentElementProps>({\n  contents: contentsData,\n  margin,\n  onContentClick,\n  onContentIntersect,\n}: {\n  contents: T[]\n  margin?: MarginPadding\n  onContentClick?: (e: SyntheticEvent, content: T) => void\n  onContentIntersect?: (content: T) => void\n}) {\n  const contents = contentsData.map(({ title, ...content }) => ({\n    title: title.replace('\\n', ' '),\n    ...content,\n  })) as T[]\n\n  return (\n    <RecommendedContentsContainer margin={margin}>\n      <Responsive maxWidth={767}>\n        <Container\n          as=\"ul\"\n          css={{\n            padding: '0 15px 0 0',\n          }}\n        >\n          {contents.map((content, index) => (\n            <IntersectionObserver\n              key={index}\n              onChange={({ isIntersecting }) =>\n                isIntersecting &&\n                onContentIntersect &&\n                onContentIntersect(content)\n              }\n            >\n              <RecommendedContentWithMobileResolution\n                onClick={onContentClick && ((e) => onContentClick(e, content))}\n              >\n                <Image src={content.backgroundImageUrl} />\n                <ImageColorOverlay />\n                <Text\n                  lineHeight=\"20px\"\n                  color=\"white\"\n                  bold\n                  maxLines={3}\n                  padding={{ top: 20, left: 15, right: 15 }}\n                >\n                  {content.title}\n                </Text>\n              </RecommendedContentWithMobileResolution>\n            </IntersectionObserver>\n          ))}\n        </Container>\n      </Responsive>\n      <Responsive as=\"ul\" minWidth={768}>\n        {contents.map((content, index) => (\n          <IntersectionObserver\n            key={index}\n            onChange={({ isIntersecting }) =>\n              isIntersecting &&\n              onContentIntersect &&\n              onContentIntersect(content)\n            }\n          >\n            <RecommendedContentWithDesktopResolution\n              backgroundImageUrl={content.backgroundImageUrl}\n              onClick={onContentClick && ((e) => onContentClick(e, content))}\n            >\n              <Text lineHeight=\"20px\" color=\"white\" bold maxLines={3}>\n                {content.title}\n              </Text>\n            </RecommendedContentWithDesktopResolution>\n          </IntersectionObserver>\n        ))}\n      </Responsive>\n    </RecommendedContentsContainer>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/auto-resizing-textarea.tsx",
    "content": "import {\n  ChangeEvent,\n  useState,\n  forwardRef,\n  ForwardedRef,\n  useImperativeHandle,\n  useRef,\n  useEffect,\n} from 'react'\nimport { styled } from 'styled-components'\n\nconst Textarea = styled.textarea<{ $lineHeight: number }>`\n  resize: none;\n  font-size: 15px;\n  outline: none;\n  color: var(--color-gray);\n  line-height: ${({ $lineHeight }) => $lineHeight}px;\n  flex-grow: 2;\n\n  &::placeholder {\n    color: var(--color-gray300);\n  }\n`\n\nconst TEXTAREA_LINE_HEIGHT = 19\n\ninterface TextareaProps {\n  value: string\n  minRows: number\n  maxRows: number\n  readOnly: boolean\n  placeholder: string\n  onChange: (message: string) => void\n}\n\nexport interface TextAreaHandle {\n  focusInput: () => void\n}\n\nexport const AutoResizingTextarea = forwardRef(function AutoResizingTextarea(\n  { value, minRows, maxRows, readOnly, placeholder, onChange }: TextareaProps,\n  ref: ForwardedRef<TextAreaHandle>,\n) {\n  const [rows, setRows] = useState(minRows)\n\n  const textareaRef = useRef<HTMLTextAreaElement>(null)\n\n  const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {\n    const currentRows = Math.floor(\n      event.target.scrollHeight / TEXTAREA_LINE_HEIGHT,\n    )\n\n    if (currentRows === rows) {\n      setRows(currentRows)\n    }\n\n    if (currentRows >= maxRows) {\n      setRows(maxRows)\n      event.target.scrollTop = event.target.scrollHeight\n    }\n\n    onChange(event.target.value)\n    setRows(currentRows < maxRows ? currentRows : maxRows)\n  }\n\n  useImperativeHandle(ref, () => ({\n    focusInput: () => {\n      textareaRef.current?.focus()\n    },\n  }))\n\n  useEffect(() => {\n    if (value === '') {\n      setRows(minRows)\n    }\n  }, [value, minRows])\n\n  return (\n    <Textarea\n      rows={rows}\n      value={value}\n      placeholder={placeholder}\n      onChange={handleChange}\n      $lineHeight={TEXTAREA_LINE_HEIGHT}\n      ref={textareaRef}\n      readOnly={readOnly}\n    />\n  )\n})\n"
  },
  {
    "path": "packages/tds-widget/src/replies/context.tsx",
    "content": "import {\n  createContext,\n  PropsWithChildren,\n  useContext,\n  useMemo,\n  useState,\n} from 'react'\n\nexport interface EditingMessage {\n  currentMessageId?: string\n  parentMessageId?: string\n  content: {\n    plaintext?: string\n    mentioningUserUid?: string\n    mentioningUserName?: string\n  }\n}\n\ninterface ReplyBaseActions {\n  setEditingMessage: ({\n    currentMessageId,\n    parentMessageId,\n    content: { plaintext, mentioningUserUid, mentioningUserName },\n  }: EditingMessage) => void\n  initializeEditingMessage: () => void\n  handleContentChange: (content: string) => void\n}\n\nconst RepliesContext = createContext<\n  (EditingMessage & ReplyBaseActions) | undefined\n>(undefined)\n\nexport function RepliesProvider({ children }: PropsWithChildren<unknown>) {\n  const [\n    {\n      currentMessageId,\n      parentMessageId,\n      content: { plaintext, mentioningUserUid, mentioningUserName },\n    },\n    setEditingMessage,\n  ] = useState<EditingMessage>({\n    currentMessageId: undefined,\n    parentMessageId: undefined,\n    content: {\n      plaintext: '',\n      mentioningUserUid: undefined,\n      mentioningUserName: undefined,\n    },\n  })\n\n  const initializeEditingMessage = () => {\n    setEditingMessage({\n      currentMessageId: undefined,\n      parentMessageId: undefined,\n      content: {\n        plaintext: '',\n        mentioningUserUid: undefined,\n        mentioningUserName: undefined,\n      },\n    })\n  }\n\n  const handleContentChange = (content: string) => {\n    setEditingMessage((prev) => ({\n      ...prev,\n      content: {\n        ...prev.content,\n        plaintext: content,\n      },\n    }))\n  }\n\n  const value = useMemo(\n    () => ({\n      currentMessageId,\n      parentMessageId,\n      content: {\n        plaintext,\n        mentioningUserUid,\n        mentioningUserName,\n      },\n      setEditingMessage,\n      initializeEditingMessage,\n      handleContentChange,\n    }),\n    [\n      currentMessageId,\n      parentMessageId,\n      plaintext,\n      mentioningUserName,\n      mentioningUserUid,\n    ],\n  )\n\n  return (\n    <RepliesContext.Provider value={value}>{children}</RepliesContext.Provider>\n  )\n}\n\nexport function useRepliesContext() {\n  const context = useContext(RepliesContext)\n\n  if (context === undefined) {\n    throw new Error('RepliesContext의 Provider가 없습니다.')\n  }\n\n  return context\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/guide-text.tsx",
    "content": "import { useTranslation, useHashRouter } from '@titicaca/triple-web'\nimport { FlexBox, Text, Icon } from '@titicaca/tds-ui'\n\nimport { useRepliesContext } from './context'\n\nconst HASH_EDIT_CLOSE_MODAL = 'reply.edit-close-modal'\n\nexport function GuideText() {\n  const t = useTranslation()\n\n  const {\n    currentMessageId,\n    parentMessageId,\n    content: { mentioningUserName },\n    initializeEditingMessage,\n  } = useRepliesContext()\n\n  const { addUriHash } = useHashRouter()\n\n  const handleClose =\n    currentMessageId && parentMessageId\n      ? () => addUriHash(HASH_EDIT_CLOSE_MODAL)\n      : () => initializeEditingMessage()\n\n  return (\n    <>\n      {parentMessageId ? (\n        <FlexBox\n          flex\n          alignItems=\"center\"\n          justifyContent=\"space-between\"\n          backgroundColor=\"gray50\"\n          css={{\n            padding: '10px 20px',\n          }}\n        >\n          <Text size={12} lineHeight=\"19px\" bold color=\"gray700\">\n            {!currentMessageId\n              ? t('{{mentioningUserName}}님께 답글 작성 중', {\n                  mentioningUserName,\n                })\n              : currentMessageId === parentMessageId\n                ? t('댓글 수정 중')\n                : t('{{mentioningUserName}}님에게 작성한 답글 수정 중', {\n                    mentioningUserName,\n                  })}\n          </Text>\n\n          <Icon\n            onClick={handleClose}\n            src=\"https://assets.triple.guide/images/btn-com-close@3x.png\"\n          />\n        </FlexBox>\n      ) : null}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/hook.tsx",
    "content": "import { useLoginCtaModal } from '@titicaca/triple-web'\n\nimport { SessionError } from './replies-api-client'\n\nexport function useHttpResponseError() {\n  const { show: showLoginCtaModal } = useLoginCtaModal()\n\n  return (error: SessionError | Error) => {\n    if (error instanceof SessionError) {\n      showLoginCtaModal()\n    } else {\n      // Handle other types of errors\n    }\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/index.ts",
    "content": "export * from './types'\nexport * from './replies'\n"
  },
  {
    "path": "packages/tds-widget/src/replies/list/index.tsx",
    "content": "import { Container, HR1, List, Text, Confirm } from '@titicaca/tds-ui'\nimport {\n  useTranslation,\n  useHashRouter,\n  useClientAppActions,\n} from '@titicaca/triple-web'\n\nimport { Reply as ReplyType } from '../types'\nimport { useRepliesContext } from '../context'\nimport { deleteReply } from '../replies-api-client'\n\nimport { NotExistReplies } from './not-exist-replies'\nimport { HASH_DELETE_CLOSE_MODAL, Reply } from './reply'\n\nconst HASH_EDIT_CLOSE_MODAL = 'reply.edit-close-modal'\n\nexport function ReplyList({\n  replies,\n  isMoreButtonActive,\n  fetchMoreReplies,\n  focusInput,\n  onReplyDelete,\n  onReplyEdit,\n  ...props\n}: {\n  replies: ReplyType[]\n  isMoreButtonActive: boolean\n  fetchMoreReplies: (reply?: ReplyType) => void\n  focusInput: () => void\n  onReplyDelete: (response: ReplyType) => void\n  onReplyEdit: (response: ReplyType) => void\n}) {\n  const t = useTranslation()\n\n  const {\n    currentMessageId,\n    content: { mentioningUserName },\n    initializeEditingMessage,\n  } = useRepliesContext()\n\n  const { showToast } = useClientAppActions()\n\n  const description = mentioningUserName\n    ? t('답글을 삭제하시겠습니까?')\n    : t('댓글을 삭제하시겠습니까?')\n\n  const handleReplyDelete = async () => {\n    const response = await deleteReply({\n      currentMessageId,\n    })\n\n    if (response) {\n      if (response.childrenCount > 0) {\n        onReplyEdit(response)\n      } else {\n        onReplyDelete(response)\n      }\n\n      if (showToast) {\n        showToast(t('삭제되었습니다.'))\n      } else {\n        alert(t('삭제되었습니다.'))\n      }\n    }\n  }\n\n  return (\n    <>\n      {replies.length <= 0 ? (\n        <NotExistReplies />\n      ) : (\n        <Container\n          css={{\n            marginLeft: 30,\n            marginRight: 30,\n            marginBottom: 30,\n          }}\n          {...props}\n        >\n          {isMoreButtonActive ? (\n            <Text\n              padding={{ top: 20 }}\n              color=\"blue\"\n              size={14}\n              bold\n              cursor=\"pointer\"\n              inlineBlock\n              onClick={() => fetchMoreReplies()}\n            >\n              {t('이전 댓글 더보기')}\n            </Text>\n          ) : null}\n\n          <List margin={{ top: 20 }}>\n            {replies.map((reply) => (\n              <List.Item key={reply.id}>\n                <HR1\n                  color=\"var(--color-gray50)\"\n                  compact\n                  css={{ marginBottom: 20 }}\n                />\n                <Reply\n                  reply={reply}\n                  focusInput={focusInput}\n                  fetchMoreReplies={fetchMoreReplies}\n                />\n              </List.Item>\n            ))}\n          </List>\n\n          <ConfirmEditModal\n            onConfirm={() => {\n              initializeEditingMessage()\n            }}\n          />\n\n          <ConfirmDeleteModal\n            onConfirm={() => {\n              handleReplyDelete()\n            }}\n            description={description}\n          />\n        </Container>\n      )}\n    </>\n  )\n}\n\nfunction ConfirmEditModal({ onConfirm }: { onConfirm: () => void }) {\n  const t = useTranslation()\n\n  const { uriHash, removeUriHash } = useHashRouter()\n\n  return (\n    <Confirm\n      open={uriHash === HASH_EDIT_CLOSE_MODAL}\n      onClose={removeUriHash}\n      onConfirm={onConfirm}\n    >\n      {t('수정을 취소하시겠습니까? 수정한 내용은 저장되지 않습니다.')}\n    </Confirm>\n  )\n}\n\nfunction ConfirmDeleteModal({\n  description,\n  onConfirm,\n}: {\n  description: string\n  onConfirm: () => void\n}) {\n  const { uriHash, removeUriHash } = useHashRouter()\n\n  return (\n    <Confirm\n      open={uriHash === HASH_DELETE_CLOSE_MODAL}\n      onClose={removeUriHash}\n      onConfirm={onConfirm}\n    >\n      {description}\n    </Confirm>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/list/not-exist-replies.tsx",
    "content": "import { useTranslation } from '@titicaca/triple-web'\nimport { Container, HR1, Text } from '@titicaca/tds-ui'\n\nexport function NotExistReplies() {\n  const t = useTranslation()\n\n  return (\n    <>\n      <HR1\n        color=\"var(--color-gray50)\"\n        compact\n        css={{ marginTop: 20, marginLeft: 30, marginRight: 30 }}\n      />\n\n      <Container\n        css={{\n          padding: '40px 0 50px',\n          textAlign: 'center',\n        }}\n      >\n        <Text size={14} lineHeight={1.2} color=\"gray300\">\n          {t('아직 댓글이 없어요. 가장 먼저 댓글을 작성해보세요!')}\n        </Text>\n      </Container>\n\n      <HR1 color=\"var(--color-gray50)\" compact />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/list/reply.tsx",
    "content": "import { useState, useCallback } from 'react'\nimport {\n  useTranslation,\n  useHashRouter,\n  useSessionCallback,\n  useClientAppCallback,\n} from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport {\n  Container,\n  FlexBox,\n  List,\n  SquareImage,\n  Text,\n  ActionSheet,\n  ActionSheetItem,\n} from '@titicaca/tds-ui'\nimport { formatTimestamp, findFoldedPosition } from '@titicaca/view-utilities'\nimport { useNavigate, useIsomorphicNavigate } from '@titicaca/router'\n\nimport { Reply as ReplyType, Writer } from '../types'\nimport { likeReply, unlikeReply } from '../replies-api-client'\nimport { useRepliesContext } from '../context'\nimport { useHttpResponseError } from '../hook'\n\nconst MoreActionsButton = styled.button`\n  width: 19px;\n  height: 19px;\n  padding-left: 3px;\n  margin-top: -3px;\n  background-repeat: no-repeat;\n  background-size: cover;\n  background-image: url('https://assets.triple.guide/images/btn-review-more@4x.png');\n`\n\nconst ReactionBox = styled(FlexBox)`\n  div {\n    &::before {\n      font-size: 12px;\n      padding: 0 3px 0 4px;\n      content: '·';\n    }\n  }\n`\n\nconst ChildResourceListItem = styled(List.Item)`\n  padding-left: 40px;\n`\n\nconst MentionUser = styled.span`\n  color: var(--color-blue);\n  margin-right: 5px;\n`\n\nconst ThanksButton = styled.button`\n  outline: none;\n  background: white;\n  width: 14px;\n  height: 14px;\n`\n\nconst ThanksIcon = styled.img`\n  vertical-align: baseline;\n  margin-bottom: 2px;\n`\n\nconst HASH_MORE_ACTION_SHEET = 'reply.more-action-sheet'\nexport const HASH_DELETE_CLOSE_MODAL = 'reply.delete-close-modal'\n\nexport function Reply({\n  reply,\n  reply: {\n    writer: { profileImage, name, href: writeHref },\n    blinded,\n    createdAt,\n    content: { mentionedUser, text, markdownText },\n    reactions,\n    childrenCount,\n    children,\n    id,\n    parentId,\n    deleted,\n    actionSpecifications: { delete: isMine, reply: actionReply, edit },\n  },\n  focusInput,\n  fetchMoreReplies,\n}: {\n  reply: ReplyType\n  focusInput: () => void\n  fetchMoreReplies: (reply?: ReplyType) => void\n}) {\n  const t = useTranslation()\n\n  const [likeReaction, setLikeReactions] = useState(reactions.like)\n  const { setEditingMessage } = useRepliesContext()\n  const { addUriHash, removeUriHash } = useHashRouter()\n  const { asyncBack } = useIsomorphicNavigate()\n  const { navigate } = useNavigate()\n  const likeReactionCount = likeReaction?.count\n\n  const handleMoreClick = useCallback(\n    (id: string) => {\n      addUriHash(`${HASH_MORE_ACTION_SHEET}.${id}`)\n    },\n    [addUriHash],\n  )\n\n  const handleWriteReplyClick = useSessionCallback(\n    (actionReply: ReplyType['actionSpecifications']['reply']) => {\n      setEditingMessage({\n        parentMessageId: actionReply?.toMessageId || '',\n        content: {\n          mentioningUserUid: actionReply?.mentioningUserUid || '',\n          mentioningUserName: actionReply?.mentioningUserName || '',\n        },\n      })\n\n      focusInput()\n    },\n  )\n\n  const handleEditReplyClick = ({\n    mentionedUserName,\n    mentionedUserUid,\n    messageId,\n    toMessageId,\n    plaintext,\n  }: ReplyType['actionSpecifications']['edit'] & {\n    toMessageId?: string\n    messageId?: string\n  }) => {\n    setEditingMessage({\n      currentMessageId: messageId,\n      parentMessageId: toMessageId,\n      content: {\n        plaintext,\n        mentioningUserUid: mentionedUserUid,\n        mentioningUserName: mentionedUserName,\n      },\n    })\n\n    focusInput()\n  }\n\n  const handleHttpResponseError = useHttpResponseError()\n\n  const handleDeleteReplyClick = useCallback(\n    async ({\n      mentionedUserName,\n      mentionedUserUid,\n      messageId,\n    }: ReplyType['actionSpecifications']['edit'] & {\n      messageId?: string\n    }) => {\n      setEditingMessage({\n        currentMessageId: messageId,\n        content: {\n          mentioningUserUid: mentionedUserUid,\n          mentioningUserName: mentionedUserName,\n        },\n      })\n\n      await asyncBack(removeUriHash)\n\n      addUriHash(HASH_DELETE_CLOSE_MODAL)\n\n      return true\n    },\n    [setEditingMessage, asyncBack, removeUriHash, addUriHash],\n  )\n\n  const handleLikeReplyClick = useSessionCallback(\n    async ({ messageId }: { messageId: string }) => {\n      try {\n        await likeReply({ messageId })\n        setLikeReactions((prev) => ({\n          count: (prev?.count || 0) + 1,\n          haveMine: true,\n        }))\n      } catch (e) {\n        if (e instanceof Error) {\n          handleHttpResponseError(e)\n        }\n      }\n    },\n    false,\n  )\n\n  const handleUnlikeReplyClick = useSessionCallback(\n    async ({ messageId }: { messageId: string }) => {\n      try {\n        await unlikeReply({ messageId })\n        setLikeReactions((prev) => ({\n          count: Math.max(0, (prev?.count || 0) - 1),\n          haveMine: false,\n        }))\n      } catch (e) {\n        if (e instanceof Error) {\n          handleHttpResponseError(e)\n        }\n      }\n    },\n    false,\n  )\n\n  const handleReportReplyClick = useClientAppCallback(\n    useCallback(\n      (id: string) => {\n        navigate(`/reply/${id}/report`)\n      },\n      [navigate],\n    ),\n  )\n\n  function deriveContent({\n    text,\n    deleted,\n    blinded,\n    childrenCount,\n  }: {\n    text: string\n    deleted: boolean\n    blinded: boolean\n    childrenCount: number\n  }) {\n    const contentText = {\n      deleted: t('작성자가 삭제한 댓글입니다.'),\n      blinded: t('다른 사용자의 신고로 블라인드 되었습니다.'),\n    }\n\n    const type =\n      deleted || blinded\n        ? deleted && childrenCount >= 0\n          ? 'deleted'\n          : 'blinded'\n        : 'default'\n\n    return type === 'default' ? text : contentText[type]\n  }\n\n  const derivedText = deriveContent({\n    text: text || markdownText || '',\n    deleted,\n    blinded,\n    childrenCount,\n  })\n\n  const handleUserClick = useClientAppCallback(\n    useSessionCallback(\n      useCallback(\n        (href: string) => {\n          navigate(href)\n        },\n        [navigate],\n      ),\n    ),\n  )\n\n  return (\n    <>\n      <SquareImage\n        floated=\"left\"\n        size=\"small\"\n        src={profileImage}\n        borderRadius={20}\n        alt={name || ''}\n        onClick={() => handleUserClick(writeHref)}\n      />\n\n      <Container\n        css={{\n          padding: '0 0 3px 50px',\n          margin: '0 0 20px',\n        }}\n      >\n        <FlexBox flex justifyContent=\"space-between\" alignItems=\"start\">\n          <Container\n            css={{\n              minWidth: 80,\n              maxWidth: 135,\n            }}\n          >\n            <Text\n              size={15}\n              bold\n              ellipsis\n              onClick={() => handleUserClick(writeHref)}\n            >\n              {name}\n            </Text>\n          </Container>\n\n          <FlexBox\n            flex\n            alignItems=\"start\"\n            css={{\n              padding: '3px 0 0 5px',\n            }}\n          >\n            <Text size={12} padding={{ right: 5 }} bold color=\"gray300\">\n              {formatTimestamp(createdAt)}\n            </Text>\n\n            <MoreActionsButton\n              onClick={actionReply ? () => handleMoreClick(id) : undefined}\n            />\n          </FlexBox>\n        </FlexBox>\n\n        <Content\n          mentionedUser={mentionedUser}\n          blinded={!!blinded}\n          deleted={!!deleted}\n          text={derivedText}\n        />\n\n        {!deleted && !blinded ? (\n          <ReactionBox\n            flex\n            alignItems=\"center\"\n            css={{\n              padding: '7px 0 0',\n              cursor: 'pointer',\n            }}\n          >\n            {likeReaction?.haveMine ? (\n              <ThanksButton\n                onClick={() => handleUnlikeReplyClick({ messageId: id })}\n                aria-label=\"unlike-button\"\n              >\n                <ThanksIcon\n                  width={14}\n                  height={14}\n                  src=\"https://assets.triple.guide/images/btn-lounge-thanks-on@3x.png\"\n                  alt=\"thanks on icon\"\n                />\n              </ThanksButton>\n            ) : (\n              <ThanksButton\n                onClick={() => handleLikeReplyClick({ messageId: id })}\n                aria-label=\"like-button\"\n              >\n                <ThanksIcon\n                  width={14}\n                  height={14}\n                  src=\"https://assets.triple.guide/images/btn-lounge-thanks-off@3x.png\"\n                  alt=\"thanks off icon\"\n                />\n              </ThanksButton>\n            )}\n\n            {likeReactionCount && likeReactionCount > 0 ? (\n              <Text padding={{ left: 2 }} size={12} color=\"gray300\" bold>\n                {t('좋아요 {{likeReactionCount}}', { likeReactionCount })}\n              </Text>\n            ) : null}\n\n            <Text\n              padding={{ left: 2 }}\n              size={12}\n              color=\"gray300\"\n              bold\n              onClick={() => handleWriteReplyClick(actionReply)}\n            >\n              {t('답글달기')}\n            </Text>\n          </ReactionBox>\n        ) : null}\n      </Container>\n\n      {childrenCount > children.length ? (\n        <Text\n          padding={{ left: 40 }}\n          color=\"blue\"\n          size={14}\n          bold\n          cursor=\"pointer\"\n          inlineBlock\n          onClick={() => fetchMoreReplies(reply)}\n        >\n          {t('이전 답글 더보기')}\n        </Text>\n      ) : null}\n\n      {childrenCount > 0 ? (\n        <List margin={{ top: 20 }}>\n          {children.map((childReply) => (\n            <ChildResourceListItem key={childReply.id} margin={{ bottom: 20 }}>\n              <Reply\n                reply={childReply}\n                focusInput={focusInput}\n                fetchMoreReplies={fetchMoreReplies}\n              />\n            </ChildResourceListItem>\n          ))}\n        </List>\n      ) : null}\n\n      <FeatureActionSheet\n        isMine={isMine}\n        title={\n          isMine\n            ? parentId\n              ? t('내 답글')\n              : t('내 댓글')\n            : parentId\n              ? t('답글')\n              : t('댓글')\n        }\n        actionSheetHash={`${HASH_MORE_ACTION_SHEET}.${id}`}\n        onEditClick={() =>\n          handleEditReplyClick({\n            ...edit,\n            toMessageId: actionReply ? actionReply.toMessageId : '',\n            messageId: id,\n          })\n        }\n        onDeleteClick={() =>\n          handleDeleteReplyClick({\n            ...edit,\n            messageId: id,\n          })\n        }\n        onReportClick={async () => {\n          await asyncBack(removeUriHash)\n          handleReportReplyClick(id)\n        }}\n      />\n    </>\n  )\n}\n\nfunction Content({\n  text,\n  mentionedUser,\n  blinded,\n  deleted,\n}: {\n  text: string\n  mentionedUser?: Writer\n  blinded: boolean\n  deleted: boolean\n}) {\n  const t = useTranslation()\n\n  const [unfolded, setUnfolded] = useState(false)\n  const foldedPosition = findFoldedPosition(5, text)\n  const { navigate } = useNavigate()\n\n  const handleMentionedUserNameClick = useClientAppCallback(\n    useCallback(\n      (href: string) => {\n        navigate(href)\n      },\n      [navigate],\n    ),\n  )\n\n  return (\n    <Container\n      css={{\n        padding: '3px 0 0',\n      }}\n    >\n      <Text inline padding={{ top: 3, bottom: 5 }} size={15}>\n        {!unfolded && foldedPosition ? (\n          text.slice(0, foldedPosition)\n        ) : (\n          <>\n            {mentionedUser && !blinded && (\n              <MentionUser\n                onClick={() => handleMentionedUserNameClick(mentionedUser.href)}\n              >\n                {mentionedUser?.name}\n              </MentionUser>\n            )}\n            <Text\n              size={15}\n              lineHeight=\"18px\"\n              inlineBlock\n              color={blinded || deleted ? 'gray300' : 'gray'}\n            >\n              {text}\n            </Text>\n          </>\n        )}\n      </Text>\n\n      {!unfolded && foldedPosition ? (\n        <Text\n          inline\n          color=\"blue\"\n          size={15}\n          cursor=\"pointer\"\n          onClick={() => setUnfolded((prevState) => !prevState)}\n        >\n          {t('...더보기')}\n        </Text>\n      ) : null}\n    </Container>\n  )\n}\n\nfunction FeatureActionSheet({\n  isMine,\n  title,\n  actionSheetHash,\n  onEditClick,\n  onDeleteClick,\n  onReportClick,\n}: {\n  isMine: boolean\n  title: string\n  actionSheetHash: string\n  onEditClick: () => void\n  onDeleteClick: () => void\n  onReportClick: () => void\n}) {\n  const t = useTranslation()\n  const { uriHash, removeUriHash } = useHashRouter()\n\n  return (\n    <ActionSheet\n      open={uriHash === actionSheetHash}\n      onClose={removeUriHash}\n      title={title}\n    >\n      {isMine ? (\n        <>\n          <ActionSheetItem onClick={onEditClick}>\n            {t('수정하기')}\n          </ActionSheetItem>\n          <ActionSheetItem onClick={onDeleteClick}>\n            {t('삭제하기')}\n          </ActionSheetItem>\n        </>\n      ) : (\n        <ActionSheetItem onClick={onReportClick}>\n          {t('신고하기')}\n        </ActionSheetItem>\n      )}\n    </ActionSheet>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/register.test.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport { ClientAppName } from '@titicaca/triple-web'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport { RepliesProvider } from './context'\nimport { Register } from './register'\n\nconst mockedOnReplyAdd = jest.fn()\nconst mockedOnReplyEdit = jest.fn()\n\ndescribe('Reply 등록 버튼을 테스트합니다.', () => {\n  test('입력 창에 입력된 값이 없으면 버튼의 글자 색상은 blue500 입니다.', () => {\n    render(\n      <RepliesProvider>\n        <Register\n          resourceId=\"\"\n          resourceType=\"article\"\n          onReplyAdd={mockedOnReplyAdd}\n          onReplyEdit={mockedOnReplyEdit}\n        />\n      </RepliesProvider>,\n      {\n        wrapper: createTestWrapper({\n          clientAppProvider: {\n            metadata: { name: ClientAppName.iOS, version: '6.5.0' },\n            device: { autoplay: 'always', networkType: 'wifi' },\n          },\n        }),\n      },\n    )\n\n    const registerButtonElement = screen.getByRole('button', { name: /등록/ })\n\n    expect(registerButtonElement).toHaveStyleRule(\n      'color',\n      'var(--color-blue500)',\n    )\n  })\n\n  test('입력 창에 입력된 값이 있으면 버튼의 글자 색상은 blue 입니다.', async () => {\n    render(\n      <RepliesProvider>\n        <Register\n          resourceId=\"\"\n          resourceType=\"article\"\n          onReplyAdd={mockedOnReplyAdd}\n          onReplyEdit={mockedOnReplyEdit}\n        />\n      </RepliesProvider>,\n      {\n        wrapper: createTestWrapper({\n          clientAppProvider: {\n            metadata: { name: ClientAppName.iOS, version: '6.5.0' },\n            device: { autoplay: 'always', networkType: 'wifi' },\n          },\n        }),\n      },\n    )\n\n    const textareaElement = screen.getByRole('textbox')\n\n    fireEvent.change(textareaElement, { target: { value: 'Hi' } })\n\n    const registerButtonElement = screen.getByRole('button', { name: /등록/ })\n\n    expect(registerButtonElement).toHaveStyleRule('color', 'var(--color-blue)')\n  })\n})\n"
  },
  {
    "path": "packages/tds-widget/src/replies/register.tsx",
    "content": "import { ForwardedRef, forwardRef } from 'react'\nimport {\n  useTranslation,\n  useSessionAvailability,\n  useLoginCtaModal,\n  useSessionCallback,\n} from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport { Container, FlexBox, HR1 } from '@titicaca/tds-ui'\n\nimport { authorMessage } from './replies-api-client'\nimport { AutoResizingTextarea, TextAreaHandle } from './auto-resizing-textarea'\nimport { useRepliesContext } from './context'\nimport { ResourceType, Reply, Placeholders } from './types'\n\nconst RegisterButton = styled.button<{ $active: boolean }>`\n  width: 26px;\n  white-space: nowrap;\n  padding: 0;\n  font-size: 15px;\n  font-weight: bold;\n  color: ${(props) =>\n    props.$active ? 'var(--color-blue)' : 'var(--color-blue500)'};\n  outline: none;\n`\n\nexport const Register = forwardRef(function Register(\n  {\n    resourceId,\n    resourceType,\n    placeholders,\n    onReplyAdd,\n    onReplyEdit,\n    onCompleteReplyAdd,\n  }: {\n    resourceId: string\n    resourceType: ResourceType\n    placeholders?: Placeholders\n    onReplyAdd: (response: Reply) => void\n    onReplyEdit: (response: Reply) => void\n    onCompleteReplyAdd?: (reply: Reply) => void\n  },\n  ref: ForwardedRef<TextAreaHandle>,\n) {\n  const t = useTranslation()\n\n  const {\n    parentMessageId,\n    currentMessageId,\n    content: { plaintext, mentioningUserUid },\n    initializeEditingMessage,\n    handleContentChange,\n  } = useRepliesContext()\n\n  const sessionAvailable = useSessionAvailability()\n  const { show: showLoginCta } = useLoginCtaModal()\n\n  const handleRegister = useSessionCallback(async () => {\n    if (!plaintext) {\n      return\n    }\n\n    const { response, authoringRequestType } = (await authorMessage({\n      resourceId,\n      resourceType,\n      currentMessageId,\n      parentMessageId,\n      content: plaintext,\n      mentionedUserUid: mentioningUserUid,\n    })) as { response: Reply; authoringRequestType: string }\n\n    if (authoringRequestType === 'editReply') {\n      onReplyEdit(response)\n    } else {\n      onReplyAdd(response)\n      onCompleteReplyAdd?.(response)\n    }\n\n    initializeEditingMessage()\n\n    handleContentChange('')\n  })\n\n  const placeholder = parentMessageId\n    ? placeholders?.childReply || t('답글을 입력하세요.')\n    : placeholders?.reply || t('댓글을 입력하세요.')\n\n  return (\n    <Container\n      onClick={!sessionAvailable ? () => showLoginCta() : undefined}\n      css={{\n        cursor: 'pointer',\n      }}\n    >\n      <HR1 compact />\n\n      <FlexBox\n        flex\n        justifyContent=\"space-between\"\n        css={{\n          padding: '20px',\n        }}\n      >\n        <AutoResizingTextarea\n          placeholder={placeholder ?? ''}\n          minRows={1}\n          maxRows={4}\n          value={plaintext || ''}\n          onChange={handleContentChange}\n          ref={ref}\n          readOnly={!sessionAvailable}\n        />\n\n        <RegisterButton\n          onClick={() => {\n            handleRegister()\n          }}\n          $active={!!plaintext}\n        >\n          {t('등록')}\n        </RegisterButton>\n      </FlexBox>\n\n      <HR1 compact />\n    </Container>\n  )\n})\n"
  },
  {
    "path": "packages/tds-widget/src/replies/replies-api-client.test.ts",
    "content": "import { authGuardedFetchers } from '@titicaca/fetcher'\n\nimport {\n  fetchReplies,\n  fetchChildReplies,\n  deleteReply,\n  authorMessage,\n} from './replies-api-client'\nimport { ResourceType, Reply } from './types'\n\njest.mock('@titicaca/fetcher')\n\nconst MOCKED_REPLY = {\n  id: '00000000-0000-0000-0000-00000000000',\n  writer: {\n    href: '/my',\n    name: '테스트_닉네임',\n    profileImage:\n      'https://media.triple.guide/triple-dev/image/upload/w_256,h_256,c_thumb,f_auto/v1620873995/ckeqqm84ovq7daqmx0oz.jpg',\n    badges: [],\n  },\n  content: {\n    text: '댓글&답글 작성 내용',\n  },\n  children: [],\n  childrenCount: 0,\n  isMine: true,\n  createdAt: '2022-01-13T06:05:08.668Z',\n  updatedAt: '2022-01-13T06:05:08.668Z',\n  blinded: false,\n  deleted: false,\n  actionSpecifications: {\n    reaction: true,\n    report: false,\n    delete: true,\n    reply: {\n      toMessageId: '00000000-0000-0000-0000-00000000000',\n      mentioningUserName: '테스트_닉네임',\n      mentioningUserUid: 'USER_UUID',\n      mentioningUserHref: '/users/USER_UUID',\n    },\n    edit: {\n      plaintext: '댓글&답글 작성 내용',\n    },\n  },\n  reactions: {},\n}\n\ntype CustomJestFn = jest.MockedFunction<\n  () => Promise<{\n    ok: boolean\n    parsedBody: unknown\n  }>\n>\n\nconst mockedAuthGuardedFetchers = {\n  get: authGuardedFetchers.get as unknown as CustomJestFn,\n  post: authGuardedFetchers.post as unknown as CustomJestFn,\n  del: authGuardedFetchers.del as unknown as CustomJestFn,\n  put: authGuardedFetchers.put as unknown as CustomJestFn,\n}\n\nbeforeEach(() => {\n  mockedAuthGuardedFetchers.get.mockClear()\n  mockedAuthGuardedFetchers.post.mockClear()\n  mockedAuthGuardedFetchers.del.mockClear()\n  mockedAuthGuardedFetchers.put.mockClear()\n})\n\ndescribe('Get Replies', () => {\n  test('올바른 요청일 때, 10개의 Reply를 반환합니다.', async () => {\n    mockedAuthGuardedFetchers.get.mockImplementation(() =>\n      Promise.resolve({\n        ok: true,\n        parsedBody: [MOCKED_REPLY],\n      }),\n    )\n\n    const replies = await fetchReplies({\n      resourceId: 'resource_id',\n      resourceType: 'article',\n      page: 0,\n      size: 10,\n    })\n\n    expect(replies).toStrictEqual([MOCKED_REPLY])\n  })\n\n  test('올바르지 않은 요청일 때, 빈 배열을 반환합니다.', async () => {\n    mockedAuthGuardedFetchers.get.mockImplementation(() =>\n      Promise.resolve({\n        ok: true,\n        parsedBody: [],\n      }),\n    )\n\n    const replies = await fetchReplies({\n      resourceId: 'resource_id',\n      resourceType: 'article',\n      page: 0,\n      size: 10,\n    })\n\n    expect(replies).toStrictEqual([])\n  })\n})\n\ndescribe('Get ChildReplies', () => {\n  test('올바른 요청일 때, 3개의 ChildReply를 반환합니다.', async () => {\n    const mockedBody = [\n      {\n        ...MOCKED_REPLY,\n        id: '1',\n      },\n      {\n        ...MOCKED_REPLY,\n        id: '2',\n      },\n      {\n        ...MOCKED_REPLY,\n        id: '3',\n      },\n    ]\n\n    mockedAuthGuardedFetchers.get.mockImplementation(() =>\n      Promise.resolve({\n        ok: true,\n        parsedBody: mockedBody,\n      }),\n    )\n\n    const childReply = await fetchChildReplies({\n      id: 'parent_reply_id',\n    })\n\n    expect(childReply).toStrictEqual(mockedBody)\n  })\n\n  test('올바르지 않은 요청일 때, 빈 배열을 반환합니다.', async () => {\n    mockedAuthGuardedFetchers.get.mockImplementation(() =>\n      Promise.resolve({\n        ok: false,\n        parsedBody: undefined,\n      }),\n    )\n\n    const childReply = await fetchChildReplies({\n      id: 'parent_reply_id',\n    })\n\n    expect(childReply).toStrictEqual([])\n  })\n})\n\ndescribe('Delete Reply', () => {\n  test('올바른 요청일 때, 삭제된 reply를 반환합니다.', async () => {\n    const mockedBodyWithDeletedReply = {\n      ...MOCKED_REPLY,\n      id: '1',\n    }\n\n    mockedAuthGuardedFetchers.del.mockImplementation(() =>\n      Promise.resolve({\n        ok: true,\n        parsedBody: mockedBodyWithDeletedReply,\n      }),\n    )\n\n    const deletedReply = await deleteReply({\n      currentMessageId: mockedBodyWithDeletedReply.id,\n    })\n\n    expect(deletedReply).toStrictEqual(mockedBodyWithDeletedReply)\n  })\n\n  test('올바르지 않은 요청일 때, undefined를 반환합니다.', async () => {\n    mockedAuthGuardedFetchers.del.mockImplementation(() =>\n      Promise.resolve({\n        ok: false,\n        parsedBody: undefined,\n      }),\n    )\n\n    const deletedReply = await deleteReply({ currentMessageId: '1' })\n\n    expect(deletedReply).toBeUndefined()\n  })\n})\n\ndescribe('Author Message', () => {\n  const mockedBaseReplyProps = {\n    resourceId: 'resource_id',\n    resourceType: 'resource_type' as ResourceType,\n    content: 'content',\n  }\n\n  test('props로 파생된 타입이 writeReply일 때, 작성된 reply를 반환합니다.', async () => {\n    const mockedBodyWithWritedReply = {\n      ...MOCKED_REPLY,\n      content: {\n        text: '댓글을 작성합니다.',\n      },\n    }\n\n    mockedAuthGuardedFetchers.post.mockImplementation(() =>\n      Promise.resolve({\n        ok: true,\n        parsedBody: mockedBodyWithWritedReply,\n      }),\n    )\n\n    const { response, authoringRequestType } = (await authorMessage(\n      mockedBaseReplyProps,\n    )) as { response: Reply; authoringRequestType: string }\n\n    expect(authoringRequestType).toBe('writeReply')\n    expect(response).toStrictEqual(mockedBodyWithWritedReply)\n  })\n\n  test('props로 파생된 타입이 writeChildReply일 때, 작성된 child reply를 반환합니다.', async () => {\n    const mockedBodyWithWritedChildReply = {\n      ...MOCKED_REPLY,\n      content: {\n        text: '답글을 작성합니다.',\n      },\n    }\n\n    mockedAuthGuardedFetchers.post.mockImplementation(() =>\n      Promise.resolve({\n        ok: true,\n        parsedBody: mockedBodyWithWritedChildReply,\n      }),\n    )\n\n    const mockedWriteChildReplyProps = {\n      ...mockedBaseReplyProps,\n      parentMessageId: 'parent_message_id',\n      mentionedUserUid: 'mentioned_user_uid',\n    }\n\n    const { response, authoringRequestType } = (await authorMessage(\n      mockedWriteChildReplyProps,\n    )) as {\n      response: Reply\n      authoringRequestType: string\n    }\n\n    expect(authoringRequestType).toBe('writeChildReply')\n    expect(response).toStrictEqual(mockedBodyWithWritedChildReply)\n  })\n\n  test('props로 파생된 타입이 editReply일 때, 수정된 reply를 반환합니다.', async () => {\n    const mockedBodyWithEditedReply = {\n      ...MOCKED_REPLY,\n      content: {\n        text: '수정된 텍스트입니다.',\n      },\n    }\n\n    mockedAuthGuardedFetchers.put.mockImplementation(() =>\n      Promise.resolve({\n        ok: true,\n        parsedBody: mockedBodyWithEditedReply,\n      }),\n    )\n\n    const mockedEditedReplyProps = {\n      ...mockedBaseReplyProps,\n      parentMessageId: 'parent_message_id',\n      currentMessageId: 'current_message_id',\n    }\n\n    const { response, authoringRequestType } = (await authorMessage(\n      mockedEditedReplyProps,\n    )) as { response: Reply; authoringRequestType: string }\n\n    expect(authoringRequestType).toBe('editReply')\n    expect(response).toStrictEqual(mockedBodyWithEditedReply)\n  })\n})\n"
  },
  {
    "path": "packages/tds-widget/src/replies/replies-api-client.ts",
    "content": "import {\n  authGuardedFetchers,\n  captureHttpError,\n  HttpResponse,\n} from '@titicaca/fetcher'\nimport { generateUrl } from '@titicaca/view-utilities'\nimport qs from 'qs'\n\nimport { ResourceType, Reply } from './types'\n\nexport async function fetchReplies({\n  resourceId,\n  resourceType,\n  page = 0,\n  size = 10,\n}: {\n  resourceId: string\n  resourceType: ResourceType\n  size?: number\n  page?: number\n}): Promise<Reply[]> {\n  const response = await authGuardedFetchers.get<Reply[]>(\n    generateUrl({\n      path: `/api/reply/messages`,\n      query: qs.stringify({\n        page,\n        resourceId,\n        resourceType,\n        size,\n      }),\n    }),\n  )\n\n  const confirmedResponse = confirmAuthorization<Reply[]>(response)\n  const replies = parseRepliesListResponse(confirmedResponse)\n\n  return replies\n}\n\nexport async function fetchChildReplies({\n  id,\n  page = 0,\n  size = 2,\n}: {\n  id: string\n  page?: number\n  size?: number\n}): Promise<Reply[]> {\n  const response = await authGuardedFetchers.get<Reply[]>(\n    generateUrl({\n      path: `/api/reply/messages/${id}/messages`,\n      query: qs.stringify({\n        page,\n        size,\n      }),\n    }),\n  )\n\n  const confirmedResponse = confirmAuthorization<Reply[]>(response)\n  const replies = parseRepliesListResponse(confirmedResponse)\n\n  return replies\n}\n\nexport async function authorMessage({\n  resourceId,\n  resourceType,\n  currentMessageId,\n  parentMessageId,\n  content,\n  mentionedUserUid,\n}: {\n  resourceId: string\n  resourceType: ResourceType\n  currentMessageId?: string\n  parentMessageId?: string\n  content: string\n  mentionedUserUid?: string\n}) {\n  const authoringRequestType = deriveAuthoringRequestType({\n    currentMessageId,\n    parentMessageId,\n    mentionedUserUid,\n  })\n\n  if (authoringRequestType === 'writeReply') {\n    const response = await writeReply({\n      resourceId,\n      resourceType,\n      content,\n      mentionedUserUid,\n    })\n\n    return { response, authoringRequestType }\n  }\n\n  if (authoringRequestType === 'writeChildReply') {\n    const response = await writeChildReply({\n      parentMessageId,\n      content,\n      mentionedUserUid,\n    })\n\n    return { response, authoringRequestType }\n  }\n\n  if (authoringRequestType === 'editReply') {\n    const response = await editReply({\n      currentMessageId,\n      content,\n      mentionedUserUid,\n    })\n\n    return { response, authoringRequestType }\n  }\n}\n\nfunction deriveAuthoringRequestType({\n  currentMessageId,\n  parentMessageId,\n  mentionedUserUid,\n}: {\n  currentMessageId?: string\n  parentMessageId?: string\n  mentionedUserUid?: string\n}) {\n  const type = parentMessageId\n    ? mentionedUserUid && !currentMessageId\n      ? 'writeChildReply'\n      : 'editReply'\n    : 'writeReply'\n\n  return type\n}\n\nasync function writeReply({\n  resourceId,\n  resourceType,\n  content,\n  mentionedUserUid,\n}: {\n  resourceId: string\n  resourceType: string\n  content: string\n  mentionedUserUid?: string\n}) {\n  const response = await authGuardedFetchers.post<Reply>(\n    generateUrl({\n      path: `/api/reply/messages`,\n      query: qs.stringify({ contentFormat: 'plaintext' }),\n    }),\n    {\n      body: {\n        resourceId,\n        resourceType,\n        content,\n        mentionedUserUid,\n      },\n    },\n  )\n\n  const confirmedResponse = confirmAuthorization<Reply>(response)\n  const reply = parseReplyResponse(confirmedResponse)\n\n  return reply\n}\n\nasync function writeChildReply({\n  parentMessageId,\n  content,\n  mentionedUserUid,\n}: {\n  parentMessageId?: string\n  content: string\n  mentionedUserUid?: string\n}) {\n  const response = await authGuardedFetchers.post<Reply>(\n    generateUrl({\n      path: `/api/reply/messages/${parentMessageId}/messages`,\n      query: qs.stringify({ contentFormat: 'plaintext' }),\n    }),\n    {\n      body: {\n        messageId: parentMessageId,\n        content,\n        mentionedUserUid,\n      },\n    },\n  )\n\n  const confirmedResponse = confirmAuthorization<Reply>(response)\n  const reply = parseReplyResponse(confirmedResponse)\n\n  return reply\n}\n\nasync function editReply({\n  currentMessageId,\n  content,\n  mentionedUserUid,\n}: {\n  currentMessageId?: string\n  content: string\n  mentionedUserUid?: string\n}) {\n  const response = await authGuardedFetchers.put<Reply>(\n    generateUrl({\n      path: `/api/reply/messages/${currentMessageId}`,\n      query: qs.stringify({ contentFormat: 'plaintext' }),\n    }),\n    {\n      body: {\n        content,\n        mentionedUserUid,\n      },\n    },\n  )\n\n  const confirmedResponse = confirmAuthorization<Reply>(response)\n  const reply = parseReplyResponse(confirmedResponse)\n\n  return reply\n}\n\nexport async function deleteReply({\n  currentMessageId,\n}: {\n  currentMessageId?: string\n}) {\n  const response = await authGuardedFetchers.del<Reply>(\n    `/api/reply/messages/${currentMessageId}`,\n    {\n      body: {\n        messageId: currentMessageId,\n      },\n    },\n  )\n\n  const confirmedResponse = confirmAuthorization<Reply>(response)\n  const reply = parseReplyResponse(confirmedResponse)\n\n  return reply\n}\n\nexport class SessionError extends Error {\n  public constructor() {\n    super('로그인이 필요한 호출입니다.')\n  }\n}\n\nexport async function likeReply({ messageId }: { messageId: string }) {\n  const response = await authGuardedFetchers.put(\n    `/api/reply/messages/${messageId}/like`,\n    {\n      body: {\n        messageId,\n      },\n    },\n  )\n\n  throwResponseError(response)\n}\n\nexport async function unlikeReply({ messageId }: { messageId: string }) {\n  const response = await authGuardedFetchers.del(\n    `/api/reply/messages/${messageId}/like`,\n    {\n      body: {\n        messageId,\n      },\n    },\n  )\n\n  throwResponseError(response)\n}\n\nfunction parseRepliesListResponse(\n  response: HttpResponse<Reply[], unknown>,\n): Reply[] {\n  if (response.ok) {\n    const { parsedBody } = response\n    const sortedReplies = parsedBody.map((reply) => sortChildren(reply))\n\n    return sortedReplies\n  } else {\n    return []\n  }\n}\n\nfunction parseReplyResponse(\n  response: HttpResponse<Reply, unknown>,\n): Reply | undefined {\n  if (response.ok) {\n    const { parsedBody } = response\n\n    return sortChildren(parsedBody)\n  }\n}\n\nfunction confirmAuthorization<T>(\n  response: 'NEED_LOGIN' | HttpResponse<T, unknown>,\n): HttpResponse<T, unknown> {\n  if (response === 'NEED_LOGIN') {\n    throw new SessionError()\n  }\n\n  captureHttpError(response)\n\n  return response\n}\n\nfunction sortChildren(reply: Reply): Reply {\n  const sortedChildReplies = reply.children.sort(\n    (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),\n  )\n\n  const result = {\n    ...reply,\n    children: sortedChildReplies,\n  }\n\n  return result\n}\n\nfunction throwResponseError<S, F>(response: 'NEED_LOGIN' | HttpResponse<S, F>) {\n  if (response === 'NEED_LOGIN') {\n    throw new SessionError()\n  }\n\n  captureHttpError(response)\n  if (!response.ok) {\n    throw new Error('Failed to like the reply')\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/replies.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { http, HttpResponse } from 'msw'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { Replies } from './replies'\n\nexport default {\n  title: 'tds-widget / replies / Replies',\n  component: Replies,\n  parameters: {\n    msw: {\n      handlers: [\n        http.post<object, { content: string }>(\n          '/api/reply/messages?contentFormat=plaintext',\n          async ({ request }) => {\n            const newReply = await request.json()\n            return HttpResponse.json({\n              id: 'new reply',\n              parentId: undefined,\n              blinded: false,\n              deleted: false,\n              isMine: true,\n              childrenCount: 0,\n              createdAt: new Date(2024, 1, 1).toString(),\n              updatedAt: new Date(2024, 1, 1).toString(),\n              reactions: {},\n              content: { text: newReply.content },\n              children: [],\n              writer: {\n                href: '',\n                name: '트리플',\n                profileImage: '',\n                badges: [],\n              },\n              actionSpecifications: {\n                delete: false,\n                reaction: false,\n                report: false,\n              },\n            })\n          },\n        ),\n      ],\n    },\n  },\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n  argTypes: {\n    resourceId: {\n      type: 'string',\n      required: true,\n    },\n    resourceType: {\n      options: ['itinerary', 'review', 'article'],\n      control: {\n        type: 'select',\n        required: true,\n      },\n    },\n    placeholders: {\n      reply: {\n        type: 'string',\n        required: false,\n      },\n      childReply: {\n        type: 'string',\n        required: false,\n      },\n    },\n    size: {\n      type: 'number',\n      required: false,\n      defaultValue: 10,\n    },\n    isFormFixed: {\n      type: 'boolean',\n      required: false,\n    },\n  },\n} as Meta<typeof Replies>\n\nexport const Basic: StoryObj<typeof Replies> = {\n  args: {\n    resourceId: 'c31a0e75-0053-4ef2-9407-d2bdc7f116e3',\n    resourceType: 'article',\n    placeholders: {\n      reply: '댓글을 입력하세요.',\n      childReply: '답글을 입력하세요.',\n    },\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/replies.tsx",
    "content": "import { useEffect, useState, useCallback, useRef } from 'react'\nimport {\n  Container,\n  safeAreaInsetMixin,\n  SafeAreaInsetMixinProps,\n} from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\n\nimport { fetchReplies, fetchChildReplies } from './replies-api-client'\nimport { Reply, ResourceType, Placeholders } from './types'\nimport { ReplyList } from './list'\nimport { GuideText } from './guide-text'\nimport { Register } from './register'\nimport { RepliesProvider } from './context'\nimport { TextAreaHandle } from './auto-resizing-textarea'\nimport {\n  addReply,\n  appendReplyChildren,\n  deleteReply,\n  editReply,\n} from './reply-tree-manipulators'\nimport { checkUniqueReply } from './utils'\n\nconst FixedBottom = styled(Container).attrs<SafeAreaInsetMixinProps>({\n  backgroundColor: 'white',\n})`\n  position: fixed;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  z-index: 3;\n\n  ${safeAreaInsetMixin}\n`\n\n/**\n * 댓글 컴포넌트\n */\nexport function Replies({\n  resourceId,\n  resourceType,\n  placeholders,\n  isFormFixed,\n  size = 10,\n  initialSize,\n  onCompleteReplyAdd,\n  onCompleteReplyDelete,\n  ...props\n}: {\n  /**\n   * resourceId, resourceType: 아래 스웨거를 참고해주세요.\n   *\n   * https://reply.proxy.triple-dev.titicaca-corp.com/webjars/swagger-ui/index.html?configUrl=/v3/api-docs/swagger-config\n   */\n  resourceId: string\n  resourceType: ResourceType\n  /**\n   * Register 컴포넌트 내부의 문구를 커스터마이징.\n   */\n  placeholders?: Placeholders\n  /**\n   * 화면 최하단에 댓글/답글 입력창을 고정할 지 선택.\n   */\n  isFormFixed?: boolean\n  /**\n   * api 호출 시 댓글을 가져오는 크기.\n   */\n  size?: number\n  initialSize?: number\n  onCompleteReplyAdd?: (reply: Reply) => void\n  onCompleteReplyDelete?: (reply: Reply) => void\n}) {\n  const [replies, setReplies] = useState<Reply[]>([])\n\n  const [hasNextPage, setHasNextPage] = useState(false)\n\n  const handleReplyAdd = (response: Reply): void => {\n    if (response.parentId) {\n      const newReplies = replies.map((reply) => addReply(response, reply))\n\n      setReplies(newReplies)\n    } else {\n      setReplies((prev) => [...prev, response])\n    }\n  }\n\n  const handleReplyDelete = (response: Reply): void => {\n    const deletedReplies = replies\n      .map((reply) => deleteReply(response, reply))\n      .filter(Boolean) as Reply[]\n\n    setReplies(deletedReplies)\n    onCompleteReplyDelete?.(response)\n  }\n\n  const handleReplyEdit = (response: Reply): void => {\n    const editedReplies = replies.map((reply) =>\n      editReply(response, response, reply),\n    )\n\n    setReplies(editedReplies)\n  }\n\n  const fetchMoreReplies = useCallback(\n    async (reply?: Reply) => {\n      if (!size) {\n        return\n      }\n\n      const actualTree =\n        reply ||\n        ({\n          id: null,\n          children: replies,\n        } as unknown as Reply)\n\n      const childrenCount = actualTree.children.length\n      const pageNumber = Math.floor(Number(childrenCount / size))\n\n      const repliesResponse: Reply[] = actualTree.id\n        ? await fetchChildReplies({\n            id: actualTree.id,\n            size,\n            page: pageNumber,\n          })\n        : await fetchReplies({\n            resourceId,\n            resourceType,\n            size,\n            page: pageNumber,\n          })\n\n      if (!actualTree.id) {\n        const nextRepliesResponse: Reply[] = await fetchReplies({\n          resourceId,\n          resourceType,\n          size,\n          page: pageNumber + 1,\n        })\n\n        setHasNextPage(nextRepliesResponse.length > 0)\n      }\n\n      const { children: newReplies } = appendReplyChildren(\n        actualTree,\n        repliesResponse,\n        {\n          id: null,\n          children: replies,\n        } as unknown as Reply,\n      )\n\n      setReplies(newReplies)\n    },\n    [resourceId, resourceType, size, replies],\n  )\n\n  const fetchInitialReplies = useCallback(\n    async (initialSize: number) => {\n      if (initialSize > size) {\n        throw new Error('Failed to fetchInitialReplies')\n      }\n\n      const [initialReplies, nextReplies] = await Promise.all([\n        fetchReplies({\n          resourceId,\n          resourceType,\n          size: initialSize,\n          page: 0,\n        }),\n        fetchReplies({\n          resourceId,\n          resourceType,\n          size: initialSize,\n          page: 1,\n        }),\n      ])\n\n      const newReplies = checkUniqueReply(initialReplies)\n\n      setReplies(newReplies)\n      setHasNextPage(nextReplies.length > 0)\n    },\n    [resourceId, resourceType, size],\n  )\n\n  useEffect(() => {\n    if (initialSize) {\n      fetchInitialReplies(initialSize)\n    } else {\n      fetchMoreReplies()\n    }\n\n    // fetchInitialReplies, fetchMoreReplies deps의 replies가 계속 업데이트되므로 제거했습니다.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [initialSize, resourceId, resourceType, size])\n\n  const registerRef = useRef<TextAreaHandle>(null)\n\n  const focusInput = () => {\n    registerRef.current?.focusInput()\n  }\n\n  const InputContainer = isFormFixed ? FixedBottom : Container\n\n  return (\n    <RepliesProvider>\n      <ReplyList\n        replies={replies}\n        isMoreButtonActive={hasNextPage}\n        fetchMoreReplies={fetchMoreReplies}\n        focusInput={focusInput}\n        onReplyDelete={handleReplyDelete}\n        onReplyEdit={handleReplyEdit}\n        {...props}\n      />\n\n      <InputContainer>\n        <GuideText />\n\n        <Register\n          ref={registerRef}\n          resourceId={resourceId}\n          resourceType={resourceType}\n          placeholders={placeholders}\n          onReplyAdd={handleReplyAdd}\n          onReplyEdit={handleReplyEdit}\n          onCompleteReplyAdd={onCompleteReplyAdd}\n        />\n      </InputContainer>\n    </RepliesProvider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/reply-tree-manipulators.test.tsx",
    "content": "import {\n  addReply,\n  deleteReply,\n  editReply,\n  appendReplyChildren,\n} from './reply-tree-manipulators'\nimport { Reply } from './types'\n\nconst MOCK_BASE_REPLY = {\n  id: '00000000-0000-0000-0000-00000000000',\n  writer: {\n    href: '/my',\n    name: '테스트_닉네임',\n    profileImage:\n      'https://media.triple.guide/triple-dev/image/upload/w_256,h_256,c_thumb,f_auto/v1620873995/ckeqqm84ovq7daqmx0oz.jpg',\n    badges: [],\n  },\n  content: {\n    text: '댓글&답글 작성 내용',\n  },\n  isMine: true,\n  createdAt: '2022-01-13T06:05:08.668Z',\n  updatedAt: '2022-01-13T06:05:08.668Z',\n  reactions: {\n    like: {\n      count: 0,\n      haveMine: false,\n    },\n  },\n  blinded: false,\n  deleted: false,\n  actionSpecifications: {\n    reaction: false,\n    report: false,\n    delete: false,\n    reply: {\n      toMessageId: '00000000-0000-0000-0000-00000000000',\n      mentioningUserName: '테스트_닉네임',\n      mentioningUserUid: 'USER_UUID',\n      mentioningUserHref: '/users/USER_UUID',\n    },\n    edit: {\n      plaintext: '댓글&답글 작성 내용',\n    },\n  },\n  children: [],\n  childrenCount: 0,\n}\n\ndescribe('addReply', () => {\n  const addingReply = generateMockReply({\n    id: '12345678-1234-1234-1234-12345678912',\n    parentId: '11111111-1111-1111-1111-11111111111',\n  })\n\n  describe('탐색 깊이가 1보다 클 때', () => {\n    test('Reply 트리에 노드를 추가하는 경우, 노드가 추가된 Reply 트리를 반환합니다.', () => {\n      const originalReply = generateMockReply({\n        id: '11111111-1111-1111-1111-11111111111',\n        children: [\n          generateMockReply({\n            id: '23456789-4321-4321-4321-23456789111',\n            parentId: '11111111-1111-1111-1111-11111111111',\n          }),\n        ],\n        childrenCount: 1,\n      })\n\n      const addedReply = addReply(addingReply, originalReply)\n\n      const expectedReply = generateMockReply({\n        id: '11111111-1111-1111-1111-11111111111',\n        children: [\n          generateMockReply({\n            id: '23456789-4321-4321-4321-23456789111',\n            parentId: '11111111-1111-1111-1111-11111111111',\n          }),\n          addingReply,\n        ],\n        childrenCount: 2,\n      })\n\n      expect(addedReply).toEqual(expectedReply)\n    })\n\n    test('Child를 추가할 노드를 순회를 통해 찾은 경우, Child 노드가 추가된 트리를 반환합니다.', () => {\n      const mockReplies = [\n        generateMockReply({\n          id: '23456789-4321-4321-4321-23456789111',\n          children: [\n            generateMockReply({\n              id: '123123as-11mf-123m-12hv-12345678912',\n              parentId: '23456789-4321-4321-4321-23456789111',\n            }),\n          ],\n          childrenCount: 1,\n        }),\n        generateMockReply({\n          id: '11111111-1111-1111-1111-11111111111',\n          children: [\n            generateMockReply({\n              id: '23456789-4321-4321-4321-23456789111',\n              parentId: '11111111-1111-1111-1111-11111111111',\n            }),\n          ],\n          childrenCount: 1,\n        }),\n      ]\n\n      const originalReply = generateMockReply({\n        children: mockReplies,\n      })\n\n      const addedReply = addReply(addingReply, originalReply)\n\n      const expectedReply = generateMockReply({\n        children: [\n          mockReplies[0],\n          generateMockReply({\n            id: '11111111-1111-1111-1111-11111111111',\n            children: [\n              generateMockReply({\n                id: '23456789-4321-4321-4321-23456789111',\n                parentId: '11111111-1111-1111-1111-11111111111',\n              }),\n              addingReply,\n            ],\n            childrenCount: 2,\n          }),\n        ],\n      })\n\n      expect(addedReply).toEqual(expectedReply)\n    })\n\n    test('Child를 추가할 노드를 순회 했지만 못찾은 경우, 기존 트리를 반환합니다.', () => {\n      const originalReply = generateMockReply({\n        id: '00000000-0000-0000-0000-00000000000',\n        children: [\n          generateMockReply({\n            id: '23456789-4321-4321-4321-23456789111',\n            parentId: '00000000-0000-0000-0000-00000000000',\n          }),\n        ],\n      })\n\n      const addedReply = addReply(addingReply, originalReply)\n\n      expect(addedReply).toEqual(originalReply)\n    })\n  })\n})\n\ndescribe('deleteReply', () => {\n  describe('탐색 깊이가 1일 때', () => {\n    const mockDeletingReply = generateMockReply()\n\n    test('Child 노드가 없는 Reply 트리를 삭제할 경우, undefined를 반환합니다.', () => {\n      const originalReply = generateMockReply()\n\n      const deletedReply = deleteReply(mockDeletingReply, originalReply)\n\n      expect(deletedReply).toBeUndefined()\n    })\n\n    test('삭제해야하는 Reply 트리의 ID가 일치하지 않을 경우, 기존 Reply 트리를 반환합니다.', () => {\n      const originalReply = generateMockReply({\n        id: '11111111-1111-1111-1111-11111111111',\n      })\n\n      const deletedReply = deleteReply(mockDeletingReply, originalReply)\n\n      expect(deletedReply).toEqual(originalReply)\n    })\n  })\n\n  describe('탐색 깊이가 1보다 클 때', () => {\n    test('삭제해야하는 Child 노드를 순회하여 찾은 경우, 해당 노드가 삭제된 Reply 트리를 반환합니다.', () => {\n      const mockDeletingChildReply = generateMockReply({\n        id: '12345678-1234-1234-1234-12345678912',\n        parentId: '11111111-1111-1111-1111-11111111111',\n      })\n\n      const originalReply = generateMockReply({\n        id: '11111111-1111-1111-1111-11111111111',\n        children: [\n          generateMockReply({\n            id: '12345678-1234-1234-1234-12345678912',\n            parentId: '11111111-1111-1111-1111-11111111111',\n          }),\n        ],\n        childrenCount: 1,\n      })\n\n      const deletedReply = deleteReply(mockDeletingChildReply, originalReply)\n\n      const expetedReply = generateMockReply({\n        id: '11111111-1111-1111-1111-11111111111',\n        children: [],\n        childrenCount: 0,\n      })\n\n      expect(deletedReply).toEqual(expetedReply)\n    })\n\n    test('삭제해야하는 Child 노드의 ID가 일치하지 않을 경우, 기존 Reply 트리를 반환합니다.', () => {\n      const mockDeletingChildReply = generateMockReply({\n        id: '12345678-1234-1234-1234-12345678912',\n      })\n\n      const originalReply = generateMockReply({\n        id: '11111111-1111-1111-1111-11111111111',\n        children: [\n          generateMockReply({\n            id: '23456789-1234-1234-1234-23456789123',\n            parentId: '11111111-1111-1111-1111-11111111111',\n          }),\n        ],\n        childrenCount: 1,\n      })\n\n      const deletedReply = deleteReply(mockDeletingChildReply, originalReply)\n\n      expect(deletedReply).toEqual(originalReply)\n    })\n  })\n})\n\ndescribe('editReply', () => {\n  describe('탐색 깊이가 1일 때', () => {\n    test('수정해야하는 Reply 트리를 찾을 경우, 수정된 Reply 트리를 반환합니다.', () => {\n      const mockEditingReply = generateMockReply({\n        id: '11111111-1111-1111-1111-11111111111',\n        content: { text: '수정된 텍스트' },\n      })\n\n      const originalReply = generateMockReply({\n        id: '11111111-1111-1111-1111-11111111111',\n        content: { text: '원본 텍스트' },\n      })\n\n      const editedReply = editReply(\n        mockEditingReply,\n        mockEditingReply,\n        originalReply,\n      )\n\n      const expectedReply = {\n        ...originalReply,\n        ...mockEditingReply,\n      }\n\n      expect(editedReply).toEqual(expectedReply)\n    })\n\n    test('수정해야하는 Reply 트리의 ID가 일치하지 않을 경우, 기존 Reply 트리를 반환합니다.', () => {\n      const mockEditingReply = generateMockReply({\n        id: '11111111-1111-1111-1111-11111111111',\n        content: { text: '수정된 텍스트' },\n      })\n\n      const originalReply = generateMockReply({\n        id: '22222222-2222-2222-2222-22222222222',\n        content: { text: '원본 텍스트' },\n      })\n\n      const editedReply = editReply(\n        mockEditingReply,\n        mockEditingReply,\n        originalReply,\n      )\n\n      expect(editedReply).toEqual(originalReply)\n    })\n  })\n\n  describe('탐색 깊이가 1보다 클 때,', () => {\n    test('Child 노드를 순회하여 찾은 경우, 해당 Child 노드를 수정한 Reply 트리를 반환합니다.', () => {\n      const mockEditingChildReply = generateMockReply({\n        id: '11111111-1111-1111-1111-11111111111',\n        content: { text: '수정된 텍스트' },\n      })\n\n      const originalReply = generateMockReply({\n        id: '12345678-1234-1234-1234-12345678912',\n        children: [\n          generateMockReply({\n            id: '11111111-1111-1111-1111-11111111111',\n            content: { text: '원본 텍스트' },\n          }),\n        ],\n      })\n\n      const editedReply = editReply(\n        mockEditingChildReply,\n        mockEditingChildReply,\n        originalReply,\n      )\n\n      const expectedReply = {\n        ...originalReply,\n        children: [mockEditingChildReply],\n      }\n\n      expect(editedReply).toEqual(expectedReply)\n    })\n  })\n})\n\ndescribe('appendReplyChildren', () => {\n  test('페이징을 이용하여 Reply 트리를 추가할 경우, 트리가 추가된 Reply 트리를 반환합니다.', () => {\n    const mockAddingReply = {\n      id: null,\n      children: [MOCK_BASE_REPLY],\n    } as unknown as Reply\n\n    const appendingReply = [\n      generateMockReply({ id: '11111111-1111-1111-1111-11111111111' }),\n      generateMockReply({ id: '22222222-2222-2222-2222-22222222222' }),\n    ]\n\n    const originalReply = {\n      id: null,\n      children: [MOCK_BASE_REPLY],\n    } as unknown as Reply\n\n    const { children: newReplies } = appendReplyChildren(\n      mockAddingReply,\n      appendingReply,\n      originalReply,\n    )\n\n    const expectedReply = [...mockAddingReply.children, ...appendingReply]\n\n    expect(newReplies).toEqual(expectedReply)\n  })\n\n  test('페이징을 이용하여 Child 노드를 추가할 경우, Child 노드가 추가된 Reply 트리를 반환합니다.', () => {\n    const mockAddingReply = generateMockReply({\n      id: '11111111-1111-1111-1111-11111111111',\n      children: [\n        generateMockReply({\n          id: '12345678-1234-1234-1234-12345678912',\n          parentId: '11111111-1111-1111-1111-11111111111',\n        }),\n        generateMockReply({\n          id: '23456789-1234-1234-1234-12345678912',\n          parentId: '11111111-1111-1111-1111-11111111111',\n        }),\n        generateMockReply({\n          id: '34567891-1234-1234-1234-12345678912',\n          parentId: '11111111-1111-1111-1111-11111111111',\n        }),\n      ],\n      childrenCount: 3,\n    })\n\n    const appendingReply = [\n      generateMockReply({\n        id: '4567891-1234-1234-1234-12345678912',\n        parentId: '11111111-1111-1111-1111-11111111111',\n      }),\n      generateMockReply({\n        id: '5678912-1234-1234-1234-12345678912',\n        parentId: '11111111-1111-1111-1111-11111111111',\n      }),\n      generateMockReply({\n        id: '6789123-1234-1234-1234-12345678912',\n        parentId: '11111111-1111-1111-1111-11111111111',\n      }),\n    ]\n\n    const originalReply = generateMockReply({\n      id: '12345678-1234-1234-12345678912',\n      children: [mockAddingReply],\n      childrenCount: 1,\n    })\n\n    const { children: newReplies } = appendReplyChildren(\n      mockAddingReply,\n      appendingReply,\n      originalReply,\n    )\n\n    const expectedReply = [\n      {\n        ...mockAddingReply,\n        children: [...mockAddingReply.children, ...appendingReply],\n      },\n    ]\n\n    expect(newReplies).toEqual(expectedReply)\n  })\n})\n\nfunction generateMockReply(updatedAttributes?: Partial<Reply>): Reply {\n  return {\n    ...MOCK_BASE_REPLY,\n    ...updatedAttributes,\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/reply-tree-manipulators.ts",
    "content": "import { Reply } from './types'\nimport { checkUniqueReply } from './utils'\n\nexport function addReply(reply: Reply, tree: Reply): Reply {\n  if (reply.parentId === tree.id) {\n    const addedReplyTree = [...tree.children, reply]\n    return {\n      ...tree,\n      children: addedReplyTree,\n      childrenCount: tree.childrenCount + 1,\n    }\n  } else {\n    return {\n      ...tree,\n      children: tree.children.map((child) => addReply(reply, child)),\n    }\n  }\n}\n\nexport function deleteReply(reply: Reply, tree: Reply): Reply | undefined {\n  if (reply.id === tree.id) {\n    return undefined\n  } else {\n    const createdChildrenTree = tree.children\n      .map((child) => deleteReply(reply, child))\n      .filter(Boolean) as Reply[]\n\n    const delta = createdChildrenTree.length - tree.children.length\n    const newChildrenCount = tree.childrenCount + delta\n\n    return {\n      ...tree,\n      children: createdChildrenTree,\n      childrenCount: newChildrenCount,\n    }\n  }\n}\n\nexport function editReply(\n  originalReply: Reply,\n  updatedAttributes: Partial<Reply>,\n  tree: Reply,\n): Reply {\n  if (originalReply.id === tree.id) {\n    return {\n      ...originalReply,\n      ...updatedAttributes,\n    }\n  } else {\n    return {\n      ...tree,\n      children: tree.children.map((child) =>\n        editReply(originalReply, updatedAttributes, child),\n      ),\n    }\n  }\n}\n\nexport function appendReplyChildren(\n  originalReply: Reply,\n  appendingChildren: Reply[],\n  tree: Reply,\n): Reply {\n  if (originalReply.id === tree.id) {\n    return {\n      ...tree,\n      children: checkUniqueReply([...tree.children, ...appendingChildren]),\n    }\n  } else {\n    return {\n      ...tree,\n      children: tree.children.map((child) =>\n        appendReplyChildren(originalReply, appendingChildren, child),\n      ),\n    }\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/reply.test.tsx",
    "content": "import { ThemeProvider } from 'styled-components'\nimport { defaultTheme } from '@titicaca/tds-theme'\nimport { render, screen, waitFor } from '@testing-library/react'\nimport { ClientAppName, EventTrackingProvider } from '@titicaca/triple-web'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport { RepliesProvider } from './context'\nimport { Reply } from './list/reply'\nimport { Reply as ReplyType } from './types'\n\njest.mock('@titicaca/triple-web')\njest.mock('@titicaca/router')\nconst onFocusInput = jest.fn()\nconst fetchMoreReplies = jest.fn()\n\njest.mock('@titicaca/triple-web', () => ({\n  ...jest.requireActual('@titicaca/triple-web'),\n  useClientAppCallback: jest.fn().mockImplementation(() => jest.fn()),\n  useSessionCallback: jest.fn().mockImplementation(() => jest.fn()),\n}))\njest.mock('@titicaca/router', () => ({\n  ...jest.requireActual('@titicaca/router'),\n  useNavigate: jest.fn().mockImplementation(() => ({\n    navigate: jest.fn(),\n    openWindow: jest.fn(),\n  })),\n}))\njest.mock('./replies-api-client')\n\nbeforeEach(() => {\n  jest.clearAllMocks()\n})\n\nconst MOCKED_REPLY = {\n  id: '00000000-0000-0000-0000-00000000000',\n  writer: {\n    href: '/my',\n    name: '테스트_닉네임',\n    profileImage:\n      'https://media.triple.guide/triple-dev/image/upload/w_256,h_256,c_thumb,f_auto/v1620873995/ckeqqm84ovq7daqmx0oz.jpg',\n    badges: [],\n  },\n  content: {\n    text: '댓글&답글 작성 내용',\n  },\n  children: [],\n  childrenCount: 0,\n  isMine: true,\n  createdAt: '2022-01-13T06:05:08.668Z',\n  updatedAt: '2022-01-13T06:05:08.668Z',\n  blinded: false,\n  deleted: false,\n  actionSpecifications: {\n    reaction: true,\n    report: false,\n    delete: true,\n    reply: {\n      toMessageId: '00000000-0000-0000-0000-00000000000',\n      mentioningUserName: 'TripleTester',\n      mentioningUserUid: 'USER_UUID',\n      mentioningUserHref: '/users/USER_UUID',\n    },\n    edit: {\n      plaintext: '댓글&답글 작성 내용',\n    },\n  },\n}\n\ndescribe('좋아요 수에 따른 문구 노출 조건을 테스트합니다.', () => {\n  test('갯수가 양수일 때, 좋아요 문구 및 갯수를 노출합니다.', async () => {\n    const reply = generateMockReply({\n      reactions: {\n        like: {\n          count: 1,\n          haveMine: false,\n        },\n      },\n    })\n\n    render(\n      <ThemeProvider theme={defaultTheme}>\n        <EventTrackingProvider page={{ label: 'test', path: '/test' }} utm={{}}>\n          <RepliesProvider>\n            <Reply\n              reply={reply}\n              focusInput={onFocusInput}\n              fetchMoreReplies={fetchMoreReplies}\n            />\n          </RepliesProvider>\n        </EventTrackingProvider>\n      </ThemeProvider>,\n      {\n        wrapper: createTestWrapper({\n          clientAppProvider: {\n            device: { autoplay: 'always', networkType: 'unknown' },\n            metadata: { name: ClientAppName.iOS, version: '6.5.0' },\n          },\n        }),\n      },\n    )\n\n    const likeCountElement = screen.queryByText(/좋아요/)\n\n    await waitFor(() => {\n      expect(likeCountElement).toBeInTheDocument()\n    })\n  })\n\n  test('갯수가 0일 때, 좋아요 문구 및 갯수를 노출하지 않습니다.', async () => {\n    const reply = generateMockReply({\n      reactions: {\n        like: {\n          count: 0,\n          haveMine: false,\n        },\n      },\n    })\n\n    render(\n      <ThemeProvider theme={defaultTheme}>\n        <EventTrackingProvider page={{ label: 'test', path: '/test' }} utm={{}}>\n          <RepliesProvider>\n            <Reply\n              reply={reply}\n              focusInput={onFocusInput}\n              fetchMoreReplies={fetchMoreReplies}\n            />\n          </RepliesProvider>\n        </EventTrackingProvider>\n      </ThemeProvider>,\n      {\n        wrapper: createTestWrapper({\n          clientAppProvider: {\n            device: { autoplay: 'always', networkType: 'unknown' },\n            metadata: { name: ClientAppName.iOS, version: '6.5.0' },\n          },\n        }),\n      },\n    )\n\n    const likeCountElement = screen.queryByText(/좋아요/)\n\n    await waitFor(() => {\n      expect(likeCountElement).not.toBeInTheDocument()\n    })\n  })\n\n  test('갯수가 음수일 때, 좋아요 문구 및 갯수를 노출하지 않습니다.', async () => {\n    const reply = generateMockReply({\n      reactions: {\n        like: {\n          count: -1,\n          haveMine: false,\n        },\n      },\n    })\n\n    render(\n      <ThemeProvider theme={defaultTheme}>\n        <EventTrackingProvider page={{ label: 'test', path: '/test' }} utm={{}}>\n          <RepliesProvider>\n            <Reply\n              reply={reply}\n              focusInput={onFocusInput}\n              fetchMoreReplies={fetchMoreReplies}\n            />\n          </RepliesProvider>\n        </EventTrackingProvider>\n      </ThemeProvider>,\n      {\n        wrapper: createTestWrapper({\n          clientAppProvider: {\n            device: { autoplay: 'always', networkType: 'unknown' },\n            metadata: { name: ClientAppName.iOS, version: '6.5.0' },\n          },\n        }),\n      },\n    )\n\n    const likeCountElement = screen.queryByText(/좋아요/)\n\n    await waitFor(() => {\n      expect(likeCountElement).not.toBeInTheDocument()\n    })\n  })\n})\n\nfunction generateMockReply(reactions: Pick<ReplyType, 'reactions'>) {\n  return { ...MOCKED_REPLY, ...reactions }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/types.ts",
    "content": "export type ResourceType = 'review' | 'itinerary' | 'article'\n\nexport interface Writer {\n  href: string\n  name: string\n  profileImage: string\n  badges: {\n    type: string\n    label: string | null\n    icon: string\n  }[]\n}\n\nexport interface Reply {\n  id: string\n  parentId?: string\n  blinded: boolean\n  deleted: boolean\n  isMine: boolean\n  childrenCount: number\n  createdAt: string\n  updatedAt: string\n  reactions: {\n    like?: {\n      count: number\n      haveMine: boolean\n    }\n  }\n  content: { text?: string; markdownText?: string; mentionedUser?: Writer }\n  children: Reply[]\n  writer: Writer\n  actionSpecifications: {\n    delete: boolean\n    edit?: {\n      text?: string\n      plaintext?: string\n      mentionedUserHref?: string\n      mentionedUserName?: string\n      mentionedUserUid?: string\n    }\n    reaction: boolean\n    reply?: {\n      mentioningUserHref: string\n      mentioningUserName: string\n      mentioningUserUid: string\n      toMessageId: string\n    }\n    report: boolean\n  }\n}\n\nexport interface Placeholders {\n  reply?: string\n  childReply?: string\n}\n"
  },
  {
    "path": "packages/tds-widget/src/replies/utils.ts",
    "content": "import { Reply } from './types'\n\nexport function checkUniqueReply(reply: Reply[]): Reply[] {\n  const result = Array.from(\n    new Map((reply || []).map((item) => [item.id, item])).values(),\n  ).sort(\n    (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),\n  )\n\n  return result\n}\n"
  },
  {
    "path": "packages/tds-widget/src/resource-list-elements/extended-resource-list-element.stories.tsx",
    "content": "import type { Meta, StoryFn } from '@storybook/react'\nimport { Container } from '@titicaca/tds-ui'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { Pricing } from '../pricing'\nimport { ScrapButtonMask } from '../scrap-button'\nimport { ScrapsProvider } from '../scrap/provider'\n\nimport { ExtendedResourceListElement } from './extended-resource-list-element'\n\nconst meta: Meta<typeof ExtendedResourceListElement> = {\n  title: 'tds-widget / resource-list-element / extended-resource-list-element',\n  component: ExtendedResourceListElement,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <ScrapsProvider>\n          <Story />\n        </ScrapsProvider>\n      </EventTrackingProvider>\n    ),\n  ],\n}\n\nconst defaultArgs = {\n  scrapResource: {\n    id: 'scrapable_id',\n    type: 'scrapable_type',\n  },\n  name: '카멜리아 힐 입장권',\n  comment: '500여 품종 동백나무로 사계절 내내 아름다운 포토 스팟 수목원',\n  note: '현장에서 입장권 확인이 필요합니다.',\n  reviewsCount: 5,\n  reviewsRating: 3,\n  scrapsCount: 2,\n  partnerName: '브이패스',\n  areaName: '서울특별시',\n}\n\nexport default meta\n\nconst Template: StoryFn<typeof ExtendedResourceListElement> = (args) => {\n  return (\n    <ExtendedResourceListElement {...args}>\n      <Container\n        css={{\n          margin: '18px 0 0',\n        }}\n      >\n        <Pricing basePrice={30000} basePriceUnit=\"원\" salePrice={25000} rich />\n      </Container>\n    </ExtendedResourceListElement>\n  )\n}\n\nexport const Basic = {\n  render: Template,\n  args: {\n    ...defaultArgs,\n  },\n}\n\nexport const NoScrap = {\n  render: Template,\n  decorators: [\n    (Story: StoryFn) => (\n      <ScrapButtonMask masked>\n        <Story />\n      </ScrapButtonMask>\n    ),\n  ],\n  args: {\n    ...defaultArgs,\n  },\n}\n\nexport const Tags = {\n  render: Template,\n  args: {\n    ...defaultArgs,\n    tags: [\n      { color: 'blue', emphasized: true, text: 'blue' },\n      { color: 'blue', emphasized: false, text: 'blue' },\n      { color: 'red', emphasized: true, text: 'red' },\n      { color: 'purple', emphasized: true, text: 'purple' },\n      { color: 'purple', emphasized: false, text: 'purple' },\n      { color: 'gray', emphasized: true, text: 'gray' },\n      { color: 'gray', emphasized: false, text: 'gray' },\n      { color: 'green', emphasized: true, text: 'green' },\n      { color: 'green', emphasized: false, text: 'green' },\n      { color: 'white', emphasized: true, text: 'white' },\n      { color: 'white', emphasized: false, text: 'white' },\n      { color: 'orange', emphasized: true, text: 'orange' },\n      { color: 'orange', emphasized: false, text: 'orange' },\n      { color: 'skyblue', emphasized: true, text: 'skyblue' },\n      { color: 'skyblue', emphasized: false, text: 'skyblue' },\n      { color: 'lightpurple', emphasized: true, text: 'lightpurple' },\n      { color: 'lightpurple', emphasized: false, text: 'lightpurple' },\n    ],\n  },\n}\n\nexport const Advertisement = {\n  render: Template,\n  args: {\n    ...defaultArgs,\n    isAdvertisement: true,\n  },\n}\n\nexport const Badge = {\n  render: Template,\n\n  args: {\n    ...defaultArgs,\n    badge: {\n      icon: 'https://assets.triple.guide/images/seoulcon/default/ic_spot.svg',\n      text: '즉시확정',\n    },\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/resource-list-elements/extended-resource-list-element.tsx",
    "content": "import { MouseEventHandler, PropsWithChildren } from 'react'\nimport { useTranslation } from '@titicaca/triple-web'\nimport { styled, css, WebTarget } from 'styled-components'\nimport {\n  Container,\n  Label,\n  LabelColor,\n  Text,\n  List,\n  Image,\n  FlexBox,\n  ListItemProps,\n} from '@titicaca/tds-ui'\nimport { ImageMeta } from '@titicaca/type-definitions'\n\nimport { OverlayScrapButton } from '../scrap-button'\n\nimport { ReviewScrapStat } from './review-scrap-stat'\n\ninterface ResourceMeta {\n  id: string\n  type?: string\n  scraped?: boolean\n}\n\nexport type ResourceListElementProps<R extends ResourceMeta> = {\n  /** @deprecated */\n  resource?: R\n  scrapResource?: R\n  image?: ImageMeta\n  imagePlaceholder?: string\n  name?: string\n  comment?: string\n  distance?: number | string\n  distanceSuffix?: string\n  note?: string\n  tags?: {\n    text?: string\n    color?: LabelColor\n    emphasized?: boolean\n  }[]\n  badge?: {\n    text: string\n    icon?: string\n  }\n  scrapsCount?: number\n  reviewsCount?: number\n  reviewsRating?: number\n  maxCommentLines?: number\n  isAdvertisement?: boolean\n  partnerName?: string\n  areaName?: string\n  onClick?: MouseEventHandler<HTMLLIElement>\n  optimized?: boolean\n  as?: WebTarget\n} & ListItemProps\n\nconst ResourceListItem = styled(List.Item)`\n  position: relative;\n  padding: 20px 0;\n  cursor: pointer;\n`\n\nconst LabelContainer = styled.div`\n  position: absolute;\n  bottom: 20px;\n`\n\nconst Badge = styled.div<{ $icon?: string }>`\n  padding: 0 6px 0 ${({ $icon: icon }) => (icon ? 4 : 6)}px;\n  border-radius: 4px;\n  box-shadow: 0 0 0 1px var(--color-gray100) inset;\n  display: inline-block;\n\n  ${({ $icon }) =>\n    $icon &&\n    css`\n      &::before {\n        content: '';\n        display: inline-block;\n        width: 12px;\n        height: 12px;\n        margin-right: 2px;\n        background-image: url(${$icon});\n        background-size: 12px;\n        background-position: center;\n        background-repeat: no-repeat;\n        vertical-align: middle;\n      }\n    `};\n`\n\nexport function ExtendedResourceListElement<R extends ResourceMeta>({\n  resource,\n  scrapResource,\n  image,\n  imagePlaceholder,\n  name,\n  comment,\n  distance,\n  distanceSuffix = 'm',\n  note,\n  tags,\n  badge,\n  scrapsCount,\n  reviewsCount,\n  reviewsRating,\n  onClick,\n  maxCommentLines,\n  isAdvertisement,\n  partnerName,\n  areaName,\n  children,\n  optimized,\n  ...props\n}: PropsWithChildren<ResourceListElementProps<R>>) {\n  const t = useTranslation()\n\n  const { id, type, scraped } = scrapResource || resource || {}\n  const labels = tags || []\n  const formattedNames = [partnerName, areaName].filter(Boolean).join(' · ')\n\n  return (\n    <ResourceListItem onClick={onClick} {...props}>\n      <FlexBox flex justifyContent=\"space-between\" gap=\"16px\">\n        <Container css={{ width: '100%' }}>\n          <FlexBox\n            flex\n            alignItems=\"flex-start\"\n            justifyContent=\"space-between\"\n            gap=\"7px\"\n          >\n            <Text bold maxLines={2} size=\"large\">\n              {name}\n            </Text>\n\n            {isAdvertisement ? (\n              <Text\n                size={10}\n                lineHeight=\"12px\"\n                color=\"gray400\"\n                css={{\n                  minWidth: '24px',\n                  border: '1px solid var(--color-gray200)',\n                  borderRadius: '4px',\n                  padding: '1px 2px',\n                }}\n              >\n                {t('광고')}\n              </Text>\n            ) : null}\n          </FlexBox>\n\n          <Text\n            alpha={0.7}\n            maxLines={maxCommentLines}\n            size=\"small\"\n            margin={{ top: 5 }}\n          >\n            {comment}\n          </Text>\n\n          <ReviewScrapStat\n            reviewsCount={reviewsCount}\n            scrapsCount={scrapsCount}\n            reviewsRating={reviewsRating}\n            css={{ marginTop: 5 }}\n          />\n\n          {formattedNames ? (\n            <Container\n              css={{\n                margin: '5px 0 0',\n              }}\n            >\n              <Text inlineBlock size=\"tiny\" color=\"gray\" alpha={0.5}>\n                {formattedNames}\n              </Text>\n            </Container>\n          ) : null}\n\n          {distance || distance === 0 || note || isAdvertisement ? (\n            <Container\n              css={{\n                margin: '3px 0 0',\n              }}\n            >\n              {distance || distance === 0 ? (\n                <Text inline color=\"blue\" size=\"small\" alpha={1}>\n                  {`${distance}${distanceSuffix} `}\n                </Text>\n              ) : null}\n              {note ? (\n                <Text inline size=\"small\" alpha={0.4}>\n                  {note}\n                </Text>\n              ) : null}\n            </Container>\n          ) : null}\n\n          {badge ? (\n            <Container css={{ margin: '7px 0 0' }}>\n              <Badge $icon={badge.icon}>\n                <Text bold inline size={11} lineHeight=\"23px\" color=\"gray900\">\n                  {badge.text}\n                </Text>\n              </Badge>\n            </Container>\n          ) : null}\n        </Container>\n\n        <Container position=\"relative\">\n          <Container clearing>\n            <Image>\n              <Image.FixedDimensionsFrame size=\"small\" width={90}>\n                {image ? (\n                  optimized ? (\n                    <Image.OptimizedImg\n                      cloudinaryId={image.cloudinaryId as string}\n                      cloudinaryBucket={image.cloudinaryBucket}\n                      alt={name}\n                    />\n                  ) : (\n                    <Image.Img\n                      src={\n                        ('small_square' in image.sizes\n                          ? image.sizes.small_square\n                          : image.sizes.smallSquare\n                        ).url\n                      }\n                      alt={name}\n                    />\n                  )\n                ) : (\n                  <Image.Placeholder src={imagePlaceholder || ''} />\n                )}\n              </Image.FixedDimensionsFrame>\n            </Image>\n\n            {id && type ? (\n              <Container\n                position=\"absolute\"\n                css={{\n                  top: '3px',\n                  right: '3px',\n                }}\n              >\n                <OverlayScrapButton\n                  resource={{ id, type, scraped }}\n                  size={36}\n                />\n              </Container>\n            ) : null}\n          </Container>\n        </Container>\n      </FlexBox>\n      {children}\n\n      {labels.length > 0 ? (\n        <LabelContainer>\n          <Label.Group horizontalGap={5}>\n            {labels.map(({ text, color, emphasized }, index) => (\n              <Label key={index} promo color={color} emphasized={emphasized}>\n                {text}\n              </Label>\n            ))}\n          </Label.Group>\n        </LabelContainer>\n      ) : null}\n    </ResourceListItem>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/resource-list-elements/index.ts",
    "content": "export * from './extended-resource-list-element'\nexport * from './resource-list-element-stats'\nexport * from './review-scrap-stat'\n"
  },
  {
    "path": "packages/tds-widget/src/resource-list-elements/resource-list-element-stats.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { ResourceListElementStats } from './resource-list-element-stats'\n\nconst meta: Meta<typeof ResourceListElementStats> = {\n  title: 'tds-widget / resource-list-element / resource-list-element-stats',\n  component: ResourceListElementStats,\n}\n\nexport default meta\n\nexport const Word: StoryObj<typeof ResourceListElementStats> = {\n  args: {\n    stats: ['볼거리'],\n  },\n}\n\nexport const Phrase: StoryObj<typeof ResourceListElementStats> = {\n  args: {\n    stats: ['볼거리', '판교'],\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/resource-list-elements/resource-list-element-stats.tsx",
    "content": "import { Text } from '@titicaca/tds-ui'\n\nexport function ResourceListElementStats({\n  stats,\n  ...textProps\n}: {\n  stats: (string | null | undefined)[]\n} & Parameters<typeof Text>[0]) {\n  if (stats.length === 0) {\n    return null\n  }\n\n  return <Text {...textProps}>{stats.filter((stat) => stat).join(' · ')}</Text>\n}\n"
  },
  {
    "path": "packages/tds-widget/src/resource-list-elements/review-scrap-stat.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { ReviewScrapStat } from './review-scrap-stat'\n\nconst meta: Meta<typeof ReviewScrapStat> = {\n  title: 'tds-widget / resource-list-element / review-scrap-stat',\n  component: ReviewScrapStat,\n}\n\nexport default meta\n\nexport const Review: StoryObj<typeof ReviewScrapStat> = {\n  args: {\n    reviewsCount: 23,\n    reviewsRating: 3.7,\n  },\n}\n\nexport const Scrap: StoryObj<typeof ReviewScrapStat> = {\n  args: {\n    scrapsCount: 1027,\n  },\n}\n\nexport const Full: StoryObj<typeof ReviewScrapStat> = {\n  args: {\n    reviewsCount: 23,\n    scrapsCount: 7,\n    reviewsRating: 3.7,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/resource-list-elements/review-scrap-stat.tsx",
    "content": "import { useTranslation } from '@titicaca/triple-web'\nimport { Container, Rating } from '@titicaca/tds-ui'\nimport { formatNumber } from '@titicaca/view-utilities'\n\nimport { ResourceListElementStats } from './resource-list-element-stats'\n\nexport function ReviewScrapStat({\n  reviewsCount,\n  scrapsCount,\n  reviewsRating,\n  ...containerProps\n}: Parameters<typeof Container>[0] & {\n  reviewsRating: number | undefined\n  reviewsCount: number | undefined\n  scrapsCount: number | undefined\n}) {\n  const t = useTranslation()\n\n  if (!reviewsCount && !scrapsCount) {\n    return null\n  }\n\n  const formattedScrapsCount = formatNumber(scrapsCount)\n\n  return (\n    <Container {...containerProps}>\n      {reviewsCount ? (\n        <Rating verticalAlign=\"middle\" size=\"tiny\" score={reviewsRating} />\n      ) : null}\n\n      <ResourceListElementStats\n        stats={[\n          reviewsCount ? `(${formatNumber(reviewsCount)})` : null,\n          scrapsCount\n            ? t('저장 {{formattedScrapsCount}}', { formattedScrapsCount })\n            : null,\n        ]}\n        inline\n        size=\"tiny\"\n        alpha={0.4}\n      />\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/filter-context.tsx",
    "content": "import {\n  PropsWithChildren,\n  createContext,\n  useState,\n  useCallback,\n  useMemo,\n  useContext,\n  useEffect,\n} from 'react'\nimport { useClientAppActions, useTrackEvent } from '@titicaca/triple-web'\n\ninterface FilterValues {\n  isRecentTrip: boolean\n  isMediaCollection: boolean\n  handleRecentTripChange: () => void\n  handleMediaChange: () => void\n}\n\nconst FilterContext = createContext<FilterValues | undefined>(undefined)\n\nconst EVENT_TYPE = 'reviews-web/filter-change'\n\nexport function FilterProvider({\n  receiverId,\n  initialRecentTrip = false,\n  initialMediaFilter = false,\n  children,\n}: PropsWithChildren<{\n  receiverId?: string\n  initialRecentTrip?: boolean\n  initialMediaFilter?: boolean\n}>) {\n  const [isRecentTrip, setIsRecentTrip] = useState(initialRecentTrip)\n  const [isMediaCollection, setIsMediaCollection] = useState(initialMediaFilter)\n\n  const trackEvent = useTrackEvent()\n  const { broadcastMessage, subscribe, unsubscribe } = useClientAppActions()\n\n  const handleRecentTripChange = useCallback(() => {\n    setIsRecentTrip((prevState) => !prevState)\n\n    const action = isRecentTrip ? '리뷰_최근여행_해제' : '리뷰_최근여행_선택'\n\n    trackEvent({\n      ga: [action],\n      fa: {\n        action,\n      },\n    })\n  }, [isRecentTrip, trackEvent])\n\n  const handleMediaChange = useCallback(() => {\n    setIsMediaCollection((prevState) => !prevState)\n\n    const action = isMediaCollection\n      ? '리뷰_사진영상필터_해제'\n      : '리뷰_사진영상필터_선택'\n\n    trackEvent({\n      fa: {\n        action,\n      },\n    })\n  }, [isMediaCollection, trackEvent])\n\n  useEffect(() => {\n    if (receiverId) {\n      broadcastMessage &&\n        broadcastMessage({\n          receiverId,\n          type: EVENT_TYPE,\n          filter: {\n            isRecentTrip,\n            hasMedia: isMediaCollection,\n          },\n        })\n    }\n  }, [receiverId, isRecentTrip, isMediaCollection, broadcastMessage])\n\n  useEffect(() => {\n    const handleReceiveMessage = ({\n      payload,\n    }: {\n      payload?: {\n        type: string\n        filter: {\n          isRecentTrip: boolean\n          hasMedia: boolean\n        }\n      }\n    }) => {\n      if (!payload || payload.type !== EVENT_TYPE) {\n        return\n      }\n\n      const { isRecentTrip, hasMedia } = payload.filter\n\n      setIsRecentTrip(isRecentTrip)\n      setIsMediaCollection(hasMedia)\n    }\n\n    subscribe && subscribe('receiveMessage', handleReceiveMessage)\n\n    return () => {\n      unsubscribe && unsubscribe('receiveMessage', handleReceiveMessage)\n    }\n  }, [subscribe, unsubscribe, setIsRecentTrip, setIsMediaCollection])\n\n  const values = useMemo(\n    () => ({\n      isRecentTrip,\n      isMediaCollection,\n      handleRecentTripChange,\n      handleMediaChange,\n    }),\n    [\n      isRecentTrip,\n      isMediaCollection,\n      handleRecentTripChange,\n      handleMediaChange,\n    ],\n  )\n\n  return (\n    <FilterContext.Provider value={values}>{children}</FilterContext.Provider>\n  )\n}\n\nexport function useReviewFilters() {\n  const context = useContext(FilterContext)\n\n  if (context === undefined) {\n    throw new Error('FilterProvider is not mount.')\n  }\n\n  return context\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/filter.tsx",
    "content": "import { useState } from 'react'\nimport { styled } from 'styled-components'\nimport { useTranslation, useTrackEvent } from '@titicaca/triple-web'\nimport { FlexBox, Text, Container } from '@titicaca/tds-ui'\n\nimport { useReviewFilters } from './filter-context'\n\nconst CheckBox = styled.input`\n  appearance: none;\n  width: 22px;\n  height: 22px;\n  border: 1px solid var(--color-gray200);\n  border-radius: 5px;\n  cursor: pointer;\n\n  &::after {\n    content: '';\n    display: block;\n    width: 100%;\n    height: 100%;\n    background-image: url('https://assets.triple.guide/images/ico-check@3x.png');\n    background-size: 100% 100%;\n  }\n\n  &:checked {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    background-color: var(--color-blue);\n    border: none;\n  }\n`\n\nconst TooltipContainer = styled(Container)`\n  position: absolute;\n  min-height: 100%;\n  top: 105px;\n  right: 0;\n  bottom: 0;\n  z-index: 1;\n`\n\nconst TooltipText = styled(Text)`\n  min-width: 200px;\n  background: var(--color-white);\n  border: 1px solid var(--color-brightGray);\n  transform: translateY(calc(-100% - 10px));\n  box-shadow: 0 0 15px rgba(0, 0, 0, 0.05);\n  border-radius: 8px;\n`\n\nconst OpenIcon = styled.img`\n  margin-top: 3px;\n  margin-left: 4px;\n  vertical-align: baseline;\n`\n\nconst CloseIcon = styled.img`\n  position: absolute;\n  top: 17px;\n  right: 15px;\n`\n\nexport function Filters() {\n  const {\n    isRecentTrip,\n    isMediaCollection,\n    handleRecentTripChange,\n    handleMediaChange,\n  } = useReviewFilters()\n\n  const t = useTranslation()\n\n  return (\n    <FlexBox flex alignItems=\"center\" position=\"relative\">\n      <Container css={{ marginRight: '12px' }}>\n        <Filter\n          title={t('사진/동영상')}\n          checked={isMediaCollection}\n          onClick={handleMediaChange}\n        />\n      </Container>\n\n      <Filter\n        title={t('최근여행')}\n        checked={isRecentTrip}\n        onClick={handleRecentTripChange}\n      />\n\n      <ToolTip />\n    </FlexBox>\n  )\n}\n\nfunction Filter({\n  title,\n  checked,\n  onClick,\n}: {\n  title: string\n  checked: boolean\n  onClick: () => void\n}) {\n  return (\n    <FlexBox\n      flex\n      alignItems=\"center\"\n      gap=\"6px\"\n      onClick={onClick}\n      css={{\n        cursor: 'pointer',\n      }}\n    >\n      <CheckBox readOnly type=\"checkbox\" checked={checked} />\n      <Text size={14}>{title}</Text>\n    </FlexBox>\n  )\n}\n\nfunction ToolTip() {\n  const t = useTranslation()\n\n  const [visible, setVisible] = useState(false)\n\n  const trackEvent = useTrackEvent()\n\n  return (\n    <Container>\n      <OpenIcon\n        width={16}\n        height={16}\n        src=\"https://assets.triple.guide/images/ico_tooltip_info_black@4x.png\"\n        onClick={() => {\n          setVisible(true)\n\n          trackEvent({\n            fa: {\n              action: '최근여행_툴팁_선택',\n            },\n          })\n        }}\n      />\n      {visible ? (\n        <TooltipContainer>\n          <TooltipText\n            size={12}\n            lineHeight=\"16px\"\n            color=\"gray800\"\n            padding={{ top: 15, left: 15, bottom: 15, right: 37 }}\n          >\n            {t('최근 6개월 내에 방문한 여행의 리뷰만 모아 볼 수 있습니다.')}\n            <CloseIcon\n              width={10}\n              height={10}\n              src=\"https://assets.triple.guide/images/ico_tooltip_delete.png\"\n              onClick={() => setVisible(false)}\n            />\n          </TooltipText>\n        </TooltipContainer>\n      ) : null}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/full-list-button.tsx",
    "content": "import { useCallback } from 'react'\nimport {\n  useTranslation,\n  useClientAppActions,\n  useSessionCallback,\n  useTrackEvent,\n} from '@titicaca/triple-web'\nimport { Button } from '@titicaca/tds-ui'\nimport { useNavigate } from '@titicaca/router'\nimport qs from 'qs'\n\nimport { SHORTENED_REVIEWS_COUNT_PER_PAGE } from '../constants'\n\nimport type { SortingType, SortingOption } from './sorting-context'\n\ninterface Props {\n  reviewsCount: number | undefined\n  resourceId: string\n  resourceType: string\n  regionId: string | undefined\n  hasMedia: boolean\n  recentTrip: boolean\n  sortingType?: SortingType\n  sortingOption: SortingOption\n}\n\nconst OPTION_LABELS = {\n  recommendation: '추천순',\n  latest: '최신순',\n  'star-rating-desc': '별점 높은순',\n  'star-rating-asc': '별점 낮은순',\n}\n\nexport const FullListButton = ({\n  reviewsCount,\n  resourceId,\n  resourceType,\n  regionId,\n  hasMedia,\n  recentTrip,\n  sortingType,\n  sortingOption,\n}: Props) => {\n  const t = useTranslation()\n  const trackEvent = useTrackEvent()\n  const { navigate } = useNavigate()\n  const { getWindowId } = useClientAppActions()\n\n  const reviewListUrl = `/reviews/list?_triple_no_navbar&${qs.stringify({\n    ...(regionId && regionId !== 'null' && { region_id: regionId }),\n    resource_id: resourceId,\n    resource_type: resourceType,\n    recent_trip: recentTrip,\n    sorting_type: sortingType,\n    sorting_option: sortingOption,\n    has_media: hasMedia,\n    opener_id: getWindowId && getWindowId(),\n  })}`\n\n  const fullListButtonClickCallback = useSessionCallback(\n    useCallback(() => {\n      navigate(reviewListUrl)\n    }, [navigate, reviewListUrl]),\n    {\n      returnUrl: reviewListUrl,\n      triggeredEventAction: '리뷰_리스트더보기_선택',\n    },\n  )\n\n  const restReviewsCount = reviewsCount\n    ? reviewsCount - SHORTENED_REVIEWS_COUNT_PER_PAGE\n    : 0\n\n  const handleClick = () => {\n    trackEvent({\n      ga: ['리뷰_리스트더보기_선택'],\n      fa: {\n        action: '리뷰_리스트더보기_선택',\n        item_id: resourceId,\n        tab_name: OPTION_LABELS[sortingOption],\n      },\n    })\n    fullListButtonClickCallback()\n  }\n\n  if (restReviewsCount <= 0) {\n    return null\n  }\n\n  return (\n    <Button\n      basic\n      fluid\n      compact\n      size=\"small\"\n      css={{\n        margin: '40px 0 0',\n      }}\n      onClick={handleClick}\n    >\n      {t('{{numOfRestReviews}}개 리뷰 더보기', {\n        numOfRestReviews: restReviewsCount,\n      })}\n    </Button>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/infinite-list/index.ts",
    "content": "export * from './popular-reviews-infinite'\nexport * from './latest-reviews-infinite'\nexport * from './rating-infinite-list'\nexport * from './types'\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/infinite-list/infinite-list.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react'\nimport { List, Spinner } from '@titicaca/tds-ui'\nimport { StaticIntersectionObserver } from '@titicaca/intersection-observer'\nimport { useClientAppActions } from '@titicaca/triple-web'\n\nimport { useDescriptions, useMyReview } from '../../services'\nimport { BaseReviewFragment } from '../../data/graphql'\nimport { ReviewElement } from '../review-element'\nimport { MyReviewActionSheet } from '../my-review-action-sheet'\nimport { OthersReviewActionSheet } from '../others-review-action-sheet'\nimport { ReviewsPlaceholder } from '../review-placeholder-with-rating'\nimport type { SortingType, SortingOption } from '../sorting-context'\n\ninterface Props {\n  resourceId: string\n  resourceType: string\n  regionId: string | undefined\n  hasMedia: boolean\n  recentTrip: boolean\n  placeholderText: string | undefined\n  sortingType?: SortingType\n  sortingOption: SortingOption\n  reviewsCount: number | undefined\n  reviews: BaseReviewFragment[] | undefined\n  hasNextPage?: boolean\n  fetchNextPage?: () => void\n  refetch: () => void\n}\n\nexport function InfiniteList({\n  resourceId,\n  resourceType,\n  regionId,\n  hasMedia,\n  recentTrip,\n  placeholderText,\n  sortingType,\n  sortingOption,\n  reviewsCount,\n  reviews,\n  hasNextPage,\n  fetchNextPage,\n  refetch,\n}: Props) {\n  const [selectedReviewId, setSelectedReviewId] = useState<string | undefined>(\n    undefined,\n  )\n  const { subscribeLikedChangeEvent, unsubscribeLikedChangeEvent } =\n    useClientAppActions()\n\n  const { data: myReviewData } = useMyReview({\n    resourceId,\n    resourceType,\n  })\n  const { data: descriptionsData } = useDescriptions({\n    resourceId,\n    resourceType,\n  })\n\n  const sortedReviews = useMemo(() => {\n    if (!reviews || !myReviewData) {\n      return undefined\n    }\n\n    let newReviews = reviews.filter(\n      (review) => review.id !== myReviewData.myReview?.id,\n    )\n\n    if (myReviewData.myReview) {\n      newReviews = [myReviewData.myReview].concat(newReviews)\n    }\n\n    return newReviews\n  }, [myReviewData, reviews])\n\n  useEffect(() => {\n    subscribeLikedChangeEvent?.(refetch)\n\n    return () => unsubscribeLikedChangeEvent?.(refetch)\n  }, [refetch, subscribeLikedChangeEvent, unsubscribeLikedChangeEvent])\n\n  if (!myReviewData || !descriptionsData || !sortedReviews) {\n    return <Spinner />\n  }\n\n  if (sortedReviews.length === 0) {\n    return (\n      <ReviewsPlaceholder\n        hasReviews={(reviewsCount ?? 0) > 0}\n        isMorePage={!!hasNextPage}\n        resourceId={resourceId}\n        resourceType={resourceType}\n        regionId={regionId}\n        sortingType={sortingType}\n        sortingOption={sortingOption}\n        hasMedia={hasMedia}\n        recentTrip={recentTrip}\n        placeholderText={placeholderText}\n      />\n    )\n  }\n\n  return (\n    <>\n      <List divided margin={{ top: 24 }} verticalGap={48}>\n        {sortedReviews.map((review, i) => (\n          <ReviewElement\n            key={i}\n            isFullList\n            isMyReview={myReviewData.myReview?.id === review.id}\n            review={review}\n            reviewRateDescriptions={\n              descriptionsData.reviewsSpecification?.rating?.description\n            }\n            resourceId={resourceId}\n            regionId={regionId}\n            onMenuClick={setSelectedReviewId}\n          />\n        ))}\n      </List>\n\n      {hasNextPage ? (\n        <StaticIntersectionObserver\n          onChange={({ isIntersecting }) => {\n            if (isIntersecting) {\n              fetchNextPage?.()\n            }\n          }}\n        >\n          <div />\n        </StaticIntersectionObserver>\n      ) : null}\n\n      {myReviewData?.myReview ? (\n        <MyReviewActionSheet\n          reviewId={myReviewData.myReview.id}\n          reviewBlinded={myReviewData.myReview.blinded ?? false}\n          resourceType={resourceType}\n          resourceId={resourceId}\n          regionId={regionId}\n        />\n      ) : null}\n\n      {selectedReviewId ? (\n        <OthersReviewActionSheet reviewId={selectedReviewId} />\n      ) : null}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/infinite-list/latest-reviews-infinite.tsx",
    "content": "import type { SortingType } from '../sorting-context'\n\nimport { InfiniteList } from './infinite-list'\nimport { useInfiniteLatestReviews } from './services'\nimport type { InfiniteReviewProps } from './types'\n\nexport function LatestReviewsInfinite({\n  value: {\n    resourceId,\n    resourceType,\n    regionId,\n    recentTrip,\n    hasMedia,\n    placeholderText,\n    reviewsCount,\n    sortingType,\n  },\n}: {\n  value: InfiniteReviewProps & { sortingType?: SortingType }\n}) {\n  const { data, hasNextPage, fetchNextPage, refetch } =\n    useInfiniteLatestReviews({\n      resourceId,\n      resourceType,\n      recentTrip,\n      hasMedia,\n    })\n\n  return (\n    <InfiniteList\n      resourceId={resourceId}\n      resourceType={resourceType}\n      regionId={regionId}\n      hasMedia={hasMedia}\n      recentTrip={recentTrip}\n      placeholderText={placeholderText}\n      sortingType={sortingType}\n      sortingOption=\"latest\"\n      reviewsCount={reviewsCount}\n      reviews={data?.pages.flat()}\n      hasNextPage={hasNextPage}\n      fetchNextPage={fetchNextPage}\n      refetch={refetch}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/infinite-list/popular-reviews-infinite.tsx",
    "content": "import type { SortingType } from '../sorting-context'\n\nimport { InfiniteList } from './infinite-list'\nimport { useInfinitePopularReviews } from './services'\nimport type { InfiniteReviewProps } from './types'\n\nexport function PopularReviewsInfinite({\n  value: {\n    resourceId,\n    resourceType,\n    regionId,\n    recentTrip,\n    hasMedia,\n    placeholderText,\n    reviewsCount,\n    sortingType,\n  },\n}: {\n  value: InfiniteReviewProps & { sortingType?: SortingType }\n}) {\n  const { data, hasNextPage, fetchNextPage, refetch } =\n    useInfinitePopularReviews({\n      resourceId,\n      resourceType,\n      recentTrip,\n      hasMedia,\n    })\n\n  return (\n    <InfiniteList\n      resourceId={resourceId}\n      resourceType={resourceType}\n      regionId={regionId}\n      hasMedia={hasMedia}\n      recentTrip={recentTrip}\n      placeholderText={placeholderText}\n      sortingType={sortingType}\n      sortingOption=\"recommendation\"\n      reviewsCount={reviewsCount}\n      reviews={data?.pages.flat()}\n      hasNextPage={hasNextPage}\n      fetchNextPage={fetchNextPage}\n      refetch={refetch}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/infinite-list/rating-infinite-list.tsx",
    "content": "import type { SortingType } from '../sorting-context'\n\nimport { InfiniteList } from './infinite-list'\nimport { useInfiniteRatingReviews } from './services'\nimport type { ExtendInfiniteReviewProps } from './types'\n\nexport function RatingReviewsInfinite({\n  value: {\n    resourceId,\n    resourceType,\n    regionId,\n    recentTrip,\n    hasMedia,\n    placeholderText,\n    reviewsCount,\n    sortingLabel,\n    sortingType,\n  },\n}: {\n  value: ExtendInfiniteReviewProps & { sortingType?: SortingType }\n}) {\n  const sort = sortingLabel.replace(/^star-rating-/, '')\n\n  const { data, hasNextPage, fetchNextPage, refetch } =\n    useInfiniteRatingReviews({\n      resourceId,\n      resourceType,\n      recentTrip,\n      hasMedia,\n      sortBy: {\n        rating: sort,\n      },\n    })\n\n  return (\n    <InfiniteList\n      resourceId={resourceId}\n      resourceType={resourceType}\n      regionId={regionId}\n      hasMedia={hasMedia}\n      recentTrip={recentTrip}\n      placeholderText={placeholderText}\n      sortingType={sortingType}\n      sortingOption={sortingLabel}\n      reviewsCount={reviewsCount}\n      reviews={data?.pages.flat()}\n      hasNextPage={hasNextPage}\n      fetchNextPage={fetchNextPage}\n      refetch={refetch}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/infinite-list/services.ts",
    "content": "import {\n  InfiniteData,\n  UseInfiniteQueryResult,\n  keepPreviousData,\n  useInfiniteQuery,\n} from '@tanstack/react-query'\n\nimport {\n  GetPopularReviewsQueryVariables,\n  GetLatestReviewsQueryVariables,\n  GetReviewsByRatingQueryVariables,\n  client,\n  GetReviewsByRatingQuery,\n  GetLatestReviewsQuery,\n  GetPopularReviewsQuery,\n} from '../../data/graphql'\nimport { DEFAULT_REVIEWS_COUNT_PER_PAGE } from '../../constants'\n\nexport function useInfinitePopularReviews(\n  params: Omit<GetPopularReviewsQueryVariables, 'from' | 'size'>,\n): UseInfiniteQueryResult<\n  InfiniteData<GetPopularReviewsQuery['popularReviews']>\n> {\n  return useInfiniteQuery({\n    queryKey: [\n      'review/getInfinitePopularReviews',\n      { ...params, size: DEFAULT_REVIEWS_COUNT_PER_PAGE },\n    ],\n    queryFn: ({ pageParam }) =>\n      client.GetPopularReviews({\n        ...params,\n        from: (pageParam - 1) * DEFAULT_REVIEWS_COUNT_PER_PAGE,\n        size: DEFAULT_REVIEWS_COUNT_PER_PAGE,\n      }),\n    initialPageParam: 1,\n    getNextPageParam: (lastPage, allPages) => {\n      if (lastPage.popularReviews.length === 0) {\n        return undefined\n      }\n      return allPages.length + 1\n    },\n    select: ({ pageParams, pages }) => ({\n      pageParams,\n      pages: pages.map((item) => item.popularReviews),\n    }),\n    placeholderData: keepPreviousData,\n    refetchOnWindowFocus: false,\n  })\n}\n\nexport function useInfiniteLatestReviews(\n  params: Omit<GetLatestReviewsQueryVariables, 'from' | 'size'>,\n): UseInfiniteQueryResult<\n  InfiniteData<GetLatestReviewsQuery['latestReviews']>\n> {\n  return useInfiniteQuery({\n    queryKey: [\n      'review/getInfiniteLatestReviews',\n      { ...params, size: DEFAULT_REVIEWS_COUNT_PER_PAGE },\n    ],\n    queryFn: ({ pageParam }) =>\n      client.GetLatestReviews({\n        ...params,\n        from: (pageParam - 1) * DEFAULT_REVIEWS_COUNT_PER_PAGE,\n        size: DEFAULT_REVIEWS_COUNT_PER_PAGE,\n      }),\n    initialPageParam: 1,\n    getNextPageParam: (lastPage, allPages) => {\n      if (lastPage.latestReviews.length === 0) {\n        return undefined\n      }\n      return allPages.length + 1\n    },\n    select: ({ pageParams, pages }) => ({\n      pageParams,\n      pages: pages.map((item) => item.latestReviews),\n    }),\n    placeholderData: keepPreviousData,\n    refetchOnWindowFocus: false,\n  })\n}\n\nexport function useInfiniteRatingReviews(\n  params: Omit<GetReviewsByRatingQueryVariables, 'from' | 'size'>,\n): UseInfiniteQueryResult<\n  InfiniteData<GetReviewsByRatingQuery['ratingReviews']>\n> {\n  return useInfiniteQuery({\n    queryKey: [\n      'review/getInfiniteRatingReviews',\n      { ...params, size: DEFAULT_REVIEWS_COUNT_PER_PAGE },\n    ],\n    queryFn: ({ pageParam }) =>\n      client.GetReviewsByRating({\n        ...params,\n        from: (pageParam - 1) * DEFAULT_REVIEWS_COUNT_PER_PAGE,\n        size: DEFAULT_REVIEWS_COUNT_PER_PAGE,\n      }),\n    initialPageParam: 1,\n    getNextPageParam: (lastPage, allPages) => {\n      if (lastPage.ratingReviews.length === 0) {\n        return undefined\n      }\n      return allPages.length + 1\n    },\n    select: ({ pageParams, pages }) => ({\n      pageParams,\n      pages: pages.map((item) => item.ratingReviews),\n    }),\n    placeholderData: keepPreviousData,\n    refetchOnWindowFocus: false,\n  })\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/infinite-list/types.ts",
    "content": "export interface InfiniteReviewProps {\n  resourceId: string\n  resourceType: string\n  regionId?: string\n  placeholderText?: string\n  reviewsCount?: number\n  recentTrip: boolean\n  hasMedia: boolean\n}\n\nexport type ExtendInfiniteReviewProps = InfiniteReviewProps & {\n  sortingLabel: 'star-rating-asc' | 'star-rating-desc'\n}\n\nexport type InfinityReviewValue =\n  | InfiniteReviewProps\n  | ExtendInfiniteReviewProps\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/mileage-button.tsx",
    "content": "import { Text } from '@titicaca/tds-ui'\nimport {\n  useClientApp,\n  useTrackEvent,\n  useTranslation,\n} from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\n\nimport { useClientActions } from '../services'\n\nconst StyledButton = styled.button`\n  position: relative;\n  display: block;\n  border-radius: 4px;\n  background-color: rgba(58, 58, 58, 0.03);\n  width: 100%;\n  margin-top: 25px;\n  padding: 22px 20px 19px;\n  font-size: 13px;\n  color: #2987f0;\n  cursor: pointer;\n\n  @media only screen and (max-width: 640px) {\n    padding: 16px 14px 16px 20px;\n  }\n`\n\nconst BulletRight = styled.img.attrs({\n  src: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgdmlld0JveD0iMCAwIDIwIDIwIj4KICAgIDxwYXRoIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMjk4N0YwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS13aWR0aD0iMS42IiBkPSJNNy4wNyAxNkwxMyAxMC4wMzUgNyA0Ii8+Cjwvc3ZnPgo=',\n})`\n  position: absolute;\n  right: 20px;\n  top: 50%;\n  transform: translateY(-50%);\n\n  @media only screen and (max-width: 640px) {\n    right: 9px;\n  }\n`\n\ninterface Props {\n  resourceId: string\n}\n\nexport function MileageButton({ resourceId }: Props) {\n  const t = useTranslation()\n  const trackEvent = useTrackEvent()\n  const app = useClientApp()\n  const { navigateMileageIntro } = useClientActions()\n\n  return (\n    <StyledButton\n      onClick={(e) => {\n        trackEvent({\n          ga: ['리뷰_여행자클럽선택'],\n          fa: {\n            action: '리뷰_여행자클럽선택',\n            item_id: resourceId,\n          },\n        })\n        e.preventDefault()\n        if (app) {\n          navigateMileageIntro()\n        } else {\n          window.location.href = `/hybrid/mileage/intro`\n        }\n      }}\n    >\n      <Text color=\"gray\" size=\"small\" alpha={0.6} lineHeight={1.7}>\n        {t('리뷰 쓰면 여행자 클럽 최대 3포인트!')}\n      </Text>\n      <Text color=\"blue\" size=\"small\" lineHeight={1.7}>\n        {t('포인트별 혜택 보기')}\n      </Text>\n      <BulletRight alt={t('포인트별 혜택 보기')} />\n    </StyledButton>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/my-review-action-sheet.tsx",
    "content": "import { ActionSheet, ActionSheetItem, Confirm } from '@titicaca/tds-ui'\nimport { useTranslation, useEnv, useHashRouter } from '@titicaca/triple-web'\nimport qs from 'qs'\n\nimport { useDeleteReviewMutation } from '../services'\n\nexport const HASH_MY_REVIEW_ACTION_SHEET =\n  'common.reviews-list.my-review-action-sheet'\n\nconst HASH_DELETION_MODAL = 'common.reviews-list.deletion-modal'\n\ninterface MyReviewActionSheetProps {\n  reviewId: string\n  reviewBlinded: boolean\n  resourceType: string\n  resourceId: string\n  regionId: string | undefined\n}\n\nexport function MyReviewActionSheet({\n  reviewId,\n  reviewBlinded,\n  resourceType,\n  resourceId,\n  regionId,\n}: MyReviewActionSheetProps) {\n  const t = useTranslation()\n\n  const { appUrlScheme } = useEnv()\n  const { uriHash, addUriHash, removeUriHash } = useHashRouter()\n\n  const { mutate } = useDeleteReviewMutation()\n\n  const handleDeleteMenuClick = () => {\n    addUriHash(HASH_DELETION_MODAL, 'replace')\n\n    return true\n  }\n\n  const handleDeleteReview = () => {\n    mutate({ id: reviewId, resourceId, resourceType })\n\n    removeUriHash('replace')\n  }\n\n  const handleEditReview = () => {\n    const params = qs.stringify({\n      region_id: regionId,\n      resource_type: resourceType,\n      resource_id: resourceId,\n    })\n    window.location.href = `${appUrlScheme}:///reviews/edit?${params}`\n  }\n\n  return (\n    <>\n      <ActionSheet\n        open={uriHash === HASH_MY_REVIEW_ACTION_SHEET}\n        onClose={() => removeUriHash('replace')}\n      >\n        {!reviewBlinded ? (\n          <ActionSheetItem icon=\"review\" onClick={handleEditReview}>\n            {t('수정하기')}\n          </ActionSheetItem>\n        ) : null}\n        <ActionSheetItem icon=\"delete\" onClick={handleDeleteMenuClick}>\n          {t('삭제하기')}\n        </ActionSheetItem>\n      </ActionSheet>\n\n      <Confirm\n        open={uriHash === HASH_DELETION_MODAL}\n        onClose={() => removeUriHash('replace')}\n        onConfirm={handleDeleteReview}\n      >\n        {t('삭제하겠습니까? 삭제하면 적립된 리뷰 포인트도 함께 사라집니다.')}\n      </Confirm>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/others-review-action-sheet.tsx",
    "content": "import { ActionSheet, ActionSheetItem } from '@titicaca/tds-ui'\nimport { useTranslation, useHashRouter } from '@titicaca/triple-web'\n\nimport { useClientActions } from '../services'\n\nexport const HASH_REVIEW_ACTION_SHEET =\n  'common.reviews-list.review-action-sheet'\n\nexport interface OthersReviewActionSheetProps {\n  reviewId: string\n}\n\nexport function OthersReviewActionSheet({\n  reviewId,\n}: OthersReviewActionSheetProps) {\n  const t = useTranslation()\n\n  const { uriHash, removeUriHash } = useHashRouter()\n  const { reportReview } = useClientActions()\n\n  const handleReportClick = () => {\n    reportReview(reviewId)\n\n    removeUriHash()\n  }\n\n  return (\n    <ActionSheet\n      open={uriHash === HASH_REVIEW_ACTION_SHEET}\n      onClose={removeUriHash}\n    >\n      <ActionSheetItem icon=\"report\" onClick={handleReportClick}>\n        {t('신고하기')}\n      </ActionSheetItem>\n    </ActionSheet>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/badges.tsx",
    "content": "import { FlexBox, Text } from '@titicaca/tds-ui'\nimport { useTranslation } from '@titicaca/triple-web'\n\nexport function ReviewBadges({\n  recentTrip,\n  verifiedPurchase,\n}: {\n  recentTrip: boolean\n  verifiedPurchase: boolean\n}) {\n  return recentTrip || verifiedPurchase ? (\n    <FlexBox flex gap=\"5px\" css={{ marginBottom: 6 }}>\n      {recentTrip ? <RecentTripBadge /> : null}\n      {verifiedPurchase ? <VerifiedPurchaseBadge /> : null}\n    </FlexBox>\n  ) : null\n}\n\nexport function RecentTripBadge() {\n  const t = useTranslation()\n\n  return (\n    <FlexBox\n      flex\n      gap=\"3px\"\n      css={{\n        padding: '5px 7px',\n        backgroundColor: 'rgba(54, 143, 255, 0.1)',\n        borderRadius: 4,\n      }}\n    >\n      <img\n        width={14}\n        height={14}\n        src=\"https://assets.triple.guide/images/ico_recently_badge@4x.png\"\n        alt=\"recent-trip-icon\"\n      />\n      <Text size={12} color=\"blue\" bold>\n        {t('최근여행')}\n      </Text>\n    </FlexBox>\n  )\n}\n\nexport function VerifiedPurchaseBadge() {\n  const t = useTranslation()\n\n  return (\n    <FlexBox\n      flex\n      gap=\"3px\"\n      css={{ padding: '5px 7px', backgroundColor: '#E9FAF9' }}\n      borderRadius={4}\n    >\n      <img\n        width={14}\n        height={14}\n        src=\"https://assets.triple.guide/images/ico_certified.svg\"\n        alt=\"recent-trip-icon\"\n      />\n      <Text size={12} bold style={{ color: '#18BFC0' }}>\n        {t('구매 인증 리뷰')}\n      </Text>\n    </FlexBox>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/comment.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { Text } from '@titicaca/tds-ui'\n\nexport function Comment({ children }: PropsWithChildren<unknown>) {\n  return (\n    <Text size=\"large\" color=\"gray\" lineHeight={1.5}>\n      {children}\n    </Text>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/foldable-comment.tsx",
    "content": "import { MouseEventHandler } from 'react'\nimport { useTranslation } from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport { findFoldedPosition } from '@titicaca/view-utilities'\n\nimport { Comment } from './comment'\n\nconst MAX_COMMENT_WITH_IMAGE_LINES = 3\nconst MAX_COMMENT_LINES = 6\n\nconst Unfold = styled.button`\n  display: inline-block;\n  color: #2987f0;\n  outline: 0;\n`\n\nexport function FoldableComment({\n  comment,\n  hasImage,\n  onUnfoldButtonClick,\n}: {\n  comment: string\n  hasImage: boolean\n  onUnfoldButtonClick: MouseEventHandler<HTMLButtonElement>\n}) {\n  const foldedPosition = findFoldedPosition(\n    hasImage ? MAX_COMMENT_WITH_IMAGE_LINES : MAX_COMMENT_LINES,\n    comment,\n  )\n\n  return foldedPosition ? (\n    <FoldedComment\n      comment={comment.slice(0, foldedPosition)}\n      onUnfoldButtonClick={onUnfoldButtonClick}\n    />\n  ) : (\n    <Comment>{comment}</Comment>\n  )\n}\n\nfunction FoldedComment({\n  comment,\n  onUnfoldButtonClick,\n}: {\n  comment: string\n  onUnfoldButtonClick: MouseEventHandler<HTMLButtonElement>\n}) {\n  const t = useTranslation()\n\n  return (\n    <Comment>\n      {`${comment} …`}\n      <Unfold onClick={onUnfoldButtonClick}>{t('더보기')}</Unfold>\n    </Comment>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/index.tsx",
    "content": "import { Container, FlexBox, List, Rating, Text } from '@titicaca/tds-ui'\nimport { StaticIntersectionObserver as IntersectionObserver } from '@titicaca/intersection-observer'\nimport {\n  useTranslation,\n  useTrackEvent,\n  useHashRouter,\n  useSessionCallback,\n  useClientAppCallback,\n  useClientAppActions,\n} from '@titicaca/triple-web'\nimport { formatTimestamp } from '@titicaca/view-utilities'\nimport { PropsWithChildren, useCallback, useState } from 'react'\nimport { styled, css } from 'styled-components'\nimport { getMonth, getYear } from 'date-fns'\n\nimport { BaseReviewFragment } from '../../data/graphql'\nimport {\n  useClientActions,\n  useLikeReviewMutation,\n  useUnlikeReviewMutation,\n} from '../../services'\nimport { HASH_MY_REVIEW_ACTION_SHEET } from '../my-review-action-sheet'\nimport { HASH_REVIEW_ACTION_SHEET } from '../others-review-action-sheet'\n\nimport { Comment } from './comment'\nimport { FoldableComment } from './foldable-comment'\nimport { Media } from './media'\nimport { PinnedMessage } from './pinned-message'\nimport { User } from './user'\nimport { ReviewBadges } from './badges'\nimport PurchaseInfo from './purchaseInfo'\n\nconst MetaContainer = styled.div`\n  margin-top: 5px;\n  height: 27px;\n`\n\nconst MoreIcon = styled.img`\n  margin-top: -3px;\n  margin-left: 5px;\n  width: 30px;\n  height: 30px;\n  vertical-align: middle;\n  cursor: pointer;\n`\n\nconst MessageCount = styled(Container)<{ $isCommaVisible?: boolean }>`\n  font-weight: bold;\n  background-image: url('https://assets.triple.guide/images/btn-lounge-comment-off@3x.png');\n  background-size: 18px 18px;\n  background-repeat: no-repeat;\n\n  ${({ $isCommaVisible }) =>\n    $isCommaVisible &&\n    css`\n      margin-left: 8px;\n\n      &::before {\n        position: absolute;\n        left: -10px;\n        content: '·';\n      }\n    `}\n`\n\nconst LikeButton = styled.button<{ $liked?: boolean }>`\n  font-weight: bold;\n  text-decoration: none;\n  background-size: 18px 18px;\n  background-repeat: no-repeat;\n  color: ${({ $liked }) => ($liked ? '--color-blue' : '--color-gray400')};\n  background-image: ${({ $liked: liked }) =>\n    liked\n      ? \"url('https://assets.triple.guide/images/btn-lounge-thanks-on@3x.png')\"\n      : \"url('https://assets.triple.guide/images/btn-lounge-thanks-off@3x.png')\"};\n`\n\nconst ReviewMetadataInfo = styled(FlexBox)`\n  margin-top: 16px;\n  margin-bottom: 16px;\n\n  & > * {\n    height: 16px;\n    display: inline-block;\n    line-height: 16px;\n  }\n\n  & > :not(:last-child)::after {\n    display: block;\n    float: right;\n    content: '';\n    background: url('https://assets.triple.guide/images/dot-gray.svg') 0 0\n      no-repeat;\n    background-position: center;\n    width: 3px;\n    height: 16px;\n    margin-left: 4px;\n    margin-right: 6px;\n  }\n`\n\nexport interface ReviewElementProps {\n  review: BaseReviewFragment\n  isFullList: boolean\n  isMyReview: boolean\n  reviewRateDescriptions: string[] | null | undefined\n  resourceId: string\n  regionId?: string\n  onMenuClick: (reviewId: string) => void\n}\n\nexport function ReviewElement({\n  review,\n  review: {\n    user,\n    blinded,\n    comment,\n    recentTrip,\n    reviewedAt,\n    rating,\n    media,\n    replyBoard,\n    resourceType,\n    visitDate: visitDateString,\n    liked,\n    likesCount,\n    purchaseInfo,\n  },\n  isFullList,\n  isMyReview,\n  reviewRateDescriptions,\n  resourceId,\n  regionId,\n  onMenuClick,\n}: ReviewElementProps) {\n  const t = useTranslation()\n\n  const visitDate = visitDateString ? new Date(visitDateString) : null\n\n  const [unfolded, setUnfolded] = useState(false)\n  const trackEvent = useTrackEvent()\n  const { addUriHash } = useHashRouter()\n  const { showToast } = useClientAppActions()\n  const { navigateReviewDetail, navigateUserDetail } = useClientActions()\n\n  const { mutate: likeReview, isPending: isLikeLoading } =\n    useLikeReviewMutation()\n  const { mutate: unlikeReview, isPending: isUnlikeLoading } =\n    useUnlikeReviewMutation()\n\n  const likeButtonAction = `리뷰_땡쓰${liked ? '취소' : ''}_선택`\n\n  const handleUserClick = useClientAppCallback(\n    useSessionCallback(\n      useCallback(() => {\n        if (!review.user) {\n          return\n        }\n\n        const { uid, mileage, unregister } = review.user\n\n        trackEvent({\n          ga: ['리뷰 프로필'],\n          fa: {\n            action: '리뷰_프로필',\n            item_id: resourceId,\n            user_id: uid,\n            review_id: review.id,\n            level: mileage?.level ?? 0,\n          },\n        })\n\n        if (unregister) {\n          showToast?.(t('탈퇴한 사용자입니다.'))\n        } else {\n          navigateUserDetail(uid)\n        }\n      }, [\n        review.user,\n        review.id,\n        trackEvent,\n        resourceId,\n        showToast,\n        t,\n        navigateUserDetail,\n      ]),\n      { triggeredEventAction: '리뷰_프로필_선택' },\n    ),\n    { triggeredEventAction: '리뷰_프로필' },\n  )\n\n  const handleMenuClick = useClientAppCallback(\n    useSessionCallback(\n      useCallback(() => {\n        if (isMyReview) {\n          addUriHash(HASH_MY_REVIEW_ACTION_SHEET)\n        } else {\n          onMenuClick?.(review.id)\n          addUriHash(HASH_REVIEW_ACTION_SHEET)\n        }\n      }, [isMyReview, onMenuClick, addUriHash, review.id]),\n      { triggeredEventAction: '리뷰_메뉴_선택' },\n    ),\n    { triggeredEventAction: '리뷰_메뉴_선택' },\n  )\n\n  const handleReviewClick = useClientAppCallback(\n    useCallback(() => {\n      trackEvent({\n        ga: ['리뷰_리뷰내용_선택', review.id],\n        fa: {\n          action: '리뷰_리뷰내용_선택',\n          item_id: review.id,\n          resource_id: resourceId,\n          ...(recentTrip && { recent_trip: '최근여행' }),\n        },\n      })\n\n      navigateReviewDetail({ reviewId: review.id, regionId, resourceId })\n\n      unfolded && setUnfolded(false)\n    }, [\n      unfolded,\n      trackEvent,\n      review.id,\n      resourceId,\n      recentTrip,\n      navigateReviewDetail,\n      regionId,\n    ]),\n    { triggeredEventAction: '리뷰_리뷰내용_선택' },\n    false,\n  )\n\n  const handleLikeButtonClick = useSessionCallback(\n    useCallback(() => {\n      trackEvent({\n        ga: [likeButtonAction, review.id],\n        fa: {\n          action: likeButtonAction,\n          item_id: review.id,\n          resource_id: resourceId,\n        },\n      })\n      liked\n        ? unlikeReview({ reviewId: review.id, resourceId })\n        : likeReview({ reviewId: review.id, resourceId })\n    }, [\n      likeButtonAction,\n      likeReview,\n      liked,\n      resourceId,\n      review.id,\n      trackEvent,\n      unlikeReview,\n    ]),\n    { triggeredEventAction: likeButtonAction },\n  )\n\n  const handleMessageCountClick = useClientAppCallback(\n    useSessionCallback(\n      useCallback(() => {\n        trackEvent({\n          ga: ['리뷰_댓글_선택', review.id],\n          fa: {\n            action: '리뷰_댓글_선택',\n            item_id: review.id,\n            resource_id: resourceId,\n            region_id: regionId,\n            content_type: resourceType,\n          },\n        })\n\n        navigateReviewDetail({\n          reviewId: review.id,\n          regionId,\n          resourceId,\n          anchor: 'reply',\n        })\n      }, [\n        navigateReviewDetail,\n        regionId,\n        resourceId,\n        resourceType,\n        review.id,\n        trackEvent,\n      ]),\n      { triggeredEventAction: '리뷰_댓글_선택' },\n    ),\n    { triggeredEventAction: '리뷰_댓글_선택' },\n  )\n\n  const reviewExposureAction = `${\n    isFullList ? '리뷰_전체보기_노출' : '리뷰_노출'\n  }`\n\n  return (\n    <IntersectionObserver\n      onChange={({ isIntersecting }) => {\n        if (isIntersecting) {\n          trackEvent({\n            ga: [reviewExposureAction, review.id],\n            fa: {\n              action: reviewExposureAction,\n              item_id: review.id,\n              poi_id: resourceId,\n              ...(review.recentTrip && { recent_trip: '최근여행' }),\n            },\n          })\n        }\n      }}\n    >\n      <List.Item style={{ paddingTop: 6 }}>\n        {user ? <User user={user} onClick={handleUserClick} /> : null}\n        {!blinded ? (\n          <ReviewMetadataInfo flex css={{ alignItems: 'center' }}>\n            {rating ? <Score score={rating} /> : null}\n            {visitDate ? <ReviewDayInfo visitDate={visitDate} /> : null}\n          </ReviewMetadataInfo>\n        ) : null}\n\n        {!blinded ? (\n          <ReviewBadges\n            recentTrip={!!visitDate && recentTrip}\n            verifiedPurchase={!!purchaseInfo}\n          />\n        ) : null}\n\n        {!blinded && purchaseInfo ? (\n          <PurchaseInfo\n            displayName={purchaseInfo.displayName}\n            purchaseCount={purchaseInfo.purchaseCount}\n          />\n        ) : null}\n\n        <Content onClick={handleReviewClick}>\n          {blinded ? (\n            t('신고가 접수되어 블라인드 처리되었습니다.')\n          ) : comment ? (\n            unfolded ? (\n              comment\n            ) : (\n              <FoldableComment\n                comment={comment}\n                hasImage={(media || []).length > 0}\n                onUnfoldButtonClick={(e) => {\n                  e.stopPropagation()\n                  trackEvent({\n                    ga: ['리뷰_리뷰글더보기_선택'],\n                    fa: {\n                      action: '리뷰_리뷰글더보기_선택',\n                      item_id: review.id,\n                      resource_id: resourceId,\n                    },\n                  })\n                  setUnfolded(true)\n                }}\n              />\n            )\n          ) : (\n            <RateDescription\n              rating={rating}\n              reviewRateDescriptions={reviewRateDescriptions}\n            />\n          )}\n        </Content>\n        {!blinded && media && media.length > 0 ? (\n          <Container\n            css={{\n              margin: '10px 0 0',\n            }}\n          >\n            <Media media={media} reviewId={review.id} />\n          </Container>\n        ) : null}\n        {replyBoard?.pinnedMessages[0] ? (\n          <PinnedMessage\n            pinnedMessage={replyBoard?.pinnedMessages[0]}\n            onPinnedMessageClick={handleReviewClick}\n          />\n        ) : null}\n        <Meta>\n          {!blinded ? (\n            <LikeButton\n              $liked={liked}\n              css={{\n                marginTop: 5,\n                padding: '2px 10px 2px 20px',\n                height: 18,\n              }}\n              onClick={handleLikeButtonClick}\n              disabled={isLikeLoading || isUnlikeLoading}\n            >\n              {likesCount}\n            </LikeButton>\n          ) : null}\n\n          <MessageCount\n            display=\"inline-block\"\n            position=\"relative\"\n            $isCommaVisible={!blinded}\n            css={{\n              height: 18,\n              marginTop: 5,\n              padding: '2px 0 2px 20px',\n            }}\n            onClick={handleMessageCountClick}\n          >\n            {replyBoard\n              ? replyBoard.rootMessagesCount +\n                replyBoard.childMessagesCount +\n                replyBoard.pinnedMessagesCount\n              : 0}\n          </MessageCount>\n\n          {!blinded || (blinded && isMyReview) ? (\n            <Container\n              floated=\"right\"\n              css={{\n                margin: '2px 0 0',\n              }}\n            >\n              <>{formatTimestamp(reviewedAt)}</>\n              <MoreIcon\n                src=\"https://assets.triple.guide/images/btn-review-more@4x.png\"\n                onClick={handleMenuClick}\n              />\n            </Container>\n          ) : null}\n        </Meta>\n      </List.Item>\n    </IntersectionObserver>\n  )\n}\n\nfunction Score({ score }: { score?: number }) {\n  return (\n    <Container css={{ height: '16px' }}>\n      <Rating score={score} verticalAlign=\"top\" />\n    </Container>\n  )\n}\n\nfunction Content({\n  onClick,\n  children,\n}: PropsWithChildren<{ onClick?: () => void }>) {\n  return (\n    <Container clearing>\n      {/* eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}\n      <a onClick={onClick}>\n        <Comment>{children}</Comment>\n      </a>\n    </Container>\n  )\n}\n\nfunction Meta({ children }: PropsWithChildren<unknown>) {\n  return (\n    <MetaContainer>\n      <Text size=\"mini\" color=\"gray\" alpha={0.4}>\n        {children}\n      </Text>\n    </MetaContainer>\n  )\n}\n\nfunction RateDescription({\n  rating,\n  reviewRateDescriptions,\n}: {\n  rating?: number | null | undefined\n  reviewRateDescriptions: string[] | null | undefined\n}) {\n  const comment =\n    rating && reviewRateDescriptions ? reviewRateDescriptions[rating] : ''\n  return <Comment>{comment}</Comment>\n}\n\nfunction ReviewDayInfo({ visitDate }: { visitDate: Date }) {\n  const t = useTranslation()\n\n  const visitYear = getYear(visitDate)\n  const visitMonth = getMonth(visitDate) + 1\n\n  return (\n    <Text size={13} color=\"gray700\" lineHeight=\"13px\">\n      {t('{{visitYear}}년 {{visitMonth}}월 여행', {\n        visitYear,\n        visitMonth,\n      })}\n    </Text>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/media/compare-media.ts",
    "content": "import { ImageMeta } from '@titicaca/type-definitions'\n\nexport function compareMedia(a: ImageMeta, b: ImageMeta) {\n  if (a.type === b.type) {\n    return 0\n  }\n\n  if (a.type === 'video') {\n    return -1\n  }\n\n  return 1\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/media/elements.ts",
    "content": "import { Container, FlexBox } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\n\nexport const GridWrapper = styled(Container)`\n  display: grid;\n  grid-gap: 5px;\n`\n\nexport const MediumWrapper = styled(Container)`\n  position: relative;\n  padding-top: 100%;\n  width: 100%;\n  height: 0;\n\n  & > * {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n  }\n\n  @media (min-width: 500px) {\n    padding-top: 0;\n    height: 100%;\n  }\n`\n\nexport const Dimmer = styled(FlexBox)`\n  position: absolute;\n  top: 0;\n  width: 100%;\n  height: 100%;\n  border-radius: 4px;\n  color: var(--color-white900);\n  font-weight: bold;\n`\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/media/image.tsx",
    "content": "import { Container } from '@titicaca/tds-ui'\nimport { ImageMeta } from '@titicaca/type-definitions'\nimport { styled } from 'styled-components'\n\ninterface Props {\n  medium: ImageMeta\n}\n\nconst Wrapper = styled(Container)`\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n`\n\nconst Img = styled.img`\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  width: 100%;\n  height: 100%;\n  border-radius: 4px;\n  object-fit: cover;\n`\n\nexport function Image({ medium }: Props) {\n  return (\n    <Wrapper>\n      <Img src={medium.sizes.large.url} />\n    </Wrapper>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/media/index.tsx",
    "content": "import { ImageMeta } from '@titicaca/type-definitions'\nimport { useMemo, useState } from 'react'\nimport {\n  useClientApp,\n  useHashRouter,\n  useLoginCtaModal,\n  useSessionAvailability,\n  useTrackEvent,\n} from '@titicaca/triple-web'\n\nimport { useClientActions } from '../../../services'\nimport { ImageViewerPopup } from '../../../../image-viewer'\n\nimport { compareMedia } from './compare-media'\nimport { Dimmer, MediumWrapper } from './elements'\nimport { MediaWrapper } from './media-wrapper'\nimport { Medium } from './medium'\n\ninterface Props {\n  media: ImageMeta[]\n  reviewId: string\n}\n\nconst HASH_IMAGE_VIEWER_POPUP = 'popup.review-image-viewer'\n\nexport function Media({ media, reviewId }: Props) {\n  const trackEvent = useTrackEvent()\n  const { navigateImages } = useClientActions()\n  const app = useClientApp()\n  const sessionAvailable = useSessionAvailability()\n  const { show: showLoginCtaModal } = useLoginCtaModal()\n  const { uriHash, addUriHash, removeUriHash } = useHashRouter()\n\n  const [imageIndex, setImageIndex] = useState<number | null>(null)\n\n  const hasVideo = media.some((medium) => medium.type === 'video')\n\n  const sortedMedia = useMemo(\n    () => (hasVideo ? [...media].sort(compareMedia) : media),\n    [media, hasVideo],\n  )\n\n  const limit = hasVideo ? 3 : 5\n  const length = Math.min(sortedMedia.length, limit)\n  const restLength = sortedMedia.length - length\n\n  const onMediumClick = (medium: ImageMeta) => {\n    const thumbnailType = medium.type === 'video' ? '비디오' : '사진'\n\n    trackEvent({\n      ga: ['리뷰_리뷰썸네일_클릭', thumbnailType],\n      fa: {\n        action: '리뷰_리뷰썸네일_클릭',\n        media_id: medium.id,\n        type: thumbnailType,\n        review_id: reviewId,\n      },\n    })\n\n    if (!app && !sessionAvailable) {\n      return showLoginCtaModal({ triggeredEventAction: '리뷰_리뷰썸네일_클릭' })\n    }\n\n    const originalIndex = sortedMedia.findIndex(\n      (originalMedium) => originalMedium.id === medium.id,\n    )\n\n    if (app) {\n      navigateImages(media, originalIndex)\n    } else {\n      setImageIndex(originalIndex)\n      addUriHash(HASH_IMAGE_VIEWER_POPUP)\n    }\n  }\n\n  const handleImageViewerPopupClose = () => {\n    trackEvent({ fa: { action: '이미지팝업_닫기_선택' } })\n    setImageIndex(null)\n    removeUriHash()\n  }\n\n  const onImageMetadataIntersecting = (medium: ImageMeta, index?: number) => {\n    trackEvent({\n      fa: {\n        action: '이미지팝업_미디어_노출',\n        media_id: medium.id,\n        type: medium.type === 'image' ? '사진' : '비디오',\n        position: ((index || 0) + 1).toString(),\n      },\n    })\n  }\n\n  if (sortedMedia.length === 0) {\n    return null\n  }\n\n  return (\n    <>\n      <MediaWrapper length={length}>\n        {sortedMedia.slice(0, limit).map((medium, index) => (\n          <MediumWrapper\n            key={medium.id}\n            onClick={() => {\n              onMediumClick(medium)\n            }}\n          >\n            <Medium medium={medium} />\n            {restLength > 0 && index === limit - 1 ? (\n              <Dimmer\n                flex\n                alignItems=\"center\"\n                justifyContent=\"center\"\n                backgroundColor=\"gray500\"\n              >\n                +{restLength}\n              </Dimmer>\n            ) : null}\n          </MediumWrapper>\n        ))}\n      </MediaWrapper>\n\n      {imageIndex != null && uriHash === HASH_IMAGE_VIEWER_POPUP ? (\n        <ImageViewerPopup\n          open={uriHash === HASH_IMAGE_VIEWER_POPUP}\n          images={sortedMedia}\n          totalCount={sortedMedia.length}\n          defaultImageIndex={imageIndex}\n          onClose={handleImageViewerPopupClose}\n          onImageMetadataIntersecting={onImageMetadataIntersecting}\n        />\n      ) : null}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/media/media-wrapper.tsx",
    "content": "import { ReactNode } from 'react'\nimport { styled } from 'styled-components'\n\nimport { GridWrapper } from './elements'\n\ninterface Props {\n  length: number\n  children?: ReactNode\n}\n\nconst MonoMediaWrapper = styled(GridWrapper)`\n  @media (min-width: 500px) {\n    grid-template-rows: 293px;\n  }\n`\n\nconst DuoMediaWrapper = styled(GridWrapper)`\n  grid-template-columns: repeat(2, 1fr);\n  grid-template-rows: 2fr;\n\n  & > :nth-child(1) {\n    grid-column: 1;\n  }\n\n  & > :nth-child(2) {\n    grid-column: 2;\n  }\n\n  @media (min-width: 500px) {\n    grid-template-rows: 217px;\n  }\n`\n\nconst TriMediaWrapper = styled(GridWrapper)`\n  grid-template-columns: repeat(3, 1fr);\n  grid-template-rows: repeat(2, 1fr);\n\n  & > :nth-child(1) {\n    grid-column: span 2;\n    grid-row: span 2;\n  }\n\n  & > :nth-child(2) {\n    grid-column: 3;\n    grid-row: 1;\n  }\n\n  & > :nth-child(3) {\n    grid-column: 3;\n    grid-row: 2;\n  }\n\n  @media (min-width: 500px) {\n    grid-template-rows: repeat(2, 143px);\n  }\n`\n\nconst QuadMediaWrapper = styled(GridWrapper)`\n  grid-template-columns: repeat(4, 1fr);\n  grid-template-rows: repeat(3, 1fr);\n\n  & > :nth-child(1) {\n    grid-column: span 3;\n    grid-row: span 3;\n  }\n\n  & > :nth-child(2) {\n    grid-column: 4;\n    grid-row: 1;\n  }\n\n  & > :nth-child(3) {\n    grid-column: 4;\n    grid-row: 2;\n  }\n\n  & > :nth-child(4) {\n    grid-column: 4;\n    grid-row: 3;\n  }\n\n  @media (min-width: 500px) {\n    grid-template-rows: repeat(3, 105px);\n  }\n`\n\nconst PentaMediaWrapper = styled(GridWrapper)`\n  grid-template-columns: repeat(6, 1fr);\n  grid-template-rows: repeat(5, 1fr);\n\n  & > :nth-child(1) {\n    grid-column: span 3;\n    grid-row: span 3;\n  }\n\n  & > :nth-child(2) {\n    grid-column: 4 / span 3;\n    grid-row: span 3;\n  }\n\n  & > :nth-child(3) {\n    grid-column: span 2;\n    grid-row: 4 / span 2;\n  }\n\n  & > :nth-child(4) {\n    grid-column: 3 / span 2;\n    grid-row: 4 / span 2;\n  }\n\n  & > :nth-child(5) {\n    grid-column: 5 / span 2;\n    grid-row: 4 / span 2;\n  }\n\n  @media (min-width: 500px) {\n    grid-template-rows: none;\n    grid-auto-rows: 217px 143px;\n\n    & > :nth-child(1) {\n      grid-row: 1;\n    }\n\n    & > :nth-child(2) {\n      grid-row: 1;\n    }\n\n    & > :nth-child(3) {\n      grid-row: 2;\n    }\n\n    & > :nth-child(4) {\n      grid-row: 2;\n    }\n\n    & > :nth-child(5) {\n      grid-row: 2;\n    }\n  }\n`\n\nexport function MediaWrapper({ length, children }: Props) {\n  switch (length) {\n    case 0:\n      return null\n    case 1:\n      return <MonoMediaWrapper>{children}</MonoMediaWrapper>\n    case 2:\n      return <DuoMediaWrapper>{children}</DuoMediaWrapper>\n    case 3:\n      return <TriMediaWrapper>{children}</TriMediaWrapper>\n    case 4:\n      return <QuadMediaWrapper>{children}</QuadMediaWrapper>\n    default:\n      return <PentaMediaWrapper>{children}</PentaMediaWrapper>\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/media/medium.tsx",
    "content": "import { ImageMeta } from '@titicaca/type-definitions'\n\nimport { Image } from './image'\nimport { Video } from './video'\n\ninterface Props {\n  medium: ImageMeta\n}\n\nexport function Medium({ medium }: Props) {\n  const isVideo = medium.type === 'video'\n\n  if (isVideo) {\n    return <Video medium={medium} />\n  }\n\n  return <Image medium={medium} />\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/media/video.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { ImageMeta } from '@titicaca/type-definitions'\nimport { Container } from '@titicaca/tds-ui'\nimport { useIntersection } from '@titicaca/intersection-observer'\nimport { useClientApp } from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\n\ninterface Props {\n  medium: ImageMeta\n}\n\nconst StyledPoster = styled.div`\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  width: 100%;\n  height: 100%;\n  background-size: cover;\n  background-position: center;\n`\n\nconst StyledVideo = styled.video<{ $isOncePlayed: boolean }>`\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  transition: opacity 0.3s;\n  opacity: ${({ $isOncePlayed }) => ($isOncePlayed ? 1 : 0)};\n`\n\nconst PLAY_BUTTON_IMAGE_URL =\n  'https://assets.triple.guide/images/btn-video-play@3x.png'\n\nconst PlayPauseButtonBase = styled.span`\n  position: absolute;\n  border: none;\n  background: none;\n  width: 60px;\n  height: 60px;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  background-image: url(${PLAY_BUTTON_IMAGE_URL});\n  background-size: cover;\n\n  &:focus {\n    outline: none;\n  }\n\n  transition: opacity 0.3s;\n`\n\nexport function Video({ medium }: Props) {\n  const [isOncePlayed, setIsOncePlayed] = useState(false)\n  const { ref, isIntersecting } = useIntersection<HTMLVideoElement>({\n    threshold: 0.5,\n  })\n\n  const clientApp = useClientApp()\n\n  const [videoAutoplay, setVideoAutoPlay] = useState(\n    !clientApp ||\n      clientApp?.device.autoplay === 'always' ||\n      (clientApp?.device.autoplay === 'wifi_only' &&\n        clientApp?.device.networkType === 'wifi'),\n  )\n\n  useEffect(() => {\n    async function togglePlay() {\n      if (!videoAutoplay || !ref.current) {\n        return\n      }\n\n      ref.current.playsInline = true\n      ref.current.muted = true\n\n      try {\n        if (isIntersecting) {\n          ref.current.play()\n        } else {\n          ref.current.pause()\n        }\n      } catch (error) {\n        if (error instanceof DOMException && error.name === 'NotAllowedError') {\n          setVideoAutoPlay(false)\n        }\n      }\n    }\n\n    togglePlay()\n  }, [isIntersecting, ref, videoAutoplay])\n\n  return (\n    <Container borderRadius={6}>\n      <StyledPoster\n        style={{ backgroundImage: `url(\"${medium.sizes.large.url}\")` }}\n      />\n      <StyledVideo\n        ref={ref}\n        src={medium.video?.large.url}\n        controls={false}\n        loop\n        muted\n        playsInline\n        $isOncePlayed={isOncePlayed}\n        onTimeUpdate={isOncePlayed ? undefined : () => setIsOncePlayed(true)}\n      />\n      {!videoAutoplay && <PlayPauseButtonBase />}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/pinned-message.tsx",
    "content": "import { MouseEventHandler, useEffect, useRef, useState } from 'react'\nimport { Button, FlexBox, Text } from '@titicaca/tds-ui'\nimport { useTranslation } from '@titicaca/triple-web'\nimport { format } from 'date-fns'\n\nimport { BasePinnedMessageFragment } from '../../data/graphql'\n\ninterface PinnedMessageProps {\n  pinnedMessage: BasePinnedMessageFragment\n  onPinnedMessageClick: MouseEventHandler<HTMLButtonElement>\n}\n\nexport function PinnedMessage({\n  pinnedMessage,\n  onPinnedMessageClick,\n}: PinnedMessageProps) {\n  const t = useTranslation()\n  const textRef = useRef<HTMLDivElement>(null)\n  const [isTextClamped, setIsTextClamped] = useState(false)\n\n  const {\n    content: { text, markdownText },\n    writer,\n    updatedAt,\n  } = pinnedMessage\n\n  useEffect(() => {\n    if (!textRef.current) {\n      return\n    }\n\n    const textElement = textRef.current\n    setIsTextClamped(textElement.scrollHeight > textElement.clientHeight)\n  }, [])\n\n  if (!text && !markdownText) {\n    return null\n  }\n\n  return (\n    <Button\n      css={{\n        display: 'block',\n        width: '100%',\n        background: 'rgba(58, 58, 58, 0.03)',\n        textAlign: 'left',\n        margin: '10px 0 20px',\n        padding: 18,\n        borderRadius: 10,\n      }}\n      onClick={onPinnedMessageClick}\n    >\n      <FlexBox\n        flex\n        css={{\n          alignItems: 'center',\n          justifyContent: 'space-between',\n          marginBottom: 2,\n        }}\n      >\n        <FlexBox flex css={{ alignItems: 'center' }}>\n          <Text css={{ fontSize: 15, fontWeight: 700 }}>{writer?.name}</Text>\n          <img\n            src=\"https://assets.triple.guide/images/img-badge-verified@4x.png\"\n            alt=\"verified badge\"\n            width={18}\n            height={18}\n          />\n        </FlexBox>\n        <Text\n          css={{\n            color: 'var(--color-gray300)',\n            fontSize: 12,\n            fontWeight: 700,\n          }}\n        >\n          {format(new Date(updatedAt), 'M.d')}\n        </Text>\n      </FlexBox>\n      <Text\n        ref={textRef}\n        maxLines={2}\n        css={{ fontSize: 15, lineHeight: '20px' }}\n      >\n        {text ?? markdownText}\n      </Text>\n      {isTextClamped ? (\n        <Text css={{ color: 'var(--color-blue)' }}>{t('더보기')}</Text>\n      ) : null}\n    </Button>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/purchaseInfo.tsx",
    "content": "import { FlexBox } from '@titicaca/tds-ui'\nimport { useCallback, useState } from 'react'\nimport { styled } from 'styled-components'\n\nconst RepresentativePurchaseName = styled.span`\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  word-break: break-all;\n`\n\nconst ExtraInfo = styled.span`\n  white-space: nowrap;\n  flex-shrink: 0;\n`\n\nconst ShowMoreButton = styled.button`\n  margin-left: 5px;\n  background: url('https://assets.triple.guide/images/ico_arrow_down_gray30.svg')\n    center center no-repeat;\n  width: 18px;\n  height: 20px;\n  flex-shrink: 0;\n`\n\nexport default function PurchaseInfo({\n  displayName,\n  purchaseCount,\n}: {\n  displayName: string\n  purchaseCount: number\n}) {\n  const [isEllipsis, setIsEllipsis] = useState(false)\n  const [showFullName, setShowFullName] = useState(false)\n\n  const ref = useCallback((node: HTMLDivElement) => {\n    if (node !== null) {\n      setIsEllipsis(node.scrollWidth > node.clientWidth)\n    }\n  }, [])\n\n  const displayPurchaseCount = purchaseCount > 1 ? purchaseCount - 1 : null\n\n  return (\n    <FlexBox\n      flex\n      css={{\n        width: '100%',\n        marginBottom: 6,\n        color: 'var(--color-gray500)',\n        fontSize: 14,\n        lineHeight: '20px',\n      }}\n    >\n      {!showFullName ? (\n        <>\n          <RepresentativePurchaseName ref={ref}>\n            {displayName}\n          </RepresentativePurchaseName>\n          {displayPurchaseCount ? (\n            <ExtraInfo>&nbsp;{`외 ${displayPurchaseCount}건`}</ExtraInfo>\n          ) : null}\n          {isEllipsis ? (\n            <ShowMoreButton onClick={() => setShowFullName(true)} />\n          ) : null}\n        </>\n      ) : (\n        <>\n          {displayName}\n          {displayPurchaseCount ? ` 외 ${displayPurchaseCount}건` : null}\n        </>\n      )}\n    </FlexBox>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-element/user.tsx",
    "content": "import { PropsWithChildren, MouseEventHandler } from 'react'\nimport { useTranslation } from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport { Container, Text } from '@titicaca/tds-ui'\n\nimport { BaseUserFragment } from '../../data/graphql'\n\nconst UserPhoto = styled.img`\n  margin-right: 9px;\n  width: 36px;\n  height: 36px;\n  float: left;\n  background-color: #efefef;\n  border-radius: 19px;\n  object-fit: cover;\n`\n\nconst Badge = styled.img`\n  position: absolute;\n  top: 22px;\n  left: 25px;\n  width: 18px;\n  height: 18px;\n`\n\nconst DEFAULT_USER_PROFILE_IMAGE =\n  'https://assets.triple.guide/images/ico-default-profile.svg'\n\nexport function User({\n  user: { photo, name, userBoard, mileage, unregister },\n  onClick,\n}: {\n  onClick?: MouseEventHandler\n  user: BaseUserFragment\n}) {\n  const t = useTranslation()\n\n  const { reviewsV2: reviewsCount } = userBoard || {}\n  const { badges = [], level } = mileage || {}\n\n  return (\n    <Container display=\"flex\" css={{ marginBottom: 16 }}>\n      <UserPhoto src={photo || DEFAULT_USER_PROFILE_IMAGE} onClick={onClick} />\n      {badges?.[0]?.icon?.image_url ? (\n        <Badge src={badges[0]?.icon.image_url} />\n      ) : null}\n      <div>\n        <Name onClick={onClick}>{name}</Name>\n        {!unregister ? (\n          <Text\n            margin={{ top: 3 }}\n            size=\"mini\"\n            color=\"gray\"\n            alpha={0.4}\n            onClick={onClick}\n          >\n            {level && level > 0 ? `LEVEL${level} / ` : null}\n            {reviewsCount\n              ? t('{{reviewsCount}}개의 리뷰', {\n                  reviewsCount,\n                })\n              : null}\n          </Text>\n        ) : null}\n      </div>\n    </Container>\n  )\n}\n\nfunction Name({\n  onClick,\n  children,\n}: PropsWithChildren<{ onClick?: MouseEventHandler<HTMLDivElement> }>) {\n  return (\n    <Text bold size=\"medium\" onClick={onClick} wordBreak=\"break-word\">\n      {children}\n    </Text>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/review-placeholder-with-rating.tsx",
    "content": "import { SyntheticEvent, useCallback } from 'react'\nimport {\n  useTranslation,\n  useTrackEvent,\n  useClientAppCallback,\n  useSessionCallback,\n} from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport { Button, Container, Rating, Text } from '@titicaca/tds-ui'\n\nimport { useClientActions } from '../services'\n\nimport type { SortingOption, SortingType } from './sorting-context'\n\nconst PlaceholderContainer = styled(Container)`\n  width: 100%;\n  text-align: center;\n`\n\nconst GuideImage = styled.img`\n  content: url('https://assets.triple.guide/images/img-card-guide-review@3x.png');\n  display: block;\n  width: 50px;\n  height: 50px;\n  margin: auto;\n`\n\nconst NavigateToReviewsListButton = styled(Button)`\n  padding: 10px 20px;\n`\n\nconst RecentTripContainer = styled(Container)`\n  text-align: center;\n  padding-top: 180px;\n  padding-bottom: 60px;\n\n  @media only screen and (max-width: 667px) {\n    padding-top: 120px;\n  }\n`\n\nexport interface ReviewsPlaceholderProps {\n  isMorePage: boolean\n  hasReviews: boolean\n  resourceId: string\n  resourceType: string\n  regionId: string | undefined\n  hasMedia: boolean\n  recentTrip: boolean\n  placeholderText?: string\n  sortingType?: SortingType\n  sortingOption: SortingOption\n}\n\nconst OPTION_LABELS = {\n  recommendation: '추천순',\n  latest: '최신순',\n  'star-rating-desc': '별점 높은순',\n  'star-rating-asc': '별점 낮은순',\n}\n\nexport function ReviewsPlaceholder({\n  isMorePage,\n  hasReviews,\n  resourceId,\n  resourceType,\n  regionId,\n  hasMedia,\n  recentTrip,\n  placeholderText,\n  sortingType,\n  sortingOption,\n}: ReviewsPlaceholderProps) {\n  const trackEvent = useTrackEvent()\n  const { writeReview, navigateReviewList } = useClientActions()\n\n  const handleFullClick = useClientAppCallback(\n    useSessionCallback(\n      useCallback(() => {\n        navigateReviewList({\n          resourceId,\n          resourceType,\n          hasMedia: false,\n          recentTrip: false,\n          sortingType,\n          sortingOption,\n        })\n      }, [\n        navigateReviewList,\n        resourceId,\n        resourceType,\n        sortingType,\n        sortingOption,\n      ]),\n      { triggeredEventAction: '리뷰_리스트더보기_선택' },\n    ),\n    { triggeredEventAction: '리뷰_리스트더보기_선택' },\n  )\n\n  const handleWriteClick = useClientAppCallback(\n    useSessionCallback(\n      useCallback(\n        (rating = 0) => {\n          trackEvent({\n            ga: ['리뷰_리뷰쓰기'],\n            fa: {\n              action: '리뷰_리뷰쓰기',\n              item_id: resourceId,\n            },\n          })\n\n          writeReview({\n            resourceType,\n            resourceId,\n            regionId,\n            rating,\n          })\n        },\n        [regionId, resourceId, resourceType, trackEvent, writeReview],\n      ),\n      { triggeredEventAction: '리뷰_리뷰쓰기' },\n    ),\n    { triggeredEventAction: '리뷰_리뷰쓰기' },\n  )\n\n  const handleClick = (rating?: number) => {\n    trackEvent({\n      ga: ['리뷰_리스트더보기_선택'],\n      fa: {\n        action: '리뷰_리스트더보기_선택',\n        item_id: resourceId,\n        tab_name: OPTION_LABELS[sortingOption],\n      },\n    })\n    if (recentTrip || hasMedia) {\n      handleFullClick()\n    } else {\n      handleWriteClick(rating)\n    }\n  }\n\n  return (\n    <PlaceholderContainer\n      css={{\n        margin: '40px 0 0',\n      }}\n      onClick={!isMorePage ? () => handleClick() : undefined}\n    >\n      {!recentTrip && !hasMedia ? (\n        resourceType === 'article' ? (\n          <GuideImage />\n        ) : (\n          <Rating size=\"medium\" onClick={(_, rating) => handleClick(rating)} />\n        )\n      ) : null}\n\n      {recentTrip || hasMedia ? (\n        <FilterPlaceholder\n          isMorePage={isMorePage}\n          hasReviews={hasReviews}\n          onClick={(e) => {\n            e.stopPropagation()\n            handleClick()\n          }}\n        />\n      ) : (\n        <DefaultPlaceholder placeholderText={placeholderText} />\n      )}\n    </PlaceholderContainer>\n  )\n}\n\nfunction DefaultPlaceholder({\n  placeholderText,\n}: {\n  placeholderText: string | undefined\n}) {\n  const t = useTranslation()\n\n  return (\n    <Text\n      margin={{ top: 8 }}\n      size=\"large\"\n      color=\"gray\"\n      alpha={1}\n      lineHeight={1.5}\n    >\n      {placeholderText ?? t('이곳의 첫 번째 리뷰를 올려주세요.')}\n    </Text>\n  )\n}\n\nfunction FilterPlaceholder({\n  isMorePage,\n  hasReviews,\n  onClick,\n}: {\n  isMorePage: boolean\n  hasReviews: boolean\n  onClick?: (e: SyntheticEvent, rating?: number) => void\n}) {\n  const t = useTranslation()\n\n  return isMorePage ? (\n    <RecentTripContainer>\n      <img\n        width={44}\n        height={44}\n        src=\"https://assets.triple.guide/images/ico_empty_review@4x.png\"\n        alt=\"write-review-icon\"\n      />\n      <Text\n        size={18}\n        padding={{ top: 20, bottom: 8 }}\n        bold\n        lineHeight=\"21px\"\n        textAlign=\"center\"\n      >\n        {t('선택한 조건의 리뷰가 없습니다.')}\n      </Text>\n      <Text size={14} lineHeight=\"19px\" textAlign=\"center\" color=\"gray500\">\n        {t('다녀온 여행지의 리뷰를 남겨보세요.')}\n      </Text>\n    </RecentTripContainer>\n  ) : (\n    <Container\n      css={{\n        padding: '60px 0',\n      }}\n    >\n      <Text size={14} color=\"gray500\">\n        {t('선택한 조건의 리뷰가 없습니다.')}\n      </Text>\n      {hasReviews ? (\n        <NavigateToReviewsListButton\n          inverted\n          margin={{ top: 10 }}\n          onClick={onClick}\n        >\n          <Text size={13} color=\"white\" bold>\n            {t('전체 리뷰 보기')}\n          </Text>\n        </NavigateToReviewsListButton>\n      ) : null}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/reviews-shorten.tsx",
    "content": "import { ComponentType, ReactNode, useEffect } from 'react'\nimport { styled } from 'styled-components'\nimport { FlexBox, Section, Text } from '@titicaca/tds-ui'\nimport { useTranslation, useClientAppActions } from '@titicaca/triple-web'\nimport { formatNumber } from '@titicaca/view-utilities'\n\nimport { useReviewCount } from '../services'\n\nimport { PopularReviews, LatestReviews, RatingReviews } from './shorten-list'\nimport { WriteButton } from './write-button'\nimport { FilterProvider, useReviewFilters } from './filter-context'\nimport {\n  SortingOptionsProvider,\n  useReviewSortingOptions,\n} from './sorting-context'\nimport type { SortingOption, SortingType } from './sorting-context'\nimport { Filters } from './filter'\nimport { SortingOptions } from './sorting-options'\nimport type { ShortenReviewValue } from './shorten-list'\n\nconst REVIEWS_SECTION_ID = 'reviews'\n\ntype ResourceType = 'article' | 'attraction' | 'restaurant' | 'hotel' | 'tna'\ninterface ReviewsShortenProps {\n  resourceId: string\n  resourceType: ResourceType\n  regionId?: string\n  initialReviewsCount: number\n  initialMediaFilter?: boolean\n  initialRecentTrip?: boolean\n  initialSortingOption?: SortingOption\n  sortingType?: SortingType\n  placeholderText?: string\n  receiverId?: string\n  banner?: ReactNode\n}\n\nconst OptionContainer = styled(FlexBox)`\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin: 23px 0 0;\n\n  @media (max-width: 359px) {\n    flex-direction: column;\n    align-items: flex-start;\n    gap: 20px;\n  }\n`\n\nexport function ReviewsShorten({\n  resourceId,\n  resourceType,\n  regionId,\n  initialReviewsCount,\n  initialRecentTrip,\n  initialMediaFilter,\n  initialSortingOption = 'recommendation',\n  sortingType = 'default',\n  placeholderText,\n  receiverId,\n  banner,\n}: ReviewsShortenProps) {\n  return (\n    <FilterProvider\n      receiverId={receiverId}\n      initialRecentTrip={initialRecentTrip}\n      initialMediaFilter={initialMediaFilter}\n    >\n      <SortingOptionsProvider\n        type={sortingType}\n        resourceId={resourceId}\n        initialSortingOption={initialSortingOption}\n      >\n        <ReviewsShortenComponent\n          resourceId={resourceId}\n          resourceType={resourceType}\n          regionId={regionId}\n          initialReviewsCount={initialReviewsCount}\n          placeholderText={placeholderText}\n          sortingType={sortingType}\n          banner={banner}\n        />\n      </SortingOptionsProvider>\n    </FilterProvider>\n  )\n}\n\nconst REVIEW_SHORTEN_LIST_TYPES = {\n  recommendation: PopularReviews,\n  latest: LatestReviews,\n  'star-rating-desc': RatingReviews,\n  'star-rating-asc': RatingReviews,\n}\n\nfunction ReviewsShortenComponent({\n  resourceId,\n  resourceType,\n  regionId,\n  initialReviewsCount,\n  placeholderText,\n  sortingType,\n  banner,\n}: Omit<ReviewsShortenProps, 'initialRecentTrip' | 'initialSortingOption'>) {\n  const { isRecentTrip, isMediaCollection } = useReviewFilters()\n  const { selectedOption } = useReviewSortingOptions()\n  const t = useTranslation()\n\n  const { subscribeReviewUpdateEvent, unsubscribeReviewUpdateEvent } =\n    useClientAppActions()\n\n  const { data: reviewsCountData, refetch: refetchReviewsCount } =\n    useReviewCount(\n      {\n        resourceId,\n        resourceType,\n        recentTrip: isRecentTrip,\n        hasMedia: isMediaCollection,\n      },\n      initialReviewsCount,\n    )\n\n  useEffect(() => {\n    subscribeReviewUpdateEvent?.(refetchReviewsCount)\n\n    return () => unsubscribeReviewUpdateEvent?.(refetchReviewsCount)\n  }, [\n    refetchReviewsCount,\n    subscribeReviewUpdateEvent,\n    unsubscribeReviewUpdateEvent,\n  ])\n\n  const ListElement = REVIEW_SHORTEN_LIST_TYPES[\n    selectedOption\n  ] as ComponentType<{ value: ShortenReviewValue }>\n  const isRatingOption = selectedOption.startsWith('star-rating')\n\n  const value = {\n    resourceId,\n    resourceType,\n    regionId,\n    recentTrip: isRecentTrip,\n    hasMedia: isMediaCollection,\n    placeholderText,\n    reviewsCount: reviewsCountData?.reviewsCount,\n    sortingType,\n    ...(isRatingOption && { sortingLabel: selectedOption }),\n  }\n\n  return (\n    <Section anchor={REVIEWS_SECTION_ID}>\n      <FlexBox flex alignItems=\"center\">\n        <div>\n          <Text bold size=\"huge\" color=\"gray\" alpha={1} inline>\n            {t('리뷰')}\n          </Text>\n          {(reviewsCountData?.reviewsCount ?? 0) > 0 ? (\n            <Text bold size=\"huge\" color=\"blue\" alpha={1} inline>\n              {` ${formatNumber(reviewsCountData?.reviewsCount)}`}\n            </Text>\n          ) : null}\n        </div>\n        <WriteButton\n          resourceId={resourceId}\n          resourceType={resourceType}\n          regionId={regionId}\n        />\n      </FlexBox>\n      {banner}\n      <OptionContainer>\n        <SortingOptions />\n\n        <Filters />\n      </OptionContainer>\n\n      <ListElement value={value} />\n    </Section>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/reviews.tsx",
    "content": "import { ComponentType, useEffect } from 'react'\nimport { styled } from 'styled-components'\nimport { FlexBox, Section, Container, Text } from '@titicaca/tds-ui'\nimport { formatNumber } from '@titicaca/view-utilities'\nimport { useClientAppActions, useTranslation } from '@titicaca/triple-web'\n\nimport { useReviewCount } from '../services'\n\nimport {\n  PopularReviewsInfinite,\n  LatestReviewsInfinite,\n  RatingReviewsInfinite,\n} from './infinite-list'\nimport {\n  SortingOptionsProvider,\n  useReviewSortingOptions,\n} from './sorting-context'\nimport type { SortingOption, SortingType } from './sorting-context'\nimport { FilterProvider, useReviewFilters } from './filter-context'\nimport { SortingOptions } from './sorting-options'\nimport { Filters } from './filter'\nimport type { InfinityReviewValue } from './infinite-list'\n\nconst REVIEWS_SECTION_ID = 'reviews'\n\nconst OptionContainer = styled(FlexBox)`\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin: 23px 0 0;\n\n  @media (max-width: 359px) {\n    flex-direction: column;\n    align-items: flex-start;\n    gap: 20px;\n  }\n`\n\ninterface ReviewsProps {\n  resourceId: string\n  resourceType: string\n  regionId?: string\n  initialReviewsCount?: number\n  initialMediaFilter?: boolean\n  initialRecentTrip?: boolean\n  initialSortingOption?: SortingOption\n  sortingType?: SortingType\n  placeholderText?: string\n  receiverId?: string\n}\n\nexport function Reviews({\n  resourceId,\n  resourceType,\n  regionId,\n  initialReviewsCount,\n  initialMediaFilter,\n  initialRecentTrip,\n  initialSortingOption = 'recommendation',\n  sortingType = 'poi',\n  placeholderText,\n  receiverId,\n}: ReviewsProps) {\n  return (\n    <FilterProvider\n      initialRecentTrip={initialRecentTrip}\n      initialMediaFilter={initialMediaFilter}\n      receiverId={receiverId}\n    >\n      <SortingOptionsProvider\n        type={sortingType}\n        receiverId={receiverId}\n        resourceId={resourceId}\n        initialSortingOption={initialSortingOption}\n      >\n        <ReviewsComponent\n          resourceId={resourceId}\n          resourceType={resourceType}\n          regionId={regionId}\n          initialReviewsCount={initialReviewsCount}\n          placeholderText={placeholderText}\n          sortingType={sortingType}\n        />\n      </SortingOptionsProvider>\n    </FilterProvider>\n  )\n}\n\nconst REVIEW_INFINITY_LIST_TYPES = {\n  recommendation: PopularReviewsInfinite,\n  latest: LatestReviewsInfinite,\n  'star-rating-desc': RatingReviewsInfinite,\n  'star-rating-asc': RatingReviewsInfinite,\n}\n\nfunction ReviewsComponent({\n  resourceId,\n  resourceType,\n  regionId,\n  initialReviewsCount,\n  placeholderText,\n  sortingType,\n}: Omit<ReviewsProps, 'initialRecentTrip' | 'initialSortingOption'>) {\n  const { isRecentTrip, isMediaCollection } = useReviewFilters()\n  const { selectedOption } = useReviewSortingOptions()\n  const t = useTranslation()\n\n  const { subscribeReviewUpdateEvent, unsubscribeReviewUpdateEvent } =\n    useClientAppActions()\n\n  const { data: reviewsCountData, refetch: refetchReviewsCount } =\n    useReviewCount(\n      {\n        resourceId,\n        resourceType,\n        recentTrip: isRecentTrip,\n        hasMedia: isMediaCollection,\n      },\n      initialReviewsCount,\n    )\n\n  useEffect(() => {\n    subscribeReviewUpdateEvent?.(refetchReviewsCount)\n\n    return () => unsubscribeReviewUpdateEvent?.(refetchReviewsCount)\n  }, [\n    refetchReviewsCount,\n    subscribeReviewUpdateEvent,\n    unsubscribeReviewUpdateEvent,\n  ])\n\n  const ListElement = REVIEW_INFINITY_LIST_TYPES[\n    selectedOption\n  ] as ComponentType<{ value: InfinityReviewValue }>\n\n  const isRatingOption = selectedOption.startsWith('star-rating')\n  const value = {\n    resourceId,\n    resourceType,\n    regionId,\n    recentTrip: isRecentTrip,\n    hasMedia: isMediaCollection,\n    placeholderText,\n    reviewsCount: reviewsCountData?.reviewsCount,\n    sortingType,\n    ...(isRatingOption && { sortingLabel: selectedOption }),\n  }\n\n  return (\n    <Section anchor={REVIEWS_SECTION_ID}>\n      <Container>\n        <Text bold size=\"huge\" color=\"blue\" alpha={1} inline>\n          {formatNumber(reviewsCountData?.reviewsCount)}\n        </Text>\n        <Text bold size=\"huge\" color=\"gray\" alpha={1} inline>\n          {t('개의 리뷰')}\n        </Text>\n      </Container>\n\n      <OptionContainer>\n        <SortingOptions />\n\n        <Filters />\n      </OptionContainer>\n\n      <ListElement value={value} />\n    </Section>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/shorten-list/index.ts",
    "content": "export * from './popular-reviews'\nexport * from './latest-reviews'\nexport * from './rating-reviews'\nexport * from './types'\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/shorten-list/latest-reviews.tsx",
    "content": "import type { SortingType } from '../sorting-context'\n\nimport { ReviewsList } from './reviews-list'\nimport { useLatestReviews } from './services'\nimport type { ShortenReview } from './types'\n\nexport function LatestReviews({\n  value: {\n    resourceId,\n    resourceType,\n    regionId,\n    recentTrip,\n    hasMedia,\n    placeholderText,\n    reviewsCount,\n    sortingType,\n  },\n}: {\n  value: ShortenReview & { sortingType?: SortingType }\n}) {\n  const { data, refetch } = useLatestReviews({\n    resourceId,\n    resourceType,\n    recentTrip,\n    hasMedia,\n  })\n\n  return (\n    <ReviewsList\n      resourceId={resourceId}\n      resourceType={resourceType}\n      regionId={regionId}\n      hasMedia={hasMedia}\n      recentTrip={recentTrip}\n      placeholderText={placeholderText}\n      sortingType={sortingType}\n      sortingOption=\"latest\"\n      reviewsCount={reviewsCount}\n      reviews={data?.latestReviews}\n      refetch={refetch}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/shorten-list/popular-reviews.tsx",
    "content": "import type { SortingType } from '../sorting-context'\n\nimport { ReviewsList } from './reviews-list'\nimport { usePopularReviews } from './services'\nimport type { ShortenReview } from './types'\n\nexport function PopularReviews({\n  value: {\n    resourceId,\n    resourceType,\n    regionId,\n    recentTrip,\n    hasMedia,\n    placeholderText,\n    reviewsCount,\n    sortingType,\n  },\n}: {\n  value: ShortenReview & { sortingType?: SortingType }\n}) {\n  const { data, refetch } = usePopularReviews({\n    resourceId,\n    resourceType,\n    recentTrip,\n    hasMedia,\n  })\n\n  return (\n    <ReviewsList\n      resourceId={resourceId}\n      resourceType={resourceType}\n      regionId={regionId}\n      hasMedia={hasMedia}\n      recentTrip={recentTrip}\n      placeholderText={placeholderText}\n      sortingType={sortingType}\n      sortingOption=\"recommendation\"\n      reviewsCount={reviewsCount}\n      reviews={data?.popularReviews}\n      refetch={refetch}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/shorten-list/rating-reviews.tsx",
    "content": "import type { SortingType } from '../sorting-context'\n\nimport { ReviewsList } from './reviews-list'\nimport { useRatingReviews } from './services'\nimport type { ExtendShortenReview } from './types'\n\nexport function RatingReviews({\n  value: {\n    resourceId,\n    resourceType,\n    regionId,\n    recentTrip,\n    hasMedia,\n    placeholderText,\n    reviewsCount,\n    sortingLabel,\n    sortingType,\n  },\n}: {\n  value: ExtendShortenReview & { sortingType?: SortingType }\n}) {\n  const sort = sortingLabel.replace(/^star-rating-/, '')\n\n  const { data, refetch } = useRatingReviews({\n    resourceId,\n    resourceType,\n    recentTrip,\n    hasMedia,\n    sortBy: {\n      rating: sort,\n    },\n  })\n\n  return (\n    <ReviewsList\n      resourceId={resourceId}\n      resourceType={resourceType}\n      regionId={regionId}\n      hasMedia={hasMedia}\n      recentTrip={recentTrip}\n      placeholderText={placeholderText}\n      sortingType={sortingType}\n      sortingOption={sortingLabel}\n      reviewsCount={reviewsCount}\n      reviews={data?.ratingReviews}\n      refetch={refetch}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/shorten-list/reviews-list.tsx",
    "content": "import { List, Spinner } from '@titicaca/tds-ui'\nimport { useEffect, useMemo, useState } from 'react'\nimport { useClientAppActions } from '@titicaca/triple-web'\n\nimport { BaseReviewFragment } from '../../data/graphql'\nimport { useDescriptions, useMyReview } from '../../services'\nimport { FullListButton } from '../full-list-button'\nimport { MileageButton } from '../mileage-button'\nimport { MyReviewActionSheet } from '../my-review-action-sheet'\nimport { OthersReviewActionSheet } from '../others-review-action-sheet'\nimport { ReviewElement } from '../review-element'\nimport { ReviewsPlaceholder } from '../review-placeholder-with-rating'\nimport type { SortingType, SortingOption } from '../sorting-context'\n\ninterface Props {\n  resourceId: string\n  resourceType: string\n  regionId: string | undefined\n  hasMedia: boolean\n  recentTrip: boolean\n  placeholderText: string | undefined\n  sortingType?: SortingType\n  sortingOption: SortingOption\n  reviewsCount: number | undefined\n  reviews: BaseReviewFragment[] | undefined\n  refetch: () => void\n}\n\nexport function ReviewsList({\n  resourceId,\n  resourceType,\n  regionId,\n  hasMedia,\n  recentTrip,\n  placeholderText,\n  sortingType,\n  sortingOption,\n  reviewsCount,\n  reviews,\n  refetch,\n}: Props) {\n  const [selectedReviewId, setSelectedReviewId] = useState<string | undefined>(\n    undefined,\n  )\n  const { subscribeLikedChangeEvent, unsubscribeLikedChangeEvent } =\n    useClientAppActions()\n\n  const { data: myReviewData } = useMyReview({\n    resourceId,\n    resourceType,\n  })\n  const { data: descriptionsData } = useDescriptions({\n    resourceId,\n    resourceType,\n  })\n\n  const sortedReviews = useMemo(() => {\n    if (!reviews || !myReviewData) {\n      return undefined\n    }\n\n    if (reviews.length === 0) {\n      return []\n    }\n\n    let newReviews = reviews.filter(\n      (review) => review.id !== myReviewData.myReview?.id,\n    )\n\n    if (myReviewData.myReview) {\n      newReviews = [myReviewData.myReview].concat(newReviews)\n    }\n\n    return newReviews\n  }, [myReviewData, reviews])\n\n  useEffect(() => {\n    subscribeLikedChangeEvent?.(refetch)\n\n    return () => unsubscribeLikedChangeEvent?.(refetch)\n  }, [refetch, subscribeLikedChangeEvent, unsubscribeLikedChangeEvent])\n\n  if (!myReviewData || !descriptionsData || !sortedReviews) {\n    return <Spinner />\n  }\n\n  if (sortedReviews.length === 0) {\n    return (\n      <ReviewsPlaceholder\n        hasReviews={(reviewsCount ?? 0) > 0}\n        isMorePage={false}\n        resourceId={resourceId}\n        resourceType={resourceType}\n        regionId={regionId}\n        sortingType={sortingType}\n        sortingOption={sortingOption}\n        hasMedia={hasMedia}\n        recentTrip={recentTrip}\n        placeholderText={placeholderText}\n      />\n    )\n  }\n\n  return (\n    <>\n      <List divided margin={{ top: 24 }} verticalGap={48}>\n        {sortedReviews.map((review, i) => (\n          <ReviewElement\n            key={i}\n            isFullList={false}\n            isMyReview={myReviewData.myReview?.id === review.id}\n            review={review}\n            reviewRateDescriptions={\n              descriptionsData.reviewsSpecification?.rating?.description\n            }\n            resourceId={resourceId}\n            regionId={regionId}\n            onMenuClick={setSelectedReviewId}\n          />\n        ))}\n      </List>\n\n      <FullListButton\n        reviewsCount={reviewsCount}\n        resourceId={resourceId}\n        resourceType={resourceType}\n        regionId={regionId}\n        hasMedia={hasMedia}\n        recentTrip={recentTrip}\n        sortingType={sortingType}\n        sortingOption={sortingOption}\n      />\n\n      <MileageButton resourceId={resourceId} />\n\n      {myReviewData?.myReview ? (\n        <MyReviewActionSheet\n          reviewId={myReviewData.myReview.id}\n          reviewBlinded={myReviewData.myReview.blinded ?? false}\n          resourceType={resourceType}\n          resourceId={resourceId}\n          regionId={regionId}\n        />\n      ) : null}\n\n      {selectedReviewId ? (\n        <OthersReviewActionSheet reviewId={selectedReviewId} />\n      ) : null}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/shorten-list/services.ts",
    "content": "import { UseQueryResult, useQuery } from '@tanstack/react-query'\n\nimport { SHORTENED_REVIEWS_COUNT_PER_PAGE } from '../../constants'\nimport {\n  GetPopularReviewsQueryVariables,\n  GetLatestReviewsQueryVariables,\n  GetReviewsByRatingQueryVariables,\n  client,\n  GetPopularReviewsQuery,\n  GetLatestReviewsQuery,\n  GetReviewsByRatingQuery,\n  reviewClient,\n} from '../../data/graphql'\n\nexport function usePopularReviews(\n  params: Omit<GetPopularReviewsQueryVariables, 'from' | 'size'>,\n): UseQueryResult<GetPopularReviewsQuery> {\n  return useQuery({\n    queryKey: [\n      'review/getPopularReviews',\n      { ...params, size: SHORTENED_REVIEWS_COUNT_PER_PAGE },\n    ],\n    queryFn: () =>\n      reviewClient(() =>\n        client.GetPopularReviews({\n          ...params,\n          size: SHORTENED_REVIEWS_COUNT_PER_PAGE,\n        }),\n      ),\n    refetchOnWindowFocus: false,\n  })\n}\n\nexport function useLatestReviews(\n  params: Omit<GetLatestReviewsQueryVariables, 'from' | 'size'>,\n): UseQueryResult<GetLatestReviewsQuery> {\n  return useQuery({\n    queryKey: [\n      'review/getLatestReviews',\n      { ...params, size: SHORTENED_REVIEWS_COUNT_PER_PAGE },\n    ],\n    queryFn: () =>\n      reviewClient(() =>\n        client.GetLatestReviews({\n          ...params,\n          size: SHORTENED_REVIEWS_COUNT_PER_PAGE,\n        }),\n      ),\n    refetchOnWindowFocus: false,\n  })\n}\n\nexport function useRatingReviews(\n  params: Omit<GetReviewsByRatingQueryVariables, 'from' | 'size'>,\n): UseQueryResult<GetReviewsByRatingQuery> {\n  return useQuery({\n    queryKey: [\n      'review/getReviewsByRating',\n      { ...params, size: SHORTENED_REVIEWS_COUNT_PER_PAGE },\n    ],\n    queryFn: () =>\n      reviewClient(() =>\n        client.GetReviewsByRating({\n          ...params,\n          size: SHORTENED_REVIEWS_COUNT_PER_PAGE,\n        }),\n      ),\n    refetchOnWindowFocus: false,\n  })\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/shorten-list/types.ts",
    "content": "export interface ShortenReview {\n  resourceId: string\n  resourceType: string\n  regionId?: string\n  placeholderText?: string\n  reviewsCount?: number\n  recentTrip: boolean\n  hasMedia: boolean\n}\n\nexport type ExtendShortenReview = ShortenReview & {\n  sortingLabel: 'star-rating-asc' | 'star-rating-desc'\n}\n\nexport type ShortenReviewValue = ShortenReview | ExtendShortenReview\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/sorting-context.tsx",
    "content": "import {\n  PropsWithChildren,\n  createContext,\n  useCallback,\n  useContext,\n  useMemo,\n  useState,\n  useEffect,\n} from 'react'\nimport {\n  useClientAppActions,\n  useTrackEvent,\n  useTranslation,\n} from '@titicaca/triple-web'\n\nimport { useReviewFilters } from './filter-context'\n\nexport type SortingOption =\n  | 'recommendation'\n  | 'latest'\n  | 'star-rating-desc'\n  | 'star-rating-asc'\n\nexport type SortingType = 'default' | 'poi'\n\nconst EVENT_TYPE = 'reviews-web/sorting-option-change'\n\ninterface SortingOptionsProps {\n  type?: SortingType\n  receiverId?: string\n  resourceId: string\n  initialSortingOption?: SortingOption\n}\n\ninterface SortingOptionsValues {\n  selectedOption: SortingOption\n  sortingOptions: { key: SortingOption; text: string }[]\n  handleOptionSelect: (option: SortingOption) => void\n}\n\nconst SortingOptionsContext = createContext<SortingOptionsValues | undefined>(\n  undefined,\n)\n\nconst OPTION_LABELS = {\n  recommendation: '추천순',\n  latest: '최신순',\n  'star-rating-desc': '별점 높은순',\n  'star-rating-asc': '별점 낮은순',\n}\n\nexport function SortingOptionsProvider({\n  type = 'default',\n  receiverId,\n  resourceId,\n  initialSortingOption = 'recommendation',\n  children,\n}: PropsWithChildren<SortingOptionsProps>) {\n  const [selectedOption, setSelectedOption] = useState(initialSortingOption)\n\n  const t = useTranslation()\n  const trackEvent = useTrackEvent()\n  const { isRecentTrip } = useReviewFilters()\n  const { broadcastMessage, subscribe, unsubscribe } = useClientAppActions()\n\n  const defaultOptions = [\n    { key: 'recommendation' as const, text: t('추천순') },\n    { key: 'latest' as const, text: t('최신순') },\n  ]\n\n  const poiOptions = [\n    ...defaultOptions,\n    {\n      key: 'star-rating-desc' as const,\n      text: t('별점 높은순'),\n    },\n    {\n      key: 'star-rating-asc' as const,\n      text: t('별점 낮은순'),\n    },\n  ]\n\n  const sortingOptions = type === 'default' ? defaultOptions : poiOptions\n\n  const handleOptionSelect = useCallback(\n    (sortingOption: SortingOption) => {\n      const eventLabel = OPTION_LABELS[sortingOption]\n\n      trackEvent({\n        ga: ['리뷰_리뷰정렬', eventLabel],\n        fa: {\n          action: '리뷰_리뷰정렬',\n          sort_order: eventLabel,\n          item_id: resourceId,\n          ...(isRecentTrip && { filter_name: '최근여행' }),\n        },\n      })\n\n      setSelectedOption(sortingOption)\n    },\n    [isRecentTrip, resourceId, trackEvent],\n  )\n\n  useEffect(() => {\n    if (receiverId) {\n      broadcastMessage &&\n        broadcastMessage({\n          receiverId,\n          type: EVENT_TYPE,\n          selectedSortingOption: selectedOption,\n        })\n    }\n  }, [receiverId, selectedOption, broadcastMessage])\n\n  useEffect(() => {\n    const handleReceiveMessage = ({\n      payload,\n    }: {\n      payload?: {\n        type: string\n        selectedSortingOption: SortingOption\n      }\n    }) => {\n      if (!payload || payload.type !== EVENT_TYPE) {\n        return\n      }\n\n      setSelectedOption(payload.selectedSortingOption)\n    }\n\n    subscribe && subscribe('receiveMessage', handleReceiveMessage)\n\n    return () => {\n      unsubscribe && unsubscribe('receiveMessage', handleReceiveMessage)\n    }\n  }, [subscribe, unsubscribe, setSelectedOption])\n\n  const values = useMemo(\n    () => ({\n      selectedOption,\n      sortingOptions,\n      handleOptionSelect,\n    }),\n    [selectedOption, sortingOptions, handleOptionSelect],\n  )\n\n  return (\n    <SortingOptionsContext.Provider value={values}>\n      {children}\n    </SortingOptionsContext.Provider>\n  )\n}\n\nexport function useReviewSortingOptions() {\n  const context = useContext(SortingOptionsContext)\n\n  if (context === undefined) {\n    throw new Error('SortingOptionsProvider is not mount.')\n  }\n\n  return context\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/sorting-options-action-sheet.tsx",
    "content": "import { ActionSheet, ActionSheetItem } from '@titicaca/tds-ui'\nimport { useHashRouter } from '@titicaca/triple-web'\n\nimport { useReviewSortingOptions } from './sorting-context'\nimport type { SortingOption } from './sorting-context'\n\nexport const HASH_SORTING_OPTIONS_ACTION_SHEET =\n  'common.sort-options.review-action-sheet'\n\nexport function SortingOptionsActionSheet() {\n  const { uriHash, removeUriHash } = useHashRouter()\n  const { selectedOption, sortingOptions, handleOptionSelect } =\n    useReviewSortingOptions()\n\n  const handleSelect = (option: SortingOption) => {\n    handleOptionSelect(option)\n\n    removeUriHash()\n  }\n\n  return (\n    <ActionSheet\n      title=\"정렬\"\n      open={uriHash === HASH_SORTING_OPTIONS_ACTION_SHEET}\n      onClose={removeUriHash}\n    >\n      {sortingOptions.map(({ key, text }, index) => (\n        <ActionSheetItem\n          key={index}\n          checked={key === selectedOption}\n          onClick={() => handleSelect(key)}\n        >\n          {text}\n        </ActionSheetItem>\n      ))}\n    </ActionSheet>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/sorting-options.tsx",
    "content": "import { useCallback } from 'react'\nimport { styled } from 'styled-components'\nimport { Text } from '@titicaca/tds-ui'\nimport { useHashRouter } from '@titicaca/triple-web'\n\nimport { useReviewSortingOptions } from './sorting-context'\nimport {\n  SortingOptionsActionSheet,\n  HASH_SORTING_OPTIONS_ACTION_SHEET,\n} from './sorting-options-action-sheet'\n\nconst SortingOption = styled.button`\n  display: flex;\n  align-items: center;\n  cursor: pointer;\n  gap: 4px;\n`\nconst ArrowIcon = styled.div`\n  width: 12px;\n  height: 12px;\n  background-image: url('https://assets.triple.guide/images/ico_arrow_down_12@3x.png');\n  background-size: 12px 12px;\n`\n\nexport function SortingOptions() {\n  const { selectedOption, sortingOptions } = useReviewSortingOptions()\n  const { addUriHash } = useHashRouter()\n\n  const { text } =\n    sortingOptions.find(({ key }) => key === selectedOption) || {}\n\n  const handleActionSheetOpen = useCallback(() => {\n    addUriHash(HASH_SORTING_OPTIONS_ACTION_SHEET)\n  }, [addUriHash])\n\n  return (\n    <>\n      <SortingOption onClick={handleActionSheetOpen}>\n        <Text size={14} color=\"gray\">\n          {text}\n        </Text>\n\n        <ArrowIcon />\n      </SortingOption>\n\n      <SortingOptionsActionSheet />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/types.tsx",
    "content": "export interface AppNativeActionProps {\n  subscribeReviewUpdateEvent?: (\n    handler: (params?: { id: string }) => void,\n  ) => void\n  unsubscribeReviewUpdateEvent?: (\n    handler: (params?: { id: string }) => void,\n  ) => void\n  showToast: (message: string) => void\n  notifyReviewDeleted: (resourceId: string, reviewId: string) => void\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/components/write-button.tsx",
    "content": "import { ButtonBase } from '@titicaca/tds-ui'\nimport {\n  useTrackEvent,\n  useClientAppCallback,\n  useSessionCallback,\n} from '@titicaca/triple-web'\nimport { useCallback } from 'react'\nimport { styled } from 'styled-components'\n\nimport { useClientActions } from '../services'\n\nconst WriteIcon = styled.img`\n  width: 34px;\n  height: 34px;\n`\n\ninterface Props {\n  resourceId: string\n  resourceType: string\n  regionId: string | undefined\n}\n\nexport const WriteButton = ({ resourceId, resourceType, regionId }: Props) => {\n  const trackEvent = useTrackEvent()\n  const { writeReview } = useClientActions()\n\n  const handleClick = useClientAppCallback(\n    useSessionCallback(\n      useCallback(() => {\n        trackEvent({\n          ga: ['리뷰_리뷰쓰기'],\n          fa: {\n            action: '리뷰_리뷰쓰기',\n            item_id: resourceId,\n          },\n        })\n\n        writeReview({\n          resourceType,\n          resourceId,\n          regionId,\n        })\n      }, [trackEvent, resourceId, writeReview, resourceType, regionId]),\n      { triggeredEventAction: '리뷰_리뷰쓰기' },\n    ),\n    { triggeredEventAction: '리뷰_리뷰쓰기' },\n  )\n\n  return (\n    <ButtonBase css={{ marginLeft: 'auto' }} onClick={handleClick}>\n      <WriteIcon src=\"https://assets.triple.guide/images/btn-com-write@2x.png\" />\n    </ButtonBase>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/constants.ts",
    "content": "export const DEFAULT_REVIEWS_COUNT_PER_PAGE = 20\nexport const SHORTENED_REVIEWS_COUNT_PER_PAGE = 4\n"
  },
  {
    "path": "packages/tds-widget/src/review/data/graphql/client.ts",
    "content": "import { ClientError, request } from 'graphql-request'\nimport {\n  sessionRefresh,\n  ACCESS_TOKEN_EXPIRED_EXCEPTION,\n} from '@titicaca/fetcher'\n\nimport { Requester, getSdk } from './generated'\n\nconst requester: Requester = (doc, vars) =>\n  request({\n    document: doc,\n    url: '/api/graphql',\n    variables: vars ?? undefined,\n  })\n\nexport const client = getSdk(requester)\n\nexport async function reviewClient<T>(query: () => Promise<T>) {\n  try {\n    const response = await query()\n    return response\n  } catch (e) {\n    if (e instanceof ClientError && e.response.status === 401) {\n      if (e.response.exception === ACCESS_TOKEN_EXPIRED_EXCEPTION) {\n        const refreshResponse = await sessionRefresh({})\n        if (refreshResponse) {\n          const newResponse = await query()\n          return newResponse\n        }\n      }\n    }\n    throw e\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/data/graphql/generated.ts",
    "content": "import { DocumentNode } from 'graphql';\nexport type Maybe<T> = T | null;\nexport type InputMaybe<T> = Maybe<T>;\nexport type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };\nexport type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };\nexport type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };\nexport type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };\nexport type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };\n/** All built-in and custom scalars, mapped to their actual values */\nexport type Scalars = {\n  ID: { input: string; output: string; }\n  String: { input: string; output: string; }\n  Boolean: { input: boolean; output: boolean; }\n  Int: { input: number; output: number; }\n  Float: { input: number; output: number; }\n  DateTime: { input: any; output: any; }\n  JSON: { input: any; output: any; }\n};\n\nexport type Article = {\n  __typename?: 'Article';\n  createdAt: Scalars['DateTime']['output'];\n  deletedAt?: Maybe<Scalars['DateTime']['output']>;\n  id: Scalars['ID']['output'];\n  metadata: Metadata;\n  recommendedPosts: Array<Article>;\n  reviewImage?: Maybe<Scalars['JSON']['output']>;\n  reviewed?: Maybe<Scalars['Boolean']['output']>;\n  scraped?: Maybe<Scalars['Boolean']['output']>;\n  seoMetadata?: Maybe<ArticleSeoMetadata>;\n  serviceMetadata?: Maybe<ArticleServiceMetadata>;\n  source: ArticleSource;\n  type: Scalars['String']['output'];\n  updatedAt: Scalars['DateTime']['output'];\n};\n\nexport type ArticleMetadata = {\n  __typename?: 'ArticleMetadata';\n  author?: Maybe<Scalars['JSON']['output']>;\n  description?: Maybe<Scalars['String']['output']>;\n  destinationTags?: Maybe<Array<Scalars['JSON']['output']>>;\n  exposedAt?: Maybe<Scalars['DateTime']['output']>;\n  geotags?: Maybe<Array<Scalars['JSON']['output']>>;\n  image?: Maybe<Scalars['JSON']['output']>;\n  newsletter?: Maybe<Scalars['JSON']['output']>;\n  notable?: Maybe<Scalars['Boolean']['output']>;\n  ogImage?: Maybe<Scalars['JSON']['output']>;\n  ogTitle?: Maybe<Scalars['String']['output']>;\n  readableTimestamp?: Maybe<Scalars['String']['output']>;\n  readonly?: Maybe<Scalars['Boolean']['output']>;\n  recommendable?: Maybe<Scalars['Boolean']['output']>;\n  relatedLinks?: Maybe<Array<Scalars['JSON']['output']>>;\n  requireLogin?: Maybe<Scalars['String']['output']>;\n  tags?: Maybe<Array<Scalars['JSON']['output']>>;\n  template: Scalars['JSON']['output'];\n  title?: Maybe<Scalars['String']['output']>;\n};\n\nexport type ArticleSeoMetadata = {\n  __typename?: 'ArticleSeoMetadata';\n  description: Scalars['String']['output'];\n  id: Scalars['ID']['output'];\n};\n\nexport type ArticleServiceMetadata = {\n  __typename?: 'ArticleServiceMetadata';\n  id: Scalars['ID']['output'];\n  review?: Maybe<ReviewMetadata>;\n  scrap?: Maybe<ScrapMetadata>;\n};\n\nexport type ArticleSource = {\n  __typename?: 'ArticleSource';\n  body: Array<Scalars['JSON']['output']>;\n  header?: Maybe<Scalars['JSON']['output']>;\n  metadata: ArticleMetadata;\n};\n\nexport type City = {\n  __typename?: 'City';\n  country: Country;\n  id: Scalars['ID']['output'];\n  names: Names;\n  region?: Maybe<Region>;\n  representative?: Maybe<Scalars['Boolean']['output']>;\n  zone?: Maybe<Zone>;\n};\n\nexport type Content = {\n  __typename?: 'Content';\n  deleted: Scalars['Boolean']['output'];\n  id: Scalars['ID']['output'];\n  image?: Maybe<Scalars['JSON']['output']>;\n  regionId: Scalars['ID']['output'];\n  reviewsCount: Scalars['Int']['output'];\n  scrapsCount: Scalars['Int']['output'];\n  title: Scalars['String']['output'];\n  type: ScrapContentType;\n};\n\nexport type CoordinatesArg = {\n  lat: Scalars['Float']['input'];\n  lng: Scalars['Float']['input'];\n};\n\nexport type Country = {\n  __typename?: 'Country';\n  id: Scalars['ID']['output'];\n  names: Names;\n};\n\nexport type CustomPoi = {\n  __typename?: 'CustomPoi';\n  customPoiHasCategory: Scalars['Boolean']['output'];\n  region?: Maybe<Region>;\n  source: CustomPoiSource;\n  title: Scalars['String']['output'];\n  type: Scalars['String']['output'];\n};\n\nexport type CustomPoiAddress = {\n  __typename?: 'CustomPoiAddress';\n  en?: Maybe<Scalars['String']['output']>;\n  ko?: Maybe<Scalars['String']['output']>;\n  local?: Maybe<Scalars['String']['output']>;\n};\n\nexport type CustomPoiGeoLocation = {\n  __typename?: 'CustomPoiGeoLocation';\n  coordinates?: Maybe<Array<Maybe<Scalars['Float']['output']>>>;\n  type?: Maybe<Scalars['String']['output']>;\n};\n\nexport type CustomPoiNames = {\n  __typename?: 'CustomPoiNames';\n  en?: Maybe<Scalars['String']['output']>;\n  ko?: Maybe<Scalars['String']['output']>;\n  local?: Maybe<Scalars['String']['output']>;\n};\n\nexport type CustomPoiSource = {\n  __typename?: 'CustomPoiSource';\n  addresses: CustomPoiAddress;\n  geolocation: CustomPoiGeoLocation;\n  names: CustomPoiNames;\n  regionId?: Maybe<Scalars['ID']['output']>;\n};\n\nexport type Destination = Region | Zone;\n\nexport type FeaturedDestinationsList = {\n  __typename?: 'FeaturedDestinationsList';\n  createdAt: Scalars['DateTime']['output'];\n  id: Scalars['ID']['output'];\n  source: FeaturedDestinationsListSource;\n  updatedAt: Scalars['DateTime']['output'];\n};\n\nexport type FeaturedDestinationsListSource = {\n  __typename?: 'FeaturedDestinationsListSource';\n  destinations: Array<Destination>;\n};\n\nexport type Festa = {\n  __typename?: 'Festa';\n  address?: Maybe<FestaAddress>;\n  areas: Array<FestaGeotag>;\n  category: Scalars['String']['output'];\n  contents: Array<FestaContent>;\n  createdAt: Scalars['String']['output'];\n  customSchedules: Scalars['JSON']['output'];\n  duration?: Maybe<FestaDuration>;\n  eventArrivalInstructions?: Maybe<Scalars['String']['output']>;\n  geolocation?: Maybe<FestaGeoPoint>;\n  headImage?: Maybe<FestaImage>;\n  id: Scalars['ID']['output'];\n  links: Array<FestaLink>;\n  pricing?: Maybe<FestaPricing>;\n  regions: Array<FestaGeotag>;\n  relatedArticles: Array<Article>;\n  relatedFestas: Array<Festa>;\n  relatedPois: Array<Poi>;\n  resourceId: Scalars['ID']['output'];\n  schedules: Array<FestaScheduleItem>;\n  scraped?: Maybe<Scalars['Boolean']['output']>;\n  subtitle: Scalars['String']['output'];\n  tags: Array<Maybe<Scalars['String']['output']>>;\n  title: Scalars['String']['output'];\n  updatedAt: Scalars['String']['output'];\n};\n\nexport type FestaAddress = {\n  __typename?: 'FestaAddress';\n  city?: Maybe<Scalars['String']['output']>;\n  country?: Maybe<Scalars['String']['output']>;\n  state?: Maybe<Scalars['String']['output']>;\n  street?: Maybe<Scalars['String']['output']>;\n  zip?: Maybe<Scalars['String']['output']>;\n};\n\nexport type FestaContent = {\n  __typename?: 'FestaContent';\n  image?: Maybe<Array<Maybe<FestaImage>>>;\n  text?: Maybe<Scalars['String']['output']>;\n  title?: Maybe<Scalars['String']['output']>;\n};\n\nexport type FestaDuration = {\n  __typename?: 'FestaDuration';\n  description?: Maybe<Scalars['String']['output']>;\n  end?: Maybe<Scalars['String']['output']>;\n  start: Scalars['String']['output'];\n};\n\nexport type FestaGeoPoint = {\n  __typename?: 'FestaGeoPoint';\n  coordinates: Array<Scalars['Float']['output']>;\n  type: Scalars['String']['output'];\n};\n\nexport type FestaGeotag = {\n  __typename?: 'FestaGeotag';\n  id: Scalars['ID']['output'];\n  names?: Maybe<FestaTranslatedNames>;\n  type: Scalars['String']['output'];\n};\n\nexport type FestaImage = {\n  __typename?: 'FestaImage';\n  cloudinaryBucket?: Maybe<Scalars['String']['output']>;\n  cloudinaryId?: Maybe<Scalars['String']['output']>;\n  description?: Maybe<Scalars['String']['output']>;\n  frame?: Maybe<Scalars['String']['output']>;\n  height?: Maybe<Scalars['Int']['output']>;\n  id: Scalars['ID']['output'];\n  link?: Maybe<Scalars['String']['output']>;\n  quality?: Maybe<Scalars['String']['output']>;\n  sizes: FestaSizes;\n  sourceUrl?: Maybe<Scalars['String']['output']>;\n  title?: Maybe<Scalars['String']['output']>;\n  type?: Maybe<Scalars['String']['output']>;\n  video?: Maybe<FestaSizes>;\n  width?: Maybe<Scalars['Int']['output']>;\n};\n\nexport type FestaLink = {\n  __typename?: 'FestaLink';\n  description?: Maybe<Scalars['String']['output']>;\n  href: Scalars['String']['output'];\n  id?: Maybe<Scalars['String']['output']>;\n  image?: Maybe<FestaImage>;\n  label: Scalars['String']['output'];\n  level?: Maybe<Scalars['String']['output']>;\n  target?: Maybe<Scalars['String']['output']>;\n};\n\nexport type FestaList = {\n  __typename?: 'FestaList';\n  items: Array<Festa>;\n  pagination?: Maybe<FestaPagination>;\n};\n\nexport type FestaPagination = {\n  __typename?: 'FestaPagination';\n  count: Scalars['Int']['output'];\n  nextItemId?: Maybe<Scalars['String']['output']>;\n};\n\nexport type FestaPricing = {\n  __typename?: 'FestaPricing';\n  description?: Maybe<Scalars['String']['output']>;\n  type?: Maybe<PricingType>;\n};\n\nexport type FestaScheduleItem = {\n  __typename?: 'FestaScheduleItem';\n  breakHours?: Maybe<Span>;\n  closed?: Maybe<Scalars['Boolean']['output']>;\n  description?: Maybe<Scalars['String']['output']>;\n  operatingHours?: Maybe<Span>;\n};\n\nexport type FestaSize = {\n  __typename?: 'FestaSize';\n  url: Scalars['String']['output'];\n};\n\nexport type FestaSizes = {\n  __typename?: 'FestaSizes';\n  full: FestaSize;\n  large: FestaSize;\n  small_square: FestaSize;\n};\n\nexport type FestaTranslatedNames = {\n  __typename?: 'FestaTranslatedNames';\n  en?: Maybe<Scalars['String']['output']>;\n  ko?: Maybe<Scalars['String']['output']>;\n  local?: Maybe<Scalars['String']['output']>;\n  primary?: Maybe<Scalars['String']['output']>;\n};\n\nexport type ForeignEntity = {\n  __typename?: 'ForeignEntity';\n  name?: Maybe<Scalars['String']['output']>;\n  service: Scalars['String']['output'];\n  url?: Maybe<Scalars['String']['output']>;\n};\n\nexport type GeoMetadata = {\n  __typename?: 'GeoMetadata';\n  areas?: Maybe<Array<Scalars['JSON']['output']>>;\n  geotags?: Maybe<Array<Scalars['JSON']['output']>>;\n  timeZone?: Maybe<Scalars['String']['output']>;\n  vicinity?: Maybe<Scalars['String']['output']>;\n};\n\nexport type Geotag = {\n  __typename?: 'Geotag';\n  createdAt: Scalars['DateTime']['output'];\n  id: Scalars['ID']['output'];\n  source: GeotagSource;\n  updatedAt: Scalars['DateTime']['output'];\n};\n\nexport type GeotagInput = {\n  id: Scalars['String']['input'];\n  type: Scalars['String']['input'];\n};\n\nexport type GeotagSource = {\n  __typename?: 'GeotagSource';\n  attractionCategories?: Maybe<Array<Scalars['JSON']['output']>>;\n  attractionFilters?: Maybe<Array<Scalars['JSON']['output']>>;\n  countryCode?: Maybe<Scalars['String']['output']>;\n  currencies?: Maybe<Array<Scalars['String']['output']>>;\n  defaultRange?: Maybe<Scalars['Int']['output']>;\n  featuredNames?: Maybe<Array<Maybe<Scalars['String']['output']>>>;\n  foreignEntities?: Maybe<Array<ForeignEntity>>;\n  geofence?: Maybe<Scalars['JSON']['output']>;\n  languages?: Maybe<Array<Scalars['String']['output']>>;\n  media?: Maybe<Scalars['JSON']['output']>;\n  menu?: Maybe<Scalars['JSON']['output']>;\n  names: Scalars['JSON']['output'];\n  popularKeywords?: Maybe<Array<Scalars['String']['output']>>;\n  ranges?: Maybe<Array<Scalars['Int']['output']>>;\n  relatedGeotags?: Maybe<Array<RelatedGeotag>>;\n  restaurantCategories?: Maybe<Array<Scalars['JSON']['output']>>;\n  restaurantFilters?: Maybe<Array<Scalars['JSON']['output']>>;\n  stale?: Maybe<Scalars['JSON']['output']>;\n  timeZone?: Maybe<Scalars['String']['output']>;\n};\n\nexport type GetFestaArgs = {\n  isPublished?: InputMaybe<Scalars['Boolean']['input']>;\n  resourceId: Scalars['ID']['input'];\n};\n\nexport type GetFestasArgs = {\n  areaIds?: InputMaybe<Array<Scalars['String']['input']>>;\n  category?: InputMaybe<Scalars['String']['input']>;\n  coordinates?: InputMaybe<CoordinatesArg>;\n  durationEndGte?: InputMaybe<Scalars['String']['input']>;\n  durationEndLte?: InputMaybe<Scalars['String']['input']>;\n  durationStartGte?: InputMaybe<Scalars['String']['input']>;\n  durationStartLte?: InputMaybe<Scalars['String']['input']>;\n  isGeolocationExists?: InputMaybe<Scalars['Boolean']['input']>;\n  isPublished?: InputMaybe<Scalars['Boolean']['input']>;\n  keyword?: InputMaybe<Scalars['String']['input']>;\n  maxDistance?: InputMaybe<Scalars['Int']['input']>;\n  nextItemId?: InputMaybe<Scalars['String']['input']>;\n  orderByDurationEnd?: InputMaybe<Scalars['Int']['input']>;\n  regionIds?: InputMaybe<Array<Scalars['String']['input']>>;\n  resourceIds?: InputMaybe<Array<Scalars['ID']['input']>>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n  tags?: InputMaybe<Array<Scalars['String']['input']>>;\n};\n\nexport type GetFestasByScheduleArgs = {\n  regionIds?: InputMaybe<Array<Scalars['String']['input']>>;\n  schedule?: InputMaybe<Scalars['String']['input']>;\n};\n\nexport type Message = {\n  __typename?: 'Message';\n  blindedAt?: Maybe<Scalars['DateTime']['output']>;\n  createdAt: Scalars['DateTime']['output'];\n  deletedAt?: Maybe<Scalars['DateTime']['output']>;\n  id: Scalars['ID']['output'];\n  parentMessage?: Maybe<Message>;\n  payload: MessagePayload;\n  room: Room;\n  user: User;\n};\n\nexport type MessageInput = {\n  parentId?: InputMaybe<Scalars['ID']['input']>;\n  payload: MessagePayloadInput;\n  roomId: Scalars['ID']['input'];\n};\n\nexport enum MessageOrderType {\n  Asc = 'asc',\n  Desc = 'desc'\n}\n\nexport type MessagePayload = {\n  __typename?: 'MessagePayload';\n  images?: Maybe<Array<Scalars['JSON']['output']>>;\n  message?: Maybe<Scalars['String']['output']>;\n  type: MessagePayloadType;\n};\n\nexport type MessagePayloadInput = {\n  images?: InputMaybe<Array<Scalars['JSON']['input']>>;\n  message?: InputMaybe<Scalars['String']['input']>;\n  type: MessagePayloadType;\n};\n\nexport enum MessagePayloadType {\n  Image = 'image',\n  Text = 'text'\n}\n\nexport type Metadata = {\n  __typename?: 'Metadata';\n  hasTnaProducts?: Maybe<Scalars['Boolean']['output']>;\n  reviewImagesCount?: Maybe<Scalars['Int']['output']>;\n  reviewsCount?: Maybe<Scalars['Int']['output']>;\n  reviewsRating?: Maybe<Scalars['Float']['output']>;\n  scrapsCount?: Maybe<Scalars['Int']['output']>;\n  structuredAddress?: Maybe<StructuredAddress>;\n};\n\nexport type Mutation = {\n  __typename?: 'Mutation';\n  createPurchaseToken: ReviewPurchaseToken;\n  createReview: Review;\n  deleteMessage: Message;\n  deleteRecommendationImage: Poi;\n  deleteReview: Scalars['Boolean']['output'];\n  editReview: Review;\n  likeReview: ReviewReaction;\n  reportReview: ReviewReaction;\n  sendMessage: Message;\n  unlikeReview: Scalars['Boolean']['output'];\n  uploadRecommendationImage: Poi;\n};\n\n\nexport type MutationCreatePurchaseTokenArgs = {\n  input: PurchaseTokenInput;\n};\n\n\nexport type MutationCreateReviewArgs = {\n  input: ReviewCreateInput;\n};\n\n\nexport type MutationDeleteMessageArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type MutationDeleteRecommendationImageArgs = {\n  poiId: Scalars['ID']['input'];\n  recommendationId: Scalars['ID']['input'];\n};\n\n\nexport type MutationDeleteReviewArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type MutationEditReviewArgs = {\n  id: Scalars['ID']['input'];\n  input: ReviewUpdateInput;\n};\n\n\nexport type MutationLikeReviewArgs = {\n  reviewId: Scalars['String']['input'];\n};\n\n\nexport type MutationReportReviewArgs = {\n  input: ReportReviewInput;\n  reviewId: Scalars['String']['input'];\n};\n\n\nexport type MutationSendMessageArgs = {\n  message: MessageInput;\n};\n\n\nexport type MutationUnlikeReviewArgs = {\n  reviewId: Scalars['String']['input'];\n};\n\n\nexport type MutationUploadRecommendationImageArgs = {\n  image: Scalars['JSON']['input'];\n  poiId: Scalars['ID']['input'];\n  recommendationId: Scalars['ID']['input'];\n};\n\nexport type Names = {\n  __typename?: 'Names';\n  en?: Maybe<Scalars['String']['output']>;\n  ko?: Maybe<Scalars['String']['output']>;\n  local?: Maybe<Scalars['String']['output']>;\n  primary?: Maybe<Scalars['String']['output']>;\n};\n\nexport type Poi = {\n  __typename?: 'Poi';\n  associatedArticles: Array<Article>;\n  categories?: Maybe<Array<Scalars['JSON']['output']>>;\n  createdAt: Scalars['DateTime']['output'];\n  deletedAt?: Maybe<Scalars['DateTime']['output']>;\n  equippingPois: Array<Poi>;\n  geoMetadata: GeoMetadata;\n  id: Scalars['ID']['output'];\n  metadata: Metadata;\n  readableSpecialHours?: Maybe<Array<Scalars['JSON']['output']>>;\n  region?: Maybe<Region>;\n  regions: Array<Region>;\n  relationshipCounts: Scalars['JSON']['output'];\n  restaurantRecommendations?: Maybe<Array<Maybe<Scalars['JSON']['output']>>>;\n  reviewImage?: Maybe<Scalars['JSON']['output']>;\n  reviewed?: Maybe<Scalars['Boolean']['output']>;\n  scraped?: Maybe<Scalars['Boolean']['output']>;\n  seoMetadata?: Maybe<PoiSeoMetadata>;\n  serviceMetadata?: Maybe<PoiServiceMetadata>;\n  source: PoiSource;\n  starRating?: Maybe<Scalars['Float']['output']>;\n  type: Scalars['String']['output'];\n  updatedAt: Scalars['DateTime']['output'];\n};\n\nexport type PoiSeoMetadata = {\n  __typename?: 'PoiSeoMetadata';\n  description: Scalars['String']['output'];\n  id: Scalars['ID']['output'];\n};\n\nexport type PoiServiceMetadata = {\n  __typename?: 'PoiServiceMetadata';\n  id: Scalars['ID']['output'];\n  review?: Maybe<ReviewMetadata>;\n  scrap?: Maybe<ScrapMetadata>;\n};\n\nexport type PoiSource = {\n  __typename?: 'PoiSource';\n  addresses: Scalars['JSON']['output'];\n  areas: Array<Scalars['JSON']['output']>;\n  businessHourComment?: Maybe<Scalars['String']['output']>;\n  businessHours?: Maybe<Array<Scalars['JSON']['output']>>;\n  businessHoursState?: Maybe<Scalars['String']['output']>;\n  categories?: Maybe<Array<Scalars['JSON']['output']>>;\n  clusterId?: Maybe<Scalars['String']['output']>;\n  comment?: Maybe<Scalars['String']['output']>;\n  directions?: Maybe<Scalars['String']['output']>;\n  dishTypes?: Maybe<Array<Maybe<Scalars['String']['output']>>>;\n  estimatedDuration?: Maybe<Scalars['String']['output']>;\n  exposedAt?: Maybe<Scalars['DateTime']['output']>;\n  externalLinks: Array<Scalars['JSON']['output']>;\n  extraContent: Array<Scalars['JSON']['output']>;\n  extraProperties?: Maybe<Array<Scalars['JSON']['output']>>;\n  featuredContent: Array<Scalars['JSON']['output']>;\n  featuredContentMetadata?: Maybe<Scalars['JSON']['output']>;\n  fee?: Maybe<Scalars['Boolean']['output']>;\n  feeComment?: Maybe<Scalars['String']['output']>;\n  foreignEntities: Array<Scalars['JSON']['output']>;\n  geofence?: Maybe<Scalars['JSON']['output']>;\n  geolocation?: Maybe<Scalars['JSON']['output']>;\n  geotags?: Maybe<Array<Scalars['JSON']['output']>>;\n  grade?: Maybe<Scalars['Int']['output']>;\n  hiddenAt?: Maybe<Scalars['DateTime']['output']>;\n  image?: Maybe<Scalars['JSON']['output']>;\n  images?: Maybe<Array<Scalars['JSON']['output']>>;\n  keywords: Array<Scalars['String']['output']>;\n  menus?: Maybe<Array<Scalars['JSON']['output']>>;\n  names: Scalars['JSON']['output'];\n  officialSiteUrl?: Maybe<Scalars['String']['output']>;\n  permanentlyClosedAt?: Maybe<Scalars['DateTime']['output']>;\n  phoneNumber?: Maybe<Scalars['String']['output']>;\n  readableBusinessHours?: Maybe<Array<Scalars['JSON']['output']>>;\n  readableSpecialHours?: Maybe<Array<Scalars['JSON']['output']>>;\n  recommendations?: Maybe<Array<Scalars['JSON']['output']>>;\n  regionId?: Maybe<Scalars['String']['output']>;\n  remarks?: Maybe<Array<Scalars['String']['output']>>;\n  resourceRelationships?: Maybe<Scalars['JSON']['output']>;\n  starRating?: Maybe<Scalars['Int']['output']>;\n  synonyms: Array<Scalars['String']['output']>;\n  tags?: Maybe<Array<Scalars['JSON']['output']>>;\n  timeZone?: Maybe<Scalars['String']['output']>;\n  tips?: Maybe<Array<Scalars['String']['output']>>;\n  vicinity?: Maybe<Scalars['String']['output']>;\n};\n\nexport enum PricingType {\n  Free = 'FREE',\n  Paid = 'PAID',\n  Unknown = 'UNKNOWN'\n}\n\nexport type PurchaseTokenInput = {\n  displayName: Scalars['String']['input'];\n  orderId: Scalars['String']['input'];\n  purchaseCount: Scalars['Int']['input'];\n  purchaseDate: Scalars['String']['input'];\n  resourceId: Scalars['String']['input'];\n  resourceType: Scalars['String']['input'];\n};\n\nexport type Query = {\n  __typename?: 'Query';\n  getAnnouncements: Array<Article>;\n  getArticle?: Maybe<Article>;\n  getCountry?: Maybe<Country>;\n  getFeaturedDestinationsList?: Maybe<FeaturedDestinationsList>;\n  getFesta?: Maybe<Festa>;\n  getFestas?: Maybe<FestaList>;\n  getFestasBySchedule?: Maybe<FestaList>;\n  getGeotag?: Maybe<Geotag>;\n  getGeotags: Array<Geotag>;\n  getGuides: Array<Article>;\n  getLatestReviews: Array<Review>;\n  getMessage: Message;\n  getMessages: Array<Message>;\n  getMyReview?: Maybe<Review>;\n  getMyReviewUserBoard: ReviewUserBoard;\n  getMyReviewsByResourceId: Array<Review>;\n  getMyReviewsList: Array<Review>;\n  getNewsletters: Array<Article>;\n  getNextNewsletter?: Maybe<Article>;\n  getPoi?: Maybe<Poi>;\n  getPois?: Maybe<Array<Poi>>;\n  getPopularReviews: Array<Review>;\n  getPosts: Array<Article>;\n  getPrevNewsletter?: Maybe<Article>;\n  getRegion?: Maybe<Region>;\n  getRegionCategories: Array<RegionCategory>;\n  getRegionCategory?: Maybe<RegionCategory>;\n  getRegionsByIds: Array<Region>;\n  getReviewResourceBoard: ReviewResourceBoard;\n  getReviewResourceBoardsByResourceIds: Array<ReviewResourceBoard>;\n  getReviewSpecification?: Maybe<ReviewSpecification>;\n  getReviewsByRating: Array<Review>;\n  getReviewsByResourceIds: Array<Review>;\n  getReviewsCount: Scalars['Int']['output'];\n  getRoom: Room;\n  getScraps: Array<Scrap>;\n  getTripByTripCode: Trip;\n  getTripPlans: Array<Array<TripPlan>>;\n  getTripPlansByTripCode: Array<Array<TripPlan>>;\n  getZone?: Maybe<Zone>;\n  isJoinedTrip: Scalars['Boolean']['output'];\n  mgetArticleSeoMetadata?: Maybe<Array<Maybe<ArticleSeoMetadata>>>;\n  mgetArticleServiceMetadata?: Maybe<Array<Maybe<ArticleServiceMetadata>>>;\n  mgetArticles: Array<Article>;\n  mgetCountries: Array<Country>;\n  mgetGeotags: Array<Maybe<Geotag>>;\n  mgetMessages: Array<Message>;\n  mgetPoiServiceMetadata?: Maybe<Array<Maybe<PoiServiceMetadata>>>;\n  mgetPois: Array<Poi>;\n  mgetRegionCategories: Array<Maybe<RegionCategory>>;\n  mgetRegions: Array<Maybe<Region>>;\n  mgetReplyBoards: Array<ReplyBoard>;\n  mgetReviewArticleServiceMetadata?: Maybe<Array<ArticleServiceMetadata>>;\n  mgetReviewPoiServiceMetadata?: Maybe<Array<PoiServiceMetadata>>;\n  mgetReviewedArticles: Array<Article>;\n  mgetReviewedPois: Array<Poi>;\n  mgetReviews: Array<Review>;\n  mgetRooms: Array<Room>;\n  mgetScrapArticleServiceMetadata?: Maybe<Array<ArticleServiceMetadata>>;\n  mgetScrapPoiServiceMetadata?: Maybe<Array<PoiServiceMetadata>>;\n  mgetScrapedArticles?: Maybe<Array<Article>>;\n  mgetScrapedFestas: Array<Festa>;\n  mgetScrapedPois: Array<Poi>;\n  mgetScrapedTna: Array<TnaProduct>;\n  mgetUsersByFbIds: Array<User>;\n  mgetZones: Array<Maybe<Zone>>;\n  searchCities: Array<City>;\n};\n\n\nexport type QueryGetAnnouncementsArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  sinceId?: InputMaybe<Scalars['String']['input']>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n};\n\n\nexport type QueryGetArticleArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetCountryArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetFeaturedDestinationsListArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetFestaArgs = {\n  args?: InputMaybe<GetFestaArgs>;\n};\n\n\nexport type QueryGetFestasArgs = {\n  args?: InputMaybe<GetFestasArgs>;\n};\n\n\nexport type QueryGetFestasByScheduleArgs = {\n  args?: InputMaybe<GetFestasByScheduleArgs>;\n};\n\n\nexport type QueryGetGeotagArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetGeotagsArgs = {\n  from: Scalars['Int']['input'];\n  size: Scalars['Int']['input'];\n  type: Scalars['String']['input'];\n};\n\n\nexport type QueryGetGuidesArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  regionId?: InputMaybe<Scalars['String']['input']>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n  tagId?: InputMaybe<Scalars['String']['input']>;\n};\n\n\nexport type QueryGetLatestReviewsArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  hasMedia?: InputMaybe<Scalars['Boolean']['input']>;\n  recentTrip?: InputMaybe<Scalars['Boolean']['input']>;\n  resourceId: Scalars['String']['input'];\n  resourceType: Scalars['String']['input'];\n  size?: InputMaybe<Scalars['Int']['input']>;\n};\n\n\nexport type QueryGetMessageArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetMessagesArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  orderBy?: InputMaybe<MessageOrderType>;\n  roomId: Scalars['String']['input'];\n  sinceId?: InputMaybe<Scalars['ID']['input']>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n  untilId?: InputMaybe<Scalars['ID']['input']>;\n};\n\n\nexport type QueryGetMyReviewArgs = {\n  resourceId: Scalars['String']['input'];\n  resourceType: Scalars['String']['input'];\n};\n\n\nexport type QueryGetMyReviewsByResourceIdArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  resourceId: Scalars['String']['input'];\n  size?: InputMaybe<Scalars['Int']['input']>;\n};\n\n\nexport type QueryGetMyReviewsListArgs = {\n  from: Scalars['Int']['input'];\n  size: Scalars['Int']['input'];\n};\n\n\nexport type QueryGetNewslettersArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n};\n\n\nexport type QueryGetNextNewsletterArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetPoiArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetPoisArgs = {\n  categoryId?: InputMaybe<Scalars['String']['input']>;\n  from?: InputMaybe<Scalars['Int']['input']>;\n  geotags?: InputMaybe<Array<Scalars['JSON']['input']>>;\n  keyword?: InputMaybe<Scalars['String']['input']>;\n  regionIds?: InputMaybe<Array<Scalars['String']['input']>>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n  sortBy?: InputMaybe<Scalars['String']['input']>;\n  types?: InputMaybe<Array<Scalars['String']['input']>>;\n};\n\n\nexport type QueryGetPopularReviewsArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  hasMedia?: InputMaybe<Scalars['Boolean']['input']>;\n  recentTrip?: InputMaybe<Scalars['Boolean']['input']>;\n  resourceId: Scalars['String']['input'];\n  resourceType: Scalars['String']['input'];\n  size?: InputMaybe<Scalars['Int']['input']>;\n};\n\n\nexport type QueryGetPostsArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  sinceId?: InputMaybe<Scalars['String']['input']>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n  tagId?: InputMaybe<Scalars['String']['input']>;\n};\n\n\nexport type QueryGetPrevNewsletterArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetRegionArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetRegionCategoryArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetRegionsByIdsArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryGetReviewResourceBoardArgs = {\n  resourceId: Scalars['String']['input'];\n};\n\n\nexport type QueryGetReviewResourceBoardsByResourceIdsArgs = {\n  resourceIds: Array<Scalars['String']['input']>;\n};\n\n\nexport type QueryGetReviewSpecificationArgs = {\n  resourceId: Scalars['String']['input'];\n  resourceType: Scalars['String']['input'];\n};\n\n\nexport type QueryGetReviewsByRatingArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  hasMedia?: InputMaybe<Scalars['Boolean']['input']>;\n  recentTrip?: InputMaybe<Scalars['Boolean']['input']>;\n  resourceId: Scalars['String']['input'];\n  resourceType: Scalars['String']['input'];\n  size?: InputMaybe<Scalars['Int']['input']>;\n  sortBy?: InputMaybe<SortByRatingsInput>;\n};\n\n\nexport type QueryGetReviewsByResourceIdsArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  hasMedia?: InputMaybe<Scalars['Boolean']['input']>;\n  resourceIds: Array<Scalars['String']['input']>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n  sortBy?: InputMaybe<ReviewByResourceIdsSortByInput>;\n};\n\n\nexport type QueryGetReviewsCountArgs = {\n  hasMedia?: InputMaybe<Scalars['Boolean']['input']>;\n  recentTrip?: InputMaybe<Scalars['Boolean']['input']>;\n  resourceId: Scalars['String']['input'];\n  resourceType: Scalars['String']['input'];\n};\n\n\nexport type QueryGetRoomArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetScrapsArgs = {\n  pageIdx?: InputMaybe<Scalars['Float']['input']>;\n  pageSize?: InputMaybe<Scalars['Float']['input']>;\n  region?: InputMaybe<Scalars['ID']['input']>;\n  regionIds?: InputMaybe<Array<Scalars['ID']['input']>>;\n  types?: InputMaybe<Array<ScrapContentType>>;\n  zone?: InputMaybe<Scalars['ID']['input']>;\n};\n\n\nexport type QueryGetTripByTripCodeArgs = {\n  tripCode: Scalars['String']['input'];\n};\n\n\nexport type QueryGetTripPlansArgs = {\n  tripId: Scalars['ID']['input'];\n};\n\n\nexport type QueryGetTripPlansByTripCodeArgs = {\n  tripCode: Scalars['String']['input'];\n};\n\n\nexport type QueryGetZoneArgs = {\n  id: Scalars['ID']['input'];\n};\n\n\nexport type QueryIsJoinedTripArgs = {\n  tripCode: Scalars['String']['input'];\n};\n\n\nexport type QueryMgetArticleSeoMetadataArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetArticleServiceMetadataArgs = {\n  ids?: InputMaybe<Array<Scalars['ID']['input']>>;\n};\n\n\nexport type QueryMgetArticlesArgs = {\n  excludeUnpublished?: InputMaybe<Scalars['Boolean']['input']>;\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetCountriesArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetGeotagsArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetMessagesArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetPoiServiceMetadataArgs = {\n  ids?: InputMaybe<Array<Scalars['ID']['input']>>;\n};\n\n\nexport type QueryMgetPoisArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetRegionCategoriesArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetRegionsArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetReplyBoardsArgs = {\n  ids: Array<Scalars['String']['input']>;\n};\n\n\nexport type QueryMgetReviewArticleServiceMetadataArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetReviewPoiServiceMetadataArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetReviewedArticlesArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetReviewedPoisArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetReviewsArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetRoomsArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetScrapArticleServiceMetadataArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetScrapPoiServiceMetadataArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetScrapedArticlesArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetScrapedFestasArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetScrapedPoisArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetScrapedTnaArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QueryMgetUsersByFbIdsArgs = {\n  fbIds: Array<Scalars['String']['input']>;\n};\n\n\nexport type QueryMgetZonesArgs = {\n  ids: Array<Scalars['ID']['input']>;\n};\n\n\nexport type QuerySearchCitiesArgs = {\n  keyword?: InputMaybe<Scalars['String']['input']>;\n  page: Scalars['Int']['input'];\n  size: Scalars['Int']['input'];\n};\n\nexport type Region = {\n  __typename?: 'Region';\n  city?: Maybe<City>;\n  country?: Maybe<Country>;\n  createdAt: Scalars['DateTime']['output'];\n  id: Scalars['ID']['output'];\n  regionCategory?: Maybe<RegionCategory>;\n  regionCategoryId?: Maybe<Scalars['String']['output']>;\n  source: RegionSource;\n  state: Scalars['String']['output'];\n  updatedAt: Scalars['DateTime']['output'];\n  zoneIds: Array<Scalars['String']['output']>;\n  zones: Array<Zone>;\n};\n\nexport type RegionCategory = {\n  __typename?: 'RegionCategory';\n  createdAt: Scalars['DateTime']['output'];\n  id: Scalars['ID']['output'];\n  name?: Maybe<Scalars['String']['output']>;\n  priority?: Maybe<Scalars['Int']['output']>;\n  regions: Array<Region>;\n  updatedAt: Scalars['DateTime']['output'];\n};\n\n\nexport type RegionCategoryRegionsArgs = {\n  from?: InputMaybe<Scalars['Int']['input']>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n};\n\nexport type RegionSource = {\n  __typename?: 'RegionSource';\n  attractionAreas: Array<Scalars['JSON']['output']>;\n  attractionCategories: Array<Scalars['JSON']['output']>;\n  attractionFilters: Array<Scalars['JSON']['output']>;\n  attractionGeotags?: Maybe<Array<Scalars['JSON']['output']>>;\n  countryCode?: Maybe<Scalars['String']['output']>;\n  currencies: Array<Scalars['String']['output']>;\n  defaultRange?: Maybe<Scalars['Int']['output']>;\n  featuredNames: Array<Scalars['String']['output']>;\n  flightHours?: Maybe<Scalars['Int']['output']>;\n  foreignEntities?: Maybe<Array<ForeignEntity>>;\n  geofence?: Maybe<Scalars['JSON']['output']>;\n  geotags?: Maybe<Array<Scalars['JSON']['output']>>;\n  guideTags: Array<Scalars['JSON']['output']>;\n  hotelAreas: Array<Scalars['JSON']['output']>;\n  hotelTags: Array<Scalars['JSON']['output']>;\n  languages: Array<Scalars['String']['output']>;\n  media: Scalars['JSON']['output'];\n  menu: Scalars['JSON']['output'];\n  names: Scalars['JSON']['output'];\n  popularKeywords: Array<Scalars['String']['output']>;\n  ranges?: Maybe<Array<Scalars['Int']['output']>>;\n  restaurantAreas: Array<Scalars['JSON']['output']>;\n  restaurantCategories: Array<Scalars['JSON']['output']>;\n  restaurantClustering?: Maybe<Scalars['Boolean']['output']>;\n  restaurantFilters: Array<Scalars['JSON']['output']>;\n  restaurantGeotags?: Maybe<Array<Scalars['JSON']['output']>>;\n  stale?: Maybe<Scalars['JSON']['output']>;\n  terminals: Array<Scalars['JSON']['output']>;\n  timeZone?: Maybe<Scalars['String']['output']>;\n  weatherSpots: Array<Scalars['JSON']['output']>;\n};\n\nexport type RelatedGeotag = {\n  __typename?: 'RelatedGeotag';\n  alias: Scalars['String']['output'];\n  id: Scalars['ID']['output'];\n  type: Scalars['String']['output'];\n};\n\nexport type ReplyBoard = {\n  __typename?: 'ReplyBoard';\n  childMessagesCount: Scalars['Int']['output'];\n  id: Scalars['ID']['output'];\n  pinnedMessages: Array<ReplyMessage>;\n  pinnedMessagesCount: Scalars['Int']['output'];\n  resourceId: Scalars['String']['output'];\n  resourceType: Scalars['String']['output'];\n  rootMessagesCount: Scalars['Int']['output'];\n};\n\nexport type ReplyMessage = {\n  __typename?: 'ReplyMessage';\n  children?: Maybe<Array<ReplyMessage>>;\n  content: ReplyMessageContent;\n  createdAt: Scalars['String']['output'];\n  id: Scalars['ID']['output'];\n  parentId?: Maybe<Scalars['String']['output']>;\n  updatedAt: Scalars['String']['output'];\n  writer?: Maybe<ReplyUser>;\n};\n\nexport type ReplyMessageContent = {\n  __typename?: 'ReplyMessageContent';\n  markdownText?: Maybe<Scalars['String']['output']>;\n  mentionedUser?: Maybe<ReplyUser>;\n  text?: Maybe<Scalars['String']['output']>;\n};\n\nexport type ReplyUser = {\n  __typename?: 'ReplyUser';\n  href?: Maybe<Scalars['String']['output']>;\n  name: Scalars['String']['output'];\n  profileImage?: Maybe<Scalars['String']['output']>;\n};\n\nexport type ReportReviewInput = {\n  comment: Scalars['String']['input'];\n  email: Scalars['String']['input'];\n  type: ReviewReportType;\n};\n\nexport type Review = {\n  __typename?: 'Review';\n  blinded?: Maybe<Scalars['Boolean']['output']>;\n  comment?: Maybe<Scalars['String']['output']>;\n  geotags: Array<Scalars['JSON']['output']>;\n  id: Scalars['ID']['output'];\n  isMyReview: Scalars['Boolean']['output'];\n  language: Scalars['String']['output'];\n  liked: Scalars['Boolean']['output'];\n  likesCount: Scalars['Int']['output'];\n  media?: Maybe<Array<Scalars['JSON']['output']>>;\n  purchaseInfo?: Maybe<ReviewPurchaseInfo>;\n  rating?: Maybe<Scalars['Int']['output']>;\n  recentTrip: Scalars['Boolean']['output'];\n  regionId?: Maybe<Scalars['String']['output']>;\n  replyBoard?: Maybe<ReplyBoard>;\n  resourceId: Scalars['String']['output'];\n  resourceType: Scalars['String']['output'];\n  reviewedAt: Scalars['String']['output'];\n  serviceOrigin: ServiceOrigin;\n  translatedComment?: Maybe<TranslatedComment>;\n  user?: Maybe<User>;\n  visitDate?: Maybe<Scalars['String']['output']>;\n};\n\nexport type ReviewByResourceIdsSortByInput = {\n  rating?: InputMaybe<SortDirection>;\n  reviewedAt?: InputMaybe<SortDirection>;\n};\n\nexport type ReviewCommentSpecification = {\n  __typename?: 'ReviewCommentSpecification';\n  maxLength: Scalars['Int']['output'];\n  placeholder: Scalars['String']['output'];\n  required?: Maybe<Scalars['Boolean']['output']>;\n};\n\nexport type ReviewCreateInput = {\n  comment?: InputMaybe<Scalars['String']['input']>;\n  geotags?: InputMaybe<Array<InputMaybe<GeotagInput>>>;\n  mediaIds?: InputMaybe<Array<Scalars['String']['input']>>;\n  purchaseTokenId?: InputMaybe<Scalars['String']['input']>;\n  rating?: InputMaybe<Scalars['Int']['input']>;\n  resourceId: Scalars['String']['input'];\n  resourceName: Scalars['String']['input'];\n  resourceType: ReviewResourceType;\n  visitDate?: InputMaybe<Scalars['String']['input']>;\n};\n\nexport type ReviewMediaSpecification = {\n  __typename?: 'ReviewMediaSpecification';\n  maxCount: Scalars['Int']['output'];\n  required?: Maybe<Scalars['Boolean']['output']>;\n};\n\nexport type ReviewMetadata = {\n  __typename?: 'ReviewMetadata';\n  global?: Maybe<ReviewMetadataDetail>;\n  id: Scalars['ID']['output'];\n  triple?: Maybe<ReviewMetadataDetail>;\n};\n\nexport type ReviewMetadataDetail = {\n  __typename?: 'ReviewMetadataDetail';\n  averageRating?: Maybe<Scalars['Float']['output']>;\n  imagesCount?: Maybe<Scalars['Int']['output']>;\n  reviewsCount?: Maybe<Scalars['Int']['output']>;\n};\n\nexport type ReviewPurchaseInfo = {\n  __typename?: 'ReviewPurchaseInfo';\n  displayName: Scalars['String']['output'];\n  orderId: Scalars['String']['output'];\n  purchaseCount: Scalars['Int']['output'];\n  purchaseDate: Scalars['String']['output'];\n};\n\nexport type ReviewPurchaseToken = {\n  __typename?: 'ReviewPurchaseToken';\n  createdAt: Scalars['String']['output'];\n  displayName: Scalars['String']['output'];\n  orderId: Scalars['String']['output'];\n  purchaseCount: Scalars['Int']['output'];\n  purchaseDate: Scalars['String']['output'];\n  purchaseTokenId: Scalars['String']['output'];\n  resourceId: Scalars['String']['output'];\n  resourceType: Scalars['String']['output'];\n};\n\nexport type ReviewRatingSpecification = {\n  __typename?: 'ReviewRatingSpecification';\n  description?: Maybe<Array<Scalars['String']['output']>>;\n  required?: Maybe<Scalars['Boolean']['output']>;\n};\n\nexport type ReviewReaction = {\n  __typename?: 'ReviewReaction';\n  id: Scalars['ID']['output'];\n  reactedAt: Scalars['String']['output'];\n  review: Review;\n  serviceOrigin: ServiceOrigin;\n  type: Scalars['String']['output'];\n  user: User;\n};\n\nexport enum ReviewReportType {\n  Abuse = 'ABUSE',\n  Commercial = 'COMMERCIAL',\n  Delete = 'DELETE',\n  Etc = 'ETC',\n  Illegal = 'ILLEGAL',\n  Infringement = 'INFRINGEMENT',\n  NotRelevant = 'NOT_RELEVANT',\n  Obscene = 'OBSCENE',\n  Privacy = 'PRIVACY',\n  SameContents = 'SAME_CONTENTS'\n}\n\nexport type ReviewResourceBoard = {\n  __typename?: 'ReviewResourceBoard';\n  averageRating: Scalars['Float']['output'];\n  imagesCount: Scalars['Int']['output'];\n  resourceId: Scalars['ID']['output'];\n  resourceType: Scalars['String']['output'];\n  reviewsCount: Scalars['Int']['output'];\n  reviewsWithMediaCount: Scalars['Int']['output'];\n};\n\nexport enum ReviewResourceType {\n  Article = 'article',\n  Attraction = 'attraction',\n  Hotel = 'hotel',\n  Package = 'package',\n  Poi = 'poi',\n  Restaurant = 'restaurant',\n  Tna = 'tna'\n}\n\nexport type ReviewSpecification = {\n  __typename?: 'ReviewSpecification';\n  comment: ReviewCommentSpecification;\n  media: ReviewMediaSpecification;\n  rating?: Maybe<ReviewRatingSpecification>;\n};\n\nexport type ReviewUpdateInput = {\n  comment?: InputMaybe<Scalars['String']['input']>;\n  mediaIds?: InputMaybe<Array<Scalars['String']['input']>>;\n  rating?: InputMaybe<Scalars['Int']['input']>;\n  visitDate?: InputMaybe<Scalars['String']['input']>;\n};\n\nexport type ReviewUserBoard = {\n  __typename?: 'ReviewUserBoard';\n  reports: Scalars['Int']['output'];\n  reviewsV2: Scalars['Int']['output'];\n  thanks: Scalars['Int']['output'];\n};\n\nexport type Room = {\n  __typename?: 'Room';\n  geotag: RoomGeotag;\n  id: Scalars['ID']['output'];\n  lastMessage?: Maybe<Message>;\n  title: Scalars['String']['output'];\n  travelingUsers?: Maybe<TravelingUsers>;\n};\n\nexport type RoomGeotag = {\n  __typename?: 'RoomGeotag';\n  id: Scalars['ID']['output'];\n  names: Scalars['JSON']['output'];\n  type: Scalars['String']['output'];\n};\n\nexport type Scrap = {\n  __typename?: 'Scrap';\n  comment?: Maybe<Scalars['String']['output']>;\n  content: Content;\n  createdAt: Scalars['Float']['output'];\n  id: Scalars['ID']['output'];\n  updatedAt: Scalars['Float']['output'];\n};\n\nexport enum ScrapContentType {\n  ArticlesArticle = 'ARTICLES_ARTICLE',\n  PoisAttraction = 'POIS_ATTRACTION',\n  PoisHotel = 'POIS_HOTEL',\n  PoisRestaurant = 'POIS_RESTAURANT',\n  Tna = 'TNA'\n}\n\nexport type ScrapMetadata = {\n  __typename?: 'ScrapMetadata';\n  global?: Maybe<ScrapMetadataDetail>;\n  id: Scalars['ID']['output'];\n  triple?: Maybe<ScrapMetadataDetail>;\n};\n\nexport type ScrapMetadataDetail = {\n  __typename?: 'ScrapMetadataDetail';\n  scrapsCount?: Maybe<Scalars['Int']['output']>;\n};\n\nexport enum ServiceOrigin {\n  Global = 'global',\n  Int = 'int',\n  Triple = 'triple'\n}\n\nexport type SortByRatingsInput = {\n  rating?: InputMaybe<Scalars['String']['input']>;\n};\n\nexport enum SortDirection {\n  Asc = 'asc',\n  Desc = 'desc'\n}\n\nexport type Span = {\n  __typename?: 'Span';\n  end: Scalars['String']['output'];\n  start: Scalars['String']['output'];\n};\n\nexport type StructuredAddress = {\n  __typename?: 'StructuredAddress';\n  addressCountry?: Maybe<Scalars['String']['output']>;\n  addressLocality?: Maybe<Scalars['String']['output']>;\n  addressRegion?: Maybe<Scalars['String']['output']>;\n  postalCode?: Maybe<Scalars['String']['output']>;\n  streetAddress?: Maybe<Scalars['String']['output']>;\n};\n\nexport type TnaProduct = {\n  __typename?: 'TnaProduct';\n  id: Scalars['ID']['output'];\n  scraped?: Maybe<Scalars['Boolean']['output']>;\n};\n\nexport type TranslatedComment = {\n  __typename?: 'TranslatedComment';\n  en?: Maybe<Scalars['String']['output']>;\n  ja?: Maybe<Scalars['String']['output']>;\n  ko?: Maybe<Scalars['String']['output']>;\n  zh?: Maybe<Scalars['String']['output']>;\n};\n\nexport type TravelingUsers = {\n  __typename?: 'TravelingUsers';\n  count: Scalars['Int']['output'];\n  recordedAt: Scalars['DateTime']['output'];\n};\n\nexport type Trip = {\n  __typename?: 'Trip';\n  companionCount?: Maybe<Scalars['Int']['output']>;\n  dateInterval?: Maybe<Scalars['Int']['output']>;\n  deleted?: Maybe<Scalars['Boolean']['output']>;\n  end?: Maybe<Scalars['String']['output']>;\n  geotagStyles: Array<TripGeotagStyle>;\n  geotags: Array<TripGeotag>;\n  localEndDate?: Maybe<Scalars['String']['output']>;\n  localStartDate?: Maybe<Scalars['String']['output']>;\n  share?: Maybe<TripShare>;\n  start?: Maybe<Scalars['String']['output']>;\n  timezone?: Maybe<Scalars['String']['output']>;\n  tripCompanions: Array<Maybe<TripCompanion>>;\n  tripTitle?: Maybe<Scalars['String']['output']>;\n};\n\nexport type TripCompanion = {\n  __typename?: 'TripCompanion';\n  name: Scalars['String']['output'];\n  photo: Scalars['String']['output'];\n};\n\nexport type TripFlightInformation = {\n  __typename?: 'TripFlightInformation';\n  airport?: Maybe<TripFlightScheduleAirport>;\n  schedule?: Maybe<Scalars['String']['output']>;\n  scheduleDate?: Maybe<Scalars['String']['output']>;\n  scheduleTime?: Maybe<Scalars['String']['output']>;\n  terminal?: Maybe<Scalars['String']['output']>;\n};\n\nexport type TripFlightSchedule = {\n  __typename?: 'TripFlightSchedule';\n  airline: TripFlightScheduleAirline;\n  arrival: TripFlightInformation;\n  attachments?: Maybe<Array<TripFlightScheduleAttachment>>;\n  departure: TripFlightInformation;\n  departureDate: Scalars['String']['output'];\n  flightDuration: Scalars['String']['output'];\n  flightName: Scalars['String']['output'];\n  flightNumber: Scalars['String']['output'];\n  flightScheduleId: Scalars['ID']['output'];\n  memo?: Maybe<Scalars['String']['output']>;\n  operatingAirline?: Maybe<TripFlightScheduleAirline>;\n  operatingFlight?: Maybe<Scalars['String']['output']>;\n  orderId?: Maybe<Scalars['String']['output']>;\n};\n\nexport type TripFlightScheduleAirline = {\n  __typename?: 'TripFlightScheduleAirline';\n  iata: Scalars['String']['output'];\n  id: Scalars['ID']['output'];\n  nameEn: Scalars['String']['output'];\n  nameKo: Scalars['String']['output'];\n};\n\nexport type TripFlightScheduleAirport = {\n  __typename?: 'TripFlightScheduleAirport';\n  airport: Scalars['String']['output'];\n  cityNameEn?: Maybe<Scalars['String']['output']>;\n  cityNameKo?: Maybe<Scalars['String']['output']>;\n  iata: Scalars['String']['output'];\n  id?: Maybe<Scalars['ID']['output']>;\n  nameEn?: Maybe<Scalars['String']['output']>;\n  nameKo?: Maybe<Scalars['String']['output']>;\n  poiId?: Maybe<Scalars['String']['output']>;\n};\n\nexport type TripFlightScheduleAttachment = {\n  __typename?: 'TripFlightScheduleAttachment';\n  format: Scalars['String']['output'];\n  fullImage: Scalars['String']['output'];\n  height: Scalars['Int']['output'];\n  id: Scalars['ID']['output'];\n  largeThumbnail: Scalars['String']['output'];\n  name: Scalars['String']['output'];\n  smallThumbnail: Scalars['String']['output'];\n  url: Scalars['String']['output'];\n  width: Scalars['Int']['output'];\n};\n\nexport type TripGeotag = {\n  __typename?: 'TripGeotag';\n  id: Scalars['String']['output'];\n  type: Scalars['String']['output'];\n};\n\nexport type TripGeotagMedia = {\n  __typename?: 'TripGeotagMedia';\n  backgroundImage: TripMediaSource;\n  backgroundVideo: TripMediaSource;\n  blurredBackgroundImage: TripMediaSource;\n  logoImage: TripMediaSource;\n};\n\nexport type TripGeotagStyle = {\n  __typename?: 'TripGeotagStyle';\n  countryCode?: Maybe<Scalars['String']['output']>;\n  id: Scalars['String']['output'];\n  media?: Maybe<TripGeotagMedia>;\n  names?: Maybe<TripGeotagStyleName>;\n};\n\nexport type TripGeotagStyleName = {\n  __typename?: 'TripGeotagStyleName';\n  en?: Maybe<Scalars['String']['output']>;\n  ko?: Maybe<Scalars['String']['output']>;\n  local?: Maybe<Scalars['String']['output']>;\n};\n\nexport type TripLodgingBookingInfo = {\n  __typename?: 'TripLodgingBookingInfo';\n  bookingId?: Maybe<Scalars['ID']['output']>;\n  checkIn?: Maybe<Scalars['String']['output']>;\n  checkOut?: Maybe<Scalars['String']['output']>;\n};\n\nexport type TripMediaImgUrl = {\n  __typename?: 'TripMediaImgUrl';\n  url?: Maybe<Scalars['String']['output']>;\n};\n\nexport type TripMediaSize = {\n  __typename?: 'TripMediaSize';\n  full: TripMediaImgUrl;\n  large: TripMediaImgUrl;\n  small_square: TripMediaImgUrl;\n};\n\nexport type TripMediaSource = {\n  __typename?: 'TripMediaSource';\n  cloudinaryBucket: Scalars['String']['output'];\n  cloudinaryId: Scalars['String']['output'];\n  height: Scalars['Int']['output'];\n  id: Scalars['String']['output'];\n  sizes: TripMediaSize;\n  source?: Maybe<TripMediaImgUrl>;\n  type: Scalars['String']['output'];\n  video?: Maybe<TripMediaSize>;\n  width: Scalars['Int']['output'];\n};\n\nexport type TripPlan = {\n  __typename?: 'TripPlan';\n  createdAt: Scalars['String']['output'];\n  customPoi?: Maybe<CustomPoi>;\n  day: Scalars['Int']['output'];\n  flightSchedule?: Maybe<TripFlightSchedule>;\n  id: Scalars['ID']['output'];\n  lodgingBooking?: Maybe<TripLodgingBookingInfo>;\n  memo?: Maybe<Scalars['String']['output']>;\n  poi?: Maybe<Poi>;\n  time?: Maybe<Scalars['String']['output']>;\n  tnaProduct?: Maybe<TripTnaProduct>;\n  tripPlanImages?: Maybe<Array<TripPlanImage>>;\n  type: Scalars['String']['output'];\n};\n\nexport type TripPlanImage = {\n  __typename?: 'TripPlanImage';\n  format: Scalars['String']['output'];\n  fullImage: Scalars['String']['output'];\n  height: Scalars['Int']['output'];\n  id: Scalars['ID']['output'];\n  largeThumbnail: Scalars['String']['output'];\n  name: Scalars['String']['output'];\n  smallThumbnail: Scalars['String']['output'];\n  url: Scalars['String']['output'];\n  width: Scalars['Int']['output'];\n};\n\nexport type TripShare = {\n  __typename?: 'TripShare';\n  kakaoShareImage: Scalars['String']['output'];\n  message: Scalars['String']['output'];\n  shareImage: Scalars['String']['output'];\n  title: Scalars['String']['output'];\n  webLink: Scalars['String']['output'];\n};\n\nexport type TripTnaArea = {\n  __typename?: 'TripTnaArea';\n  id: Scalars['String']['output'];\n  name: Scalars['String']['output'];\n  priority: Scalars['Int']['output'];\n};\n\nexport type TripTnaCategory = {\n  __typename?: 'TripTnaCategory';\n  id: Scalars['ID']['output'];\n  name: Scalars['String']['output'];\n};\n\nexport type TripTnaGeotag = {\n  __typename?: 'TripTnaGeotag';\n  id: Scalars['String']['output'];\n  name?: Maybe<Scalars['String']['output']>;\n  type: Scalars['String']['output'];\n};\n\nexport type TripTnaLocation = {\n  __typename?: 'TripTnaLocation';\n  address: Scalars['String']['output'];\n  geotags?: Maybe<Array<TripTnaGeotag>>;\n  id: Scalars['ID']['output'];\n  lat: Scalars['Float']['output'];\n  lng: Scalars['Float']['output'];\n  representative: Scalars['Boolean']['output'];\n};\n\nexport type TripTnaProduct = {\n  __typename?: 'TripTnaProduct';\n  areas?: Maybe<Array<TripTnaArea>>;\n  basePrice?: Maybe<Scalars['Float']['output']>;\n  bookingId?: Maybe<Scalars['ID']['output']>;\n  categories?: Maybe<Array<TripTnaCategory>>;\n  displayName?: Maybe<Scalars['String']['output']>;\n  heroImage?: Maybe<Scalars['String']['output']>;\n  id: Scalars['ID']['output'];\n  locations?: Maybe<Array<TripTnaLocation>>;\n  priority?: Maybe<Scalars['Float']['output']>;\n  productId?: Maybe<Scalars['String']['output']>;\n  promotions?: Maybe<Array<TripTnaPromotion>>;\n  regions?: Maybe<Array<Region>>;\n  reviewsCount?: Maybe<Scalars['Float']['output']>;\n  reviewsRating?: Maybe<Scalars['Int']['output']>;\n  scrapsCount?: Maybe<Scalars['Float']['output']>;\n  shortTitle: Scalars['String']['output'];\n  subtitle: Scalars['String']['output'];\n  title: Scalars['String']['output'];\n};\n\nexport type TripTnaPromotion = {\n  __typename?: 'TripTnaPromotion';\n  id: Scalars['ID']['output'];\n  numOfProducts?: Maybe<Scalars['Int']['output']>;\n  priority?: Maybe<Scalars['Int']['output']>;\n  title: Scalars['String']['output'];\n};\n\nexport type User = {\n  __typename?: 'User';\n  mileage?: Maybe<UserMileage>;\n  name?: Maybe<Scalars['String']['output']>;\n  photo?: Maybe<Scalars['String']['output']>;\n  uid: Scalars['String']['output'];\n  unfriended: Scalars['Boolean']['output'];\n  unregister?: Maybe<Scalars['Boolean']['output']>;\n  unregistered?: Maybe<Scalars['Boolean']['output']>;\n  userBoard?: Maybe<UserBoard>;\n};\n\nexport type UserBoard = {\n  __typename?: 'UserBoard';\n  itineraries?: Maybe<Scalars['Int']['output']>;\n  reports?: Maybe<Scalars['Int']['output']>;\n  reviews?: Maybe<Scalars['Int']['output']>;\n  reviewsV2?: Maybe<Scalars['Int']['output']>;\n  thanks?: Maybe<Scalars['Int']['output']>;\n  trips?: Maybe<Scalars['Int']['output']>;\n};\n\nexport type UserMileage = {\n  __typename?: 'UserMileage';\n  badges?: Maybe<Array<Maybe<UserMileageBadge>>>;\n  level?: Maybe<Scalars['Int']['output']>;\n  point?: Maybe<Scalars['Int']['output']>;\n};\n\nexport type UserMileageBadge = {\n  __typename?: 'UserMileageBadge';\n  icon?: Maybe<UserMileageIcon>;\n  label?: Maybe<Scalars['String']['output']>;\n};\n\nexport type UserMileageIcon = {\n  __typename?: 'UserMileageIcon';\n  image_url?: Maybe<Scalars['String']['output']>;\n};\n\nexport type Zone = {\n  __typename?: 'Zone';\n  createdAt: Scalars['DateTime']['output'];\n  id: Scalars['ID']['output'];\n  regions: Array<Region>;\n  source: ZoneSource;\n  state: Scalars['String']['output'];\n  updatedAt: Scalars['DateTime']['output'];\n};\n\nexport type ZoneSource = {\n  __typename?: 'ZoneSource';\n  attractionCategories: Array<Scalars['JSON']['output']>;\n  attractionFilters: Array<Scalars['JSON']['output']>;\n  attractionGeotags?: Maybe<Array<Scalars['JSON']['output']>>;\n  countryCode?: Maybe<Scalars['String']['output']>;\n  currencies: Array<Scalars['String']['output']>;\n  defaultRange?: Maybe<Scalars['Int']['output']>;\n  featuredNames: Array<Scalars['String']['output']>;\n  flightHours?: Maybe<Scalars['Int']['output']>;\n  geofence?: Maybe<Scalars['JSON']['output']>;\n  geotags?: Maybe<Array<Scalars['JSON']['output']>>;\n  guideTags: Array<Scalars['JSON']['output']>;\n  languages: Array<Scalars['String']['output']>;\n  media: Scalars['JSON']['output'];\n  menu: Scalars['JSON']['output'];\n  names: Scalars['JSON']['output'];\n  popularKeywords: Array<Scalars['String']['output']>;\n  ranges?: Maybe<Array<Scalars['Int']['output']>>;\n  regionIds: Array<Scalars['String']['output']>;\n  restaurantCategories: Array<Scalars['JSON']['output']>;\n  restaurantClustering?: Maybe<Scalars['Boolean']['output']>;\n  restaurantFilters: Array<Scalars['JSON']['output']>;\n  restaurantGeotags?: Maybe<Array<Scalars['JSON']['output']>>;\n  terminals: Array<Scalars['JSON']['output']>;\n  timeZone?: Maybe<Scalars['String']['output']>;\n};\n\nexport type LikeReviewMutationVariables = Exact<{\n  reviewId: Scalars['String']['input'];\n}>;\n\n\nexport type LikeReviewMutation = { __typename?: 'Mutation', likeReview: { __typename?: 'ReviewReaction', id: string } };\n\nexport type UnlikeReviewMutationVariables = Exact<{\n  reviewId: Scalars['String']['input'];\n}>;\n\n\nexport type UnlikeReviewMutation = { __typename?: 'Mutation', unlikeReview: boolean };\n\nexport type DeleteReviewMutationVariables = Exact<{\n  id: Scalars['ID']['input'];\n}>;\n\n\nexport type DeleteReviewMutation = { __typename?: 'Mutation', deleteReview: boolean };\n\nexport type BaseReviewFragment = { __typename?: 'Review', id: string, resourceId: string, resourceType: string, comment?: string | null, media?: Array<any> | null, rating?: number | null, visitDate?: string | null, recentTrip: boolean, likesCount: number, blinded?: boolean | null, reviewedAt: string, liked: boolean, user?: { __typename?: 'User', unregister?: boolean | null, uid: string, photo?: string | null, name?: string | null, mileage?: { __typename?: 'UserMileage', level?: number | null, point?: number | null, badges?: Array<{ __typename?: 'UserMileageBadge', label?: string | null, icon?: { __typename?: 'UserMileageIcon', image_url?: string | null } | null } | null> | null } | null, userBoard?: { __typename?: 'UserBoard', trips?: number | null, reviews?: number | null, thanks?: number | null, reports?: number | null, reviewsV2?: number | null, itineraries?: number | null } | null } | null, replyBoard?: { __typename?: 'ReplyBoard', id: string, resourceId: string, resourceType: string, rootMessagesCount: number, childMessagesCount: number, pinnedMessagesCount: number, pinnedMessages: Array<{ __typename?: 'ReplyMessage', createdAt: string, updatedAt: string, content: { __typename?: 'ReplyMessageContent', text?: string | null, markdownText?: string | null }, writer?: { __typename?: 'ReplyUser', name: string } | null }> } | null, purchaseInfo?: { __typename?: 'ReviewPurchaseInfo', orderId: string, displayName: string, purchaseDate: string, purchaseCount: number } | null };\n\nexport type BaseUserFragment = { __typename?: 'User', unregister?: boolean | null, uid: string, photo?: string | null, name?: string | null, mileage?: { __typename?: 'UserMileage', level?: number | null, point?: number | null, badges?: Array<{ __typename?: 'UserMileageBadge', label?: string | null, icon?: { __typename?: 'UserMileageIcon', image_url?: string | null } | null } | null> | null } | null, userBoard?: { __typename?: 'UserBoard', trips?: number | null, reviews?: number | null, thanks?: number | null, reports?: number | null, reviewsV2?: number | null, itineraries?: number | null } | null };\n\nexport type BasePinnedMessageFragment = { __typename?: 'ReplyMessage', createdAt: string, updatedAt: string, content: { __typename?: 'ReplyMessageContent', text?: string | null, markdownText?: string | null }, writer?: { __typename?: 'ReplyUser', name: string } | null };\n\nexport type BaseReviewSpecificationFragment = { __typename?: 'ReviewSpecification', rating?: { __typename?: 'ReviewRatingSpecification', required?: boolean | null, description?: Array<string> | null } | null };\n\nexport type GetPopularReviewsQueryVariables = Exact<{\n  resourceType: Scalars['String']['input'];\n  resourceId: Scalars['String']['input'];\n  recentTrip?: InputMaybe<Scalars['Boolean']['input']>;\n  hasMedia?: InputMaybe<Scalars['Boolean']['input']>;\n  from?: InputMaybe<Scalars['Int']['input']>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n}>;\n\n\nexport type GetPopularReviewsQuery = { __typename?: 'Query', popularReviews: Array<{ __typename?: 'Review', id: string, resourceId: string, resourceType: string, comment?: string | null, media?: Array<any> | null, rating?: number | null, visitDate?: string | null, recentTrip: boolean, likesCount: number, blinded?: boolean | null, reviewedAt: string, liked: boolean, user?: { __typename?: 'User', unregister?: boolean | null, uid: string, photo?: string | null, name?: string | null, mileage?: { __typename?: 'UserMileage', level?: number | null, point?: number | null, badges?: Array<{ __typename?: 'UserMileageBadge', label?: string | null, icon?: { __typename?: 'UserMileageIcon', image_url?: string | null } | null } | null> | null } | null, userBoard?: { __typename?: 'UserBoard', trips?: number | null, reviews?: number | null, thanks?: number | null, reports?: number | null, reviewsV2?: number | null, itineraries?: number | null } | null } | null, replyBoard?: { __typename?: 'ReplyBoard', id: string, resourceId: string, resourceType: string, rootMessagesCount: number, childMessagesCount: number, pinnedMessagesCount: number, pinnedMessages: Array<{ __typename?: 'ReplyMessage', createdAt: string, updatedAt: string, content: { __typename?: 'ReplyMessageContent', text?: string | null, markdownText?: string | null }, writer?: { __typename?: 'ReplyUser', name: string } | null }> } | null, purchaseInfo?: { __typename?: 'ReviewPurchaseInfo', orderId: string, displayName: string, purchaseDate: string, purchaseCount: number } | null }> };\n\nexport type GetLatestReviewsQueryVariables = Exact<{\n  resourceType: Scalars['String']['input'];\n  resourceId: Scalars['String']['input'];\n  recentTrip?: InputMaybe<Scalars['Boolean']['input']>;\n  hasMedia?: InputMaybe<Scalars['Boolean']['input']>;\n  from?: InputMaybe<Scalars['Int']['input']>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n}>;\n\n\nexport type GetLatestReviewsQuery = { __typename?: 'Query', latestReviews: Array<{ __typename?: 'Review', id: string, resourceId: string, resourceType: string, comment?: string | null, media?: Array<any> | null, rating?: number | null, visitDate?: string | null, recentTrip: boolean, likesCount: number, blinded?: boolean | null, reviewedAt: string, liked: boolean, user?: { __typename?: 'User', unregister?: boolean | null, uid: string, photo?: string | null, name?: string | null, mileage?: { __typename?: 'UserMileage', level?: number | null, point?: number | null, badges?: Array<{ __typename?: 'UserMileageBadge', label?: string | null, icon?: { __typename?: 'UserMileageIcon', image_url?: string | null } | null } | null> | null } | null, userBoard?: { __typename?: 'UserBoard', trips?: number | null, reviews?: number | null, thanks?: number | null, reports?: number | null, reviewsV2?: number | null, itineraries?: number | null } | null } | null, replyBoard?: { __typename?: 'ReplyBoard', id: string, resourceId: string, resourceType: string, rootMessagesCount: number, childMessagesCount: number, pinnedMessagesCount: number, pinnedMessages: Array<{ __typename?: 'ReplyMessage', createdAt: string, updatedAt: string, content: { __typename?: 'ReplyMessageContent', text?: string | null, markdownText?: string | null }, writer?: { __typename?: 'ReplyUser', name: string } | null }> } | null, purchaseInfo?: { __typename?: 'ReviewPurchaseInfo', orderId: string, displayName: string, purchaseDate: string, purchaseCount: number } | null }> };\n\nexport type GetReviewsByRatingQueryVariables = Exact<{\n  resourceType: Scalars['String']['input'];\n  resourceId: Scalars['String']['input'];\n  recentTrip?: InputMaybe<Scalars['Boolean']['input']>;\n  hasMedia?: InputMaybe<Scalars['Boolean']['input']>;\n  from?: InputMaybe<Scalars['Int']['input']>;\n  size?: InputMaybe<Scalars['Int']['input']>;\n  sortBy?: InputMaybe<SortByRatingsInput>;\n}>;\n\n\nexport type GetReviewsByRatingQuery = { __typename?: 'Query', ratingReviews: Array<{ __typename?: 'Review', id: string, resourceId: string, resourceType: string, comment?: string | null, media?: Array<any> | null, rating?: number | null, visitDate?: string | null, recentTrip: boolean, likesCount: number, blinded?: boolean | null, reviewedAt: string, liked: boolean, user?: { __typename?: 'User', unregister?: boolean | null, uid: string, photo?: string | null, name?: string | null, mileage?: { __typename?: 'UserMileage', level?: number | null, point?: number | null, badges?: Array<{ __typename?: 'UserMileageBadge', label?: string | null, icon?: { __typename?: 'UserMileageIcon', image_url?: string | null } | null } | null> | null } | null, userBoard?: { __typename?: 'UserBoard', trips?: number | null, reviews?: number | null, thanks?: number | null, reports?: number | null, reviewsV2?: number | null, itineraries?: number | null } | null } | null, replyBoard?: { __typename?: 'ReplyBoard', id: string, resourceId: string, resourceType: string, rootMessagesCount: number, childMessagesCount: number, pinnedMessagesCount: number, pinnedMessages: Array<{ __typename?: 'ReplyMessage', createdAt: string, updatedAt: string, content: { __typename?: 'ReplyMessageContent', text?: string | null, markdownText?: string | null }, writer?: { __typename?: 'ReplyUser', name: string } | null }> } | null, purchaseInfo?: { __typename?: 'ReviewPurchaseInfo', orderId: string, displayName: string, purchaseDate: string, purchaseCount: number } | null }> };\n\nexport type GetMyReviewQueryVariables = Exact<{\n  resourceType: Scalars['String']['input'];\n  resourceId: Scalars['String']['input'];\n}>;\n\n\nexport type GetMyReviewQuery = { __typename?: 'Query', myReview?: { __typename?: 'Review', id: string, resourceId: string, resourceType: string, comment?: string | null, media?: Array<any> | null, rating?: number | null, visitDate?: string | null, recentTrip: boolean, likesCount: number, blinded?: boolean | null, reviewedAt: string, liked: boolean, user?: { __typename?: 'User', unregister?: boolean | null, uid: string, photo?: string | null, name?: string | null, mileage?: { __typename?: 'UserMileage', level?: number | null, point?: number | null, badges?: Array<{ __typename?: 'UserMileageBadge', label?: string | null, icon?: { __typename?: 'UserMileageIcon', image_url?: string | null } | null } | null> | null } | null, userBoard?: { __typename?: 'UserBoard', trips?: number | null, reviews?: number | null, thanks?: number | null, reports?: number | null, reviewsV2?: number | null, itineraries?: number | null } | null } | null, replyBoard?: { __typename?: 'ReplyBoard', id: string, resourceId: string, resourceType: string, rootMessagesCount: number, childMessagesCount: number, pinnedMessagesCount: number, pinnedMessages: Array<{ __typename?: 'ReplyMessage', createdAt: string, updatedAt: string, content: { __typename?: 'ReplyMessageContent', text?: string | null, markdownText?: string | null }, writer?: { __typename?: 'ReplyUser', name: string } | null }> } | null, purchaseInfo?: { __typename?: 'ReviewPurchaseInfo', orderId: string, displayName: string, purchaseDate: string, purchaseCount: number } | null } | null };\n\nexport type GetReviewSpecificationQueryVariables = Exact<{\n  resourceType: Scalars['String']['input'];\n  resourceId: Scalars['String']['input'];\n}>;\n\n\nexport type GetReviewSpecificationQuery = { __typename?: 'Query', reviewsSpecification?: { __typename?: 'ReviewSpecification', rating?: { __typename?: 'ReviewRatingSpecification', required?: boolean | null, description?: Array<string> | null } | null } | null };\n\nexport type GetReviewsCountQueryVariables = Exact<{\n  resourceType: Scalars['String']['input'];\n  resourceId: Scalars['String']['input'];\n  recentTrip?: InputMaybe<Scalars['Boolean']['input']>;\n  hasMedia?: InputMaybe<Scalars['Boolean']['input']>;\n}>;\n\n\nexport type GetReviewsCountQuery = { __typename?: 'Query', reviewsCount: number };\n\nexport const BaseUserFragmentDoc = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"User\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"unregister\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"uid\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"photo\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"mileage\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"level\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"point\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"badges\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"label\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"icon\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"image_url\"}}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"userBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"trips\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviews\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"thanks\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reports\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewsV2\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"itineraries\"}}]}}]}}]} as unknown as DocumentNode;\nexport const BasePinnedMessageFragmentDoc = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"ReplyMessage\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"content\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"text\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"markdownText\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createdAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"updatedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"writer\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}}]}}]}}]} as unknown as DocumentNode;\nexport const BaseReviewFragmentDoc = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReview\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Review\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"comment\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"media\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rating\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"visitDate\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"likesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"blinded\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"user\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"replyBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rootMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"childMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pinnedMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pinnedMessages\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"liked\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseInfo\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"orderId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"displayName\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseDate\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseCount\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"User\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"unregister\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"uid\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"photo\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"mileage\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"level\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"point\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"badges\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"label\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"icon\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"image_url\"}}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"userBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"trips\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviews\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"thanks\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reports\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewsV2\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"itineraries\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"ReplyMessage\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"content\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"text\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"markdownText\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createdAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"updatedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"writer\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}}]}}]}}]} as unknown as DocumentNode;\nexport const BaseReviewSpecificationFragmentDoc = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReviewSpecification\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"ReviewSpecification\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rating\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"required\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"description\"}}]}}]}}]} as unknown as DocumentNode;\nexport const LikeReviewDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"mutation\",\"name\":{\"kind\":\"Name\",\"value\":\"LikeReview\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewId\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"likeReview\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewId\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewId\"}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}}]}}]}}]} as unknown as DocumentNode;\nexport const UnlikeReviewDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"mutation\",\"name\":{\"kind\":\"Name\",\"value\":\"UnlikeReview\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewId\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"unlikeReview\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewId\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewId\"}}}]}]}}]} as unknown as DocumentNode;\nexport const DeleteReviewDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"mutation\",\"name\":{\"kind\":\"Name\",\"value\":\"DeleteReview\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"ID\"}}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"deleteReview\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}}}]}]}}]} as unknown as DocumentNode;\nexport const GetPopularReviewsDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"GetPopularReviews\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Boolean\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Boolean\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"from\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"size\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"alias\":{\"kind\":\"Name\",\"value\":\"popularReviews\"},\"name\":{\"kind\":\"Name\",\"value\":\"getPopularReviews\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"from\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"from\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"size\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"size\"}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReview\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReview\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Review\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"comment\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"media\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rating\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"visitDate\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"likesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"blinded\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"user\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"replyBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rootMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"childMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pinnedMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pinnedMessages\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"liked\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseInfo\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"orderId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"displayName\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseDate\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseCount\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"User\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"unregister\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"uid\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"photo\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"mileage\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"level\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"point\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"badges\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"label\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"icon\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"image_url\"}}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"userBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"trips\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviews\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"thanks\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reports\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewsV2\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"itineraries\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"ReplyMessage\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"content\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"text\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"markdownText\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createdAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"updatedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"writer\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}}]}}]}}]} as unknown as DocumentNode;\nexport const GetLatestReviewsDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"GetLatestReviews\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Boolean\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Boolean\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"from\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"size\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"alias\":{\"kind\":\"Name\",\"value\":\"latestReviews\"},\"name\":{\"kind\":\"Name\",\"value\":\"getLatestReviews\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"from\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"from\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"size\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"size\"}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReview\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReview\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Review\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"comment\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"media\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rating\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"visitDate\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"likesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"blinded\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"user\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"replyBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rootMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"childMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pinnedMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pinnedMessages\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"liked\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseInfo\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"orderId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"displayName\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseDate\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseCount\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"User\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"unregister\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"uid\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"photo\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"mileage\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"level\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"point\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"badges\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"label\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"icon\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"image_url\"}}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"userBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"trips\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviews\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"thanks\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reports\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewsV2\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"itineraries\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"ReplyMessage\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"content\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"text\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"markdownText\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createdAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"updatedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"writer\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}}]}}]}}]} as unknown as DocumentNode;\nexport const GetReviewsByRatingDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"GetReviewsByRating\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Boolean\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Boolean\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"from\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"size\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Int\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"sortBy\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"SortByRatingsInput\"}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"alias\":{\"kind\":\"Name\",\"value\":\"ratingReviews\"},\"name\":{\"kind\":\"Name\",\"value\":\"getReviewsByRating\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"from\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"from\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"size\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"size\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"sortBy\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"sortBy\"}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReview\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReview\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Review\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"comment\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"media\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rating\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"visitDate\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"likesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"blinded\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"user\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"replyBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rootMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"childMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pinnedMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pinnedMessages\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"liked\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseInfo\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"orderId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"displayName\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseDate\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseCount\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"User\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"unregister\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"uid\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"photo\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"mileage\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"level\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"point\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"badges\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"label\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"icon\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"image_url\"}}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"userBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"trips\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviews\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"thanks\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reports\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewsV2\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"itineraries\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"ReplyMessage\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"content\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"text\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"markdownText\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createdAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"updatedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"writer\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}}]}}]}}]} as unknown as DocumentNode;\nexport const GetMyReviewDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"GetMyReview\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"alias\":{\"kind\":\"Name\",\"value\":\"myReview\"},\"name\":{\"kind\":\"Name\",\"value\":\"getMyReview\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReview\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReview\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Review\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"comment\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"media\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rating\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"visitDate\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"likesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"blinded\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"user\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"replyBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"id\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rootMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"childMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pinnedMessagesCount\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"pinnedMessages\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"liked\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseInfo\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"orderId\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"displayName\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseDate\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"purchaseCount\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseUser\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"User\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"unregister\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"uid\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"photo\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"mileage\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"level\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"point\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"badges\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"label\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"icon\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"image_url\"}}]}}]}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"userBoard\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"trips\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviews\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"thanks\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reports\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"reviewsV2\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"itineraries\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BasePinnedMessage\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"ReplyMessage\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"content\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"text\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"markdownText\"}}]}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"createdAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"updatedAt\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"writer\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"name\"}}]}}]}}]} as unknown as DocumentNode;\nexport const GetReviewSpecificationDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"GetReviewSpecification\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"alias\":{\"kind\":\"Name\",\"value\":\"reviewsSpecification\"},\"name\":{\"kind\":\"Name\",\"value\":\"getReviewSpecification\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"FragmentSpread\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReviewSpecification\"}}]}}]}},{\"kind\":\"FragmentDefinition\",\"name\":{\"kind\":\"Name\",\"value\":\"BaseReviewSpecification\"},\"typeCondition\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"ReviewSpecification\"}},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"rating\"},\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"required\"}},{\"kind\":\"Field\",\"name\":{\"kind\":\"Name\",\"value\":\"description\"}}]}}]}}]} as unknown as DocumentNode;\nexport const GetReviewsCountDocument = {\"kind\":\"Document\",\"definitions\":[{\"kind\":\"OperationDefinition\",\"operation\":\"query\",\"name\":{\"kind\":\"Name\",\"value\":\"GetReviewsCount\"},\"variableDefinitions\":[{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}},\"type\":{\"kind\":\"NonNullType\",\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"String\"}}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Boolean\"}}},{\"kind\":\"VariableDefinition\",\"variable\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"}},\"type\":{\"kind\":\"NamedType\",\"name\":{\"kind\":\"Name\",\"value\":\"Boolean\"}}}],\"selectionSet\":{\"kind\":\"SelectionSet\",\"selections\":[{\"kind\":\"Field\",\"alias\":{\"kind\":\"Name\",\"value\":\"reviewsCount\"},\"name\":{\"kind\":\"Name\",\"value\":\"getReviewsCount\"},\"arguments\":[{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceType\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"resourceId\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"recentTrip\"}}},{\"kind\":\"Argument\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"},\"value\":{\"kind\":\"Variable\",\"name\":{\"kind\":\"Name\",\"value\":\"hasMedia\"}}}]}]}}]} as unknown as DocumentNode;\nexport type Requester<C = {}, E = unknown> = <R, V>(doc: DocumentNode, vars?: V, options?: C) => Promise<R> | AsyncIterable<R>\nexport function getSdk<C, E>(requester: Requester<C, E>) {\n  return {\n    LikeReview(variables: LikeReviewMutationVariables, options?: C): Promise<LikeReviewMutation> {\n      return requester<LikeReviewMutation, LikeReviewMutationVariables>(LikeReviewDocument, variables, options) as Promise<LikeReviewMutation>;\n    },\n    UnlikeReview(variables: UnlikeReviewMutationVariables, options?: C): Promise<UnlikeReviewMutation> {\n      return requester<UnlikeReviewMutation, UnlikeReviewMutationVariables>(UnlikeReviewDocument, variables, options) as Promise<UnlikeReviewMutation>;\n    },\n    DeleteReview(variables: DeleteReviewMutationVariables, options?: C): Promise<DeleteReviewMutation> {\n      return requester<DeleteReviewMutation, DeleteReviewMutationVariables>(DeleteReviewDocument, variables, options) as Promise<DeleteReviewMutation>;\n    },\n    GetPopularReviews(variables: GetPopularReviewsQueryVariables, options?: C): Promise<GetPopularReviewsQuery> {\n      return requester<GetPopularReviewsQuery, GetPopularReviewsQueryVariables>(GetPopularReviewsDocument, variables, options) as Promise<GetPopularReviewsQuery>;\n    },\n    GetLatestReviews(variables: GetLatestReviewsQueryVariables, options?: C): Promise<GetLatestReviewsQuery> {\n      return requester<GetLatestReviewsQuery, GetLatestReviewsQueryVariables>(GetLatestReviewsDocument, variables, options) as Promise<GetLatestReviewsQuery>;\n    },\n    GetReviewsByRating(variables: GetReviewsByRatingQueryVariables, options?: C): Promise<GetReviewsByRatingQuery> {\n      return requester<GetReviewsByRatingQuery, GetReviewsByRatingQueryVariables>(GetReviewsByRatingDocument, variables, options) as Promise<GetReviewsByRatingQuery>;\n    },\n    GetMyReview(variables: GetMyReviewQueryVariables, options?: C): Promise<GetMyReviewQuery> {\n      return requester<GetMyReviewQuery, GetMyReviewQueryVariables>(GetMyReviewDocument, variables, options) as Promise<GetMyReviewQuery>;\n    },\n    GetReviewSpecification(variables: GetReviewSpecificationQueryVariables, options?: C): Promise<GetReviewSpecificationQuery> {\n      return requester<GetReviewSpecificationQuery, GetReviewSpecificationQueryVariables>(GetReviewSpecificationDocument, variables, options) as Promise<GetReviewSpecificationQuery>;\n    },\n    GetReviewsCount(variables: GetReviewsCountQueryVariables, options?: C): Promise<GetReviewsCountQuery> {\n      return requester<GetReviewsCountQuery, GetReviewsCountQueryVariables>(GetReviewsCountDocument, variables, options) as Promise<GetReviewsCountQuery>;\n    }\n  };\n}\nexport type Sdk = ReturnType<typeof getSdk>;"
  },
  {
    "path": "packages/tds-widget/src/review/data/graphql/index.ts",
    "content": "export * from './client'\nexport * from './generated'\n"
  },
  {
    "path": "packages/tds-widget/src/review/data/graphql/mutation.graphql",
    "content": "mutation LikeReview($reviewId: String!) {\n  likeReview(reviewId: $reviewId) {\n    id\n  }\n}\n\nmutation UnlikeReview($reviewId: String!) {\n  unlikeReview(reviewId: $reviewId)\n}\n\nmutation DeleteReview($id: ID!) {\n  deleteReview(id: $id)\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/data/graphql/query.graphql",
    "content": "fragment BaseReview on Review {\n  id\n  resourceId\n  resourceType\n  comment\n  media\n  rating\n  visitDate\n  recentTrip\n  likesCount\n  blinded\n  reviewedAt\n  user {\n    ...BaseUser\n  }\n  replyBoard {\n    id\n    resourceId\n    resourceType\n    rootMessagesCount\n    childMessagesCount\n    pinnedMessagesCount\n    pinnedMessages {\n      ...BasePinnedMessage\n    }\n  }\n  liked\n  purchaseInfo {\n    orderId\n    displayName\n    purchaseDate\n    purchaseCount\n  }\n}\n\nfragment BaseUser on User {\n  unregister\n  uid\n  photo\n  mileage {\n    level\n    point\n    badges {\n      label\n      icon {\n        image_url\n      }\n    }\n  }\n  name\n  userBoard {\n    trips\n    reviews\n    thanks\n    reports\n    reviewsV2\n    itineraries\n  }\n}\n\nfragment BasePinnedMessage on ReplyMessage {\n  content {\n    text\n    markdownText\n  }\n  createdAt\n  updatedAt\n  writer {\n    name\n  }\n}\n\nfragment BaseReviewSpecification on ReviewSpecification {\n  rating {\n    required\n    description\n  }\n}\n\nquery GetPopularReviews(\n  $resourceType: String!\n  $resourceId: String!\n  $recentTrip: Boolean\n  $hasMedia: Boolean\n  $from: Int\n  $size: Int\n) {\n  popularReviews: getPopularReviews(\n    resourceType: $resourceType\n    resourceId: $resourceId\n    recentTrip: $recentTrip\n    hasMedia: $hasMedia\n    from: $from\n    size: $size\n  ) {\n    ...BaseReview\n  }\n}\n\nquery GetLatestReviews(\n  $resourceType: String!\n  $resourceId: String!\n  $recentTrip: Boolean\n  $hasMedia: Boolean\n  $from: Int\n  $size: Int\n) {\n  latestReviews: getLatestReviews(\n    resourceType: $resourceType\n    resourceId: $resourceId\n    recentTrip: $recentTrip\n    hasMedia: $hasMedia\n    from: $from\n    size: $size\n  ) {\n    ...BaseReview\n  }\n}\n\nquery GetReviewsByRating(\n  $resourceType: String!\n  $resourceId: String!\n  $recentTrip: Boolean\n  $hasMedia: Boolean\n  $from: Int\n  $size: Int\n  $sortBy: SortByRatingsInput\n) {\n  ratingReviews: getReviewsByRating(\n    resourceType: $resourceType\n    resourceId: $resourceId\n    recentTrip: $recentTrip\n    hasMedia: $hasMedia\n    from: $from\n    size: $size\n    sortBy: $sortBy\n  ) {\n    ...BaseReview\n  }\n}\n\nquery GetMyReview($resourceType: String!, $resourceId: String!) {\n  myReview: getMyReview(resourceType: $resourceType, resourceId: $resourceId) {\n    ...BaseReview\n  }\n}\n\nquery GetReviewSpecification($resourceType: String!, $resourceId: String!) {\n  reviewsSpecification: getReviewSpecification(\n    resourceType: $resourceType\n    resourceId: $resourceId\n  ) {\n    ...BaseReviewSpecification\n  }\n}\n\nquery GetReviewsCount(\n  $resourceType: String!\n  $resourceId: String!\n  $recentTrip: Boolean\n  $hasMedia: Boolean\n) {\n  reviewsCount: getReviewsCount(\n    resourceType: $resourceType\n    resourceId: $resourceId\n    recentTrip: $recentTrip\n    hasMedia: $hasMedia\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/index.ts",
    "content": "export * from './components/reviews-shorten'\nexport * from './components/reviews'\nexport * from './utils'\nexport * from './services'\nexport type { SortingOption, SortingType } from './components/sorting-context'\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/review-element.duo-images.json",
    "content": "{\n  \"id\": \"a3784ffc-79d2-4b09-b02b-e0f964b5b70d\",\n  \"resourceId\": \"resource-id\",\n  \"resourceType\": \"attraction\",\n  \"media\": [\n    {\n      \"cloudinaryId\": \"86dc0526-2752-4382-bcf3-a462af6d1f3a\",\n      \"id\": \"b6374985-7cca-4b78-bf43-cc6f9d04ecc1\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        }\n      },\n      \"width\": 1078,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"91d9c1d0-300d-4fea-9146-31e339262344\",\n      \"id\": \"3f66edb6-b97d-420c-bfd4-54e3deb64e0d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    }\n  ],\n  \"rating\": 4,\n  \"visitDate\": \"2023-12\",\n  \"likesCount\": 3,\n  \"reviewedAt\": \"2023-12-15T05:09:41.416Z\",\n  \"user\": {\n    \"unregister\": false,\n    \"uid\": \"LKfR6Z3QGLb3rgKHXuJhpdgvfeS2\",\n    \"photo\": \"https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile05_imckmy.png\",\n    \"name\": \"hjazz\",\n    \"mileage\": {\n      \"level\": 3,\n      \"point\": 201,\n      \"badges\": [\n        {\n          \"icon\": {\n            \"image_url\": \"https://assets.triple.guide/images/img_badge_level3.png\"\n          }\n        }\n      ]\n    },\n    \"userBoard\": {\n      \"trips\": 17,\n      \"reviews\": 0,\n      \"thanks\": 5,\n      \"reports\": 1,\n      \"reviewsV2\": 16,\n      \"itineraries\": 4,\n      \"scraps\": 0\n    }\n  },\n  \"comment\": \"미디어 2개\",\n  \"liked\": false,\n  \"recentTrip\": false,\n  \"purchaseInfo\": {\n    \"orderId\": \"11\",\n    \"purchaseCount\": 2,\n    \"displayName\": \"디럭스 트윈룸, 호수 전망 + 조식 성인2 포함 + 워터파크 + 골프장 스페셜 골드 프리미엄 얼리버드 특가 (키즈 라운지 추가금 발생)\",\n    \"purchaseDate\": \"2023-12-12\"\n  },\n  \"replyBoard\": {\n    \"id\": \"board-id\",\n    \"resourceId\": \"resource-id\",\n    \"resourceType\": \"hotel\",\n    \"pinnedMessages\": [\n      {\n        \"createdAt\": \"2023-12-15T05:09:41.416Z\",\n        \"updatedAt\": \"2023-12-15T05:09:41.416Z\",\n        \"content\": {\n          \"text\": \"리뷰에 대해 파트너가 작성한 답변입니다.\"\n        }\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/review-element.duo-videos.json",
    "content": "{\n  \"id\": \"a3784ffc-79d2-4b09-b02b-e0f964b5b70d\",\n  \"resourceId\": \"resource-id\",\n  \"resourceType\": \"attraction\",\n  \"media\": [\n    {\n      \"cloudinaryId\": \"91d9c1d0-300d-4fea-9146-31e339262344\",\n      \"id\": \"3f66edb6-b97d-420c-bfd4-54e3deb64e0d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"4b8d3d73-e959-417a-89e9-0443e9a41baf\",\n      \"id\": \"5ba70be6-5618-4f82-9ca7-a2e5cd233816\",\n      \"type\": \"video\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_fill,f_auto,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        }\n      },\n      \"width\": 720,\n      \"height\": 1280,\n      \"cloudinaryBucket\": \"triple-dev\",\n      \"video\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_fill,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        }\n      }\n    }\n  ],\n  \"rating\": 4,\n  \"visitDate\": \"2022-04\",\n  \"likesCount\": 3,\n  \"reviewedAt\": \"2022-05-30T05:09:41.416Z\",\n  \"user\": {\n    \"unregister\": false,\n    \"uid\": \"LKfR6Z3QGLb3rgKHXuJhpdgvfeS2\",\n    \"photo\": \"https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile05_imckmy.png\",\n    \"name\": \"hjazz\",\n    \"mileage\": {\n      \"level\": 3,\n      \"point\": 201,\n      \"badges\": [\n        {\n          \"icon\": {\n            \"image_url\": \"https://assets.triple.guide/images/img_badge_level3.png\"\n          }\n        }\n      ]\n    },\n    \"userBoard\": {\n      \"trips\": 17,\n      \"reviews\": 0,\n      \"thanks\": 5,\n      \"reports\": 1,\n      \"reviewsV2\": 16,\n      \"itineraries\": 4,\n      \"scraps\": 0\n    }\n  },\n  \"comment\": \"미디어 2개\",\n  \"liked\": false,\n  \"recentTrip\": false\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/review-element.mono-image.json",
    "content": "{\n  \"id\": \"a3784ffc-79d2-4b09-b02b-e0f964b5b70d\",\n  \"resourceId\": \"resource-id\",\n  \"resourceType\": \"attraction\",\n  \"media\": [\n    {\n      \"cloudinaryId\": \"86dc0526-2752-4382-bcf3-a462af6d1f3a\",\n      \"id\": \"b6374985-7cca-4b78-bf43-cc6f9d04ecc1\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        }\n      },\n      \"width\": 1078,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    }\n  ],\n  \"rating\": 4,\n  \"visitDate\": \"2023-12\",\n  \"likesCount\": 3,\n  \"reviewedAt\": \"2022-05-30T05:09:41.416Z\",\n  \"user\": {\n    \"unregister\": false,\n    \"uid\": \"LKfR6Z3QGLb3rgKHXuJhpdgvfeS2\",\n    \"photo\": \"https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile05_imckmy.png\",\n    \"name\": \"hjazz\",\n    \"mileage\": {\n      \"level\": 3,\n      \"point\": 201,\n      \"badges\": [\n        {\n          \"icon\": {\n            \"image_url\": \"https://assets.triple.guide/images/img_badge_level3.png\"\n          }\n        }\n      ]\n    },\n    \"userBoard\": {\n      \"trips\": 17,\n      \"reviews\": 0,\n      \"thanks\": 5,\n      \"reports\": 1,\n      \"reviewsV2\": 16,\n      \"itineraries\": 4,\n      \"scraps\": 0\n    }\n  },\n  \"comment\": \"미디어 1개\",\n  \"liked\": false,\n  \"recentTrip\": true,\n  \"purchaseInfo\": {\n    \"orderId\": \"11\",\n    \"purchaseCount\": 2,\n    \"displayName\": \"디럭스 트윈룸, 호수 전망 + 조식 성인2 포함 + 워터파크 + 골프장 스페셜 골드 프리미엄 얼리버드 특가 (키즈 라운지 추가금 발생)\",\n    \"purchaseDate\": \"2023-12-12\"\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/review-element.mono-video.json",
    "content": "{\n  \"id\": \"a3784ffc-79d2-4b09-b02b-e0f964b5b70d\",\n  \"resourceId\": \"resource-id\",\n  \"resourceType\": \"attraction\",\n  \"media\": [\n    {\n      \"cloudinaryId\": \"4b8d3d73-e959-417a-89e9-0443e9a41baf\",\n      \"id\": \"5ba70be6-5618-4f82-9ca7-a2e5cd233816\",\n      \"type\": \"video\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_fill,f_auto,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        }\n      },\n      \"width\": 720,\n      \"height\": 1280,\n      \"cloudinaryBucket\": \"triple-dev\",\n      \"video\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_fill,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        }\n      }\n    }\n  ],\n  \"rating\": 4,\n  \"visitDate\": \"2022-04\",\n  \"likesCount\": 3,\n  \"reviewedAt\": \"2022-05-30T05:09:41.416Z\",\n  \"user\": {\n    \"unregister\": false,\n    \"uid\": \"LKfR6Z3QGLb3rgKHXuJhpdgvfeS2\",\n    \"photo\": \"https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile05_imckmy.png\",\n    \"name\": \"hjazz\",\n    \"mileage\": {\n      \"level\": 3,\n      \"point\": 201,\n      \"badges\": [\n        {\n          \"icon\": {\n            \"image_url\": \"https://assets.triple.guide/images/img_badge_level3.png\"\n          }\n        }\n      ]\n    },\n    \"userBoard\": {\n      \"trips\": 17,\n      \"reviews\": 0,\n      \"thanks\": 5,\n      \"reports\": 1,\n      \"reviewsV2\": 16,\n      \"itineraries\": 4,\n      \"scraps\": 0\n    }\n  },\n  \"comment\": \"미디어 1개\",\n  \"liked\": false,\n  \"recentTrip\": false\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/review-element.more-images.json",
    "content": "{\n  \"id\": \"a3784ffc-79d2-4b09-b02b-e0f964b5b70d\",\n  \"resourceId\": \"resource-id\",\n  \"resourceType\": \"attraction\",\n  \"media\": [\n    {\n      \"cloudinaryId\": \"86dc0526-2752-4382-bcf3-a462af6d1f3a\",\n      \"id\": \"b6374985-7cca-4b78-bf43-cc6f9d04ecc1\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        }\n      },\n      \"width\": 1078,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"91d9c1d0-300d-4fea-9146-31e339262344\",\n      \"id\": \"3f66edb6-b97d-420c-bfd4-54e3deb64e0d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f\",\n      \"id\": \"7c6205d7-de16-4451-9057-083001e4bb9d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"cab00a17-2444-4671-9106-40170ed61e63\",\n      \"id\": \"0a784bbe-64b2-473f-88a3-9afba03cfa6d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"bb6bf641-6830-452d-869a-d7a81fa05539\",\n      \"id\": \"8a2a0b60-bec8-4a96-a96f-c4a93e2fbaed\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"bb6bf641-6830-452d-869a-d7a81fa05539\",\n      \"id\": \"8a2a0b60-bec8-4a96-a96f-c4a93e2fbaed\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"bb6bf641-6830-452d-869a-d7a81fa05539\",\n      \"id\": \"8a2a0b60-bec8-4a96-a96f-c4a93e2fbaed\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    }\n  ],\n  \"rating\": 4,\n  \"visitDate\": \"2022-04\",\n  \"likesCount\": 3,\n  \"reviewedAt\": \"2022-05-30T05:09:41.416Z\",\n  \"user\": {\n    \"unregister\": false,\n    \"uid\": \"LKfR6Z3QGLb3rgKHXuJhpdgvfeS2\",\n    \"photo\": \"https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile05_imckmy.png\",\n    \"name\": \"hjazz\",\n    \"mileage\": {\n      \"level\": 3,\n      \"point\": 201,\n      \"badges\": [\n        {\n          \"icon\": {\n            \"image_url\": \"https://assets.triple.guide/images/img_badge_level3.png\"\n          }\n        }\n      ]\n    },\n    \"userBoard\": {\n      \"trips\": 17,\n      \"reviews\": 0,\n      \"thanks\": 5,\n      \"reports\": 1,\n      \"reviewsV2\": 16,\n      \"itineraries\": 4,\n      \"scraps\": 0\n    }\n  },\n  \"comment\": \"미디어 5개 이상\",\n  \"liked\": false,\n  \"recentTrip\": false\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/review-element.more-vidoes.json",
    "content": "{\n  \"id\": \"a3784ffc-79d2-4b09-b02b-e0f964b5b70d\",\n  \"resourceId\": \"resource-id\",\n  \"resourceType\": \"attraction\",\n  \"media\": [\n    {\n      \"cloudinaryId\": \"91d9c1d0-300d-4fea-9146-31e339262344\",\n      \"id\": \"3f66edb6-b97d-420c-bfd4-54e3deb64e0d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"4b8d3d73-e959-417a-89e9-0443e9a41baf\",\n      \"id\": \"5ba70be6-5618-4f82-9ca7-a2e5cd233816\",\n      \"type\": \"video\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_fill,f_auto,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        }\n      },\n      \"width\": 720,\n      \"height\": 1280,\n      \"cloudinaryBucket\": \"triple-dev\",\n      \"video\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_fill,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        }\n      }\n    },\n    {\n      \"cloudinaryId\": \"b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f\",\n      \"id\": \"7c6205d7-de16-4451-9057-083001e4bb9d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"cab00a17-2444-4671-9106-40170ed61e63\",\n      \"id\": \"0a784bbe-64b2-473f-88a3-9afba03cfa6d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    }\n  ],\n  \"rating\": 4,\n  \"visitDate\": \"2022-04\",\n  \"likesCount\": 3,\n  \"reviewedAt\": \"2022-05-30T05:09:41.416Z\",\n  \"user\": {\n    \"unregister\": false,\n    \"uid\": \"LKfR6Z3QGLb3rgKHXuJhpdgvfeS2\",\n    \"photo\": \"https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile05_imckmy.png\",\n    \"name\": \"hjazz\",\n    \"mileage\": {\n      \"level\": 3,\n      \"point\": 201,\n      \"badges\": [\n        {\n          \"icon\": {\n            \"image_url\": \"https://assets.triple.guide/images/img_badge_level3.png\"\n          }\n        }\n      ]\n    },\n    \"userBoard\": {\n      \"trips\": 17,\n      \"reviews\": 0,\n      \"thanks\": 5,\n      \"reports\": 1,\n      \"reviewsV2\": 16,\n      \"itineraries\": 4,\n      \"scraps\": 0\n    }\n  },\n  \"comment\": \"미디어 5개 이상\",\n  \"liked\": false,\n  \"recentTrip\": false\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/review-element.penta-images.json",
    "content": "{\n  \"id\": \"a3784ffc-79d2-4b09-b02b-e0f964b5b70d\",\n  \"resourceId\": \"resource-id\",\n  \"resourceType\": \"attraction\",\n  \"media\": [\n    {\n      \"cloudinaryId\": \"86dc0526-2752-4382-bcf3-a462af6d1f3a\",\n      \"id\": \"b6374985-7cca-4b78-bf43-cc6f9d04ecc1\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        }\n      },\n      \"width\": 1078,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"91d9c1d0-300d-4fea-9146-31e339262344\",\n      \"id\": \"3f66edb6-b97d-420c-bfd4-54e3deb64e0d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f\",\n      \"id\": \"7c6205d7-de16-4451-9057-083001e4bb9d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"cab00a17-2444-4671-9106-40170ed61e63\",\n      \"id\": \"0a784bbe-64b2-473f-88a3-9afba03cfa6d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"bb6bf641-6830-452d-869a-d7a81fa05539\",\n      \"id\": \"8a2a0b60-bec8-4a96-a96f-c4a93e2fbaed\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/bb6bf641-6830-452d-869a-d7a81fa05539.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    }\n  ],\n  \"rating\": 4,\n  \"visitDate\": \"2022-04\",\n  \"likesCount\": 3,\n  \"reviewedAt\": \"2022-05-30T05:09:41.416Z\",\n  \"user\": {\n    \"unregister\": false,\n    \"uid\": \"LKfR6Z3QGLb3rgKHXuJhpdgvfeS2\",\n    \"photo\": \"https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile05_imckmy.png\",\n    \"name\": \"hjazz\",\n    \"mileage\": {\n      \"level\": 3,\n      \"point\": 201,\n      \"badges\": [\n        {\n          \"icon\": {\n            \"image_url\": \"https://assets.triple.guide/images/img_badge_level3.png\"\n          }\n        }\n      ]\n    },\n    \"userBoard\": {\n      \"trips\": 17,\n      \"reviews\": 0,\n      \"thanks\": 5,\n      \"reports\": 1,\n      \"reviewsV2\": 16,\n      \"itineraries\": 4,\n      \"scraps\": 0\n    }\n  },\n  \"comment\": \"미디어 5개\",\n  \"liked\": false,\n  \"recentTrip\": false\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/review-element.quad-images.json",
    "content": "{\n  \"id\": \"a3784ffc-79d2-4b09-b02b-e0f964b5b70d\",\n  \"resourceId\": \"resource-id\",\n  \"resourceType\": \"attraction\",\n  \"media\": [\n    {\n      \"cloudinaryId\": \"86dc0526-2752-4382-bcf3-a462af6d1f3a\",\n      \"id\": \"b6374985-7cca-4b78-bf43-cc6f9d04ecc1\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        }\n      },\n      \"width\": 1078,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"91d9c1d0-300d-4fea-9146-31e339262344\",\n      \"id\": \"3f66edb6-b97d-420c-bfd4-54e3deb64e0d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f\",\n      \"id\": \"7c6205d7-de16-4451-9057-083001e4bb9d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"cab00a17-2444-4671-9106-40170ed61e63\",\n      \"id\": \"0a784bbe-64b2-473f-88a3-9afba03cfa6d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/cab00a17-2444-4671-9106-40170ed61e63.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    }\n  ],\n  \"rating\": 4,\n  \"visitDate\": \"2022-04\",\n  \"likesCount\": 3,\n  \"reviewedAt\": \"2022-05-30T05:09:41.416Z\",\n  \"user\": {\n    \"unregister\": false,\n    \"uid\": \"LKfR6Z3QGLb3rgKHXuJhpdgvfeS2\",\n    \"photo\": \"https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile05_imckmy.png\",\n    \"name\": \"hjazz\",\n    \"mileage\": {\n      \"level\": 3,\n      \"point\": 201,\n      \"badges\": [\n        {\n          \"icon\": {\n            \"image_url\": \"https://assets.triple.guide/images/img_badge_level3.png\"\n          }\n        }\n      ]\n    },\n    \"userBoard\": {\n      \"trips\": 17,\n      \"reviews\": 0,\n      \"thanks\": 5,\n      \"reports\": 1,\n      \"reviewsV2\": 16,\n      \"itineraries\": 4,\n      \"scraps\": 0\n    }\n  },\n  \"comment\": \"미디어 4개\",\n  \"liked\": false,\n  \"recentTrip\": false\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/review-element.tri-images.json",
    "content": "{\n  \"id\": \"a3784ffc-79d2-4b09-b02b-e0f964b5b70d\",\n  \"resourceId\": \"resource-id\",\n  \"resourceType\": \"attraction\",\n  \"media\": [\n    {\n      \"cloudinaryId\": \"86dc0526-2752-4382-bcf3-a462af6d1f3a\",\n      \"id\": \"b6374985-7cca-4b78-bf43-cc6f9d04ecc1\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/86dc0526-2752-4382-bcf3-a462af6d1f3a.jpeg\"\n        }\n      },\n      \"width\": 1078,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"91d9c1d0-300d-4fea-9146-31e339262344\",\n      \"id\": \"3f66edb6-b97d-420c-bfd4-54e3deb64e0d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f\",\n      \"id\": \"7c6205d7-de16-4451-9057-083001e4bb9d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    }\n  ],\n  \"rating\": 4,\n  \"visitDate\": \"2022-04\",\n  \"likesCount\": 3,\n  \"reviewedAt\": \"2022-05-30T05:09:41.416Z\",\n  \"user\": {\n    \"unregister\": false,\n    \"uid\": \"LKfR6Z3QGLb3rgKHXuJhpdgvfeS2\",\n    \"photo\": \"https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile05_imckmy.png\",\n    \"name\": \"hjazz\",\n    \"mileage\": {\n      \"level\": 3,\n      \"point\": 201,\n      \"badges\": [\n        {\n          \"icon\": {\n            \"image_url\": \"https://assets.triple.guide/images/img_badge_level3.png\"\n          }\n        }\n      ]\n    },\n    \"userBoard\": {\n      \"trips\": 17,\n      \"reviews\": 0,\n      \"thanks\": 5,\n      \"reports\": 1,\n      \"reviewsV2\": 16,\n      \"itineraries\": 4,\n      \"scraps\": 0\n    }\n  },\n  \"comment\": \"미디어 3개\",\n  \"liked\": false,\n  \"recentTrip\": false\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/review-element.tri-videos.json",
    "content": "{\n  \"id\": \"a3784ffc-79d2-4b09-b02b-e0f964b5b70d\",\n  \"resourceId\": \"resource-id\",\n  \"resourceType\": \"attraction\",\n  \"media\": [\n    {\n      \"cloudinaryId\": \"91d9c1d0-300d-4fea-9146-31e339262344\",\n      \"id\": \"3f66edb6-b97d-420c-bfd4-54e3deb64e0d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/91d9c1d0-300d-4fea-9146-31e339262344.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    },\n    {\n      \"cloudinaryId\": \"4b8d3d73-e959-417a-89e9-0443e9a41baf\",\n      \"id\": \"5ba70be6-5618-4f82-9ca7-a2e5cd233816\",\n      \"type\": \"video\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,f_auto,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_fill,f_auto,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.jpeg\"\n        }\n      },\n      \"width\": 720,\n      \"height\": 1280,\n      \"cloudinaryBucket\": \"triple-dev\",\n      \"video\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,h_2048,w_2048/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_limit,h_1024,w_1024/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/video/upload/c_fill,h_256,w_256/4b8d3d73-e959-417a-89e9-0443e9a41baf.mp4\"\n        }\n      }\n    },\n    {\n      \"cloudinaryId\": \"b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f\",\n      \"id\": \"7c6205d7-de16-4451-9057-083001e4bb9d\",\n      \"type\": \"image\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/b1a6446b-cfb4-4fa9-9b08-d4ceeea6252f.jpeg\"\n        }\n      },\n      \"width\": 1080,\n      \"height\": 1440,\n      \"cloudinaryBucket\": \"triple-dev\"\n    }\n  ],\n  \"rating\": 4,\n  \"visitDate\": \"2022-04\",\n  \"likesCount\": 3,\n  \"reviewedAt\": \"2022-05-30T05:09:41.416Z\",\n  \"user\": {\n    \"unregister\": false,\n    \"uid\": \"LKfR6Z3QGLb3rgKHXuJhpdgvfeS2\",\n    \"photo\": \"https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile05_imckmy.png\",\n    \"name\": \"hjazz\",\n    \"mileage\": {\n      \"level\": 3,\n      \"point\": 201,\n      \"badges\": [\n        {\n          \"icon\": {\n            \"image_url\": \"https://assets.triple.guide/images/img_badge_level3.png\"\n          }\n        }\n      ]\n    },\n    \"userBoard\": {\n      \"trips\": 17,\n      \"reviews\": 0,\n      \"thanks\": 5,\n      \"reports\": 1,\n      \"reviewsV2\": 16,\n      \"itineraries\": 4,\n      \"scraps\": 0\n    }\n  },\n  \"comment\": \"미디어 3개\",\n  \"liked\": false,\n  \"recentTrip\": false\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/mocks/reviews.ts",
    "content": "import { graphql, HttpResponse } from 'msw'\n\nimport {\n  DeleteReviewMutation,\n  DeleteReviewMutationVariables,\n  GetLatestReviewsQuery,\n  GetLatestReviewsQueryVariables,\n  GetMyReviewQuery,\n  GetMyReviewQueryVariables,\n  GetPopularReviewsQuery,\n  GetPopularReviewsQueryVariables,\n  GetReviewSpecificationQuery,\n  GetReviewSpecificationQueryVariables,\n  GetReviewsByRatingQuery,\n  GetReviewsByRatingQueryVariables,\n  GetReviewsCountQuery,\n  GetReviewsCountQueryVariables,\n  LikeReviewMutation,\n  UnlikeReviewMutation,\n  UnlikeReviewMutationVariables,\n} from '../data/graphql'\n\nexport const handlers = {\n  getPopularReviews: graphql.query<\n    GetPopularReviewsQuery,\n    GetPopularReviewsQueryVariables\n  >('GetPopularReviews', ({ variables }) => {\n    const { resourceId, resourceType, from, size, recentTrip, hasMedia } =\n      variables\n\n    return HttpResponse.json({\n      data: {\n        __typename: 'Query',\n        popularReviews: Array.from({ length: size ?? 1 }).map((_, index) => {\n          const id = ((from ?? 0) + index).toString()\n\n          return {\n            id,\n            resourceId,\n            resourceType,\n            comment: '리뷰 내용',\n            media: [],\n            rating: 3,\n            visitDate: null,\n            recentTrip: recentTrip ?? false,\n            hasMedia: hasMedia ?? false,\n            likesCount: 0,\n            blinded: false,\n            reviewedAt: '2023-04-27T07:18:15.918Z',\n            user: {\n              unregister: false,\n              uid: `random-uid-${id}`,\n              photo:\n                'https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile01_yuj1eh.png',\n              mileage: {\n                level: 1,\n                point: 7,\n                badges: [],\n              },\n              name: id === '2' ? 'Me' : `User ${id}`,\n              userBoard: {\n                trips: 4,\n                reviews: 0,\n                thanks: 0,\n                reports: 0,\n                reviewsV2: 1,\n                itineraries: 1,\n              },\n            },\n            replyBoard: {\n              id: `REVIEW.${id}`,\n              resourceId: id,\n              resourceType: 'review',\n              rootMessagesCount: 3,\n              childMessagesCount: 1,\n              pinnedMessagesCount: 0,\n              pinnedMessages: [],\n            },\n            liked: false,\n          }\n        }),\n      },\n    })\n  }),\n  getLatestReviews: graphql.query<\n    GetLatestReviewsQuery,\n    GetLatestReviewsQueryVariables\n  >('GetLatestReviews', ({ variables }) => {\n    const { resourceId, resourceType, from, size, recentTrip, hasMedia } =\n      variables\n\n    return HttpResponse.json({\n      data: {\n        __typename: 'Query',\n        latestReviews: Array.from({ length: size ?? 1 }).map((_, index) => {\n          const id = ((from ?? 0) + index).toString()\n\n          return {\n            id,\n            resourceId,\n            resourceType,\n            comment: '리뷰 내용',\n            media: [],\n            rating: 3,\n            visitDate: null,\n            recentTrip: recentTrip ?? false,\n            hasMedia: hasMedia ?? false,\n            likesCount: 0,\n            blinded: false,\n            reviewedAt: '2023-04-27T07:18:15.918Z',\n            user: {\n              unregister: false,\n              uid: `random-uid-${id}`,\n              photo:\n                'https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile01_yuj1eh.png',\n              mileage: {\n                level: 1,\n                point: 7,\n                badges: [],\n              },\n              name: id === '2' ? 'Me' : `User ${id}`,\n              userBoard: {\n                trips: 4,\n                reviews: 0,\n                thanks: 0,\n                reports: 0,\n                reviewsV2: 1,\n                itineraries: 1,\n              },\n            },\n            replyBoard: {\n              id: `REVIEW.${id}`,\n              resourceId: id,\n              resourceType: 'review',\n              rootMessagesCount: 3,\n              childMessagesCount: 1,\n              pinnedMessagesCount: 0,\n              pinnedMessages: [],\n            },\n            liked: false,\n          }\n        }),\n      },\n    })\n  }),\n  getReviewsByRating: graphql.query<\n    GetReviewsByRatingQuery,\n    GetReviewsByRatingQueryVariables\n  >('GetReviewsByRating', ({ variables }) => {\n    const { resourceId, resourceType, from, size, recentTrip, hasMedia } =\n      variables\n\n    return HttpResponse.json({\n      data: {\n        __typename: 'Query',\n        ratingReviews: Array.from({ length: size ?? 1 }).map((_, index) => {\n          const id = ((from ?? 0) + index).toString()\n\n          return {\n            id,\n            resourceId,\n            resourceType,\n            comment: '리뷰 내용',\n            media: [],\n            rating: 3,\n            visitDate: null,\n            recentTrip: recentTrip ?? false,\n            hasMedia: hasMedia ?? false,\n            likesCount: 0,\n            blinded: false,\n            reviewedAt: '2023-04-27T07:18:15.918Z',\n            user: {\n              unregister: false,\n              uid: `random-uid-${id}`,\n              photo:\n                'https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile01_yuj1eh.png',\n              mileage: {\n                level: 1,\n                point: 7,\n                badges: [],\n              },\n              name: id === '2' ? 'Me' : `User ${id}`,\n              userBoard: {\n                trips: 4,\n                reviews: 0,\n                thanks: 0,\n                reports: 0,\n                reviewsV2: 1,\n                itineraries: 1,\n              },\n            },\n            replyBoard: {\n              id: `REVIEW.${id}`,\n              resourceId: id,\n              resourceType: 'review',\n              rootMessagesCount: 3,\n              childMessagesCount: 1,\n              pinnedMessagesCount: 0,\n              pinnedMessages: [],\n            },\n            liked: false,\n          }\n        }),\n      },\n    })\n  }),\n  getMyReview: graphql.query<GetMyReviewQuery, GetMyReviewQueryVariables>(\n    'GetMyReview',\n    () => {\n      return HttpResponse.json({\n        data: { __typename: 'Query', myReview: null },\n      })\n    },\n  ),\n  getReviewSpecification: graphql.query<\n    GetReviewSpecificationQuery,\n    GetReviewSpecificationQueryVariables\n  >('GetReviewSpecification', () => {\n    return HttpResponse.json({\n      data: {\n        __typename: 'Query',\n        reviewsSpecification: {\n          __typename: 'ReviewSpecification',\n          rating: {\n            required: true,\n            description: [\n              '별점을 선택해주세요!',\n              '별로예요',\n              '조금 아쉬워요',\n              '주위에 있다면 가볼만해요',\n              '꽤 가볼만해요',\n              '꼭 가야 하는 곳이에요',\n            ],\n          },\n        },\n      },\n    })\n  }),\n  getReviewsCount: graphql.query<\n    GetReviewsCountQuery,\n    GetReviewsCountQueryVariables\n  >('GetReviewsCount', () => {\n    return HttpResponse.json({\n      data: {\n        __typename: 'Query',\n        reviewsCount: 100,\n      },\n    })\n  }),\n  likeReview: graphql.mutation<\n    LikeReviewMutation,\n    UnlikeReviewMutationVariables\n  >('LikeReview', ({ variables }) => {\n    const { reviewId } = variables\n\n    return HttpResponse.json({\n      data: {\n        __typename: 'Mutation',\n        likeReview: { __typename: 'ReviewReaction', id: reviewId },\n      },\n    })\n  }),\n  unlikeReview: graphql.mutation<\n    UnlikeReviewMutation,\n    UnlikeReviewMutationVariables\n  >('UnlikeReview', () => {\n    return HttpResponse.json({\n      data: {\n        __typename: 'Mutation',\n        unlikeReview: true,\n      },\n    })\n  }),\n  deleteReview: graphql.mutation<\n    DeleteReviewMutation,\n    DeleteReviewMutationVariables\n  >('DeleteReview', () => {\n    return HttpResponse.json({\n      data: {\n        __typename: 'Mutation',\n        deleteReview: true,\n      },\n    })\n  }),\n}\n\nexport const authHandlers = {\n  getMyReview: graphql.query<GetMyReviewQuery, GetMyReviewQueryVariables>(\n    'GetMyReview',\n    () => {\n      return HttpResponse.json({\n        data: {\n          __typename: 'Query',\n          myReview: {\n            id: '2',\n            resourceId: 'some-resource-id',\n            resourceType: 'some-resource-type',\n            comment: 'ㅁㄴㅇㄹ',\n            media: [],\n            rating: 3,\n            visitDate: null,\n            recentTrip: false,\n            likesCount: 0,\n            blinded: false,\n            reviewedAt: '2023-04-27T07:18:15.918Z',\n            user: {\n              unregister: false,\n              uid: 'random-uid-2',\n              photo:\n                'https://media.triple.guide/titicaca-imgs/image/upload/v1490937645/defaul_profile01_yuj1eh.png',\n              mileage: {\n                level: 1,\n                point: 7,\n                badges: [],\n              },\n              name: 'Me',\n              userBoard: {\n                trips: 4,\n                reviews: 0,\n                thanks: 0,\n                reports: 0,\n                reviewsV2: 1,\n                itineraries: 1,\n              },\n            },\n            replyBoard: {\n              id: `REVIEW.${2}`,\n              resourceId: '2',\n              resourceType: 'review',\n              rootMessagesCount: 3,\n              childMessagesCount: 1,\n              pinnedMessagesCount: 0,\n              pinnedMessages: [],\n            },\n            liked: false,\n          },\n        },\n      })\n    },\n  ),\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/review-element.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\n\nimport { ReviewElement } from './components/review-element'\nimport duoImagesData from './mocks/review-element.duo-images.json'\nimport duoVideosData from './mocks/review-element.duo-videos.json'\nimport monoImageData from './mocks/review-element.mono-image.json'\nimport monoVideoData from './mocks/review-element.mono-video.json'\nimport moreImagesData from './mocks/review-element.more-images.json'\nimport moreVideosData from './mocks/review-element.more-vidoes.json'\nimport pentaImagesData from './mocks/review-element.penta-images.json'\nimport quadImagesData from './mocks/review-element.quad-images.json'\nimport triImagesData from './mocks/review-element.tri-images.json'\nimport triVideosData from './mocks/review-element.tri-videos.json'\n\nconst queryClient = new QueryClient()\n\nconst meta: Meta<typeof ReviewElement> = {\n  title: 'tds-widget / review / Review Element',\n  component: ReviewElement,\n  decorators: [\n    (Story) => (\n      <QueryClientProvider client={queryClient}>\n        <Story />\n      </QueryClientProvider>\n    ),\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n}\n\nexport default meta\n\nexport const MonoImage: StoryObj<typeof ReviewElement> = {\n  args: {\n    review: monoImageData,\n    isMyReview: false,\n    isFullList: false,\n  },\n}\n\nexport const DuoImages: StoryObj<typeof ReviewElement> = {\n  args: {\n    review: duoImagesData,\n    isMyReview: false,\n    isFullList: false,\n  },\n}\n\nexport const TriImages: StoryObj<typeof ReviewElement> = {\n  args: {\n    review: triImagesData,\n    isMyReview: false,\n    isFullList: false,\n  },\n}\n\nexport const QuadImages: StoryObj<typeof ReviewElement> = {\n  args: {\n    review: quadImagesData,\n    isMyReview: false,\n    isFullList: false,\n  },\n}\n\nexport const PentaImages: StoryObj<typeof ReviewElement> = {\n  args: {\n    review: pentaImagesData,\n    isMyReview: false,\n    isFullList: false,\n  },\n}\n\nexport const MoreImages: StoryObj<typeof ReviewElement> = {\n  args: {\n    review: moreImagesData,\n    isMyReview: false,\n    isFullList: false,\n  },\n}\n\nexport const MonoVideo: StoryObj<typeof ReviewElement> = {\n  args: {\n    review: monoVideoData,\n    isMyReview: false,\n    isFullList: false,\n  },\n}\n\nexport const DuoVideos: StoryObj<typeof ReviewElement> = {\n  args: {\n    review: duoVideosData,\n    isMyReview: false,\n    isFullList: false,\n  },\n}\n\nexport const TriVideos: StoryObj<typeof ReviewElement> = {\n  args: {\n    review: triVideosData,\n    isMyReview: false,\n    isFullList: false,\n  },\n}\n\nexport const MoreVideos: StoryObj<typeof ReviewElement> = {\n  args: {\n    review: moreVideosData,\n    isMyReview: false,\n    isFullList: false,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/reviews-placeholder.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { ReviewsPlaceholder } from './components/review-placeholder-with-rating'\n\nconst meta: Meta<typeof ReviewsPlaceholder> = {\n  title: 'tds-widget / review / Review Placeholder',\n  component: ReviewsPlaceholder,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n}\n\nexport default meta\n\nexport const Basic: StoryObj<typeof ReviewsPlaceholder> = {\n  args: {\n    resourceType: 'tna',\n    recentTrip: false,\n    hasReviews: false,\n    isMorePage: false,\n  },\n}\n\nexport const Article: StoryObj<typeof ReviewsPlaceholder> = {\n  args: {\n    resourceType: 'article',\n    recentTrip: false,\n    hasReviews: false,\n    isMorePage: false,\n  },\n}\n\nexport const RecentTrip: StoryObj<typeof ReviewsPlaceholder> = {\n  args: {\n    resourceType: 'tna',\n    recentTrip: true,\n    hasReviews: false,\n    isMorePage: false,\n  },\n}\n\nexport const HasReviews: StoryObj<typeof ReviewsPlaceholder> = {\n  args: {\n    resourceType: 'tna',\n    recentTrip: true,\n    hasReviews: true,\n    isMorePage: false,\n  },\n}\n\nexport const IsMorePage: StoryObj<typeof ReviewsPlaceholder> = {\n  args: {\n    resourceType: 'tna',\n    recentTrip: true,\n    hasReviews: false,\n    isMorePage: true,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/reviews-shorten.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\n\nimport { authHandlers, handlers } from './mocks/reviews'\nimport { FilterProvider } from './components/filter-context'\nimport { SortingOptionsProvider } from './components/sorting-context'\n\nimport { ReviewsShorten } from '.'\n\nconst queryClient = new QueryClient()\n\nconst meta: Meta<typeof ReviewsShorten> = {\n  title: 'tds-widget / review / ReviewsShorten',\n  component: ReviewsShorten,\n  decorators: [\n    (Story) => (\n      <QueryClientProvider client={queryClient}>\n        <Story />\n      </QueryClientProvider>\n    ),\n    (Story) => (\n      <FilterProvider>\n        <SortingOptionsProvider resourceId=\"\">\n          <Story />\n        </SortingOptionsProvider>\n      </FilterProvider>\n    ),\n\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n}\n\nexport default meta\n\nexport const Basic: StoryObj<typeof ReviewsShorten> = {\n  name: '일반',\n  args: {\n    initialReviewsCount: 120,\n    resourceId: 'f939b4cb-ea3b-34b6-b430-eb5d28fbf467',\n    resourceType: 'tna',\n    placeholderText: '이 투어·티켓 어떠셨나요?',\n  },\n  parameters: {\n    msw: {\n      handlers,\n    },\n  },\n}\n\nexport const HasMyReview: StoryObj<typeof ReviewsShorten> = {\n  name: '내 리뷰 작성됨',\n  args: {\n    ...Basic.args,\n  },\n  parameters: {\n    msw: {\n      handlers: {\n        ...handlers,\n        ...authHandlers,\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/reviews.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\n\nimport { authHandlers, handlers } from './mocks/reviews'\nimport { FilterProvider } from './components/filter-context'\nimport { SortingOptionsProvider } from './components/sorting-context'\n\nimport { Reviews } from '.'\n\nconst queryClient = new QueryClient()\n\nconst meta: Meta<typeof Reviews> = {\n  title: 'tds-widget / review / Reviews',\n  component: Reviews,\n  decorators: [\n    (Story) => (\n      <QueryClientProvider client={queryClient}>\n        <Story />\n      </QueryClientProvider>\n    ),\n    (Story) => (\n      <FilterProvider>\n        <SortingOptionsProvider resourceId=\"\">\n          <Story />\n        </SortingOptionsProvider>\n      </FilterProvider>\n    ),\n\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n}\n\nexport default meta\n\nexport const Basic: StoryObj<typeof Reviews> = {\n  name: '일반',\n  args: {\n    initialReviewsCount: 120,\n    resourceId: 'f939b4cb-ea3b-34b6-b430-eb5d28fbf467',\n    resourceType: 'tna',\n    placeholderText: '이 투어·티켓 어떠셨나요?',\n  },\n  parameters: {\n    msw: {\n      handlers,\n    },\n  },\n}\n\nexport const HasMyReview: StoryObj<typeof Reviews> = {\n  name: '내 리뷰 작성됨',\n  args: {\n    ...Basic.args,\n  },\n  parameters: {\n    msw: {\n      handlers: {\n        ...handlers,\n        ...authHandlers,\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/services/index.ts",
    "content": "export * from './use-client-actions'\nexport * from './use-reviews'\n"
  },
  {
    "path": "packages/tds-widget/src/review/services/use-client-actions.tsx",
    "content": "import { useMemo } from 'react'\nimport qs from 'qs'\nimport { useClientAppActions, useEnv } from '@titicaca/triple-web'\nimport { useNavigate } from '@titicaca/router'\nimport { ImageMeta } from '@titicaca/type-definitions'\n\nimport { writeReview } from '../utils'\nimport type { SortingType, SortingOption } from '../components/sorting-context'\n\nexport function useClientActions() {\n  const { appUrlScheme } = useEnv()\n  const { navigate } = useNavigate()\n  const { getWindowId } = useClientAppActions()\n\n  return useMemo(() => {\n    return {\n      writeReview(params: {\n        resourceType: string\n        resourceId: string\n        regionId?: string\n        rating?: number\n        photoFirst?: boolean\n      }) {\n        writeReview({ appUrlScheme, ...params })\n      },\n      editReview({\n        regionId,\n        resourceId,\n        resourceType,\n      }: {\n        regionId?: string\n        resourceId: string\n        resourceType: string\n      }) {\n        const params = qs.stringify({\n          region_id: regionId,\n          resource_type: resourceType,\n          resource_id: resourceId,\n        })\n        window.location.href = `${appUrlScheme}:///reviews/edit?${params}`\n      },\n      navigateReviewList({\n        regionId,\n        resourceId,\n        resourceType,\n        hasMedia,\n        recentTrip,\n        sortingType,\n        sortingOption,\n      }: {\n        regionId?: string\n        resourceId: string\n        resourceType: string\n        hasMedia: boolean\n        recentTrip: boolean\n        sortingType?: SortingType\n        sortingOption: SortingOption\n      }) {\n        const params = qs.stringify({\n          ...(regionId && regionId !== 'null' && { region_id: regionId }),\n          resource_id: resourceId,\n          resource_type: resourceType,\n          recent_trip: recentTrip,\n          sorting_type: sortingType,\n          sorting_option: sortingOption,\n          has_media: hasMedia,\n          opener_id: getWindowId && getWindowId(),\n        })\n\n        navigate(`/reviews/list?_triple_no_navbar&${params}`)\n      },\n      navigateUserDetail(uid: string) {\n        navigate(`${appUrlScheme}:///users/${uid}`)\n      },\n      navigateImages(images: ImageMeta[], index: number) {\n        navigate(\n          `${appUrlScheme}:///images?${qs.stringify({\n            images: JSON.stringify(images),\n            index,\n          })}`,\n        )\n      },\n      navigateReviewDetail({\n        reviewId,\n        regionId,\n        resourceId,\n        anchor,\n      }: {\n        reviewId: string\n        regionId?: string\n        resourceId: string\n        anchor?: string\n      }) {\n        const params = qs.stringify({\n          ...(regionId && regionId !== 'null' && { region_id: regionId }),\n          resource_id: resourceId,\n        })\n        navigate(\n          `${appUrlScheme}:///reviews/${reviewId}/detail?${params}${\n            anchor ? `#${anchor}` : ''\n          }`,\n        )\n      },\n      navigateMileageIntro() {\n        navigate(`${appUrlScheme}:///my/mileage/intro`)\n      },\n      reportReview(reviewId: string) {\n        window.location.href = `${appUrlScheme}:///reviews/${reviewId}/report`\n      },\n    }\n  }, [appUrlScheme, navigate, getWindowId])\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/services/use-reviews.ts",
    "content": "import { useClientAppActions } from '@titicaca/triple-web'\nimport {\n  DefaultError,\n  InfiniteData,\n  UseMutationResult,\n  UseQueryResult,\n  useMutation,\n  useQuery,\n  useQueryClient,\n} from '@tanstack/react-query'\n\nimport {\n  BaseReviewFragment,\n  DeleteReviewMutationVariables,\n  GetMyReviewQuery,\n  GetLatestReviewsQuery,\n  GetReviewsByRatingQuery,\n  GetMyReviewQueryVariables,\n  GetPopularReviewsQuery,\n  GetReviewSpecificationQueryVariables,\n  GetReviewsCountQueryVariables,\n  LikeReviewMutationVariables,\n  UnlikeReviewMutationVariables,\n  client,\n  GetReviewsCountQuery,\n  GetReviewSpecificationQuery,\n  LikeReviewMutation,\n  UnlikeReviewMutation,\n  reviewClient,\n} from '../data/graphql'\n\nexport function useReviewCount(\n  params: GetReviewsCountQueryVariables,\n  initialValue?: number,\n): UseQueryResult<GetReviewsCountQuery> {\n  return useQuery({\n    queryKey: ['reviews/getReviewCount', { ...params }],\n    queryFn: () => reviewClient(() => client.GetReviewsCount(params)),\n    refetchOnWindowFocus: false,\n    initialData: initialValue\n      ? {\n          __typename: 'Query',\n          reviewsCount: initialValue,\n        }\n      : undefined,\n  })\n}\n\nexport function useDescriptions(\n  params: GetReviewSpecificationQueryVariables,\n): UseQueryResult<GetReviewSpecificationQuery> {\n  return useQuery({\n    queryKey: ['review/getReviewSpecification', params],\n    queryFn: () => reviewClient(() => client.GetReviewSpecification(params)),\n    refetchOnWindowFocus: false,\n  })\n}\n\nexport function useMyReview(\n  params: GetMyReviewQueryVariables,\n): UseQueryResult<GetMyReviewQuery> {\n  return useQuery({\n    queryKey: ['review/getMyReview', params],\n    queryFn: () => reviewClient(() => client.GetMyReview(params)),\n    refetchOnWindowFocus: false,\n  })\n}\n\nexport function useLikeReviewMutation(): UseMutationResult<\n  LikeReviewMutation,\n  DefaultError,\n  LikeReviewMutationVariables & { resourceId: string }\n> {\n  const { notifyReviewLiked } = useClientAppActions()\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: (variables) =>\n      reviewClient(() => client.LikeReview({ reviewId: variables.reviewId })),\n    onSuccess: (data, variables) => {\n      notifyReviewLiked?.(variables.resourceId, variables.reviewId)\n\n      const updater = (review: BaseReviewFragment) =>\n        review.id === variables.reviewId\n          ? {\n              ...review,\n              liked: !review.liked,\n              likesCount: review.likesCount + 1,\n            }\n          : review\n\n      queryClient.setQueriesData<GetMyReviewQuery | undefined>(\n        { queryKey: ['review/getMyReview'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                ...(old.myReview && { myReview: updater(old.myReview) }),\n              }\n            : old,\n      )\n      queryClient.setQueriesData<GetPopularReviewsQuery | undefined>(\n        { queryKey: ['review/getPopularReviews'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                popularReviews: old.popularReviews.map(updater),\n              }\n            : old,\n      )\n      queryClient.setQueriesData<GetLatestReviewsQuery | undefined>(\n        { queryKey: ['review/getLatestReviews'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                latestReviews: old.latestReviews.map(updater),\n              }\n            : old,\n      )\n      queryClient.setQueriesData<GetReviewsByRatingQuery | undefined>(\n        { queryKey: ['review/getReviewsByRating'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                ratingReviews: old.ratingReviews.map(updater),\n              }\n            : old,\n      )\n\n      queryClient.setQueriesData<\n        InfiniteData<GetPopularReviewsQuery> | undefined\n      >({ queryKey: ['review/getInfinitePopularReviews'] }, (old) =>\n        old\n          ? {\n              ...old,\n              pages: old.pages.map((page) => ({\n                ...page,\n                popularReviews: page.popularReviews.map(updater),\n              })),\n            }\n          : old,\n      )\n      queryClient.setQueriesData<\n        InfiniteData<GetLatestReviewsQuery> | undefined\n      >({ queryKey: ['review/getInfiniteLatestReviews'] }, (old) =>\n        old\n          ? {\n              ...old,\n              pages: old.pages.map((page) => ({\n                ...page,\n                latestReviews: page.latestReviews.map(updater),\n              })),\n            }\n          : old,\n      )\n      queryClient.setQueriesData<\n        InfiniteData<GetReviewsByRatingQuery> | undefined\n      >({ queryKey: ['review/getInfiniteRatingReviews'] }, (old) =>\n        old\n          ? {\n              ...old,\n              pages: old.pages.map((page) => ({\n                ...page,\n                ratingReviews: page.ratingReviews.map(updater),\n              })),\n            }\n          : old,\n      )\n    },\n  })\n}\n\nexport function useUnlikeReviewMutation(): UseMutationResult<\n  UnlikeReviewMutation,\n  DefaultError,\n  UnlikeReviewMutationVariables & { resourceId: string }\n> {\n  const { notifyReviewUnliked } = useClientAppActions()\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: (variables) =>\n      reviewClient(() => client.UnlikeReview({ reviewId: variables.reviewId })),\n    onSuccess: (data, variables) => {\n      notifyReviewUnliked?.(variables.resourceId, variables.reviewId)\n\n      const updater = (review: BaseReviewFragment) =>\n        review.id === variables.reviewId\n          ? {\n              ...review,\n              liked: !review.liked,\n              likesCount: review.likesCount - 1,\n            }\n          : review\n\n      queryClient.setQueriesData<GetMyReviewQuery | undefined>(\n        { queryKey: ['review/getMyReview'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                ...(old.myReview && { myReview: updater(old.myReview) }),\n              }\n            : old,\n      )\n      queryClient.setQueriesData<GetPopularReviewsQuery | undefined>(\n        { queryKey: ['review/getPopularReviews'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                popularReviews: old.popularReviews.map(updater),\n              }\n            : old,\n      )\n      queryClient.setQueriesData<GetLatestReviewsQuery | undefined>(\n        { queryKey: ['review/getLatestReviews'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                latestReviews: old.latestReviews.map(updater),\n              }\n            : old,\n      )\n      queryClient.setQueriesData<GetReviewsByRatingQuery | undefined>(\n        { queryKey: ['review/getReviewsByRating'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                ratingReviews: old.ratingReviews.map(updater),\n              }\n            : old,\n      )\n      queryClient.setQueriesData<\n        InfiniteData<GetPopularReviewsQuery> | undefined\n      >({ queryKey: ['review/getInfinitePopularReviews'] }, (old) =>\n        old\n          ? {\n              ...old,\n              pages: old.pages.map((page) => ({\n                ...page,\n                popularReviews: page.popularReviews.map(updater),\n              })),\n            }\n          : old,\n      )\n      queryClient.setQueriesData<\n        InfiniteData<GetLatestReviewsQuery> | undefined\n      >({ queryKey: ['review/getInfiniteLatestReviews'] }, (old) =>\n        old\n          ? {\n              ...old,\n              pages: old.pages.map((page) => ({\n                ...page,\n                latestReviews: page.latestReviews.map(updater),\n              })),\n            }\n          : old,\n      )\n      queryClient.setQueriesData<\n        InfiniteData<GetReviewsByRatingQuery> | undefined\n      >({ queryKey: ['review/getInfiniteRatingReviews'] }, (old) =>\n        old\n          ? {\n              ...old,\n              pages: old.pages.map((page) => ({\n                ...page,\n                ratingReviews: page.ratingReviews.map(updater),\n              })),\n            }\n          : old,\n      )\n    },\n  })\n}\n\nexport function useDeleteReviewMutation() {\n  const { notifyReviewDeleted } = useClientAppActions()\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: (\n      variables: DeleteReviewMutationVariables & {\n        resourceId: string\n        resourceType: string\n      },\n    ) => reviewClient(() => client.DeleteReview(variables)),\n\n    onSuccess: (data, variables) => {\n      notifyReviewDeleted?.(\n        variables.resourceId,\n        variables.id,\n        variables.resourceType,\n      )\n\n      const updater = (review: BaseReviewFragment) => review.id !== variables.id\n\n      queryClient.setQueriesData<GetMyReviewQuery | undefined>(\n        { queryKey: ['review/getMyReview'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                myReview: null,\n              }\n            : old,\n      )\n      queryClient.setQueriesData<GetPopularReviewsQuery | undefined>(\n        { queryKey: ['review/getPopularReviews'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                popularReviews: old.popularReviews.filter(updater),\n              }\n            : old,\n      )\n      queryClient.setQueriesData<GetLatestReviewsQuery | undefined>(\n        { queryKey: ['review/getLatestReviews'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                latestReviews: old.latestReviews.filter(updater),\n              }\n            : old,\n      )\n      queryClient.setQueriesData<GetReviewsByRatingQuery | undefined>(\n        { queryKey: ['review/getReviewsByRating'] },\n        (old) =>\n          old\n            ? {\n                ...old,\n                ratingReviews: old.ratingReviews.filter(updater),\n              }\n            : old,\n      )\n      queryClient.setQueriesData<\n        InfiniteData<GetPopularReviewsQuery> | undefined\n      >({ queryKey: ['review/getInfinitePopularReviews'] }, (old) =>\n        old\n          ? {\n              ...old,\n              pages: old.pages.map((page) => ({\n                ...page,\n                popularReviews: page.popularReviews.filter(updater),\n              })),\n            }\n          : old,\n      )\n      queryClient.setQueriesData<\n        InfiniteData<GetLatestReviewsQuery> | undefined\n      >({ queryKey: ['review/getInfiniteLatestReviews'] }, (old) =>\n        old\n          ? {\n              ...old,\n              pages: old.pages.map((page) => ({\n                ...page,\n                latestReviews: page.latestReviews.filter(updater),\n              })),\n            }\n          : old,\n      )\n      queryClient.setQueriesData<\n        InfiniteData<GetReviewsByRatingQuery> | undefined\n      >({ queryKey: ['review/getInfiniteRatingReviews'] }, (old) =>\n        old\n          ? {\n              ...old,\n              pages: old.pages.map((page) => ({\n                ...page,\n                ratingReviews: page.ratingReviews.filter(updater),\n              })),\n            }\n          : old,\n      )\n    },\n  })\n}\n"
  },
  {
    "path": "packages/tds-widget/src/review/utils.ts",
    "content": "import qs from 'qs'\n\nexport function writeReview({\n  appUrlScheme,\n  resourceType,\n  resourceId,\n  regionId,\n  rating = 0,\n  photoFirst,\n}: {\n  appUrlScheme: string\n  resourceType: string\n  resourceId: string\n  regionId?: string\n  rating?: number\n  photoFirst?: boolean\n}) {\n  const params = qs.stringify({\n    region_id: regionId,\n    resource_type: resourceType,\n    resource_id: resourceId,\n    rating,\n    ...(photoFirst && { photo_first: 'true' }),\n  })\n  window.location.href = `${appUrlScheme}:///reviews/new?${params}`\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap/constants.ts",
    "content": "export const START_SCRAPE = 'START_SCRAPE'\nexport const SCRAPE = 'SCRAPE'\nexport const SCRAPE_FAILED = 'SCRAPE_FAILED'\nexport const START_UNSCRAPE = 'START_UNSCRAPE'\nexport const UNSCRAPE = 'UNSCRAPE'\nexport const UNSCRAPE_FAILED = 'UNSCRAPE_FAILED'\n"
  },
  {
    "path": "packages/tds-widget/src/scrap/context.ts",
    "content": "import { Dispatch, createContext } from 'react'\n\nimport { Scraps, Target } from './types'\nimport { ActionType } from './reducer'\n\nexport const ScrapContext = createContext<\n  | {\n      scraps: Scraps\n      updating: {\n        [id: string]: boolean\n      }\n      beforeScrapedChange?: (target: Target, scraped: boolean) => boolean\n      onScrapeFailed?: (\n        target: Target,\n        scraped: boolean,\n        errorMessage?: string,\n      ) => void\n    }\n  | undefined\n>(undefined)\nexport const ScrapDispatchContext = createContext<\n  Dispatch<{ type: ActionType; id: string }> | undefined\n>(undefined)\n"
  },
  {
    "path": "packages/tds-widget/src/scrap/index.ts",
    "content": "export * from './provider'\nexport * from './use-scrap'\n"
  },
  {
    "path": "packages/tds-widget/src/scrap/provider.tsx",
    "content": "import { PropsWithChildren, useEffect, useReducer } from 'react'\nimport {\n  subscribeScrapedChangeEvent,\n  unsubscribeScrapedChangeEvent,\n} from '@titicaca/triple-web-to-native-interfaces'\n\nimport { Scraps, Target } from './types'\nimport { reducer } from './reducer'\nimport { SCRAPE, UNSCRAPE } from './constants'\nimport { ScrapContext, ScrapDispatchContext } from './context'\n\ninterface ScrapsProviderProps {\n  initialScraps?: Scraps\n  beforeScrapedChange?: (target: Target, scraped: boolean) => boolean\n  onScrapeFailed?: (\n    target: Target,\n    scraped: boolean,\n    errorMessage?: string,\n  ) => void\n}\n\nexport function ScrapsProvider({\n  initialScraps,\n  beforeScrapedChange,\n  onScrapeFailed,\n  children,\n}: PropsWithChildren<ScrapsProviderProps>) {\n  const [value, dispatch] = useReducer(reducer, {\n    scraps: initialScraps ?? {},\n    updating: {},\n  })\n\n  useEffect(() => {\n    const handleSubscribeEvent = ({\n      id,\n      scraped,\n    }: {\n      id: string\n      scraped: boolean\n    }) => dispatch({ type: scraped ? SCRAPE : UNSCRAPE, id })\n\n    subscribeScrapedChangeEvent?.(handleSubscribeEvent)\n\n    return () => unsubscribeScrapedChangeEvent?.(handleSubscribeEvent)\n  }, [dispatch])\n\n  return (\n    <ScrapContext.Provider\n      value={{ ...value, beforeScrapedChange, onScrapeFailed }}\n    >\n      <ScrapDispatchContext.Provider value={dispatch}>\n        {children}\n      </ScrapDispatchContext.Provider>\n    </ScrapContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap/reducer.ts",
    "content": "import {\n  START_SCRAPE,\n  SCRAPE,\n  SCRAPE_FAILED,\n  START_UNSCRAPE,\n  UNSCRAPE,\n  UNSCRAPE_FAILED,\n} from './constants'\nimport type { Scraps } from './types'\n\nexport type ActionType =\n  | typeof START_SCRAPE\n  | typeof SCRAPE\n  | typeof SCRAPE_FAILED\n  | typeof START_UNSCRAPE\n  | typeof UNSCRAPE\n  | typeof UNSCRAPE_FAILED\n\nexport const reducer = (\n  {\n    scraps,\n    updating,\n  }: {\n    scraps: Scraps\n    updating: {\n      [id: string]: boolean\n    }\n  },\n  action: { type: ActionType; id: string },\n) => {\n  const { [action.id]: _, ...restUpdating } = updating\n\n  switch (action.type) {\n    case START_SCRAPE:\n      return {\n        scraps,\n        updating: { ...updating, [action.id]: true },\n      }\n\n    case SCRAPE:\n      return {\n        scraps: { ...scraps, [action.id]: true },\n        updating: restUpdating,\n      }\n\n    case SCRAPE_FAILED:\n      return { scraps, updating: restUpdating }\n\n    case START_UNSCRAPE:\n      return {\n        scraps,\n        updating: { ...updating, [action.id]: false },\n      }\n\n    case UNSCRAPE:\n      return {\n        scraps: { ...scraps, [action.id]: false },\n        updating: restUpdating,\n      }\n\n    case UNSCRAPE_FAILED:\n      return { scraps, updating: restUpdating }\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap/services.ts",
    "content": "import { authGuardedFetchers } from '@titicaca/fetcher'\n\nimport type { Target } from './types'\n\nfunction mapTypes(type: unknown) {\n  switch (type) {\n    case 'article':\n      return 'articles'\n    case 'tna':\n      return 'tna'\n    default:\n      return 'pois'\n  }\n}\n\ninterface ScrapSuccessResponse {\n  id: string\n}\n\ninterface ScrapFailResponse {\n  message: string\n}\n\nexport function fetchScrape({ id, type }: Target) {\n  return authGuardedFetchers.post<ScrapSuccessResponse, ScrapFailResponse>(\n    `/api/scraps/${mapTypes(type)}/${id}`,\n  )\n}\n\nexport function fetchUnscrape({ id, type }: Target) {\n  return authGuardedFetchers.del<unknown, ScrapFailResponse>(\n    `/api/scraps/${mapTypes(type)}/${id}`,\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap/types.ts",
    "content": "import type { TrackEventParams } from '@titicaca/triple-web'\n\nexport interface Scraps {\n  [key: string]: boolean\n}\n\nexport interface Target {\n  id: string\n  type: unknown\n  eventParams?: TrackEventParams\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap/use-scrap.ts",
    "content": "import { useCallback, useContext } from 'react'\nimport {\n  useClientApp,\n  useClientAppActions,\n  useLoginCtaModal,\n  useSessionAvailability,\n  useTrackEventWithMetadata,\n  useAppInstallCtaModal,\n} from '@titicaca/triple-web'\nimport { NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\n\nimport type { Target } from './types'\nimport { fetchScrape, fetchUnscrape } from './services'\nimport {\n  START_SCRAPE,\n  SCRAPE,\n  SCRAPE_FAILED,\n  START_UNSCRAPE,\n  UNSCRAPE,\n  UNSCRAPE_FAILED,\n} from './constants'\nimport { ScrapContext, ScrapDispatchContext } from './context'\n\nexport function useScrap(param?: { scrapableInApp?: boolean }) {\n  const scrapsContext = useContext(ScrapContext)\n  const dispatch = useContext(ScrapDispatchContext)\n\n  if (!scrapsContext || !dispatch) {\n    throw new Error('ScrapProvider가 없습니다.')\n  }\n\n  const { scraps, updating, beforeScrapedChange, onScrapeFailed } =\n    scrapsContext\n\n  const app = useClientApp()\n  const { notifyScraped, notifyUnscraped } = useClientAppActions()\n  const sessionAvailable = useSessionAvailability()\n  const { show: showLoginCta } = useLoginCtaModal()\n  const { show: showAppInstallCtaModal } = useAppInstallCtaModal()\n  const trackEventWithMetadata = useTrackEventWithMetadata()\n\n  const scrapableInApp =\n    param?.scrapableInApp !== undefined ? param.scrapableInApp : true\n\n  const deriveCurrentStateAndCount = useCallback(\n    ({\n      id,\n      scraped,\n      scrapsCount: originalScrapsCount,\n    }: {\n      id: string\n      scraped?: boolean\n      scrapsCount?: number\n    }) => {\n      const currentState =\n        typeof updating[id] !== 'undefined' ? updating[id] : scraps[id]\n      const scrapsCount = Number(originalScrapsCount || 0)\n\n      if (typeof scraped !== 'boolean' || typeof currentState !== 'boolean') {\n        /* At least one of the status are unknown: Reduces to a bitwise OR operation */\n        return {\n          scraped: !!scraped || !!currentState,\n          scrapsCount,\n        }\n      }\n\n      return {\n        scraped: currentState,\n        scrapsCount:\n          scraped === currentState\n            ? scrapsCount\n            : currentState\n              ? scrapsCount + 1\n              : scrapsCount - 1,\n      }\n    },\n    [scraps, updating],\n  )\n\n  const onScrape = useCallback(\n    async ({\n      id,\n      type,\n      eventParams = {\n        ga: ['POI저장', `${id}`],\n        fa: {\n          action: 'POI저장',\n          item_id: id,\n          content_type: type,\n        },\n      },\n    }: Target) => {\n      if (\n        beforeScrapedChange &&\n        beforeScrapedChange({ id, type, eventParams }, true) === false\n      ) {\n        return\n      }\n\n      if (typeof updating[id] !== 'undefined') {\n        return\n      }\n\n      if (scrapableInApp && !app) {\n        return showAppInstallCtaModal({ triggeredEventAction: 'POI저장' })\n      }\n\n      if (!sessionAvailable) {\n        return showLoginCta({ triggeredEventAction: 'POI저장' })\n      }\n\n      dispatch({ type: START_SCRAPE, id })\n\n      const response = await fetchScrape({ id, type })\n\n      if (response !== NEED_LOGIN_IDENTIFIER && response.ok) {\n        notifyScraped?.(id)\n        trackEventWithMetadata(eventParams)\n        dispatch({ type: SCRAPE, id })\n      } else {\n        const errorMessage =\n          response === NEED_LOGIN_IDENTIFIER\n            ? '로그인이 필요합니다.'\n            : response.parsedBody.message\n        onScrapeFailed?.({ id, type }, false, errorMessage)\n        dispatch({ type: SCRAPE_FAILED, id })\n      }\n    },\n    [\n      beforeScrapedChange,\n      updating,\n      scrapableInApp,\n      app,\n      sessionAvailable,\n      dispatch,\n      showAppInstallCtaModal,\n      showLoginCta,\n      notifyScraped,\n      trackEventWithMetadata,\n      onScrapeFailed,\n    ],\n  )\n\n  const onUnscrape = useCallback(\n    async ({\n      id,\n      type,\n      eventParams = {\n        ga: ['POI저장취소', `${id}`],\n        fa: {\n          action: 'POI저장취소',\n          item_id: id,\n          content_type: type,\n        },\n      },\n    }: Target) => {\n      if (\n        beforeScrapedChange &&\n        beforeScrapedChange({ id, type, eventParams }, true) === false\n      ) {\n        return\n      }\n\n      if (typeof updating[id] !== 'undefined') {\n        return\n      }\n\n      if (scrapableInApp && !app) {\n        return showAppInstallCtaModal({ triggeredEventAction: 'POI저장' })\n      }\n\n      if (!sessionAvailable) {\n        return showLoginCta({ triggeredEventAction: 'POI저장취소' })\n      }\n\n      dispatch({ type: START_UNSCRAPE, id })\n\n      const response = await fetchUnscrape({ id, type })\n\n      if (response !== NEED_LOGIN_IDENTIFIER && response.ok) {\n        notifyUnscraped?.(id)\n        trackEventWithMetadata(eventParams)\n        dispatch({ type: UNSCRAPE, id })\n      } else {\n        const errorMessage =\n          response === NEED_LOGIN_IDENTIFIER\n            ? '로그인이 필요합니다.'\n            : response.parsedBody.message\n        onScrapeFailed?.({ id, type }, true, errorMessage)\n        dispatch({ type: UNSCRAPE_FAILED, id })\n      }\n    },\n    [\n      beforeScrapedChange,\n      updating,\n      scrapableInApp,\n      app,\n      sessionAvailable,\n      dispatch,\n      showAppInstallCtaModal,\n      showLoginCta,\n      notifyUnscraped,\n      trackEventWithMetadata,\n      onScrapeFailed,\n    ],\n  )\n\n  return { deriveCurrentStateAndCount, onScrape, onUnscrape }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap-button/hooks.ts",
    "content": "import { useScrap } from '../scrap'\n\nimport type { ScrapButtonProps } from './types'\n\nexport function useScrapButton({\n  resource: { id, type, scraped },\n  eventParams,\n}: ScrapButtonProps) {\n  const { onScrape, onUnscrape, deriveCurrentStateAndCount } = useScrap()\n  const { scraped: actualScraped } = deriveCurrentStateAndCount({ id, scraped })\n\n  const handleToggleScrape = () => {\n    const toggleScrape = actualScraped ? onUnscrape : onScrape\n    toggleScrape({ id, type, eventParams })\n  }\n\n  return [actualScraped, handleToggleScrape] as const\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap-button/index.ts",
    "content": "export { ComposedOutlineScrapButton as OutlineScrapButton } from './outline-scrap-button'\nexport { ComposedOverlayScrapButton as OverlayScrapButton } from './overlay-scrap-button'\nexport { ScrapButtonMask } from './scrap-button-mask'\n"
  },
  {
    "path": "packages/tds-widget/src/scrap-button/outline-scrap-button.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { ScrapsProvider } from '../scrap/provider'\n\nimport { OutlineScrapButton } from '.'\n\nconst meta: Meta<typeof OutlineScrapButton> = {\n  title: 'tds-widget / scrap-button / OutlineScrapButton',\n  component: OutlineScrapButton,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <ScrapsProvider>\n          <Story />\n        </ScrapsProvider>\n      </EventTrackingProvider>\n    ),\n  ],\n}\n\nexport default meta\n\nexport const Basic: StoryObj<typeof OutlineScrapButton> = {\n  args: {\n    resource: {\n      id: 'scrapable_id',\n      type: 'scrapable_type',\n      scraped: false,\n    },\n    size: 34,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap-button/outline-scrap-button.tsx",
    "content": "import { Attributes, ComponentType } from 'react'\nimport { styled } from 'styled-components'\n\nimport { createIsolatedClickHandler } from './utils'\nimport type { ScrapButtonProps, ScrapIconProps } from './types'\nimport { withMask } from './scrap-button-mask'\nimport { useScrapButton } from './hooks'\n\nconst OUTLINE_HEART_ON =\n  'https://assets.triple.guide/images/btn-content-scrap-list-on@2x.png'\nconst OUTLINE_HEART_OFF =\n  'https://assets.triple.guide/images/btn-content-scrap-list-off@2x.png'\n\nconst ScrapingButton = styled.button<{ $size: number }>`\n  display: block;\n  outline: none;\n\n  ${({ $size }) => `\n    width: ${$size}px;\n    height: ${$size}px;\n  `}\n`\n\nfunction OutlineScrapButton({\n  resource,\n  size = 34,\n  eventParams,\n}: ScrapButtonProps) {\n  const [actualScraped, toggleScraped] = useScrapButton({\n    resource,\n    eventParams,\n  })\n\n  return (\n    <ScrapingButton\n      $size={size}\n      onClick={createIsolatedClickHandler(toggleScraped)}\n    >\n      <OutlineHeart pressed={actualScraped} size={size} />\n    </ScrapingButton>\n  )\n}\n\nfunction OutlineHeart({ pressed, size }: ScrapIconProps) {\n  return (\n    <img\n      src={pressed ? OUTLINE_HEART_ON : OUTLINE_HEART_OFF}\n      width={size}\n      height={size}\n      alt={pressed ? 'OUTLINE_HEART_ON' : 'OUTLINE_HEART_OFF'}\n    />\n  )\n}\n\nfunction composedHocs<P>(Component: ComponentType<P & Attributes>) {\n  return withMask(Component)\n}\n\nexport const ComposedOutlineScrapButton = composedHocs(OutlineScrapButton)\n"
  },
  {
    "path": "packages/tds-widget/src/scrap-button/overlay-scrap-button.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { ScrapsProvider } from '../scrap/provider'\n\nimport { OverlayScrapButton } from '.'\n\nconst meta: Meta<typeof OverlayScrapButton> = {\n  title: 'tds-widget / scrap-button / OverlayScrapButton',\n  component: OverlayScrapButton,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <ScrapsProvider>\n          <Story />\n        </ScrapsProvider>\n      </EventTrackingProvider>\n    ),\n  ],\n}\n\nexport default meta\n\nexport const Basic: StoryObj<typeof OverlayScrapButton> = {\n  args: {\n    resource: {\n      id: 'scrapable_id',\n      type: 'scrapable_type',\n      scraped: false,\n    },\n    size: 36,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap-button/overlay-scrap-button.tsx",
    "content": "import { Attributes, ComponentType } from 'react'\nimport { styled } from 'styled-components'\n\nimport type { ScrapButtonProps, ScrapIconProps } from './types'\nimport { createIsolatedClickHandler } from './utils'\nimport { withMask } from './scrap-button-mask'\nimport { useScrapButton } from './hooks'\n\nconst OVERLAY_HEART_ON =\n  'https://assets.triple.guide/images/btn-content-scrap-overlay-on@3x.png'\nconst OVERLAY_HEART_OFF =\n  'https://assets.triple.guide/images/btn-content-scrap-overlay-off@3x.png'\n\nconst ScrapingButton = styled.button<{ $size: number }>`\n  display: block;\n  outline: none;\n\n  ${({ $size }) => `\n    width: ${$size}px;\n    height: ${$size}px;\n  `}\n`\n\nfunction OverlayScrapButton({\n  resource,\n  size = 36,\n  eventParams,\n}: ScrapButtonProps) {\n  const [actualScraped, toggleScraped] = useScrapButton({\n    resource,\n    eventParams,\n  })\n\n  return (\n    <ScrapingButton\n      $size={size}\n      onClick={createIsolatedClickHandler(toggleScraped)}\n    >\n      <OverlayHeart pressed={actualScraped} size={size} />\n    </ScrapingButton>\n  )\n}\n\nfunction OverlayHeart({ pressed, size }: ScrapIconProps) {\n  return (\n    <img\n      src={pressed ? OVERLAY_HEART_ON : OVERLAY_HEART_OFF}\n      width={size}\n      height={size}\n      alt={pressed ? 'OVERLAY_HEART_ON' : 'OVERLAY_HEART_OFF'}\n    />\n  )\n}\n\nfunction composedHocs<P>(Component: ComponentType<P & Attributes>) {\n  return withMask(Component)\n}\n\nexport const ComposedOverlayScrapButton = composedHocs(OverlayScrapButton)\n"
  },
  {
    "path": "packages/tds-widget/src/scrap-button/scrap-button-mask.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport { ClientAppName } from '@titicaca/triple-web'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport { ScrapsProvider } from '../scrap/provider'\n\nimport { ScrapButtonMask } from './scrap-button-mask'\nimport { ComposedOverlayScrapButton as OverlayScrapButton } from './overlay-scrap-button'\nimport { ComposedOutlineScrapButton as OutlineScrapButton } from './outline-scrap-button'\n\ndescribe('ScrapButtonMask 컴포넌트', () => {\n  it('should not render child scrap button.', () => {\n    const { container } = render(\n      <ScrapsProvider>\n        <ScrapButtonMask masked>\n          <OverlayScrapButton\n            resource={{\n              id: 'MOCK_RESOURCE_ID',\n              type: 'MOCK_TYPE',\n              scraped: false,\n            }}\n            size={36}\n          />\n          <OutlineScrapButton\n            resource={{\n              id: 'MOCK_RESOURCE_ID',\n              type: 'MOCK_TYPE',\n              scraped: false,\n            }}\n            size={36}\n          />\n        </ScrapButtonMask>\n      </ScrapsProvider>,\n      {\n        wrapper: createTestWrapper({\n          clientAppProvider: {\n            device: { autoplay: 'always', networkType: 'unknown' },\n            metadata: { name: ClientAppName.iOS, version: '6.5.0' },\n          },\n        }),\n      },\n    )\n\n    expect(container).toBeEmptyDOMElement()\n  })\n\n  it('should render child scrap button with masked false', () => {\n    render(\n      <ScrapsProvider>\n        <ScrapButtonMask masked={false}>\n          <OverlayScrapButton\n            resource={{\n              id: 'MOCK_RESOURCE_ID',\n              type: 'MOCK_TYPE',\n              scraped: false,\n            }}\n            size={36}\n            data-testid=\"scrap-button-1\"\n          />\n          <OutlineScrapButton\n            resource={{\n              id: 'MOCK_RESOURCE_ID',\n              type: 'MOCK_TYPE',\n              scraped: false,\n            }}\n            size={36}\n            data-testid=\"scrap-button-2\"\n          />\n        </ScrapButtonMask>\n      </ScrapsProvider>,\n      {\n        wrapper: createTestWrapper({\n          clientAppProvider: {\n            device: { autoplay: 'always', networkType: 'unknown' },\n            metadata: { name: ClientAppName.iOS, version: '6.5.0' },\n          },\n        }),\n      },\n    )\n\n    expect(screen.getAllByRole('button')).toHaveLength(2)\n  })\n})\n"
  },
  {
    "path": "packages/tds-widget/src/scrap-button/scrap-button-mask.tsx",
    "content": "import {\n  Attributes,\n  ComponentType,\n  createContext,\n  PropsWithChildren,\n  useContext,\n} from 'react'\n\nconst MaskContext = createContext(false)\n\nexport function ScrapButtonMask({\n  masked,\n  children,\n}: PropsWithChildren<{ masked: boolean }>) {\n  return <MaskContext.Provider value={masked}>{children}</MaskContext.Provider>\n}\n\nexport function withMask<P extends Attributes>(Component: ComponentType<P>) {\n  function ComponentWithMask(props: P) {\n    const masked = useContext(MaskContext)\n\n    if (masked) {\n      return null\n    }\n\n    return <Component {...props} />\n  }\n\n  ComponentWithMask.displayName = `Masked${Component.displayName}`\n\n  return ComponentWithMask\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap-button/types.ts",
    "content": "import type { TrackEventParams } from '@titicaca/triple-web'\n\nexport interface ScrapableResource {\n  id: string\n  type: string\n  scraped?: boolean\n}\n\nexport interface ScrapIconProps {\n  pressed: boolean\n  size: number\n}\n\nexport interface ScrapButtonProps {\n  resource: ScrapableResource\n  size?: number\n  eventParams?: TrackEventParams\n}\n"
  },
  {
    "path": "packages/tds-widget/src/scrap-button/utils.ts",
    "content": "import { MouseEventHandler } from 'react'\n\n/**\n * 주어진 핸들러에 propagation을 막는 로직을 추가한 핸들러를 반환합니다.\n * TODO: 비슷한 역할을 하는 함수들 통합\n * @param handler\n */\nexport function createIsolatedClickHandler(\n  handler: MouseEventHandler<HTMLButtonElement>,\n): MouseEventHandler<HTMLButtonElement> {\n  return (e) => {\n    e.stopPropagation()\n    e.preventDefault()\n\n    handler(e)\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/search/index.ts",
    "content": "export * from './search'\n"
  },
  {
    "path": "packages/tds-widget/src/search/search.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { FullScreenSearchView } from './search'\n\nexport default {\n  title: 'tds-widget / search / Search',\n  component: FullScreenSearchView,\n} as Meta<typeof FullScreenSearchView>\n\nexport const Basic: StoryObj<typeof FullScreenSearchView> = {\n  args: {\n    placeholder: '“항공권 예약” 도시이름으로 검색',\n  },\n}\n\nexport const Borderless: StoryObj<typeof FullScreenSearchView> = {\n  args: {\n    ...Basic.args,\n    borderless: true,\n  },\n}\n\nexport const DefaultKeyword: StoryObj<typeof FullScreenSearchView> = {\n  args: {\n    ...Basic.args,\n    defaultKeyword: '제주',\n  },\n}\n\nexport const Controlled: StoryObj<typeof FullScreenSearchView> = {\n  args: {\n    ...Basic.args,\n    keyword: '인천',\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/search/search.tsx",
    "content": "import {\n  SyntheticEvent,\n  KeyboardEvent,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  MouseEventHandler,\n  PropsWithChildren,\n} from 'react'\nimport { styled, css } from 'styled-components'\nimport { Container, LayeringMixinProps, SearchNavbar } from '@titicaca/tds-ui'\nimport { useUserAgent } from '@titicaca/triple-web'\nimport {\n  openKeyboard,\n  closeKeyboard,\n} from '@titicaca/triple-web-to-native-interfaces'\nimport { useDebouncedState } from '@titicaca/react-hooks'\n\nconst ContentsContainer = styled(Container)<{ $isIos: boolean }>`\n  & > div:first-child {\n    ${({ $isIos }) =>\n      $isIos &&\n      css`\n        max-height: calc(100vh - 52px);\n        overflow: scroll;\n      `}\n  }\n`\n\nconst hideKeyboard = () => closeKeyboard()\n\nconst KEY_CODE_ENTER = 13\n\n/**\n * 상단에는 검색 Navbar, 하단에는 Navbar이벤트를 통해 검색결과를 그릴수 있도록 제공해주는 컴포넌트 입니다.\n */\nexport function FullScreenSearchView({\n  children,\n  onDelete,\n  onAutoComplete,\n  onEnter,\n  onInputChange,\n  onBackClick = () => {},\n  onInputClick,\n  placeholder,\n  defaultKeyword,\n  keyword: controlledKeyword,\n  focusedOnInput,\n  ...rest\n}: PropsWithChildren<\n  {\n    /**\n     * Search navbar에 있는 삭제버튼을 클릭하는 이벤트.\n     *\n     * 이 이벤트 실행 뒤에 `onEmptyKeyword` 이벤트도 실행됩니다.\n     */\n    onDelete?: (keyword: string) => void\n    /**\n     * search navbar의 keyboard input에 대한 debounce (0.5s) 후에 Keyword가 있을 경우 실행되는 이벤트\n     */\n    onAutoComplete?: (keyword: string) => void\n    /**\n     * Search Navbar에서 엔터키를 입력했을 때 실행되는 이벤트.\n     *\n     * 만약 debounce 되어있던 auto completion이 있으면 제거합니다.\n     */\n    onEnter?: (keyword: string) => void\n    /**\n     * Search Navbar에 아무런 keyword가 없을 경우 실행되는 이벤트.\n     *\n     * Search Navbar에서 삭제버튼을 누르거나, onAutoComplete에서 Keyword가 없는 경우에 실행되는 이벤트.\n     */\n    onInputChange?: (keyword: string) => void\n    /**\n     * Search Navbar의 onChange에서 실행되는 이벤트.\n     */\n    onInputClick?: MouseEventHandler<HTMLInputElement>\n    onBackClick?: () => void\n    /**\n     * Search navbar의 placeholder\n     */\n    placeholder?: string\n    /**\n     * Search Navbar input의 기본값.\n     */\n    defaultKeyword?: string\n    keyword?: string\n    borderless?: boolean\n    backIconType?: 'back' | 'close'\n    focusedOnInput?: boolean\n  } & LayeringMixinProps\n>) {\n  const {\n    os: { name },\n  } = useUserAgent()\n  const isIos = name === 'iOS'\n\n  const [keyword, setKeyword] = useState<string>(defaultKeyword || '')\n  const { debounced: debouncedKeyword, clearDebounce } = useDebouncedState(\n    keyword,\n    500,\n  )\n\n  const contentsDivRef = useRef<HTMLDivElement>(null)\n  const inputRef = useRef<HTMLInputElement>(null)\n\n  useEffect(() => {\n    const contentsDiv = contentsDivRef.current\n    if (contentsDiv && isIos) {\n      contentsDiv.addEventListener('touchmove', hideKeyboard)\n      return () => {\n        contentsDiv.removeEventListener('touchmove', hideKeyboard)\n      }\n    }\n  }, [isIos])\n\n  useEffect(\n    () => {\n      onAutoComplete?.(debouncedKeyword)\n    },\n    // HACK: 부모에서 콜백 안 쓰고 있으면\n    // 렌더링 할 때마다 fetch가 다시 일어나므로 onAutoComplete 의존성 제거.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [debouncedKeyword],\n  )\n\n  useEffect(() => {\n    if (controlledKeyword !== undefined) {\n      setKeyword(controlledKeyword || '')\n    }\n  }, [controlledKeyword])\n\n  const handleKeyUp = async (keyCode: number) => {\n    if (keyCode === KEY_CODE_ENTER) {\n      if (inputRef && inputRef.current) {\n        inputRef.current.blur()\n      }\n\n      onEnter?.(keyword)\n      clearDebounce()\n    }\n  }\n\n  const handleInputFocus = useCallback(() => {\n    if (inputRef && inputRef.current) {\n      inputRef.current.focus()\n      openKeyboard()\n    }\n  }, [inputRef])\n\n  useLayoutEffect(() => {\n    focusedOnInput && handleInputFocus()\n  }, [handleInputFocus, focusedOnInput])\n\n  const handleChange = useCallback(\n    (e: SyntheticEvent, value: string) => {\n      setKeyword(value)\n      onInputChange?.(value)\n    },\n    [onInputChange],\n  )\n\n  const handleDelete = () => {\n    onDelete?.(keyword)\n    setKeyword('')\n    handleInputFocus()\n  }\n\n  return (\n    <>\n      <SearchNavbar\n        placeholder={placeholder}\n        value={keyword}\n        onBackClick={onBackClick}\n        onDeleteClick={handleDelete}\n        onInputChange={handleChange}\n        onInputClick={onInputClick}\n        onKeyUp={(e: KeyboardEvent) => handleKeyUp(e.keyCode)}\n        onSearch={() => keyword && onEnter?.(keyword)}\n        inputRef={inputRef}\n        {...rest}\n      />\n      <ContentsContainer $isIos={isIos} css={{ userSelect: 'none' }}>\n        <div ref={contentsDivRef}>{children}</div>\n      </ContentsContainer>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/social-reviews/external-links.tsx",
    "content": "import { useTranslation } from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport {\n  Section,\n  List,\n  Text,\n  Image,\n  H1,\n  H3,\n  FlexBox,\n  FlexBoxItem,\n} from '@titicaca/tds-ui'\nimport { MouseEvent } from 'react'\n\nconst ExternalLinkEntry = styled(List.Item)`\n  cursor: pointer;\n`\n\nexport interface ExternalLink<Data> {\n  data: Data\n  title?: string\n  summary?: string\n  meta?: string\n  imageUrl?: string\n}\n\nexport type ExternalLinksProps<Data> = {\n  title: string\n  externalLinks: ExternalLink<Data>[]\n  onItemClick?: (e: MouseEvent<HTMLLIElement>, item: ExternalLink<Data>) => void\n} & Parameters<typeof Section>['0']\n\nfunction ExternalLinkItem<Data>({\n  externalLink,\n  externalLink: { title, summary, meta, imageUrl },\n  onItemClick,\n}: {\n  externalLink: ExternalLink<Data>\n  onItemClick?: (e: MouseEvent<HTMLLIElement>, item: ExternalLink<Data>) => void\n}) {\n  const t = useTranslation()\n\n  return (\n    <ExternalLinkEntry\n      minHeight={106}\n      onClick={onItemClick ? (e) => onItemClick(e, externalLink) : undefined}\n    >\n      <FlexBox\n        flex\n        gap=\"20px\"\n        css={{\n          padding: '20px 0',\n        }}\n      >\n        <FlexBoxItem\n          flexGrow={1}\n          css={{\n            minWidth: 0,\n          }}\n        >\n          <H3 maxLines={2}>{title}</H3>\n          {summary && (\n            <Text size=\"small\" alpha={0.7} margin={{ top: 6 }} ellipsis>\n              {summary}\n            </Text>\n          )}\n          {meta && (\n            <Text size=\"tiny\" alpha={0.4} margin={{ top: 6 }} ellipsis>\n              {meta}\n            </Text>\n          )}\n        </FlexBoxItem>\n\n        {imageUrl && (\n          <FlexBoxItem\n            flexShrink={0}\n            css={{\n              width: 60,\n            }}\n          >\n            <Image borderRadius={4}>\n              <Image.FixedRatioFrame frame=\"big\">\n                <Image.Img\n                  src={imageUrl}\n                  alt={t('{{title}} 썸네일', { title })}\n                />\n              </Image.FixedRatioFrame>\n            </Image>\n          </FlexBoxItem>\n        )}\n      </FlexBox>\n    </ExternalLinkEntry>\n  )\n}\n\nexport function ExternalLinks<Data>({\n  title,\n  externalLinks,\n  onItemClick,\n  ...props\n}: ExternalLinksProps<Data>) {\n  if (externalLinks.length <= 0) {\n    return null\n  }\n\n  return (\n    <Section anchor=\"external-links\" {...props}>\n      <H1>{title}</H1>\n      <List divided clearing margin={{ top: 10 }}>\n        {externalLinks.map((externalLink, i) => (\n          <ExternalLinkItem\n            key={`external-link-${i}`}\n            externalLink={externalLink}\n            onItemClick={onItemClick}\n          />\n        ))}\n      </List>\n    </Section>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/social-reviews/index.ts",
    "content": "export * from './external-links'\nexport * from './social-review'\n"
  },
  {
    "path": "packages/tds-widget/src/social-reviews/social-review.tsx",
    "content": "import { useTranslation, useTrackEvent } from '@titicaca/triple-web'\nimport { Section } from '@titicaca/tds-ui'\nimport { useNavigate } from '@titicaca/router'\n\nimport { ExternalLinks } from './external-links'\n\nexport interface SocialReview {\n  imageUrl?: string\n  publisher?: string\n  title?: string\n  url: string\n}\n\nexport type SocialReviewsProps = {\n  placeholderImageUrl?: string\n  socialReviews?: SocialReview[]\n} & Parameters<typeof Section>['0']\n\nexport function SocialReviews({\n  placeholderImageUrl,\n  socialReviews,\n  ...props\n}: SocialReviewsProps) {\n  const t = useTranslation()\n\n  const trackEvent = useTrackEvent()\n  const { navigate } = useNavigate()\n\n  if (!socialReviews || socialReviews.length === 0) {\n    return null\n  }\n\n  return (\n    <ExternalLinks\n      title={t('소셜 리뷰')}\n      externalLinks={socialReviews.map(\n        ({ imageUrl, publisher: meta, title, url }) => ({\n          data: url,\n          title,\n          meta,\n          imageUrl:\n            imageUrl &&\n            `/api/images/cast?url=${encodeURIComponent(\n              imageUrl,\n            )}&transformation=${encodeURIComponent(\n              'c_fill,f_auto,q_auto,h_256,w_256',\n            )}${\n              placeholderImageUrl\n                ? `&placeholder=${encodeURIComponent(placeholderImageUrl)}`\n                : ''\n            }`,\n        }),\n      )}\n      onItemClick={(_, { data: url, title }) => {\n        trackEvent({\n          ga: ['소셜리뷰선택'],\n          fa: { action: '소셜리뷰선택' },\n        })\n\n        navigate(url, { title })\n      }}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/social-reviews/social-reviews.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport { SocialReviews } from './social-review'\n\nexport default {\n  title: 'tds-widget / social-reviews / SocialReviews',\n  component: SocialReviews,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta<typeof SocialReviews>\n\nexport const Basic: StoryObj<typeof SocialReviews> = {\n  args: {\n    placeholderImageUrl:\n      'https://assets.triple-dev.titicaca-corp.com/images/img-empty-contents@3x.png',\n    socialReviews: [\n      {\n        imageUrl:\n          'http://blogthumb2.naver.net/MjAxNjExMDJfMTg5/MDAxNDc4MDkyMDQ0Nzgx.RSVFZBvGK1R6PZ8lKI48lszxcDPSTgbmzPXkhWUKWXkg.pBzoIKizCnBCwa8I4iHkgH_VXkGKXv_MROGWl1_ykbkg.JPEG.maron0505/NaverBlog_20161102_220724_10.jpg?type=w2',\n        publisher: 'maron0505.blog.me',\n        title: '10월 할로윈 도쿄 디즈니 리조트',\n        url: 'http://maron0505.blog.me/220851987631',\n      },\n      {\n        imageUrl:\n          'http://blogthumb2.naver.net/MjAxNzAxMDhfMjI5/MDAxNDgzODc3MDYxNTg4.uIZEmtluXxDr1FCMOl-Jw4UooHbWSxTm7cQDoTUf8lIg.QTa8i9r1x5f7OJKd01iWmyZiptVfdFhhvPdOqOxgsxMg.JPEG.annatomo/IMG_5261.JPG?type=w2',\n        publisher: 'blog.naver.com',\n        title: '도쿄 디즈니랜드 후기 및 꿀팁 ! (2편)',\n        url: 'http://blog.naver.com/annatomo/220905940459',\n      },\n      {\n        imageUrl:\n          'http://blogthumb2.naver.net/MjAxNzAxMDlfMTQy/MDAxNDgzODkwNjAxMjM4.7NCd9Fz2JM1POxwdni37vyFFpYtUrpwp8tqah8w4RDMg.GLcluxHpLHDTdFVRuQvwUQBU8zuSw0juQKSDkicyvvYg.JPEG.the_saeng/se3_image_2676960158.jpg?type=w2',\n        publisher: 'blog.naver.com',\n        title:\n          '[2017 일본 도쿄 자유여행] 4박 5일 도쿄 자유여행 3일차! / 도쿄 디즈니랜드 가는 법 , 도쿄 디즈니랜드 후기',\n        url: 'http://blog.naver.com/the_saeng/220907041546',\n      },\n      {\n        imageUrl:\n          'http://blogthumb2.naver.net/MjAxNzAzMjJfMTIg/MDAxNDkwMTkyMDg1MTU1.i7O0IdvyyhMt9V3GXUsYSBhZdTmSNkBZsGrbm0knqxcg.V6snrTTAshB2bRBXjFm8fzP--UWL1n7mN2jF5xKDMLcg.JPEG.ttttt10/P1010918.JPG?type=w2',\n        publisher: 'blog.naver.com',\n        title: '도쿄자유여행: 도쿄 디즈니랜드 후기',\n        url: 'http://blog.naver.com/ttttt10/220964776844',\n      },\n      {\n        imageUrl:\n          'http://blogthumb2.naver.net/MjAxNzAzMjhfNiAg/MDAxNDkwNjU3MTcwNDQy.kqd2jsXjsLT6UVB6PPI7cow8I-CtC0cAuVueh0ff5VEg.xvNKPFnXScAWq-2O4tCPLLUANb7jnbxur9KHIxrYCaMg.JPEG.sheepo1234/attachImage_3526602166.jpeg?type=w2',\n        publisher: 'blog.naver.com',\n        title: '도쿄 2박3일 자유여행 : 도쿄 디즈니랜드 퍼레이드 후기',\n        url: 'http://blog.naver.com/sheepo1234/220969523448',\n      },\n    ],\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/static-map/index.ts",
    "content": "export * from './static-map'\n"
  },
  {
    "path": "packages/tds-widget/src/static-map/static-map.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { StaticMap } from './static-map'\n\nexport default {\n  title: 'tds-widget / static-map / StaticMap',\n  component: StaticMap,\n} as Meta<typeof StaticMap>\n\nexport const Basic: StoryObj<typeof StaticMap> = {\n  args: {\n    type: 'attraction',\n    lat: 35.6328964,\n    lon: 139.8803943,\n  },\n}\n"
  },
  {
    "path": "packages/tds-widget/src/static-map/static-map.tsx",
    "content": "import { SyntheticEvent } from 'react'\nimport { styled, css } from 'styled-components'\nimport {\n  Container,\n  Image,\n  formatMarginPadding,\n  MEDIA_FRAME_OPTIONS,\n  marginMixin,\n  MarginMixinProps,\n} from '@titicaca/tds-ui'\nimport { FrameRatioAndSizes } from '@titicaca/type-definitions'\n\nexport type PoiType = 'attraction' | 'restaurant' | 'hotel'\nexport interface ResponsiveVariant {\n  mapSize: string\n  viewport: string\n  mapScale?: string\n  zoom?: number\n}\n\nconst Marker = styled.img`\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  margin-top: -15px;\n  margin-left: -15px;\n  width: 30px;\n  height: 30px;\n`\n\nconst StaticMapContainer = styled.div<\n  { $frame?: FrameRatioAndSizes } & MarginMixinProps\n>`\n  width: 100%;\n  position: relative;\n  background: '#f5f5f5';\n  float: none;\n  border-radius: 6px;\n  overflow: hidden;\n\n  ${marginMixin};\n\n  ${({ $frame }) =>\n    $frame && $frame !== 'original'\n      ? css`\n          ${formatMarginPadding(\n            { top: MEDIA_FRAME_OPTIONS[$frame] },\n            'padding',\n          )}\n        `\n      : css`\n          ${formatMarginPadding({ top: MEDIA_FRAME_OPTIONS.small }, 'padding')}\n        `}\n`\n\nconst StaticMapImage = styled.img`\n  width: 100%;\n  height: 100%;\n  border-radius: 6px;\n  object-fit: cover;\n  z-index: 0;\n  position: absolute;\n  top: 0;\n`\n\nconst StaticMapPicture = styled.picture`\n  width: 100%;\n  height: 100%;\n  border-radius: 6px;\n  object-fit: cover;\n  z-index: 0;\n  position: absolute;\n  top: 0;\n`\n\nconst MARKER_SOURCES: { [key: string]: string } = {\n  restaurant: 'https://assets.triple.guide/images/img_map_pin_food@4x.png',\n  hotel: 'https://assets.triple.guide/images/img_map_pin_hotel@4x.png',\n  attraction: 'https://assets.triple.guide/images/img_map_pin_sight@4x.png',\n  tna: 'https://assets.triple.guide/images/img_map_pin_tna@4x.png',\n}\n\nexport function StaticMap({\n  type,\n  lat,\n  lon,\n  zoom = 16,\n  frame = 'mini',\n  mapSize = '320x120',\n  mapScale = '2',\n  markerImage,\n  responsiveVariants,\n  onClick,\n}: {\n  type?: PoiType\n  lat: number | string\n  lon: number | string\n  zoom?: number | string\n  frame?: Parameters<typeof Image.FixedRatioFrame>['0']['frame']\n  mapSize?: string\n  mapScale?: string\n  markerImage?: string\n  responsiveVariants?: ResponsiveVariant[]\n  onClick?: (e: SyntheticEvent) => void\n}) {\n  const srcSet = responsiveVariants\n    ?.map(\n      ({\n        mapSize,\n        viewport,\n        mapScale: responsiveMapScale = 2,\n        zoom: responsiveZoom = 13,\n      }) => {\n        return `/api/maps/static-map?size=${mapSize}&scale=${responsiveMapScale}&center=${lat}%2C${lon}&zoom=${responsiveZoom} ${viewport}`\n      },\n    )\n    .join(', ')\n\n  return (\n    <Container position=\"relative\" onClick={onClick}>\n      <StaticMapContainer $frame={srcSet ? undefined : frame}>\n        <StaticMapPicture>\n          {srcSet ? (\n            <source media=\"(min-width: 600px)\" srcSet={srcSet} />\n          ) : null}\n          <StaticMapImage\n            src={`/api/maps/static-map?size=${mapSize}&scale=${mapScale}&center=${lat}%2C${lon}&zoom=${zoom}`}\n          />\n        </StaticMapPicture>\n      </StaticMapContainer>\n      <Marker src={markerImage || (type && MARKER_SOURCES[type])} />\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/user-verification/confirmation-services.test.ts",
    "content": "import { get } from '@titicaca/fetcher'\n\nimport { confirmVerification } from './confirmation-services'\n\njest.mock('@titicaca/fetcher')\n\nafterEach(() => {\n  ;(get as unknown as jest.MockedFunction<typeof get>).mockRestore()\n})\n\ndescribe('confirmVerification', () => {\n  describe('sms-verification', () => {\n    it('returns not-verified state when it is not verified', async () => {\n      const getMock = (\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 404,\n          parsedBody: { message: 'not found' },\n          ok: false,\n        }),\n      )\n\n      expect(await confirmVerification('sms-verification')).toStrictEqual({\n        verified: false,\n      })\n      expect(getMock).toHaveBeenCalledWith('/api/users/smscert')\n    })\n\n    it('returns verified state when it is verified', async () => {\n      const getMock = (\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 200,\n          parsedBody: {\n            phoneId: 123,\n            phoneNumber: '+821012345678',\n            os: 'ios 15.2.1',\n            certificated: true,\n            certificatedAt: 1645575936545,\n            verified: true,\n            verifiedAt: 1645575936545,\n            formatted: {\n              phoneNumber: '01012345678',\n              countryCallingCode: 82,\n              countryCode: 'KR',\n            },\n          },\n          ok: true,\n        }),\n      )\n      const result = await confirmVerification('sms-verification')\n\n      expect(getMock).toHaveBeenCalledWith('/api/users/smscert')\n      expect(result).toHaveProperty('verified', true)\n      expect(result).toHaveProperty('phoneNumber', '+821012345678')\n    })\n\n    it('returns undefined state when it is responded with an error', async () => {\n      const getMock = (\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 500,\n          parsedBody: {\n            message: 'internal server error',\n          },\n          ok: false,\n        }),\n      )\n      const result = await confirmVerification('sms-verification')\n\n      expect(getMock).toHaveBeenCalledWith('/api/users/smscert')\n      expect(result).toHaveProperty('verified', undefined)\n    })\n  })\n\n  describe('personal-id-verification-with-residence', () => {\n    it('returns not-verified state when it is not verified', async () => {\n      const getMock = (\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 404,\n          parsedBody: { message: 'not found' },\n          ok: false,\n        }),\n      )\n\n      expect(\n        await confirmVerification('personal-id-verification-with-residence'),\n      ).toStrictEqual({\n        verified: false,\n      })\n      expect(getMock).toHaveBeenCalledWith('/api/users/kto-stay-2021')\n    })\n\n    it('returns verified state when it is verified', async () => {\n      const getMock = (\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 200,\n          parsedBody: {\n            userId: 28,\n            residence: [\n              {\n                key: 'korea-sido',\n                value: '11',\n              },\n              {\n                key: 'korea-sgg',\n                value: '11710',\n              },\n            ],\n            nameChecked: true,\n            phoneNumber: '01012345678',\n          },\n          ok: true,\n        }),\n      )\n      const result = await confirmVerification(\n        'personal-id-verification-with-residence',\n      )\n\n      expect(result).toHaveProperty('verified', true)\n      expect(result).toHaveProperty('phoneNumber', '01012345678')\n      expect(getMock).toHaveBeenCalledWith('/api/users/kto-stay-2021')\n    })\n\n    it('returns undefined state when it is responded with an error', async () => {\n      const getMock = (\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 500,\n          parsedBody: {\n            message: 'internal server error',\n          },\n          ok: false,\n        }),\n      )\n      const result = await confirmVerification(\n        'personal-id-verification-with-residence',\n      )\n\n      expect(result).toHaveProperty('verified', undefined)\n      expect(getMock).toHaveBeenCalledWith('/api/users/kto-stay-2021')\n    })\n  })\n\n  describe('personal-id-verification', () => {\n    it('returns not-verified state whtn it is not verified', async () => {\n      const getMock = (\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 404,\n          parsedBody: { message: 'not found' },\n          ok: false,\n        }),\n      )\n\n      expect(\n        await confirmVerification('personal-id-verification'),\n      ).toStrictEqual({\n        verified: false,\n      })\n      expect(getMock).toHaveBeenCalledWith('/api/users/namecheck')\n    })\n\n    it('returns verified state when it is verified', async () => {\n      const getMock = (\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 200,\n          parsedBody: {\n            name: '트리플',\n            birthday: '20160111',\n            gender: 'MALE',\n            mobile: '01012345678',\n            isForeign: false,\n            createdAt: '2022-02-23T00:25:36.535301',\n          },\n          ok: true,\n        }),\n      )\n\n      const result = await confirmVerification('personal-id-verification')\n\n      expect(getMock).toHaveBeenCalledWith('/api/users/namecheck')\n      expect(result).toHaveProperty('verified', true)\n      expect(result).toHaveProperty('phoneNumber', '01012345678')\n    })\n\n    it('returns undefined state when it is responded with an error', async () => {\n      const getMock = (\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 500,\n          parsedBody: {\n            message: 'internal server error',\n          },\n          ok: false,\n        }),\n      )\n\n      const result = await confirmVerification('personal-id-verification')\n\n      expect(getMock).toHaveBeenCalledWith('/api/users/namecheck')\n      expect(result).toHaveProperty('verified', undefined)\n    })\n  })\n\n  describe('external-promotion-*', () => {\n    it('invokes external-promotion eligibility check api', async () => {\n      const getMock = (\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 404,\n          parsedBody: { message: 'not found' },\n          ok: false,\n        }),\n      )\n\n      expect(\n        await confirmVerification('external-promotion-kto-stay-2022'),\n      ).toStrictEqual({\n        verified: false,\n      })\n      expect(getMock).toHaveBeenCalledWith(\n        '/api/users/external-promotion/kto-stay-2022/eligibility',\n      )\n    })\n\n    it('returns not-verified state when it is not verified', async () => {\n      ;(\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 404,\n          parsedBody: { message: 'not found' },\n          ok: false,\n        }),\n      )\n\n      expect(\n        await confirmVerification('external-promotion-kto-stay-2022'),\n      ).toStrictEqual({\n        verified: false,\n      })\n    })\n\n    it('returns verified state when it is verified', async () => {\n      ;(\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 200,\n          parsedBody: {\n            userId: 28,\n            residence: [\n              {\n                key: 'korea-sido',\n                value: '11',\n              },\n              {\n                key: 'korea-sgg',\n                value: '11710',\n              },\n            ],\n            nameChecked: true,\n            phoneNumber: '01012345678',\n          },\n          ok: true,\n        }),\n      )\n      const result = await confirmVerification(\n        'external-promotion-kto-stay-2022',\n      )\n\n      expect(result).toHaveProperty('verified', true)\n      expect(result).toHaveProperty('phoneNumber', '01012345678')\n    })\n\n    it('returns undefined state when it is responded with an error', async () => {\n      ;(\n        get as unknown as jest.MockedFunction<\n          () => Promise<{\n            status: number\n            parsedBody: unknown\n            ok: boolean\n          }>\n        >\n      ).mockImplementation(() =>\n        Promise.resolve({\n          status: 500,\n          parsedBody: {\n            message: 'internal server error',\n          },\n          ok: false,\n        }),\n      )\n      const result = await confirmVerification(\n        'external-promotion-kto-stay-2022',\n      )\n\n      expect(result).toHaveProperty('verified', undefined)\n    })\n  })\n})\n"
  },
  {
    "path": "packages/tds-widget/src/user-verification/confirmation-services.ts",
    "content": "import { authGuardedFetchers, NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\n\nexport function confirmVerification(type: string): Promise<{\n  verified: boolean | undefined\n  phoneNumber?: string\n  error?: string\n  payload?: unknown\n}> {\n  if (type === 'sms-verification') {\n    return confirmSmsVerification()\n  } else if (type === 'personal-id-verification-with-residence') {\n    return confirmPersonalIdVerificationWithResidence()\n  } else if (type.match(/^external-promotion-/)) {\n    return confirmExternalPromotionEligibility(type)\n  } else {\n    return confirmPersonalIdVerification()\n  }\n}\n\nasync function confirmSmsVerification() {\n  const response = await authGuardedFetchers.get<{\n    phoneNumber: string\n  }>('/api/users/smscert')\n  if (response === NEED_LOGIN_IDENTIFIER) {\n    return { verified: undefined, error: NEED_LOGIN_IDENTIFIER }\n  } else if (response.status === 404) {\n    return { verified: false }\n  } else if (response.ok) {\n    const { phoneNumber, ...payload } = response.parsedBody\n\n    return { verified: true, phoneNumber, payload }\n  } else {\n    return { verified: undefined, error: JSON.stringify(response.parsedBody) }\n  }\n}\n\nasync function confirmPersonalIdVerificationWithResidence() {\n  const response = await authGuardedFetchers.get<{\n    phoneNumber: string\n  }>('/api/users/kto-stay-2021')\n  if (response === NEED_LOGIN_IDENTIFIER) {\n    return { verified: undefined, error: NEED_LOGIN_IDENTIFIER }\n  } else if (response.status === 404) {\n    return { verified: false }\n  } else if (response.ok) {\n    const { phoneNumber, ...payload } = response.parsedBody\n\n    return { verified: true, phoneNumber, payload }\n  } else {\n    return { verified: undefined, error: JSON.stringify(response.parsedBody) }\n  }\n}\n\nasync function confirmPersonalIdVerification() {\n  const response = await authGuardedFetchers.get<{\n    mobile: string\n  }>('/api/users/namecheck')\n  if (response === NEED_LOGIN_IDENTIFIER) {\n    return { verified: undefined, error: NEED_LOGIN_IDENTIFIER }\n  } else if (response.status === 404) {\n    return { verified: false }\n  } else if (response.ok) {\n    const { mobile: phoneNumber, ...payload } = response.parsedBody\n\n    return { verified: true, phoneNumber, payload }\n  } else {\n    return { verified: undefined, error: JSON.stringify(response.parsedBody) }\n  }\n}\n\nasync function confirmExternalPromotionEligibility(type: string) {\n  const externalPromotionId = type.replace(/^external-promotion-/, '')\n\n  const response = await authGuardedFetchers.get<{\n    phoneNumber?: string\n  }>(`/api/users/external-promotion/${externalPromotionId}/eligibility`)\n\n  if (response === NEED_LOGIN_IDENTIFIER) {\n    return { verified: undefined, error: NEED_LOGIN_IDENTIFIER }\n  } else if (response.status === 404) {\n    return { verified: false }\n  } else if (response.ok) {\n    const { phoneNumber, ...payload } = response.parsedBody\n\n    return { verified: true, phoneNumber, payload }\n  } else {\n    return { verified: undefined, error: JSON.stringify(response.parsedBody) }\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/user-verification/index.ts",
    "content": "export * from './confirmation-services'\nexport * from './use-user-verification'\nexport * from './verification-request'\n\nexport { useSendVerifiedMessage } from './verified-message'\nexport type { VerifiedMessage } from './verified-message'\nexport type { VerificationType } from './types'\n"
  },
  {
    "path": "packages/tds-widget/src/user-verification/types.ts",
    "content": "/**\n * @deprecated 다양한 외부 프로모션 타입에 대응하기 위해 string 타입을 사용합니다.\n */\nexport type VerificationType = string\n"
  },
  {
    "path": "packages/tds-widget/src/user-verification/use-user-verification.test.ts",
    "content": "import { renderHook } from '@testing-library/react'\nimport { useExternalRouter } from '@titicaca/router'\n\nimport { useUserVerification } from './use-user-verification'\nimport './confirmation-services'\n\njest.mock('@titicaca/react-hooks', () => ({\n  useVisibilityChange: jest.fn(),\n}))\njest.mock('@titicaca/router')\njest.mock('./confirmation-services', () => ({\n  confirmVerification: () => ({\n    verified: true,\n    phoneNumber: '01012345678',\n  }),\n}))\njest.mock('./verified-message')\n\ndescribe('인증 시작함수를 호출하면 인증 페이지를 엽니다.', () => {\n  async function prepareTest({\n    forceVerification = false,\n    verificationType,\n    verificationContext,\n  }: Partial<Parameters<typeof useUserVerification>[0]> = {}) {\n    const { routeExternally } = mockExternalRouterHook()\n\n    const {\n      result: {\n        current: { initiateVerification },\n      },\n    } = renderHook(useUserVerification, {\n      initialProps: {\n        forceVerification,\n        verificationType,\n        verificationContext,\n      },\n    })\n\n    initiateVerification()\n\n    return { routeExternally }\n  }\n\n  describe('verificationType에 맞는 URL을 사용합니다.', () => {\n    test.each([\n      [undefined, '/verifications/'],\n      ['sms-verification', '/verifications/'],\n      ['personal-id-verification-with-residence', '/verifications/residence'],\n      ['personal-id-verification', '/verifications/personal-id-verification'],\n      [\n        'external-promotion-kto-stay-2022',\n        '/verifications/external-promotion/kto-stay-2022',\n      ],\n    ] as const)(\n      'VerificationType: %s, path: %s',\n      async (verificationType: string | undefined, href: string) => {\n        const { routeExternally } = await prepareTest({ verificationType })\n\n        expect(routeExternally).toHaveBeenCalledWith(\n          expect.objectContaining({\n            href: expect.stringContaining(href),\n          }),\n        )\n      },\n    )\n  })\n\n  describe('verificationContext를 쿼리로 추가합니다.', () => {\n    test.each([\n      [undefined, 'context=purchase'],\n      ['purchase', 'context=purchase'],\n      ['cash', 'context=cash'],\n    ] as const)(\n      'verificationContext: %s, should contain: %s',\n      async (verificationContext, containingQuery) => {\n        const { routeExternally } = await prepareTest({ verificationContext })\n\n        expect(routeExternally).toHaveBeenCalledWith(\n          expect.objectContaining({\n            href: expect.stringContaining(containingQuery),\n          }),\n        )\n      },\n    )\n  })\n\n  test('인증 페이지를 새 창으로 엽니다.', async () => {\n    const { routeExternally } = await prepareTest()\n\n    expect(routeExternally).toHaveBeenCalledWith(\n      expect.objectContaining({ target: 'new' }),\n    )\n  })\n\n  test('noNavbar 옵션을 사용합니다.', async () => {\n    const { routeExternally } = await prepareTest()\n\n    expect(routeExternally).toHaveBeenCalledWith(\n      expect.objectContaining({ noNavbar: true }),\n    )\n  })\n})\n\nfunction mockExternalRouterHook() {\n  const routeExternally = jest.fn()\n\n  ;(\n    useExternalRouter as jest.MockedFunction<typeof useExternalRouter>\n  ).mockReturnValue(routeExternally)\n\n  return { routeExternally }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/user-verification/use-user-verification.ts",
    "content": "import { useState, useEffect, useCallback } from 'react'\nimport { useVisibilityChange } from '@titicaca/react-hooks'\nimport { useExternalRouter } from '@titicaca/router'\n\nimport { useVerifiedMessageListener, VerifiedMessage } from './verified-message'\nimport { confirmVerification } from './confirmation-services'\n\ninterface VerificationState {\n  /**\n   * 인증된 전화번호 (있을 경우)\n   */\n  phoneNumber?: string\n  /**\n   * 인증 상태\n   */\n  verified?: boolean\n  /**\n   * 에러 (있을 경우)\n   */\n  error?: string\n  payload?: unknown\n}\n\nexport function useUserVerification({\n  verificationType = 'sms-verification',\n  verificationContext = 'purchase',\n  forceVerification,\n}: {\n  verificationType?: string\n  /**\n   * 사용자 인증이 이루어지는 맥락을 명시합니다.\n   */\n  verificationContext?: 'purchase' | 'cash'\n  /**\n   * 컴포넌트 Mount와 동시에 인증 플로우로 유도할지 결정합니다.\n   */\n  forceVerification: boolean\n}) {\n  const routeExternally = useExternalRouter()\n  const [verificationState, setVerificationState] = useState<VerificationState>(\n    {\n      phoneNumber: undefined,\n      verified: undefined,\n      error: undefined,\n    },\n  )\n\n  /**\n   * 필요한 경우 호출하여 인증 플로우를 시작합니다. 트리플 앱의 브라우저 기준으로 인증 페이지를 렌더링하는 새 창을 생성합니다.\n   */\n  const initiateVerification = useCallback(() => {\n    const href = getVerificationPagePath({\n      verificationType,\n      verificationContext,\n    })\n\n    routeExternally({ href, target: 'new', noNavbar: true })\n  }, [routeExternally, verificationContext, verificationType])\n\n  const handleVerifiedMessageReceive = useCallback(\n    ({ type, phoneNumber }: VerifiedMessage) => {\n      if (type === 'USER_VERIFIED' && phoneNumber) {\n        setVerificationState({\n          verified: true,\n          phoneNumber,\n        })\n      }\n    },\n    [],\n  )\n\n  const fetchAndSetVerificationState = useCallback(\n    async (force: boolean) => {\n      const { verified, phoneNumber, payload, error } =\n        await confirmVerification(verificationType)\n\n      setVerificationState({\n        verified,\n        phoneNumber,\n        payload,\n        error,\n      })\n\n      if (verified === false && force) {\n        initiateVerification()\n      }\n    },\n    [initiateVerification, verificationType],\n  )\n\n  useEffect(() => {\n    fetchAndSetVerificationState(forceVerification)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  useVerifiedMessageListener(handleVerifiedMessageReceive)\n\n  useVisibilityChange((visible: boolean) => {\n    visible && fetchAndSetVerificationState(false)\n  })\n\n  return { verificationState, initiateVerification }\n}\n\nconst PREDEFINED_TARGET_PAGE_PATHS: Record<string, string> = {\n  'sms-verification': '/verifications/',\n  'personal-id-verification-with-residence': '/verifications/residence',\n  'personal-id-verification': '/verifications/personal-id-verification',\n}\n\nfunction getVerificationPagePath({\n  verificationType,\n  verificationContext,\n}: {\n  verificationType: string\n  verificationContext?: 'purchase' | 'cash'\n}) {\n  const predefinedTargetPagePath =\n    PREDEFINED_TARGET_PAGE_PATHS[verificationType]\n  const querystring = `?context=${verificationContext}`\n\n  if (predefinedTargetPagePath) {\n    return `${predefinedTargetPagePath}${querystring}`\n  } else if (verificationType.match(/^external-promotion-/)) {\n    const promotionId = verificationType.replace(/^external-promotion-/, '')\n\n    return `/verifications/external-promotion/${promotionId}${querystring}`\n  } else {\n    throw new Error('Unsupported user verification method')\n  }\n}\n"
  },
  {
    "path": "packages/tds-widget/src/user-verification/verification-request.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { VerificationRequest } from './verification-request'\n\nexport default {\n  title: 'tds-widget / user-verification / VerificationRequest',\n  component: VerificationRequest,\n} as Meta<typeof VerificationRequest>\n\n// TODO: 서버에 데이터가 없어서 mocking 해야 할 듯\nexport const ExampleVerificationRequest: StoryObj<typeof VerificationRequest> =\n  {\n    args: {\n      forceVerification: false,\n      onCancel: () => {},\n    },\n  }\n"
  },
  {
    "path": "packages/tds-widget/src/user-verification/verification-request.tsx",
    "content": "import { useTranslation } from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport { Modal, Text } from '@titicaca/tds-ui'\n\nimport { useUserVerification } from './use-user-verification'\n\n/**\n * 구매 동선 등에서 인증 단계를 추가할 때 mount하는 컴포넌트입니다. 사용자가 인증 단계를 거치지 않았을 경우 Modal을 표시하고 인증을 요구합니다.\n\n */\nexport function VerificationRequest({\n  forceVerification,\n  verificationContext,\n  onCancel,\n}: {\n  /**\n   * 컴포넌트 Mount와 동시에 인증 플로우로 유도할지 결정합니다.\n   */\n  forceVerification?: boolean\n  /**\n   * 사용자 인증이 이루어지는 맥락을 명시합니다.\n   */\n  verificationContext?: 'purchase' | 'cash'\n  /**\n   * Modal의 뒤로가기 액션에 대한 핸들러 함수입니다.\n   */\n  onCancel: () => void\n}) {\n  const t = useTranslation()\n\n  const {\n    verificationState: { verified },\n    initiateVerification,\n  } = useUserVerification({\n    verificationContext,\n    forceVerification:\n      typeof forceVerification !== 'undefined' ? forceVerification : true,\n  })\n\n  return (\n    <Modal open={verified === false}>\n      <Icon />\n      <Text bold center size=\"big\" color=\"gray\" margin={{ bottom: 10 }}>\n        {t('인증이 필요해요!')}\n      </Text>\n      <Text\n        center\n        size=\"small\"\n        lineHeight={1.38}\n        color=\"gray\"\n        alpha={0.7}\n        margin={{ bottom: 40 }}\n      >\n        {t('예약을 위해서는 휴대폰 인증이 필요합니다. (최초 1회)')}\n      </Text>\n      <Modal.Actions>\n        <Modal.Action onClick={() => onCancel()}>{t('뒤로가기')}</Modal.Action>\n        <Modal.Action onClick={() => initiateVerification()} color=\"blue\">\n          {t('인증하기')}\n        </Modal.Action>\n      </Modal.Actions>\n    </Modal>\n  )\n}\n\nconst SvgWithPositioning = styled.svg`\n  display: block;\n  margin: 40px auto 10px;\n`\n\nfunction Icon() {\n  return (\n    <SvgWithPositioning\n      height=\"60\"\n      viewBox=\"0 0 60 60\"\n      width=\"60\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g fill=\"none\" fillRule=\"evenodd\">\n        <rect\n          fill=\"#368fff\"\n          height=\"46.08\"\n          rx=\"3.84\"\n          width=\"30.08\"\n          x=\"14\"\n          y=\"7\"\n        />\n        <path d=\"m17.2 10.84h23.68v37.12h-23.68z\" fill=\"#fff\" />\n        <g stroke=\"#fff\" strokeWidth=\"1.92\">\n          <circle cx=\"41.84\" cy=\"42.52\" fill=\"#3d8bf7\" r=\"11.2\" />\n          <path\n            d=\"m37.68 41.796 3.115 3.115 5.271-5.271\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </g>\n      </g>\n    </SvgWithPositioning>\n  )\n}\n"
  },
  {
    "path": "packages/tds-widget/src/user-verification/verified-message.spec.tsx",
    "content": "import { renderHook } from '@testing-library/react'\nimport { ClientAppName } from '@titicaca/triple-web'\nimport { createTestWrapper } from '@titicaca/triple-web-test-utils'\n\nimport {\n  useSendVerifiedMessage,\n  useVerifiedMessageListener,\n} from './verified-message'\n\nconst broadcastMessageMockFn = jest.fn()\nconst subscribeMockFn = jest.fn()\nconst unsubscribeMockFn = jest.fn()\n\njest.mock('@titicaca/triple-web', () => ({\n  ...jest.requireActual('@titicaca/triple-web'),\n  useEnv: jest.fn().mockReturnValue({\n    webUrlBase: 'https://triple-dev.titicaca-corp.com',\n  }),\n  useClientAppActions: jest.fn().mockImplementation(() => ({\n    broadcastMessage: broadcastMessageMockFn,\n    subscribe: subscribeMockFn,\n    unsubscribe: unsubscribeMockFn,\n  })),\n  useSessionAvailability: jest.fn(),\n}))\n\nafterEach(() => {\n  jest.clearAllMocks()\n})\n\ndescribe('useSendVerifiedMessage', () => {\n  test('should call broadcastMessage if it is running on triple client', () => {\n    const {\n      result: { current: sendVerifiedMessage },\n    } = renderHook(() => useSendVerifiedMessage(), {\n      wrapper: createTestWrapper({\n        clientAppProvider: {\n          device: { autoplay: 'always', networkType: 'unknown' },\n          metadata: { name: ClientAppName.Android, version: '1.0.0' },\n        },\n      }),\n    })\n\n    sendVerifiedMessage({ type: 'USER_VERIFIED', phoneNumber: '010-1234-5678' })\n\n    expect(broadcastMessageMockFn).toHaveBeenCalled()\n  })\n\n  test('should refer parent window if it is not running on triple client', () => {\n    /* HACK: Global window에 opener 속성이 정의되지 않아 직접 정의했습니다. */\n    Object.defineProperty(global.window, 'opener', {\n      configurable: true,\n\n      get() {\n        return null\n      },\n    })\n\n    const openerSpy = jest.spyOn(global.window, 'opener', 'get')\n    const postMessage = jest.fn()\n\n    openerSpy.mockReturnValue({\n      postMessage,\n    } as unknown as Window)\n\n    const {\n      result: { current: sendVerifiedMessage },\n    } = renderHook(() => useSendVerifiedMessage(), {\n      wrapper: createTestWrapper({\n        clientAppProvider: null,\n      }),\n    })\n\n    sendVerifiedMessage({ type: 'USER_VERIFIED', phoneNumber: '010-1234-5678' })\n\n    expect(postMessage).toHaveBeenCalled()\n  })\n})\n\ndescribe('useVerifiedMessageListener', () => {\n  test('should start subscription if it is running on triple client', () => {\n    const handleVerifiedMessage = jest.fn() as Parameters<\n      typeof useVerifiedMessageListener\n    >['0']\n\n    renderHook(() => useVerifiedMessageListener(handleVerifiedMessage), {\n      wrapper: createTestWrapper({\n        clientAppProvider: {\n          device: { autoplay: 'always', networkType: 'unknown' },\n          metadata: { name: ClientAppName.Android, version: '1.0.0' },\n        },\n      }),\n    })\n\n    expect(subscribeMockFn).toHaveBeenCalled()\n  })\n\n  test('should start event listener if it is not running on triple client', () => {\n    const addEventListenerSpy = jest.spyOn(global.window, 'addEventListener')\n    const handleVerifiedMessage = jest.fn() as Parameters<\n      typeof useVerifiedMessageListener\n    >['0']\n\n    renderHook(() => useVerifiedMessageListener(handleVerifiedMessage), {\n      wrapper: createTestWrapper({\n        clientAppProvider: null,\n      }),\n    })\n\n    expect(addEventListenerSpy).toHaveBeenCalledWith(\n      'message',\n      expect.anything(),\n    )\n  })\n})\n"
  },
  {
    "path": "packages/tds-widget/src/user-verification/verified-message.ts",
    "content": "import { useClientApp, useClientAppActions, useEnv } from '@titicaca/triple-web'\nimport { useCallback, useEffect } from 'react'\n\n/**\n * verifications-web에서 전송하는 인증 결과 타입\n * 현재는 성공했을 때만 전송하므로 타입이 유일하다.\n */\nexport interface VerifiedMessage {\n  type: 'USER_VERIFIED'\n  phoneNumber: string\n}\n\nexport function useSendVerifiedMessage() {\n  const { webUrlBase } = useEnv()\n  const app = useClientApp()\n  const { broadcastMessage } = useClientAppActions()\n\n  const sendVerifiedMessage = useCallback(\n    (message: VerifiedMessage) => {\n      if (app) {\n        broadcastMessage && broadcastMessage(message)\n      } else {\n        const parentWindow: Window | null = window.opener\n\n        if (parentWindow) {\n          parentWindow.postMessage(message, webUrlBase)\n        }\n      }\n    },\n    [app, webUrlBase, broadcastMessage],\n  )\n\n  return sendVerifiedMessage\n}\n\n/**\n * 인증 완료 메시지를 기다리고 있다가 메시지가 오면 callback 함수를 실행하는 훅\n * callback의 레퍼런스가 바뀌어도 반영되지 않습니다.\n */\nexport function useVerifiedMessageListener(\n  handleVerifiedMessage: (message: VerifiedMessage) => void,\n) {\n  const app = useClientApp()\n  const { subscribe, unsubscribe } = useClientAppActions()\n\n  useEffect(() => {\n    if (!app) {\n      const handleMessage = ({ data }: MessageEvent) => {\n        handleVerifiedMessage(data)\n      }\n\n      window.addEventListener('message', handleMessage)\n\n      return () => {\n        window.removeEventListener('message', handleMessage)\n      }\n    } else if (subscribe && unsubscribe) {\n      subscribe('receiveMessage', handleVerifiedMessage)\n\n      return () => {\n        unsubscribe('receiveMessage', handleVerifiedMessage)\n      }\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n}\n"
  },
  {
    "path": "packages/tds-widget/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/tds-widget/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/tds-widget/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/triple-document/README.md",
    "content": "# triple-document\n\nTriple Document Package 는 Triple Article 시스템에서 출판되는 데이터 모델을 기반으로\n프리젠테이션할 수 있는 React 컴포넌트들로 구성됩니다.\n\n## Data Flow\n\n```sh\narticles-admin - triple-frontend(triple-document package)\n   \\ (create)\n    \\__ { triple document data model } -> stored in mongodb\n                                           / (connect)\n      >---------- articles-api ---------->\n     / (request)\narticles-web - triple-frontend(triple-document package)\n```\n\n- articles-admin: 매거진, 가이드 등을 작성하기 위한 어드민 CMS\n- triple-frontend: TripleDocument 컴포넌트를 갖는 공통 라이브러리\n- articles-web: 트리플 매거진, 가이드 서비스\n\n## Triple Document Data Model\n\nCMS 에서는 아래에 정의된 `heading1`, `heading2` 와 같은 다양한 문서 요소들의 타입으로 타입과\n컴포넌트가 맵핑되어 화면상에 렌더링됩니다.\n\n각 타입들을 고유의 데이터 구조를 갖고 있는데 이 부분은 articles-admin 프로젝트에서 살펴볼 수 있습니다.\n\n```ts\nexport const ELEMENTS: ElementSet = {\n  heading1: MH1,\n  heading2: MH2,\n  heading3: MH3,\n  heading4: MH4,\n  text: Text,\n  images: Images,\n  hr1: HR1,\n  hr2: HR2,\n  hr3: HR3,\n  hr4: HR4,\n  hr5: HR5,\n  hr6: HR6,\n  pois: Pois,\n  links: Links,\n  embedded: Embedded,\n  note: Note,\n  list: List,\n  regions: Regions,\n  video: ExternalVideo,\n  tnaProducts: TnaProductsList,\n  table: Table,\n  coupon: Coupon,\n  itinerary: Itinerary,\n}\n```\n\n### 문서요소 데이기본 구조\n\n```ts\n{\n  type: keyof typeof ELEMENTS\n  value: {\n    [keyof typeof ELEMENTS]: {\n      ...\n    }\n  }\n}\n```\n\n위의 각 문서 요소와 맵핑되는 컴포넌트들은 위의 정해진 데이터 타입에 따라 렌더링 `props` 를 받아 문서를\n표시합니다.\n\n```ts\n  [keyof typeof ELEMENTS]: { PROPS }\n```\n\n## 컴포넌트\n\n### Itinerary (지도, 추천코스)\n\n일정 데이터 구조의 원본 데이터를 기반으로 지도상에 동선과 추천 코스를 카드 나열로 표시하는 컴포넌트입니다.\n\n```json\n{\n  \"type\": \"itinerary\",\n  \"value\": {\n    \"itinerary\": {\n      \"day\": 1,\n      \"items\": [\n        {\n          \"memo\": \"\",\n          \"schedule\": \"1:00\",\n          \"transportation\": [\n            {\n              \"type\": \"transporation\",\n              \"value\": {\n                \"transportation\": \"car\",\n                \"duration\": \"0'05\\\"\"\n              }\n            }\n          ],\n          \"poi\": {\n            \"id\": \"e830bc8b-3ff3-48fb-99df-c6ccc1ce13c7\",\n            \"type\": \"restaurant\",\n            \"source\": {\n              \"id\": \"e830bc8b-3ff3-48fb-99df-c6ccc1ce13c7\",\n              \"type\": \"restaurant\",\n              \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n              \"pointGeolocation\": {\n                  \"type\": \"Point\",\n                  \"coordinates\": [\n                      100.496027,\n                      13.759168\n                  ]\n              },\n              ...,\n            }\n        }\n      }\n    ]\n   }\n},\n```\n\n- items 항목 1개의 경우 추천 코스의 하나의 카드 정보를 담습니다.\n- 지도를 표시할 때는 items 내의 `poi` 정보에 `pointGeolocation` 정보를 토대로 자동으로 연산하여 표시합니다.\n"
  },
  {
    "path": "packages/triple-document/package.json",
    "content": "{\n  \"name\": \"@titicaca/triple-document\",\n  \"version\": \"14.2.3\",\n  \"description\": \"TripleDocument: Formatted Content System\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/triple-document\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/content-type-definitions\": \"9.16.0\",\n    \"@titicaca/fetcher\": \"workspace:*\",\n    \"@titicaca/intersection-observer\": \"workspace:*\",\n    \"@titicaca/react-hooks\": \"workspace:*\",\n    \"@titicaca/router\": \"workspace:*\",\n    \"@titicaca/standard-action-handler\": \"workspace:*\",\n    \"@titicaca/tds-ui\": \"workspace:*\",\n    \"@titicaca/tds-widget\": \"workspace:*\",\n    \"@titicaca/triple-header\": \"workspace:*\",\n    \"@titicaca/type-definitions\": \"workspace:*\",\n    \"@titicaca/view-utilities\": \"workspace:*\",\n    \"date-fns\": \"^3.6.0\",\n    \"qs\": \"^6.14.0\"\n  },\n  \"devDependencies\": {\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"@types/qs\": \"^6.9.18\",\n    \"next\": \"^14.2.24\",\n    \"react\": \"^18.3.1\",\n    \"styled-components\": \"^6.1.15\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/triple-web\": \"*\",\n    \"next\": \"^13.4 || ^14.0\",\n    \"react\": \"^18.0\",\n    \"styled-components\": \"^6.0\"\n  }\n}\n"
  },
  {
    "path": "packages/triple-document/src/animation.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport ELEMENTS from './elements'\n\nconst { animation: Animation } = ELEMENTS\n\nexport default {\n  title: 'triple-document / 애니메이션',\n  component: Animation,\n} as Meta\n\nexport const Framer: StoryObj = {\n  args: {\n    value: {\n      type: 'FRAMER',\n      framer: {\n        canvas: {\n          width: 375,\n          height: 528,\n        },\n        layers: [\n          {\n            frames: [\n              {\n                type: 'image',\n                value: {\n                  image: {\n                    cloudinaryId: '495ccaab-e2c7-440f-97db-7a1cf027da3d',\n                    id: 'feba9285-4711-4097-bc23-e78799a0103c',\n                    type: 'image',\n                    source: {},\n                    width: 1500,\n                    height: 2112,\n                    cloudinaryBucket: 'triple-cms',\n                    metadata: {\n                      format: 'png',\n                    },\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg',\n                      },\n                    },\n                  },\n                },\n                width: 375,\n                height: 528,\n              },\n            ],\n          },\n          {\n            frames: [\n              {\n                type: 'image',\n                value: {\n                  image: {\n                    cloudinaryId: '666151ca-6269-4917-bff5-20e99f62c2a4',\n                    id: '685f7457-0cb0-4e29-82ac-b8f746abf3c1',\n                    type: 'image',\n                    source: {},\n                    width: 1500,\n                    height: 2112,\n                    cloudinaryBucket: 'triple-cms',\n                    metadata: {\n                      format: 'png',\n                    },\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/666151ca-6269-4917-bff5-20e99f62c2a4.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/666151ca-6269-4917-bff5-20e99f62c2a4.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/666151ca-6269-4917-bff5-20e99f62c2a4.jpeg',\n                      },\n                    },\n                  },\n                },\n                width: 375,\n                height: 528,\n              },\n              {\n                type: 'image',\n                value: {\n                  image: {\n                    cloudinaryId: '20b58ab3-60a6-4e09-93b8-612e865a105a',\n                    id: '1357b77b-971a-4a32-8c00-8ecc834ca678',\n                    type: 'image',\n                    source: {},\n                    width: 1500,\n                    height: 2112,\n                    cloudinaryBucket: 'triple-cms',\n                    metadata: {\n                      format: 'png',\n                    },\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/20b58ab3-60a6-4e09-93b8-612e865a105a.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/20b58ab3-60a6-4e09-93b8-612e865a105a.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/20b58ab3-60a6-4e09-93b8-612e865a105a.jpeg',\n                      },\n                    },\n                  },\n                },\n                width: 375,\n                height: 528,\n              },\n              {\n                type: 'image',\n                value: {\n                  image: {\n                    cloudinaryId: '2eb9f5e2-feae-482a-9c8c-a4488388f633',\n                    id: '21a0cac2-8c23-4395-8c17-6db286193725',\n                    type: 'image',\n                    source: {},\n                    width: 1500,\n                    height: 2112,\n                    cloudinaryBucket: 'triple-cms',\n                    metadata: {\n                      format: 'png',\n                    },\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/2eb9f5e2-feae-482a-9c8c-a4488388f633.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/2eb9f5e2-feae-482a-9c8c-a4488388f633.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/2eb9f5e2-feae-482a-9c8c-a4488388f633.jpeg',\n                      },\n                    },\n                  },\n                },\n                width: 375,\n                height: 528,\n              },\n            ],\n            transition: {\n              type: 'slide',\n            },\n          },\n          {\n            frames: [\n              {\n                type: 'image',\n                value: {\n                  image: {\n                    cloudinaryId: 'cde58c12-47ec-45fe-87db-9a60e0b34f15',\n                    id: 'd712d0e7-b0de-4eca-b0cf-e6b5c8940ae4',\n                    type: 'image',\n                    source: {},\n                    width: 1140,\n                    height: 216,\n                    cloudinaryBucket: 'triple-cms',\n                    metadata: {\n                      format: 'png',\n                    },\n                    sizes: {\n                      full: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/cde58c12-47ec-45fe-87db-9a60e0b34f15.jpeg',\n                      },\n                      large: {\n                        url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/cde58c12-47ec-45fe-87db-9a60e0b34f15.jpeg',\n                      },\n                      small_square: {\n                        url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/cde58c12-47ec-45fe-87db-9a60e0b34f15.jpeg',\n                      },\n                    },\n                  },\n                },\n                width: 280,\n                height: 54,\n              },\n            ],\n            positioning: {\n              top: 419,\n            },\n          },\n        ],\n      },\n    },\n  },\n}\n\nexport const Lottie: StoryObj = {\n  args: {\n    value: {\n      type: 'LOTTIE',\n      lottie: {\n        backgroundImage: {\n          cloudinaryId: '495ccaab-e2c7-440f-97db-7a1cf027da3d',\n          id: 'feba9285-4711-4097-bc23-e78799a0103c',\n          type: 'image',\n          source: {},\n          width: 1500,\n          height: 2112,\n          cloudinaryBucket: 'triple-cms',\n          metadata: {\n            format: 'png',\n          },\n          sizes: {\n            full: {\n              url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg',\n            },\n            large: {\n              url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg',\n            },\n            small_square: {\n              url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg',\n            },\n          },\n        },\n        lottieAnimationId: '',\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/anchor.tsx",
    "content": "import { styled } from 'styled-components'\n\nconst Element = styled.div`\n  height: 0;\n`\n\nexport default function Anchor({\n  value: { href },\n}: {\n  value: { href: string }\n}) {\n  return <Element id={href} />\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/animation.tsx",
    "content": "import { TripleHeader, TripleHeaderProps } from '@titicaca/triple-header'\n\nexport default function Animation({ value }: { value: TripleHeaderProps }) {\n  return <TripleHeader>{value}</TripleHeader>\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/coupon/coupon-download-buttons.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react'\nimport { useTranslation, useHashRouter, useLogin } from '@titicaca/triple-web'\nimport { Button } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\nimport { VerificationType, useUserVerification } from '@titicaca/tds-widget'\nimport { authGuardedFetchers, captureHttpError } from '@titicaca/fetcher'\nimport { useInterval } from '@titicaca/react-hooks'\nimport { isAfter, isEqual } from 'date-fns'\n\nimport { CouponData } from '../../types'\n\nimport {\n  HASH_ALREADY_DOWNLOAD_COUPON,\n  HASH_COMPLETE_DOWNLOAD_COUPON,\n  HASH_ERROR_COUPON,\n  CouponAlertModal,\n  HASH_COMPLETE_DOWNLOAD_COUPON_GROUP,\n  HASH_COMPLETE_DOWNLOAD_PART_OF_COUPON_GROUP,\n} from './modals'\n\ntype CouponErrorCode =\n  | 'COUPON_NOT_PUBLICATION_PERIOD'\n  | 'CLOSED_COUPON'\n  | 'MAX_COUPONS_PER_USER'\n  | 'ALL_COUPONS_EXHAUSTED'\n  | 'MISMATCH_PUBLISH_CRITERIA'\n  | 'NO_CI_AUTHENTICATION'\n\nconst BaseCouponDownloadButton = styled(Button)`\n  border-radius: 6px;\n  width: 100%;\n`\n\nconst MAX_COUPONS_PER_USER_ERROR_CODE = 'MAX_COUPONS_PER_USER'\n\nexport const DEFAULT_BUTTON_COLOR = {\n  background: '#368fff',\n  text: '#ffffff',\n}\n\nfunction useDownloadTimePassed(time: string | undefined) {\n  const calculator = useCallback(() => {\n    const now = new Date()\n    return time === undefined || isAfter(now, time) || isEqual(now, time)\n  }, [time])\n\n  const [passed, setPassed] = useState(calculator())\n\n  useInterval(() => {\n    setPassed(calculator)\n  }, 500)\n\n  return passed\n}\nasync function downloadCoupon(slugId: string) {\n  const response = await authGuardedFetchers.get<\n    {\n      id?: string\n    },\n    {\n      code: CouponErrorCode\n      message: string\n    }\n  >(`/api/benefit/coupons/${slugId}/download`)\n\n  if (response === 'NEED_LOGIN') {\n    return { type: 'NEED_LOGIN' } as const\n  }\n\n  if (response.ok === true) {\n    const {\n      parsedBody: { id },\n    } = response\n\n    if (id) {\n      return { type: 'SUCCESS' } as const\n    }\n\n    return { type: 'UNKNOWN_ERROR' } as const\n  }\n\n  const {\n    parsedBody: { code, message },\n  } = response\n\n  if (code === 'NO_CI_AUTHENTICATION') {\n    return { type: 'NEED_USER_VERIFICATION' } as const\n  }\n\n  return { type: 'UNKNOWN_ERROR', message } as const\n}\n\nexport function CouponDownloadButton({\n  slugId,\n  textColor = DEFAULT_BUTTON_COLOR.text,\n  backgroundColor = DEFAULT_BUTTON_COLOR.background,\n  verificationType,\n  enabledAt,\n  onClick,\n}: {\n  slugId: string\n  textColor?: string\n  backgroundColor?: string\n  verificationType?: VerificationType\n  enabledAt?: string\n  onClick?: () => void\n}) {\n  const t = useTranslation()\n\n  const [couponFetched, setCouponFetched] = useState(false)\n  const [downloaded, setDownloaded] = useState(false)\n  const [errorMessage, setErrorMessage] = useState<string | undefined>(\n    undefined,\n  )\n  const { addUriHash } = useHashRouter()\n  const { initiateVerification } = useUserVerification({\n    verificationType,\n    forceVerification: false,\n  })\n  const login = useLogin()\n\n  const [needLogin, setNeedLogin] = useState(false)\n  const timePassed = useDownloadTimePassed(enabledAt)\n\n  const buttonDisabled =\n    (couponFetched === false && needLogin === false) || !timePassed\n\n  useEffect(() => {\n    async function fetchCoupon() {\n      const response = await authGuardedFetchers.get<CouponData>(\n        `/api/benefit/coupons/${slugId}`,\n      )\n\n      if (response === 'NEED_LOGIN') {\n        setNeedLogin(true)\n        return\n      }\n\n      captureHttpError(response)\n\n      if (response.ok === true) {\n        const {\n          parsedBody: { downloaded },\n        } = response\n\n        setCouponFetched(true)\n        setDownloaded(!!downloaded)\n      }\n    }\n\n    fetchCoupon()\n  }, [slugId, timePassed])\n\n  const raiseDownloadedAlert = () =>\n    addUriHash(`${slugId}.${HASH_ALREADY_DOWNLOAD_COUPON}`)\n\n  const handleCouponDownloadButtonClick = async () => {\n    if (buttonDisabled === false) {\n      if (needLogin === true) {\n        login()\n      } else if (downloaded === true) {\n        raiseDownloadedAlert()\n      } else {\n        const response = await downloadCoupon(slugId)\n\n        const responseHandlers = {\n          SUCCESS: () => {\n            addUriHash(`${slugId}.${HASH_COMPLETE_DOWNLOAD_COUPON}`)\n            setDownloaded(true)\n          },\n          NEED_LOGIN: () => {\n            login()\n          },\n          NEED_USER_VERIFICATION: () => initiateVerification(),\n          UNKNOWN_ERROR: ({ message }: { message?: string }) => {\n            setErrorMessage(message)\n            addUriHash(`${slugId}.${HASH_ERROR_COUPON}`)\n          },\n          /* eslint-enable @typescript-eslint/naming-convention */\n        }\n        const handleResponse = responseHandlers[response.type]\n        handleResponse(response)\n      }\n    }\n\n    onClick && onClick()\n  }\n\n  return (\n    <>\n      <BaseCouponDownloadButton\n        css={{ color: textColor, backgroundColor }}\n        disabled={buttonDisabled}\n        onClick={handleCouponDownloadButtonClick}\n      >\n        {t('쿠폰 받기')}\n      </BaseCouponDownloadButton>\n      <CouponAlertModal identifier={slugId} errorMessage={errorMessage} />\n    </>\n  )\n}\n\nasync function downloadCoupons(coupons: CouponData[]) {\n  const downloadableCoupons = coupons.filter(({ downloaded }) => !downloaded)\n\n  if (downloadableCoupons.length === 0) {\n    return { type: 'NO_DOWNLOADABLE_COUPONS' } as const\n  }\n\n  const response = await authGuardedFetchers.post<\n    {\n      id: string\n      success: boolean\n      errorCode: CouponErrorCode\n      errorMessage: string\n    }[]\n  >('/api/benefit/coupons', {\n    body: {\n      ids: coupons.map(({ id }) => id),\n    },\n  })\n\n  if (response === 'NEED_LOGIN') {\n    return { type: 'NEED_LOGIN' } as const\n  }\n\n  captureHttpError(response)\n\n  if (response.ok === true) {\n    const { parsedBody: results } = response\n    const succeedCoupons = results.filter(({ success }) => success)\n\n    if (succeedCoupons.length === coupons.length) {\n      return { type: 'EVERY_COUPONS_DOWNLOADED' } as const\n    }\n\n    if (succeedCoupons.length > 0) {\n      return { type: 'SOME_COUPONS_DOWNLOADED' } as const\n    }\n\n    if (results[0].errorCode === MAX_COUPONS_PER_USER_ERROR_CODE) {\n      return { type: 'NO_DOWNLOADABLE_COUPONS' } as const\n    }\n\n    if (results[0].errorCode === 'NO_CI_AUTHENTICATION') {\n      return { type: 'NEED_USER_VERIFICATION' } as const\n    }\n\n    return {\n      type: 'UNKNOWN_ERROR',\n      message: results?.[0].errorMessage,\n    } as const\n  }\n\n  return { type: 'UNKNOWN_ERROR' } as const\n}\n\nexport function CouponGroupDownloadButton({\n  groupId,\n  textColor = DEFAULT_BUTTON_COLOR.text,\n  backgroundColor = DEFAULT_BUTTON_COLOR.background,\n  verificationType,\n  enabledAt,\n  onClick,\n}: {\n  groupId: string\n  textColor?: string\n  backgroundColor?: string\n  verificationType?: VerificationType\n  enabledAt?: string\n  onClick?: () => void\n}) {\n  const t = useTranslation()\n\n  const [coupons, setCoupons] = useState<CouponData[]>([])\n  const [errorMessage, setErrorMessage] = useState<string | undefined>(\n    undefined,\n  )\n  const { addUriHash } = useHashRouter()\n  const { initiateVerification } = useUserVerification({\n    verificationType,\n    forceVerification: false,\n  })\n  const login = useLogin()\n\n  const [needLogin, setNeedLogin] = useState(false)\n  const timePassed = useDownloadTimePassed(enabledAt)\n\n  const downloaded =\n    coupons.length === 0 || coupons.every(({ downloaded }) => downloaded)\n  const buttonDisabled =\n    (coupons.length === 0 && needLogin === false) || !timePassed\n\n  const raiseDownloadedAlert = () =>\n    addUriHash(`${groupId}.${HASH_ALREADY_DOWNLOAD_COUPON}`)\n\n  useEffect(() => {\n    async function fetchCoupons() {\n      const response = await authGuardedFetchers.get<{\n        items: CouponData[]\n        nextPageToken: string\n      }>(`/api/benefit/downloadable-coupons?groupCode=${groupId}`)\n\n      if (response === 'NEED_LOGIN') {\n        setNeedLogin(true)\n        return\n      }\n\n      captureHttpError(response)\n\n      if (response.ok === true) {\n        const {\n          parsedBody: { items },\n        } = response\n\n        setCoupons(items)\n      }\n    }\n\n    fetchCoupons()\n  }, [groupId, timePassed])\n\n  const handleCouponDownloadButtonClick = async () => {\n    if (buttonDisabled === false) {\n      if (needLogin === true) {\n        login()\n      } else if (downloaded === true) {\n        raiseDownloadedAlert()\n      } else {\n        const response = await downloadCoupons(coupons)\n\n        const responseHandlers = {\n          NEED_LOGIN: () => {\n            login()\n          },\n          EVERY_COUPONS_DOWNLOADED: () => {\n            addUriHash(`${groupId}.${HASH_COMPLETE_DOWNLOAD_COUPON_GROUP}`)\n          },\n          SOME_COUPONS_DOWNLOADED: () => {\n            addUriHash(\n              `${groupId}.${HASH_COMPLETE_DOWNLOAD_PART_OF_COUPON_GROUP}`,\n            )\n          },\n          NO_DOWNLOADABLE_COUPONS: () => {\n            raiseDownloadedAlert()\n          },\n          NEED_USER_VERIFICATION: () => {\n            initiateVerification()\n          },\n          UNKNOWN_ERROR: ({ message }: { message?: string }) => {\n            setErrorMessage(message)\n            addUriHash(`${groupId}.${HASH_ERROR_COUPON}`)\n          },\n          /* eslint-enable @typescript-eslint/naming-convention */\n        }\n\n        const handleResponse = responseHandlers[response.type]\n        handleResponse(response)\n      }\n    }\n    onClick && onClick()\n  }\n\n  return (\n    <>\n      <BaseCouponDownloadButton\n        css={{ color: textColor, backgroundColor }}\n        disabled={buttonDisabled}\n        onClick={handleCouponDownloadButtonClick}\n      >\n        {t('쿠폰 받기')}\n      </BaseCouponDownloadButton>\n      <CouponAlertModal identifier={groupId} errorMessage={errorMessage} />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/coupon/index.tsx",
    "content": "import { useCallback } from 'react'\nimport { Text, Container } from '@titicaca/tds-ui'\nimport { useTrackEventWithMetadata } from '@titicaca/triple-web'\nimport { VerificationType } from '@titicaca/tds-widget'\n\nimport { useDeepLink } from '../../prop-context/deep-link'\n\nimport { CouponModal } from './modals'\nimport {\n  CouponDownloadButton,\n  CouponGroupDownloadButton,\n  DEFAULT_BUTTON_COLOR,\n} from './coupon-download-buttons'\nimport { safeParseHexColor } from './utils'\n\nconst DEFAULT_COLOR = {\n  buttonText: DEFAULT_BUTTON_COLOR.text,\n  buttonBackground: DEFAULT_BUTTON_COLOR.background,\n  description: '#3a3a3a80',\n}\n\nexport default function Coupon({\n  value: {\n    identifier,\n    description,\n    verificationType,\n    couponType = 'single',\n    enabledAt,\n    color = DEFAULT_COLOR,\n  },\n}: {\n  value: {\n    identifier: string\n    description: string\n    verificationType?: VerificationType\n    couponType?: 'single' | 'group'\n    enabledAt?: string\n    color?: {\n      background?: string\n      buttonText?: string\n      buttonBackground?: string\n      description?: string\n    }\n  }\n}) {\n  const trackEventWithMetadata = useTrackEventWithMetadata()\n\n  const deepLink = useDeepLink()\n\n  const handleCouponClick = useCallback(() => {\n    trackEventWithMetadata({\n      fa: {\n        action: '쿠폰받기선택',\n        coupon_id: identifier,\n        coupon_type: couponType,\n      },\n    })\n  }, [couponType, identifier, trackEventWithMetadata])\n\n  if (!deepLink) {\n    // TODO: triple-document 에러 처리 방법 설계\n    return null\n  }\n\n  return (\n    <Container\n      css={{\n        padding: '44px 30px 42px',\n        backgroundColor: safeParseHexColor(color.background),\n      }}\n    >\n      {couponType === 'single' ? (\n        <CouponDownloadButton\n          verificationType={verificationType}\n          slugId={identifier}\n          enabledAt={enabledAt}\n          onClick={handleCouponClick}\n          textColor={safeParseHexColor(color.buttonText)}\n          backgroundColor={safeParseHexColor(color.buttonBackground)}\n        />\n      ) : (\n        <CouponGroupDownloadButton\n          verificationType={verificationType}\n          groupId={identifier}\n          enabledAt={enabledAt}\n          onClick={handleCouponClick}\n          textColor={safeParseHexColor(color.buttonText)}\n          backgroundColor={safeParseHexColor(color.buttonBackground)}\n        />\n      )}\n\n      {description ? (\n        <Text\n          css={{\n            color: safeParseHexColor(\n              color.description || DEFAULT_COLOR.description,\n            ),\n          }}\n          margin={{ top: 13 }}\n          lineHeight={1.46}\n          size=\"tiny\"\n        >\n          {description}\n        </Text>\n      ) : null}\n\n      <CouponModal identifier={identifier} />\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/coupon/modals.tsx",
    "content": "import { useTranslation, useHashRouter } from '@titicaca/triple-web'\nimport { Text, Modal, Alert } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\nimport { useNavigate } from '@titicaca/router'\n\ninterface HashKeyValue {\n  [hash: string]: string\n}\n\nexport const HASH_COMPLETE_DOWNLOAD_COUPON = 'coupon.download-complete.modal'\nexport const HASH_COMPLETE_DOWNLOAD_COUPON_GROUP =\n  'coupon.group-download-complete.modal'\nexport const HASH_COMPLETE_DOWNLOAD_PART_OF_COUPON_GROUP =\n  'coupon.part-of-group-download-complete.modal'\nexport const HASH_ALREADY_DOWNLOAD_COUPON = 'coupon.download-already.modal'\nexport const HASH_ERROR_COUPON = 'coupon.error.modal'\n\nconst MODAL_HASHES = [\n  HASH_COMPLETE_DOWNLOAD_COUPON,\n  HASH_COMPLETE_DOWNLOAD_COUPON_GROUP,\n  HASH_COMPLETE_DOWNLOAD_PART_OF_COUPON_GROUP,\n  HASH_ALREADY_DOWNLOAD_COUPON,\n]\n\nconst ICON_TYPES: HashKeyValue = {\n  [HASH_COMPLETE_DOWNLOAD_COUPON]:\n    'https://assets.triple.guide/images/img-popup-coupon@3x.png',\n  [HASH_COMPLETE_DOWNLOAD_COUPON_GROUP]:\n    'https://assets.triple.guide/images/img-popup-coupon@3x.png',\n  [HASH_COMPLETE_DOWNLOAD_PART_OF_COUPON_GROUP]:\n    'https://assets.triple.guide/images/img-popup-coupon@3x.png',\n}\n\nconst CouponIcon = styled.img`\n  display: block;\n  width: 60px;\n  height: 60px;\n  margin: 40px auto 10px;\n`\n\nexport function CouponModal({ identifier }: { identifier: string }) {\n  const t = useTranslation()\n  const { uriHash, removeUriHash } = useHashRouter()\n  const { navigate } = useNavigate()\n\n  const modalHash = uriHash.replace(`${identifier}.`, '')\n\n  const titleTypes: HashKeyValue = {\n    [HASH_ALREADY_DOWNLOAD_COUPON]: t('이미 받은 쿠폰입니다.'),\n    [HASH_COMPLETE_DOWNLOAD_COUPON]: t('쿠폰 받기 완료'),\n    [HASH_COMPLETE_DOWNLOAD_COUPON_GROUP]: t('쿠폰 받기 완료'),\n    [HASH_COMPLETE_DOWNLOAD_PART_OF_COUPON_GROUP]: t('쿠폰 받기 완료'),\n  }\n\n  const messageTypes: HashKeyValue = {\n    [HASH_COMPLETE_DOWNLOAD_COUPON]: t(\n      '쿠폰을 받았습니다! 쿠폰함에서 확인할 수 있어요~!',\n    ),\n\n    [HASH_COMPLETE_DOWNLOAD_COUPON_GROUP]: t(\n      '쿠폰을 모두 받았습니다. 쿠폰함에서 확인할 수 있어요~!',\n    ),\n\n    [HASH_ALREADY_DOWNLOAD_COUPON]: t('쿠폰함에서 쿠폰을 확인하세요.'),\n    [HASH_COMPLETE_DOWNLOAD_PART_OF_COUPON_GROUP]: t(\n      '쿠폰을 모두 받았습니다. (이미 받은 쿠폰 제외) 쿠폰함에서 확인할 수 있어요~!',\n    ),\n  }\n\n  const confirmMessageTypes: HashKeyValue = {\n    [HASH_ALREADY_DOWNLOAD_COUPON]: t('쿠폰함 가기'),\n    [HASH_COMPLETE_DOWNLOAD_COUPON]: t('쿠폰 확인'),\n    [HASH_COMPLETE_DOWNLOAD_COUPON_GROUP]: t('쿠폰 확인'),\n    [HASH_COMPLETE_DOWNLOAD_PART_OF_COUPON_GROUP]: t('쿠폰 확인'),\n  }\n\n  return (\n    <Modal\n      open={uriHash.includes(identifier) && MODAL_HASHES.includes(modalHash)}\n      onClose={removeUriHash}\n    >\n      {ICON_TYPES[modalHash] ? (\n        <CouponIcon src={ICON_TYPES[modalHash]} />\n      ) : null}\n      <Text\n        center\n        bold\n        size=\"big\"\n        lineHeight={1.38}\n        color=\"gray\"\n        margin={{\n          top: ICON_TYPES[modalHash] ? 0 : 40,\n          left: 30,\n          right: 30,\n          bottom: 10,\n        }}\n      >\n        {titleTypes[modalHash]}\n      </Text>\n      <Text\n        center\n        size=\"small\"\n        lineHeight={1.43}\n        color=\"gray\"\n        padding={{\n          bottom: 40,\n          left: 30,\n          right: 30,\n        }}\n        alpha={0.7}\n      >\n        {messageTypes[modalHash]}\n      </Text>\n\n      <Modal.Actions>\n        <Modal.Action color=\"gray\" onClick={() => removeUriHash()}>\n          {t('닫기')}\n        </Modal.Action>\n        <Modal.Action\n          color=\"blue\"\n          onClick={() => {\n            removeUriHash()\n            navigate(\n              `/inlink?path=${encodeURIComponent(\n                '/benefit/coupons/my?_triple_no_navbar',\n              )}`,\n            )\n          }}\n        >\n          {confirmMessageTypes[modalHash]}\n        </Modal.Action>\n      </Modal.Actions>\n    </Modal>\n  )\n}\n\nexport function CouponAlertModal({\n  identifier,\n  errorMessage,\n}: {\n  identifier: string\n  errorMessage?: string\n}) {\n  const t = useTranslation()\n  const { uriHash, removeUriHash } = useHashRouter()\n  return (\n    <Alert\n      title={t('쿠폰 다운로드 안내')}\n      open={uriHash === `${identifier}.${HASH_ERROR_COUPON}`}\n      onConfirm={removeUriHash}\n    >\n      {errorMessage}\n    </Alert>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/coupon/utils.test.ts",
    "content": "import { safeParseHexColor } from './utils'\n\ntest('sRGB 스펙에 맞는 HEX 코드 색상을 올바르게 처리합니다.', () => {\n  const threeValueExample = '#f09'\n  const fourValueExample = '#F009'\n  const sixValueExample = '#ff0099'\n  const eightValueExample = ' #ff009990'\n\n  expect(safeParseHexColor(threeValueExample)).toBe(threeValueExample)\n  expect(safeParseHexColor(fourValueExample)).toBe(fourValueExample)\n  expect(safeParseHexColor(sixValueExample)).toBe(sixValueExample)\n  expect(safeParseHexColor(eightValueExample)).toBe(eightValueExample)\n})\n\ntest('# 이 포함된 HEX 코드라면 그대로 반환합니다.', () => {\n  const example = '#ffffff80'\n  expect(safeParseHexColor(example)).toBe(example)\n})\n\ntest('# 를 제외하고 유효한 HEX 코드라면 #를 포함해서 반환합니다.', () => {\n  const example = 'ffffff80'\n  expect(safeParseHexColor(example)).toBe(`#${example}`)\n})\n\ntest('유효한 HEX 코드가 아닐 경우 그대로 반환합니다.', () => {\n  const example = 'white'\n  expect(safeParseHexColor(example)).toBe(example)\n})\n"
  },
  {
    "path": "packages/triple-document/src/elements/coupon/utils.ts",
    "content": "/**\n * @reference https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color\n */\nexport function safeParseHexColor(color?: string) {\n  if (!color) {\n    return\n  }\n\n  const HEX_ALPHA_PATTERN = /^#([A-F0-9]{3,4}|[A-F0-9]{6}|[A-F0-9]{8})$/i\n  const colorCode = color.startsWith('#') ? color : `#${color}`\n\n  return HEX_ALPHA_PATTERN.test(colorCode) ? colorCode : color\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/embedded.tsx",
    "content": "import { ComponentType } from 'react'\nimport { Container, Carousel } from '@titicaca/tds-ui'\nimport { ImageMeta } from '@titicaca/type-definitions'\nimport { Media } from '@titicaca/tds-widget'\n\nimport { TripleElementData, ElementSet } from '../types'\nimport { useImageClickHandler } from '../prop-context/image-click-handler'\nimport { useLinkClickHandler } from '../prop-context/link-click-handler'\nimport { useImageSource } from '../prop-context/image-source'\nimport { useMediaConfig } from '../prop-context/media-config'\n\nimport DocumentCarousel from './shared/document-carousel'\nimport generateClickHandler from './shared/generate-click-handler'\nimport { Text, MH3 } from './text'\nimport Links from './links'\n\nfunction Compact<P extends { compact?: boolean }>(Component: ComponentType<P>) {\n  return function CompactedComponent(props: P) {\n    return <Component compact {...props} />\n  }\n}\n\nfunction EmbeddedImage({\n  value: {\n    images: [image],\n  },\n  ...props\n}: {\n  value: {\n    images: ImageMeta[]\n  }\n} & Parameters<typeof Container>[0]) {\n  const onImageClick = useImageClickHandler()\n  const onLinkClick = useLinkClickHandler()\n  const ImageSource = useImageSource()\n  const { optimized } = useMediaConfig()\n\n  if (image) {\n    const handleClick = generateClickHandler(onLinkClick, onImageClick)\n\n    return (\n      <Container\n        css={{\n          margin: '10px 0 0',\n        }}\n        {...props}\n      >\n        <Media\n          optimized={optimized}\n          media={image}\n          ImageSource={ImageSource}\n          onClick={handleClick}\n        />\n      </Container>\n    )\n  }\n\n  return null\n}\n\nconst EMBEDDED_ELEMENTS: ElementSet = {\n  heading2: Compact(MH3), // POI의 featuredContent에서 embedded entry의 제목이 heading2로 옵니다.\n  heading3: Compact(MH3),\n  text: Compact(Text),\n  links: Compact(Links),\n  images: EmbeddedImage,\n}\n\nexport default function Embedded({\n  value: { entries },\n}: {\n  value: {\n    entries: TripleElementData[][]\n  }\n}) {\n  return (\n    <DocumentCarousel margin={{ top: 20 }}>\n      {entries.map((elements, i) => (\n        <Carousel.Item key={i} size=\"large\">\n          {elements.map(({ type, value }, j) => {\n            const Element = EMBEDDED_ELEMENTS[type]\n\n            return (\n              Element && (\n                <Element\n                  key={j}\n                  value={value}\n                  {...(j === 0 ? { css: { marginTop: 0 } } : {})}\n                />\n              )\n            )\n          })}\n        </Carousel.Item>\n      ))}\n    </DocumentCarousel>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/external-video.tsx",
    "content": "import { styled } from 'styled-components'\nimport { borderRadiusMixin } from '@titicaca/tds-ui'\n\nconst VideoContainer = styled.div<{ $borderRadius: number }>`\n  position: relative;\n  margin: 30px 30px 0;\n  height: 0;\n  padding-bottom: 56.25%;\n  ${({ $borderRadius }) => borderRadiusMixin({ borderRadius: $borderRadius })}\n`\n\nconst VideoPlayer = styled.iframe`\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n`\n\nexport default function ExternalVideo({\n  value: { provider, identifier },\n}: {\n  value: { provider: string; identifier: string }\n}) {\n  return provider === 'youtube' ? (\n    <VideoContainer $borderRadius={6}>\n      <VideoPlayer\n        className=\"chromatic-ignore\"\n        src={`https://www.youtube.com/embed/${identifier}?rel=0&amp;showinfo=0`}\n        frameBorder=\"0\"\n        allow=\"autoplay; encrypted-media\"\n        allowFullScreen\n      />\n    </VideoContainer>\n  ) : null\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/images.tsx",
    "content": "import { ImageCarouselElementContainer, ImageCaption } from '@titicaca/tds-ui'\nimport { ImageMeta } from '@titicaca/type-definitions'\nimport { DocumentImageDisplayType } from '@titicaca/content-type-definitions'\nimport { Media } from '@titicaca/tds-widget'\n\nimport { useImageClickHandler } from '../prop-context/image-click-handler'\nimport { useLinkClickHandler } from '../prop-context/link-click-handler'\nimport { useImageSource } from '../prop-context/image-source'\nimport { useMediaConfig } from '../prop-context/media-config'\n\nimport generateClickHandler from './shared/generate-click-handler'\nimport {\n  DocumentCarouselContainer,\n  ELEMENT_CONTAINER_MAP,\n  IMAGES_CONTAINER_MAP,\n} from './shared/display-containers'\n\nexport default function Images({\n  value: { images, display },\n  onImageClick: overridedOnImageClick,\n  onLinkClick: overridedOnLinkClick,\n}: {\n  value: {\n    images: ImageMeta[]\n    display: DocumentImageDisplayType\n  }\n  onImageClick?: ReturnType<typeof useImageClickHandler>\n  onLinkClick?: ReturnType<typeof useLinkClickHandler>\n}) {\n  const defaultOnImageClick = useImageClickHandler()\n  const onImageClick = overridedOnImageClick || defaultOnImageClick\n\n  const defaultOnLinkClick = useLinkClickHandler()\n  const onLinkClick = overridedOnLinkClick || defaultOnLinkClick\n\n  const ImageSource = useImageSource()\n  const { videoAutoPlay, hideVideoControls, optimized } = useMediaConfig()\n\n  const ImagesContainer: React.ElementType =\n    IMAGES_CONTAINER_MAP[display] || DocumentCarouselContainer\n\n  const ElementContainer =\n    ELEMENT_CONTAINER_MAP[display] || ImageCarouselElementContainer\n\n  const handleClick = generateClickHandler(onLinkClick, onImageClick)\n  const isOnlyImage = ['gapless-block', 'grid'].includes(display)\n\n  return (\n    <ImagesContainer images={images}>\n      {images.map((image, i) => {\n        return (\n          <ElementContainer key={i}>\n            <Media\n              frame=\"small\"\n              optimized={optimized}\n              borderRadius={isOnlyImage ? 0 : undefined}\n              autoPlay={videoAutoPlay}\n              hideControls={hideVideoControls}\n              media={image}\n              onClick={handleClick}\n              ImageSource={ImageSource}\n            />\n            {!isOnlyImage && image.title ? (\n              <ImageCaption>{image.title}</ImageCaption>\n            ) : null}\n          </ElementContainer>\n        )\n      })}\n    </ImagesContainer>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/index.ts",
    "content": "import { HR1, HR2, HR3, HR4, HR5, HR6 } from '@titicaca/tds-ui'\n\nimport { ElementSet } from '../types'\n\nimport Anchor from './anchor'\nimport Coupon from './coupon'\nimport Embedded from './embedded'\nimport ExternalVideo from './external-video'\nimport Images from './images'\nimport ItineraryElement from './itinerary'\nimport Links from './links'\nimport List from './list'\nimport Note from './note'\nimport Pois from './pois'\nimport Regions from './regions'\nimport Table from './table'\nimport { MH1, MH2, MH3, MH4, Text } from './text'\nimport { TnaProducts } from './tna'\nimport Animation from './animation'\nimport StickyTabs from './sticky-tabs'\n\nconst ELEMENTS: ElementSet = {\n  heading1: MH1,\n  heading2: MH2,\n  heading3: MH3,\n  heading4: MH4,\n  text: Text,\n  images: Images,\n  hr1: HR1,\n  hr2: HR2,\n  hr3: HR3,\n  hr4: HR4,\n  hr5: HR5,\n  hr6: HR6,\n  pois: Pois,\n  links: Links,\n  embedded: Embedded,\n  note: Note,\n  list: List,\n  regions: Regions,\n  video: ExternalVideo,\n  tnaProducts: TnaProducts,\n  table: Table,\n  coupon: Coupon,\n  itinerary: ItineraryElement,\n  anchor: Anchor,\n  animation: Animation,\n  stickyTabs: StickyTabs,\n}\n\nexport default ELEMENTS\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/badge.ts",
    "content": "import { styled, css } from 'styled-components'\n\nimport withTypeCircleBadge from './with-type-circle-badge'\n\nconst BadgeBase = styled.span`\n  display: inline-block;\n  font-weight: bold;\n  text-align: center;\n`\n\n/**\n * TODO: TF/core-elements 에 label 로 있어 이동하면서 병합 및 리펙토링을 진행합니다.\n * - [x] color\n * - [ ] size(fontSize 도 size 값에 상대적으로 동작되도록)\n * - [ ] border-radius\n * - [ ] GetGlobalColor 사용해서 컬러 설정되도록 (현재는 별도 컬러는 사용하고 있음)\n *\n * usage\n *\n * <Badge>쿠폰 한일가 적용</Badge>\n * <Badge color=\"black\">쿠폰 할인가 적용</Badge>\n * <Badge inverted>쿠폰 할인가 적용</Badge>\n * <Badge radius={100}>1</Badge>\n */\nexport const Badge = styled(BadgeBase)<{\n  $color?: string\n  $fontSize?: number\n  $radius?: number\n  $inverted?: boolean\n}>`\n  font-size: ${({ $fontSize = 10 }) => $fontSize}px;\n  border-radius: ${({ $radius = 2 }) => $radius}px;\n  padding: 3px 4px;\n\n  ${({ $color, $inverted }) =>\n    $inverted\n      ? css`\n          color: ${$color};\n          border: 1px solid ${$color};\n        `\n      : css`\n          color: var(--color-white);\n          border: 1px solid ${$color};\n          background-color: ${$color};\n        `}\n`\n\nexport const CircleBadge = styled(BadgeBase)<{\n  $color?: string\n  $borderless?: boolean\n}>`\n  font-size: 11px;\n  color: white;\n  width: 24px;\n  height: 24px;\n  padding-top: 4px;\n  border-radius: 100%;\n  background-color: ${({ $color, theme }) => $color ?? theme.colors.purple};\n\n  ${({ $borderless }) =>\n    $borderless\n      ? undefined\n      : css`\n          border: 2px solid var(--color-white);\n        `}\n`\n\nexport const HotelCircleBadge = withTypeCircleBadge('hotel')\nexport const AttractionCircleBadge = withTypeCircleBadge('attraction')\nexport const RestaurantCircleBadge = withTypeCircleBadge('restaurant')\nexport const FestaCircleBadge = withTypeCircleBadge('festa')\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/icons.tsx",
    "content": "import { styled } from 'styled-components'\n\ninterface IconDefaultProps {\n  on?: boolean\n  color?: string\n  width?: number\n  height?: number\n}\n\ninterface ImageIconProps extends IconDefaultProps {\n  /* icon name */\n  name: string\n}\n\nconst Icon = styled.svg`\n  width: ${({ width = 36 }) => width}px;\n  height: ${({ height = 36 }) => height}px;\n`\n\nfunction ImageIcon({\n  width = 48,\n  height = 48,\n  name,\n  ...props\n}: ImageIconProps) {\n  const src = `https://assets.triple.guide/images/${name}.png`\n  return <img width={width} height={height} {...props} src={src} alt=\"\" />\n}\n\nexport function Bus(props: IconDefaultProps) {\n  return (\n    <ImageIcon\n      name=\"ico_contents_trans_bus@3x\"\n      width={11}\n      height={11}\n      {...props}\n    />\n  )\n}\n\nexport function Car(props: IconDefaultProps) {\n  return (\n    <ImageIcon\n      name=\"ico_contents_trans_car@3x\"\n      width={11}\n      height={11}\n      {...props}\n    />\n  )\n}\n\nexport function Walk(props: IconDefaultProps) {\n  return (\n    <ImageIcon\n      name=\"ico_contents_trans_walk@3x\"\n      width={11}\n      height={11}\n      {...props}\n    />\n  )\n}\n\nexport function Plane(props: IconDefaultProps) {\n  return (\n    <ImageIcon\n      name=\"ico_contents_trans_plane@3x\"\n      width={11}\n      height={11}\n      {...props}\n    />\n  )\n}\n\nexport function Tram(props: IconDefaultProps) {\n  return (\n    <ImageIcon\n      name=\"ico_contents_trans_tram@3x-v2\"\n      width={11}\n      height={11}\n      {...props}\n    />\n  )\n}\n\nexport function Cable(props: IconDefaultProps) {\n  return (\n    <ImageIcon\n      name=\"ico_contents_trans_cable@3x\"\n      width={11}\n      height={11}\n      {...props}\n    />\n  )\n}\n\nexport function Train(props: IconDefaultProps) {\n  return (\n    <ImageIcon\n      name=\"ico_contents_trans_train@3x-v2\"\n      width={11}\n      height={11}\n      {...props}\n    />\n  )\n}\n\nexport function Ship(props: IconDefaultProps) {\n  return (\n    <ImageIcon\n      name=\"ico_contents_trans_ship@3x\"\n      width={11}\n      height={11}\n      {...props}\n    />\n  )\n}\n\nexport function Bike(props: IconDefaultProps) {\n  return (\n    <ImageIcon\n      name=\"ico_contents_trans_bicycle\"\n      width={11}\n      height={11}\n      {...props}\n    />\n  )\n}\n\nexport function Download({\n  color,\n  width = 18,\n  height = 18,\n  ...rest\n}: IconDefaultProps) {\n  return (\n    <Icon\n      viewBox=\"0 0 18 18\"\n      fill=\"none\"\n      width={width}\n      height={height}\n      {...rest}\n    >\n      <path\n        d=\"M11.5979 3.31982H13.8705C14.7642 3.31982 15.4875 4.05977 15.4875 4.97245V14.9372C15.4875 15.8499 14.7642 16.5898 13.8705 16.5898H3.83953C2.94587 16.5898 2.22253 15.8499 2.22253 14.9372V4.97245C2.22253 4.05977 2.94587 3.31982 3.83953 3.31982H6.1122\"\n        stroke=\"white\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M11.4348 9.20508L8.79114 11.9843\"\n        stroke=\"white\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M8.79168 1.18347V11.9847L6.14685 9.20547\"\n        stroke=\"white\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </Icon>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/itinerary-map.tsx",
    "content": "import { useCallback, MouseEvent } from 'react'\nimport { Container } from '@titicaca/tds-ui'\nimport {\n  MapView,\n  HotelCircleMarker,\n  AttractionCircleMarker,\n  RestaurantCircleMarker,\n  DotPolyline,\n  FestaCircleMarker,\n} from '@titicaca/tds-widget'\nimport { useEnv } from '@titicaca/triple-web'\nimport {\n  Itinerary,\n  ItineraryItemType,\n} from '@titicaca/content-type-definitions'\n\nimport useMapData from './use-computed-map'\n\ninterface Props {\n  /** 몇번째 일정 */\n  day: Itinerary['day']\n  /** 추천 코스 POI 목록 */\n  items: Itinerary['items']\n  /** 지도상 마커 클릭 핸들러 */\n  onClickMarker: (poi: ItineraryItemType) => void\n}\n\nexport default function ItineraryMap({ onClickMarker, items }: Props) {\n  const { googleMapsApiKey } = useEnv()\n  const { polyline, mapItems, coordinates } = useMapData(items)\n\n  const totalMapItemCount = mapItems.length\n\n  const generateClickMarkerHandler = useCallback(\n    (item: ItineraryItemType) => (e: MouseEvent) => {\n      e.preventDefault()\n\n      if (onClickMarker) {\n        onClickMarker(item)\n      }\n    },\n    [onClickMarker],\n  )\n\n  return (\n    <Container\n      className=\"chromatic-ignore\"\n      css={{\n        width: '100%',\n        height: 180,\n      }}\n    >\n      {googleMapsApiKey ? (\n        <MapView\n          coordinates={coordinates}\n          googleMapLoadOptions={{\n            googleMapsApiKey,\n          }}\n        >\n          {mapItems.map(({ position, item }, i) => {\n            const CircleMarker = ItineraryTypeCircleMarker(item)\n\n            return (\n              <CircleMarker\n                key={i}\n                zIndex={totalMapItemCount - i}\n                width={22}\n                height={22}\n                position={position}\n                onClick={generateClickMarkerHandler(item)}\n              >\n                <strong>{i + 1}</strong>\n              </CircleMarker>\n            )\n          })}\n          <DotPolyline path={polyline} />\n        </MapView>\n      ) : null}\n    </Container>\n  )\n}\n\nfunction ItineraryTypeCircleMarker(item: ItineraryItemType) {\n  const type = item.poi ? item.poi.type : 'festa'\n\n  switch (type) {\n    case 'hotel':\n      return HotelCircleMarker\n    case 'attraction':\n      return AttractionCircleMarker\n    case 'restaurant':\n      return RestaurantCircleMarker\n    case 'festa':\n      return FestaCircleMarker\n  }\n\n  throw new Error(`Unknown card type of itinerary \"${type}\"`)\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/poi-card.tsx",
    "content": "import { PoiType } from '@titicaca/content-type-definitions'\nimport { Card, Container, FlexBoxItem, Text } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\n\nconst PoiCardContainer = styled(Card)`\n  padding: 16px 15px;\n  flex: 1;\n`\n\nconst CardWrapper = styled(FlexBoxItem)`\n  min-width: 200px;\n`\n\nconst Divider = styled.div`\n  margin: 12px 0;\n  height: 1px;\n  background-color: var(--color-gray50);\n`\n\nconst Thumbnail = styled(Container)`\n  width: 40px;\n  height: 40px;\n  overflow: hidden;\n  border-radius: 4px;\n  position: absolute;\n  background: var(--color-gray20);\n\n  img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n  }\n`\n\nconst POI_IMAGE_PLACEHOLDERS_SMALL: {\n  [key in PoiType]: string\n} = {\n  attraction: 'https://assets.triple.guide/images/ico_blank_see_small@2x.png',\n  restaurant: 'https://assets.triple.guide/images/ico_blank_eat_small@2x.png',\n  hotel: 'https://assets.triple.guide/images/ico_blank_hotel_small@2x.png',\n}\n\nexport default function PoiCard({\n  type,\n  name,\n  description,\n  memo,\n  comment,\n  imageUrl,\n  onClickPoiCard,\n}: {\n  type: PoiType\n  name: string\n  description: string\n  memo?: string\n  comment?: string\n  imageUrl?: string\n  onClickPoiCard: () => void\n}) {\n  return (\n    <CardWrapper flexGrow={1} as=\"a\" onClick={onClickPoiCard}>\n      <PoiCardContainer\n        shadow=\"medium\"\n        radius={6}\n        css={{ marginTop: 5, marginBottom: 8 }}\n      >\n        <Thumbnail>\n          <img\n            src={imageUrl || POI_IMAGE_PLACEHOLDERS_SMALL[type]}\n            alt={name}\n          />\n        </Thumbnail>\n        <Container css={{ marginTop: 1, minHeight: 40, paddingLeft: 50 }}>\n          <Text size={16} bold ellipsis>\n            {name}\n          </Text>\n          {comment ? (\n            <Text\n              size={13}\n              margin={{ top: 4, bottom: 4 }}\n              maxLines={2}\n              color=\"gray800\"\n            >\n              {comment}\n            </Text>\n          ) : null}\n          <Text size={13} color=\"gray500\" lineHeight={1.4} padding={{ top: 4 }}>\n            {description}\n          </Text>\n        </Container>\n        {memo ? (\n          <>\n            <Divider />\n            <Container css={{ display: 'flex' }}>\n              <Text\n                size={13}\n                bold\n                color=\"blue\"\n                margin={{ right: 6 }}\n                css={{ flexShrink: 0 }}\n              >\n                참고\n              </Text>\n              <Text size={13} wordBreak=\"keep-all\">\n                {memo}\n              </Text>\n            </Container>\n          </>\n        ) : null}\n      </PoiCardContainer>\n    </CardWrapper>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/save-to-itinerary.tsx",
    "content": "import { useCallback } from 'react'\nimport { useTranslation, useTrackEvent } from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport { Button, Text } from '@titicaca/tds-ui'\n\nimport useHandleAddPoisToTrip from '../itinerary/use-handle-add-pois-to-trip'\n\nimport { Download } from './icons'\n\nexport interface Geotag {\n  type: 'triple-region' | 'triple-zone'\n  id: string\n}\n\nconst SaveToItineraryButton = styled(Button)`\n  > * {\n    vertical-align: middle;\n  }\n`\n\nexport default function SaveToItinerary({\n  itemIds,\n  geotag,\n  disabled = false,\n}: {\n  itemIds: string[]\n  geotag: Geotag\n  disabled?: boolean\n}) {\n  const trackEvent = useTrackEvent()\n\n  const t = useTranslation()\n\n  const addPoisToTrip = useHandleAddPoisToTrip({\n    geotag,\n  })\n\n  const handleSaveToItinerary = useCallback(() => {\n    trackEvent({\n      ga: ['내일정으로담기_선택'],\n      fa: {\n        action: '내일정으로담기_선택',\n      },\n    })\n    if (geotag) {\n      addPoisToTrip(itemIds)\n    }\n  }, [geotag, itemIds, addPoisToTrip, trackEvent])\n\n  return (\n    <SaveToItineraryButton\n      fluid\n      basic\n      bold\n      inverted\n      margin={{ top: 20 }}\n      onClick={handleSaveToItinerary}\n      disabled={disabled}\n    >\n      <Download />\n      <Text inline size={14} margin={{ left: 3 }} color=\"white\">\n        {t('내 일정으로 담기')}\n      </Text>\n    </SaveToItineraryButton>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/tag-label.ts",
    "content": "import { styled } from 'styled-components'\n\n/**\n * TODO: move to TF/core-elements\n * - direction: LEFT | RIGHT\n * - color: ?\n * - inverted: boolean\n * - radius\n */\nexport const TagLabel = styled.div`\n  display: inline-flex;\n  justify-content: center;\n  align-items: center;\n  position: relative;\n  background-color: var(--color-white);\n  padding: 5px 1px 6px 3px;\n  color: var(--color-gray);\n  text-transform: none;\n  font-weight: 700;\n  border-radius: 5px;\n  border: 1px solid var(--color-gray100);\n  border-width: 1px 0 1px 1px;\n  z-index: 2;\n  font-size: 11px;\n  min-width: 41px;\n  letter-spacing: -0.3px;\n\n  > img {\n    margin-right: 1px;\n  }\n\n  &::before {\n    border: 1px solid var(--color-gray100);\n    border-radius: 5px;\n    border-width: 1px 1px 0 0;\n    background-color: var(--color-white);\n    transform: translateX(-50%) translateY(-50%) rotate(45deg);\n    top: 50%;\n    right: -15px;\n    position: absolute;\n    content: '';\n    z-index: -1;\n    width: 18px;\n    height: 18px;\n  }\n`\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/types.ts",
    "content": "import { PoiItineraryItemType } from '@titicaca/content-type-definitions'\n\nexport type ItineraryElementType = PoiItineraryItemType['poi']['type'] | 'festa'\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/use-computed-itineraries.ts",
    "content": "import { useMemo } from 'react'\nimport {\n  TransportationType,\n  Itinerary,\n  ItineraryItemType,\n} from '@titicaca/content-type-definitions'\nimport { GuestModeType } from '@titicaca/type-definitions'\n\nimport {\n  deriveNameFromTranslations,\n  UnSafetyTranslations,\n} from './use-safety-poi'\nimport { ItineraryElementType } from './types'\n\ninterface Props {\n  itinerary: Itinerary\n  guestMode?: GuestModeType\n}\n\ninterface Course {\n  id: string\n  /** 지역아이디 */\n  regionId: string\n  /** POI 제목, 공백값 가드닝 적용 */\n  name: string\n  /** 추천코스 요소 타입 */\n  type: ItineraryElementType\n  /** 거점지역, 카테고리 */\n  description: string\n  /** 이동수단 */\n  transportation?: TransportationType\n  /** 이동시간 */\n  duration?: string\n  /** 일정 Poi 에 추가된 관리자 메모 */\n  memo?: string\n  /** 일정 도착시간 */\n  schedule?: string\n  /** POI 간 이동거리 */\n  isFirst: boolean\n  /** 마지막 아이템 판단 */\n  isLast: boolean\n  /** POI 한줄 소개 */\n  comment?: string\n  /** POI 이미지 */\n  imageUrl?: string\n}\n\nconst DEFAULT_TRANSPORTATION = {\n  duration: undefined,\n  transportation: undefined,\n}\n\n/**\n * @param param0 TripleDocument Element Structure\n */\nexport default function useItinerary({ itinerary, guestMode }: Props) {\n  const { day, items, hideAddButton } = itinerary\n\n  const hasItineraries = items.length > 0\n  /** NOTE: 일정을 일정판에 저장하기 위해 regionId 를 특정하기 위한 로직 */\n  const regionId = extractRegionId(items)\n\n  const itemIds = useMemo(\n    () => items.map((item) => (item.poi ? item.poi.id : item.festa.id)),\n    [items],\n  )\n\n  const courses = useMemo<Course[]>(() => {\n    return items.map(\n      ({ poi, festa, memo, schedule, transportation: raw }, i) => {\n        const transportation = raw?.[0]?.value || DEFAULT_TRANSPORTATION\n\n        const base = {\n          ...transportation,\n          memo,\n          schedule,\n          isFirst: i === 0,\n          isLast: items.length - 1 === i,\n        }\n\n        if (poi) {\n          const { id, type, categories: gqlCategories, source } = poi\n          const name = deriveNameFromTranslations(\n            source?.names as UnSafetyTranslations,\n          )\n\n          const categoryNames = (gqlCategories || source?.categories || [])\n            .map((category) => category.name)\n            .join(',')\n\n          const areaNames =\n            regionId &&\n            source?.regionId &&\n            source?.areas &&\n            source.areas.length > 0\n              ? source.areas.map((area) => area.name).join(',')\n              : guestMode === 'seoul-con'\n                ? null\n                : source?.vicinity\n\n          const description = [categoryNames, areaNames]\n            .filter((i) => i)\n            .join(' · ')\n\n          const imageUrl = source?.image?.sizes.small_square.url\n\n          return {\n            ...base,\n            id,\n            regionId: regionId || source?.regionId || '',\n            name,\n            type,\n            description,\n            comment: source?.comment || undefined,\n            imageUrl,\n          }\n        } else {\n          const { id, title, category, regions } = festa\n\n          const regionNames = regions?.[0]?.names\n          const regionName = deriveNameFromTranslations(regionNames ?? {})\n\n          const description = [category, regionName]\n            .filter((i) => i)\n            .join(' · ')\n\n          return {\n            ...base,\n            id,\n            regionId: regionId || regions?.[0]?.id || '',\n            name: title,\n            type: 'festa',\n            description,\n          }\n        }\n      },\n    )\n  }, [items, regionId, guestMode])\n\n  return {\n    day,\n    items,\n    courses,\n    regionId,\n    itemIds,\n    hasItineraries,\n    hideAddButton,\n  }\n}\n\nfunction extractRegionId(items: ItineraryItemType[]) {\n  for (const item of items) {\n    if (item.poi && item.poi.source?.regionId) {\n      return item.poi.source.regionId\n    }\n\n    if (item.festa && item.festa.regions?.[0]?.id) {\n      return item.festa.regions[0].id\n    }\n  }\n\n  return null\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/use-computed-map.ts",
    "content": "import { useMemo } from 'react'\nimport type { LatLngLiteral } from '@titicaca/type-definitions'\nimport type { ItineraryItemType } from '@titicaca/content-type-definitions'\n\ninterface MapItem {\n  item: ItineraryItemType\n  position: LatLngLiteral\n}\n\ninterface ItineraryMapData {\n  mapItems: MapItem[]\n  polyline: LatLngLiteral[]\n  coordinates: [number, number][]\n}\n\nfunction getItemLatLng(item: ItineraryItemType): LatLngLiteral {\n  const coordinates = item.poi\n    ? item.poi.source?.geolocation?.coordinates\n    : item.festa.geolocation?.coordinates\n\n  const [lng, lat] = coordinates || [0, 0]\n  return { lat, lng }\n}\n\n/**\n * FIXME: getGeometry 함수가 LatLngLiteral 타입 기반으로 동작하도록 개선하면\n * 유일한 타입으로 이 함수는 없어도 됩니다.\n * [number, number][] -> { lat, lng } 으로 개선이 필요\n */\nfunction extractItemCoordinates(items: ItineraryItemType[]) {\n  return items.map(({ poi, festa }) =>\n    poi ? poi.source?.geolocation?.coordinates : festa.geolocation?.coordinates,\n  )\n}\n\n/**\n * TripleDocument 추천코스 목록 데이터에서 MapView 표시해야 할 정보들을 추출하는 로직들을 담습니다.\n * @param param0 TripleDoucment Itinerary Day Items\n */\nexport default function useMapData(\n  items: ItineraryItemType[],\n): ItineraryMapData {\n  return useMemo(() => {\n    const coordinates = extractItemCoordinates(items).filter(\n      (coordinate): coordinate is [number, number] => !!coordinate,\n    )\n\n    const polyline = items.map((item) => getItemLatLng(item))\n\n    const mapItems = items.map((item) => ({\n      item,\n      position: getItemLatLng(item),\n    }))\n\n    return {\n      coordinates,\n      mapItems,\n      polyline,\n    }\n  }, [items])\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/use-handle-add-pois-to-trip.ts",
    "content": "import qs from 'qs'\nimport { useCallback } from 'react'\nimport { generateUrl } from '@titicaca/view-utilities'\nimport {\n  useEnv,\n  useClientAppCallback,\n  useSessionCallback,\n} from '@titicaca/triple-web'\nimport { useNavigate } from '@titicaca/router'\n\n/**\n * TODO: hotels-web, content-web 일정추가 액션 중복코드\n */\n\ninterface Geotag {\n  type: 'triple-region' | 'triple-zone'\n  id: string\n}\n\nexport default function useHandleAddPoiToTrip({ geotag }: { geotag: Geotag }) {\n  const { appUrlScheme } = useEnv()\n  const { navigate } = useNavigate()\n\n  const handleFn = useCallback(\n    (poiId: string | string[]) => {\n      const query = generateAddTripPlanQuery({ poiId, geotag })\n\n      navigate(\n        generateUrl({\n          scheme: appUrlScheme,\n          path: '/action/add_trip_plan',\n          query: qs.stringify(query),\n        }),\n      )\n      return true\n    },\n    [navigate, geotag, appUrlScheme],\n  )\n\n  return useClientAppCallback(useSessionCallback(handleFn), {\n    triggeredEventAction: '내일정으로담기_선택',\n  })\n}\n\nexport function generateAddTripPlanQuery({\n  poiId,\n  geotag,\n}: {\n  poiId: string | string[]\n  geotag: Geotag\n}) {\n  const pois = Array.isArray(poiId) ? poiId.join(',') : poiId\n\n  const geotagQuery =\n    geotag.type === 'triple-region'\n      ? { region_id: geotag.id }\n      : { zone_id: geotag.id }\n\n  return {\n    ...geotagQuery,\n    pois,\n  }\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/use-safety-poi.ts",
    "content": "import { useMemo } from 'react'\nimport { ImageMeta } from '@titicaca/type-definitions'\nimport { Translations } from '@titicaca/content-type-definitions'\n\ntype SafetyPoi<T> = T & {\n  /** POI Name: primary || ko || en || local || '' */\n  safeName: string\n  /** Large Size Image Url */\n  defaultImage?: string\n}\n\n/**\n * POI 의 names 에 대한 타입정의가\n * triple-frontend 와 triple-content 가 서로 다른 이슈가 있어 triple-content 쪽으로\n * 맞추기 위해 아래의 타입을 추가합니다.\n */\nexport type UnSafetyTranslations = Translations & { primary?: string }\n\nexport function deriveNameFromTranslations({\n  primary,\n  /** will be @deprecated */\n  ko,\n  en,\n  local,\n}: UnSafetyTranslations): string {\n  return primary || ko || en || local || ''\n}\n\ntype ImageSizeType = keyof ImageMeta['sizes']\n\nexport function getImage(\n  image?: ImageMeta,\n  type?: ImageSizeType,\n): string | undefined {\n  return image ? (type ? image?.sizes[type].url : '') : undefined\n}\n\n/**\n * Usage\n *\n * import { useSafetyPoi } from 'use-safety-poi'\n *\n * const { ...data, safeName, defaultImage } = useSafetyPoi<T>(data)\n * const { ...hotel, safeName, defaultImage } = useSafetyPoi<HotelDetailResponse>(hotel)\n * const { ...hotel, safeName, defaultImage } = useSafetyPoi<HotelResponse>(hotel)\n *\n * 경우에 따라 Poi 데이터 구조가 source 를 기반으로 하는 경우가 있어서 두가지 모두 가능\n *\n * { id, names, ...rest }  -> {id, names, safeName, defaultImage, ...rest }\n * or\n * { id, source: { names }, ...rest } -> {id, names, source: { names }, safeName, defaultImage, ...rest}\n *\n * Purpose\n * 1.\n * 2. Refine(Safety) unstable poi data structure\n * 3. safe prefix 는 optional 할 수 있는 값(unsafe)이 required 타입으로 변환을 의미\n *\n * TODO:\n * - [ ] poi must be required 😭\n *\n * NOTE:\n * -\n */\n\nexport function useSafetyPoi<\n  T extends {\n    image?: ImageMeta\n    names?: UnSafetyTranslations\n    source?: { names: UnSafetyTranslations; image?: ImageMeta }\n  },\n>(poi: T | undefined): SafetyPoi<T> {\n  return useMemo<SafetyPoi<T>>(() => {\n    const { names, source, image } =\n      poi ||\n      ({\n        names: undefined,\n        source: undefined,\n        image: undefined,\n      } as T)\n\n    if (source) {\n      const { names, image } = source\n\n      return {\n        ...poi,\n        defaultImage: getImage(image, 'large'),\n        safeName: deriveNameFromTranslations(names),\n      } as SafetyPoi<T>\n    }\n\n    return {\n      ...poi,\n      defaultImage: image && getImage(image, 'large'),\n      safeName: names\n        ? deriveNameFromTranslations(names as UnSafetyTranslations)\n        : '',\n    } as SafetyPoi<T>\n  }, [poi])\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary/with-type-circle-badge.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { useTheme } from 'styled-components'\n\nimport { CircleBadge } from './badge'\nimport { ItineraryElementType } from './types'\n\n/**\n * CircleBadge 에 color 결정하는 로직을 분리하고\n * poi.type 을 기준으로 컴포넌트의 색상을 자유롭게 설정하기 위한\n * High Order Component\n *\n * @param param0\n *\n *  Usage\n *  const HotelCirlceBadge = withTypeCircleBadge('hotel')\n *  const AttractionCirlceBadge = withTypeCircleBadge('attraction')\n *  const RestaurantCirlceBadge = withTypeCircleBadge('restaurant')\n */\n\ntype HocProps = Omit<Parameters<typeof CircleBadge>[0], 'color'>\n\nexport default function withTypeCircleBadge(type: ItineraryElementType) {\n  return function ColorBadgeComponent({\n    children,\n    ...rest\n  }: PropsWithChildren<HocProps>) {\n    const { colors } = useTheme()\n\n    function getColorOfType(type: ItineraryElementType) {\n      switch (type) {\n        case 'hotel':\n          return colors.mint\n        case 'restaurant':\n          /** TODO: move to color-palette */\n          return 'rgba(255, 97, 104, 1)'\n        case 'attraction':\n          return colors.purple\n        case 'festa':\n          return '#EB147B'\n      }\n\n      throw new Error('Unknown color of content type')\n    }\n\n    return (\n      <CircleBadge {...rest} $color={getColorOfType(type)}>\n        {children}\n      </CircleBadge>\n    )\n  }\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/itinerary.tsx",
    "content": "import { useCallback } from 'react'\nimport { styled } from 'styled-components'\nimport { Container, Text, FlexBox } from '@titicaca/tds-ui'\nimport type {\n  TransportationType,\n  Itinerary,\n  ItineraryItemType,\n  PoiType,\n} from '@titicaca/content-type-definitions'\nimport { useNavigate } from '@titicaca/router'\nimport { useTrackEvent } from '@titicaca/triple-web'\n\nimport { useGuestMode } from '../prop-context/guest-mode'\n\nimport ItineraryMap from './itinerary/itinerary-map'\nimport useItinerary from './itinerary/use-computed-itineraries'\nimport {\n  HotelCircleBadge,\n  AttractionCircleBadge,\n  RestaurantCircleBadge,\n  FestaCircleBadge,\n} from './itinerary/badge'\nimport { TagLabel } from './itinerary/tag-label'\nimport {\n  Bus,\n  Walk,\n  Car,\n  Train,\n  Tram,\n  Cable,\n  Plane,\n  Ship,\n  Bike,\n} from './itinerary/icons'\nimport SaveToItinerary, { Geotag } from './itinerary/save-to-itinerary'\nimport { ItineraryElementType } from './itinerary/types'\nimport PoiCard from './itinerary/poi-card'\n\ninterface Props {\n  value: {\n    itinerary: Itinerary\n    geotag?: Geotag\n  }\n}\n\nconst Timeline = styled(FlexBox)`\n  position: relative;\n  min-width: 55px;\n\n  &::before {\n    content: '';\n    position: absolute;\n    z-index: -1;\n    border-right: 1px solid var(--color-gray100);\n    width: 50%;\n    height: 100%;\n    right: 50%;\n  }\n`\n\nconst Stack = styled(Container)`\n  div:first-child ${Timeline} {\n    &::before {\n      margin-top: 5px;\n    }\n  }\n`\n\nconst Time = styled(Text)`\n  background-color: var(--color-white);\n`\n\nconst Duration = styled(Container)`\n  position: relative;\n  bottom: -10px;\n  left: -5px;\n  flex-shrink: 0;\n`\n\nexport default function ItineraryElement({ value }: Props) {\n  const trackEvent = useTrackEvent()\n\n  const guestMode = useGuestMode()\n  const { courses, regionId, itemIds, hasItineraries, hideAddButton } =\n    useItinerary({ itinerary: value.itinerary, guestMode })\n\n  const { navigate } = useNavigate()\n\n  const generatePoiClickHandler = useCallback(\n    ({\n      regionId,\n      type,\n      id,\n      name,\n    }: {\n      regionId: string\n      type: ItineraryElementType\n      id: string\n      name: string\n    }) =>\n      () => {\n        trackEvent({\n          ga: ['POI_선택', `${type}_${id}_${name}`],\n          fa: {\n            action: 'POI_선택',\n            item_id: id,\n            item_name: name,\n            type,\n          },\n        })\n\n        const url =\n          type === 'festa'\n            ? `/festas/${id}`\n            : `${regionId ? `/regions/${regionId}` : ''}/${type}s/${id}`\n\n        navigate(url)\n      },\n    [navigate, trackEvent],\n  )\n\n  const handleMarkerClick = useCallback(\n    (item: ItineraryItemType) => {\n      if (item.poi) {\n        const { id, source, type } = item.poi\n        navigate(\n          `${source?.regionId ? `/regions/${regionId}` : ''}/${type}s/${id}`,\n        )\n      } else {\n        const { id } = item.festa\n        navigate(`/festas/${id}`)\n      }\n    },\n    [navigate, regionId],\n  )\n\n  const itineraryGeotag =\n    value.geotag ||\n    (regionId ? { type: 'triple-region', id: regionId } : undefined)\n\n  return (\n    <Container\n      css={{\n        margin: '10px 0',\n      }}\n    >\n      <ItineraryMap {...value.itinerary} onClickMarker={handleMarkerClick} />\n      <Container\n        css={{\n          margin: '20px',\n        }}\n      >\n        <Stack>\n          {courses.map((course, index) => {\n            const {\n              id,\n              regionId,\n              name,\n              type,\n              description,\n              transportation,\n              duration,\n              memo,\n              schedule,\n              isLast,\n              comment,\n              imageUrl,\n            } = course\n            const hasDuration = !isLast && transportation !== undefined\n            const CircleBadge = PoiCircleBadge(type)\n            const TransportIcon = TransportationIcon(transportation)\n\n            return (\n              <FlexBox flex key={index}>\n                <Timeline flex>\n                  <FlexBox\n                    flex\n                    flexGrow={1}\n                    justifyContent=\"center\"\n                    alignItems=\"center\"\n                    flexDirection=\"column\"\n                  >\n                    <FlexBox\n                      flex\n                      flexGrow={1}\n                      alignItems=\"center\"\n                      flexDirection=\"column\"\n                      css={{\n                        padding: '20px 0 0',\n                      }}\n                    >\n                      <CircleBadge>{index + 1}</CircleBadge>\n                      {schedule ? (\n                        <Time\n                          bold\n                          size={11}\n                          color=\"gray300\"\n                          padding={{ top: 5, bottom: 5 }}\n                          letterSpacing={-0.3}\n                        >\n                          {schedule}\n                        </Time>\n                      ) : null}\n                    </FlexBox>\n                    {hasDuration ? (\n                      <Duration>\n                        <TagLabel>\n                          <TransportIcon />\n                          {duration}\n                        </TagLabel>\n                      </Duration>\n                    ) : null}\n                  </FlexBox>\n                </Timeline>\n                <PoiCard\n                  type={type as PoiType}\n                  name={name}\n                  description={description}\n                  memo={memo}\n                  comment={comment}\n                  imageUrl={imageUrl}\n                  onClickPoiCard={generatePoiClickHandler({\n                    regionId,\n                    type,\n                    id,\n                    name,\n                  })}\n                />\n              </FlexBox>\n            )\n          })}\n        </Stack>\n        {hideAddButton || guestMode || !itineraryGeotag ? null : (\n          <SaveToItinerary\n            itemIds={itemIds}\n            geotag={itineraryGeotag}\n            disabled={!hasItineraries}\n          />\n        )}\n      </Container>\n    </Container>\n  )\n}\n\nfunction PoiCircleBadge(type: ItineraryElementType) {\n  switch (type) {\n    case 'hotel':\n      return HotelCircleBadge\n    case 'attraction':\n      return AttractionCircleBadge\n    case 'restaurant':\n      return RestaurantCircleBadge\n    case 'festa':\n      return FestaCircleBadge\n  }\n\n  throw new Error(`Unknown card type of poi \"${type}\"`)\n}\n\nfunction TransportationIcon(type?: TransportationType) {\n  switch (type) {\n    case 'car':\n      return Car\n    case 'bus':\n      return Bus\n    case 'walk':\n      return Walk\n    case 'plane':\n      return Plane\n    case 'train':\n      return Train\n    case 'tram':\n      return Tram\n    case 'cable':\n      return Cable\n    case 'ship':\n      return Ship\n    case 'bike':\n      return Bike\n    default:\n      return () => null\n  }\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/links.tsx",
    "content": "import { MouseEventHandler, PropsWithChildren, SyntheticEvent } from 'react'\nimport { styled } from 'styled-components'\nimport {\n  ButtonProps,\n  Button,\n  ResourceListItem,\n  SquareImage,\n  SimpleLink,\n  Text,\n} from '@titicaca/tds-ui'\nimport { ImageMeta } from '@titicaca/type-definitions'\n\nimport { Link } from '../types'\nimport { useLinkClickHandler } from '../prop-context/link-click-handler'\n\nimport ResourceList from './shared/resource-list'\n\nconst LinksContainer = styled.div<{ $compact?: boolean }>`\n  margin: ${({ $compact }) => ($compact ? '0' : '0 30px')};\n  margin-top: ${({ $compact }) => ($compact ? '10px' : '20px')};\n  margin-bottom: ${({ $compact }) => ($compact ? '-10px' : '-20px')};\n\n  a,\n  button {\n    display: inline-block;\n    margin-bottom: ${({ $compact }) => ($compact ? '10px' : '20px')};\n    margin-right: ${({ $compact }) => ($compact ? '10px' : '20px')};\n  }\n`\n\nconst ButtonContainer = styled.div<{ $compact?: boolean }>`\n  margin: ${({ $compact }) => ($compact ? '12px 0 4px 0' : '50px 30px 0 30px')};\n  text-align: center;\n\n  a,\n  button {\n    margin-top: 5px;\n  }\n\n  a:not(:first-child),\n  button:not(:first-child) {\n    margin-left: 5px;\n  }\n\n  @media (max-width: 360px) {\n    a:not(:first-child),\n    button:not(:first-child) {\n      margin-left: 0;\n    }\n  }\n`\n\nconst BlockContainer = styled.div<{ $compact?: boolean }>`\n  margin: ${({ $compact }) => ($compact ? '7px 0 4px 0' : '30px 30px 0 30px')};\n  text-align: center;\n`\n\nfunction ButtonLink({ children, ...props }: PropsWithChildren<ButtonProps>) {\n  return (\n    <Button bold color=\"blue\" {...props}>\n      {children}\n    </Button>\n  )\n}\n\nfunction BlockLink({\n  children,\n  level,\n  ...props\n}: PropsWithChildren<ButtonProps & Link>) {\n  return (\n    <Button\n      basic={level !== 'primary'}\n      fluid\n      size=\"small\"\n      compact={!(level === 'primary' || level === 'secondary')}\n      color={level === 'primary' ? 'blue' : 'gray'}\n      borderRadius={4}\n      margin={{ top: 10 }}\n      {...props}\n    >\n      {children}\n    </Button>\n  )\n}\n\nconst ImageLinkItem = styled.a`\n  text-decoration: none;\n`\n\nconst IMAGE_PLACEHOLDER =\n  'https://assets.triple.guide/images/ico_blank_see@2x.png'\n\nfunction ImageLink({\n  href,\n  label,\n  description,\n  image,\n  onClick,\n}: {\n  href: string\n  label?: string\n  description?: string\n  image?: ImageMeta\n  onClick?: MouseEventHandler\n}) {\n  return (\n    <ResourceListItem onClick={onClick}>\n      <ImageLinkItem href={href}>\n        <SquareImage\n          floated=\"left\"\n          size=\"small\"\n          src={getDefaultImageUrl(image) || IMAGE_PLACEHOLDER}\n          alt={label}\n        />\n        <Text bold ellipsis alpha={1} margin={{ left: 50 }}>\n          {label}\n        </Text>\n        <Text size=\"tiny\" alpha={0.7} margin={{ top: 4, left: 50 }}>\n          {description}\n        </Text>\n      </ImageLinkItem>\n    </ResourceListItem>\n  )\n}\n\nfunction getDefaultImageUrl(image: ImageMeta | undefined) {\n  if (!image) {\n    return null\n  }\n\n  const sizes = image.sizes as {\n    small_square?: { url: string }\n    smallSquare?: { url: string }\n  }\n\n  if (sizes.small_square) {\n    return sizes.small_square.url\n  } else if (sizes.smallSquare) {\n    return sizes.smallSquare.url\n  } else {\n    return null\n  }\n}\n\nconst LINK_CONTAINERS = {\n  button: ButtonContainer,\n  block: BlockContainer,\n  /**\n   * @deprecated 어드민에서 만들 수 없어서 기본 타입으로 fallback합니다.\n   */\n  list: LinksContainer,\n  default: LinksContainer,\n  image: ResourceList,\n}\n\nconst LINK_ELEMENTS = {\n  button: ButtonLink,\n  block: BlockLink,\n  /**\n   * @deprecated 어드민에서 만들 수 없어서 기본 타입으로 fallback합니다.\n   */\n  list: SimpleLink,\n  default: SimpleLink,\n  image: ImageLink,\n}\n\nexport default function Links({\n  value: { display, links },\n  compact,\n  ...props\n}: {\n  value: {\n    display:\n      | (keyof typeof LINK_CONTAINERS & keyof typeof LINK_ELEMENTS)\n      | string\n    links: Link[]\n  }\n  compact?: boolean\n}) {\n  const onLinkClick = useLinkClickHandler()\n\n  const Container =\n    LINK_CONTAINERS[display as keyof typeof LINK_CONTAINERS] || LinksContainer\n  const Element =\n    LINK_ELEMENTS[display as keyof typeof LINK_ELEMENTS] || SimpleLink\n\n  return (\n    <Container {...props} $compact={compact}>\n      {links.map((link, i) => (\n        <Element\n          key={i}\n          onClick={onLinkClick && ((e: SyntheticEvent) => onLinkClick(e, link))}\n          {...link}\n          href={link.href || '#'}\n        >\n          {link.label}\n        </Element>\n      ))}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/list.tsx",
    "content": "import { styled } from 'styled-components'\nimport { Container } from '@titicaca/tds-ui'\n\nimport { TripleElementData, Link } from '../types'\n\nimport { Text } from './text'\nimport Links from './links'\n\nconst BULLET_ICON_URLS: { [key: string]: string } = {\n  oval: 'https://assets.triple.guide/images/img-bullet-oval@3x.png',\n  check: 'https://assets.triple.guide/images/img-bullet-check@3x.png',\n}\n\nconst ListItemContainer = styled.li<{ $bulletType?: string }>`\n  padding-left: 18px;\n  text-indent: -18px;\n\n  &::before {\n    display: inline-block;\n    width: 10px;\n    height: 10px;\n    ${({ $bulletType: name }) =>\n      `background-image: url(${BULLET_ICON_URLS[name || 'oval']});`}\n    background-size: 10px 10px;\n    background-position: center center;\n    background-repeat: no-repeat;\n    content: '';\n  }\n`\n\nconst ListTextElement = styled(Text)`\n  font-size: 16px;\n  margin-left: 8px;\n  display: inline;\n\n  div,\n  p,\n  pre {\n    display: inline;\n  }\n`\n\ntype TextElementData = TripleElementData<\n  'text',\n  { text: string; rawHTML: string }\n>\ntype LinksElementData = TripleElementData<'links', { links: Link[] }>\n\nexport default function List({\n  value: { bulletType, items },\n  ...props\n}: {\n  value: {\n    bulletType?: string\n    items: (TextElementData | LinksElementData)[]\n  }\n}) {\n  return (\n    <Container\n      {...props}\n      css={{\n        margin: '10px 30px 0',\n      }}\n    >\n      <ul>\n        {items.map((item, index) => (\n          <ListItemContainer $bulletType={bulletType} key={index}>\n            {item.type === 'text' ? (\n              <ListTextElement value={item.value} compact />\n            ) : null}\n            {item.type === 'links' ? (\n              <Links value={{ display: 'list', links: item.value.links }} />\n            ) : null}\n          </ListItemContainer>\n        ))}\n      </ul>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/note.tsx",
    "content": "import { Segment, Text } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\n\nconst NoteBodyText = styled(Text).attrs({\n  size: 'small',\n  color: 'gray',\n  alpha: 0.8,\n  lineHeight: 1.57,\n})``\n\nconst MarkdownText = styled(NoteBodyText)`\n  white-space: normal;\n\n  a {\n    text-decoration: underline;\n    color: var(--color-blue);\n  }\n`\n\nexport default function Note({\n  value: { title, body, rawHTML },\n}: {\n  value: { title: string; body?: string; rawHTML?: string }\n}) {\n  return (\n    <Segment css={{ margin: 30 }}>\n      <Text bold size=\"small\" color=\"gray\" lineHeight={1.57}>\n        {title}\n      </Text>\n      {rawHTML ? (\n        <MarkdownText dangerouslySetInnerHTML={{ __html: rawHTML }} />\n      ) : (\n        <NoteBodyText>{body}</NoteBodyText>\n      )}\n    </Segment>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/pois.tsx",
    "content": "import { styled } from 'styled-components'\nimport { Text } from '@titicaca/tds-ui'\nimport {\n  PoiListElement,\n  PoiCarouselElement,\n  type PoiListElementProps,\n  type PoiListElementType,\n} from '@titicaca/tds-widget'\nimport { useTranslation } from '@titicaca/triple-web'\n\nimport { useResourceClickHandler } from '../prop-context/resource-click-handler'\nimport { useGuestMode } from '../prop-context/guest-mode'\n\nimport ResourceList from './shared/resource-list'\nimport DocumentCarousel from './shared/document-carousel'\n\nconst PoiPrice = styled.div`\n  position: absolute;\n  top: 3px;\n  right: 0;\n  width: 80px;\n  padding-top: 8px;\n  padding-bottom: 7px;\n  text-align: center;\n  border-radius: 17px;\n  background-color: #fafafa;\n`\n\ntype ExtendedPoiListElementData = PoiListElementType & {\n  source: PoiListElementType['source'] & {\n    pricing?: {\n      nightlyPrice?: number | null\n    } | null\n  }\n}\n\ntype PoisDisplay = 'list' | string\n\nexport default function Pois<T extends ExtendedPoiListElementData>({\n  value: { display, pois },\n}: {\n  value: {\n    display: PoisDisplay\n    pois: T[]\n  }\n}) {\n  const guestMode = useGuestMode()\n  const t = useTranslation()\n  const onResourceClick = useResourceClickHandler()\n\n  const Container = display === 'list' ? ResourceList : DocumentCarousel\n  const margin =\n    display === 'list' ? { top: 20, left: 30, right: 30 } : { top: 20 }\n  const Element =\n    display === 'list'\n      ? function WrappedPoiListElement(\n          props: Omit<PoiListElementProps<T>, 'compact'>,\n        ) {\n          return <PoiListElement compact {...props} />\n        }\n      : PoiCarouselElement\n\n  function renderPoiListActionButton({\n    display,\n    poi,\n  }: {\n    display: PoisDisplay\n    poi: ExtendedPoiListElementData\n  }) {\n    const {\n      source: { pricing },\n    } = poi\n\n    if (display === 'list' && pricing) {\n      const { nightlyPrice } = pricing\n\n      return (\n        <PoiPrice>\n          <Text bold size=\"mini\">\n            {nightlyPrice ? `₩${nightlyPrice.toLocaleString()}` : t('보기')}\n          </Text>\n        </PoiPrice>\n      )\n    }\n\n    return null\n  }\n\n  return (\n    <Container margin={margin}>\n      {pois.map((poi) => (\n        <Element\n          key={poi.id}\n          poi={poi}\n          onClick={(e) => {\n            if (!onResourceClick) {\n              // TODO: triple-document 에러 처리 방법 설계\n              return null\n            }\n            onResourceClick(e, poi)\n          }}\n          actionButtonElement={renderPoiListActionButton({\n            display,\n            poi,\n          })}\n          guestMode={guestMode}\n        />\n      ))}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/regions.tsx",
    "content": "import { MouseEventHandler } from 'react'\nimport { useTranslation } from '@titicaca/triple-web'\nimport { styled } from 'styled-components'\nimport { ResourceListItem, Image } from '@titicaca/tds-ui'\n\nimport { RegionData } from '../types'\nimport { useResourceClickHandler } from '../prop-context/resource-click-handler'\n\nimport ResourceList from './shared/resource-list'\n\nexport default function Regions({\n  value: { regions },\n}: {\n  value: { regions: RegionData[] }\n}) {\n  const onResourceClick = useResourceClickHandler()\n\n  return (\n    <ResourceList>\n      {regions.map((region, index) => (\n        <RegionListElement\n          key={index}\n          value={region}\n          onClick={(e) => {\n            if (!onResourceClick) {\n              // TODO: triple-document 에러 처리 방법 설계\n              return null\n            }\n            onResourceClick(e, region)\n          }}\n        />\n      ))}\n    </ResourceList>\n  )\n}\n\nconst Name = styled.div`\n  float: left;\n  margin-left: 10px;\n  height: 40px;\n  line-height: 40px;\n  text-align: left center;\n  font-size: 16px;\n  font-weight: bold;\n  color: #3a3a3a;\n`\n\nconst Action = styled.div`\n  position: absolute;\n  top: 7px;\n  right: 0;\n  padding: 8px 17px 7px;\n  text-align: center;\n  font-size: 12px;\n  font-weight: bold;\n  color: #3a3a3a;\n  border-radius: 17px;\n  background-color: #fafafa;\n`\n\nexport function RegionListElement({\n  value,\n  onClick,\n}: {\n  value: RegionData | null\n  onClick?: MouseEventHandler\n}) {\n  const t = useTranslation()\n\n  if (value) {\n    const {\n      nameOverride,\n      source: { id, names, style },\n    } = value\n\n    return (\n      <ResourceListItem key={id} onClick={onClick}>\n        <Image.Circular\n          size=\"small\"\n          floated=\"left\"\n          src={style && style.backgroundImageUrl}\n        />\n        <Name>{nameOverride || names.ko || names.en || names.local}</Name>\n        <Action>{t('바로가기')}</Action>\n      </ResourceListItem>\n    )\n  }\n\n  return null\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/shared/display-containers.tsx",
    "content": "import { styled } from 'styled-components'\nimport { Fragment, PropsWithChildren } from 'react'\nimport { ImageMeta } from '@titicaca/type-definitions'\nimport {\n  Carousel,\n  Container,\n  ImageBlockElementContainer,\n  ImageCarouselElementContainer,\n} from '@titicaca/tds-ui'\n\nfunction BlockContainer({\n  children,\n  images,\n}: PropsWithChildren<{ images: ImageMeta[] }>) {\n  return (\n    <Container\n      css={{\n        marginTop: 40,\n        marginBottom: images.some(({ title }) => title) ? 10 : 30,\n      }}\n    >\n      {children}\n    </Container>\n  )\n}\n\nconst GridContainer = styled.div`\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(0, auto));\n`\n\nexport function DocumentCarouselContainer({\n  children,\n  images,\n}: PropsWithChildren<{ images: ImageMeta[] }>) {\n  return (\n    <Carousel\n      css={{\n        marginTop: '40px',\n        marginBottom: images.some(({ title }) => title) ? '10px' : '30px',\n      }}\n    >\n      {children}\n    </Carousel>\n  )\n}\n\nexport const IMAGES_CONTAINER_MAP = {\n  block: BlockContainer,\n  'gapless-block': Container,\n  grid: GridContainer,\n  default: DocumentCarouselContainer,\n  'default-v2': DocumentCarouselContainer,\n}\n\nexport const ELEMENT_CONTAINER_MAP = {\n  block: ImageBlockElementContainer,\n  'gapless-block': Container,\n  grid: Fragment,\n  default: ImageCarouselElementContainer,\n  'default-v2': ImageCarouselElementContainer,\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/shared/document-carousel.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { MarginPadding, Carousel } from '@titicaca/tds-ui'\n\nexport default function DocumentCarousel({\n  margin,\n  children,\n}: PropsWithChildren<{ margin?: MarginPadding }>) {\n  return (\n    <Carousel\n      css={{\n        paddingLeft: '30px',\n        paddingRight: '30px',\n\n        marginLeft: margin?.left,\n        marginRight: margin?.right,\n        marginTop: margin?.top,\n        marginBottom: margin?.bottom,\n      }}\n    >\n      {children}\n    </Carousel>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/shared/generate-click-handler.ts",
    "content": "import { LinkEventHandler, ImageEventHandler } from '../../types'\n\nexport default function generateClickHandler(\n  onLinkClick?: LinkEventHandler,\n  onImageClick?: ImageEventHandler,\n): ImageEventHandler {\n  return (e, image) => {\n    if (image.link && image.link.href && onLinkClick) {\n      return onLinkClick(e, image.link)\n    } else if (onImageClick) {\n      return onImageClick(e, image)\n    }\n  }\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/shared/resource-list.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { List } from '@titicaca/tds-ui'\n\nexport default function ResourceList({ children }: PropsWithChildren<unknown>) {\n  return <List margin={{ top: 20, left: 30, right: 30 }}>{children}</List>\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/sticky-tabs.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react'\nimport { styled } from 'styled-components'\nimport { ImageMeta } from '@titicaca/type-definitions'\nimport { Container } from '@titicaca/tds-ui'\n\nconst TabButton = styled.button`\n  width: 100%;\n  flex-grow: 1;\n\n  img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n  }\n`\n\nexport default function StickyTabs({\n  value: { tabs },\n}: {\n  value: {\n    tabs: {\n      defaultImage: ImageMeta | undefined\n      activeImage: ImageMeta | undefined\n      anchor: string\n    }[]\n  }\n}) {\n  const [headerHeight, setHeaderHeight] = useState(0)\n  const [tabHeight, setTabHeight] = useState(0)\n  const [currentIndex, setCurrentIndex] = useState(0)\n\n  const tabRef = useRef<HTMLDivElement>(null)\n\n  const handleScroll = useCallback(() => {\n    if (!tabs.length) {\n      return\n    }\n\n    const scrollElement = document.scrollingElement || document.documentElement\n    const firstEl = document.getElementById(tabs[0].anchor)\n\n    const isNotStart =\n      firstEl &&\n      window.scrollY + firstEl.getBoundingClientRect().top >\n        scrollElement.scrollTop\n    const isEnd =\n      scrollElement.scrollTop + window.innerHeight >= scrollElement.scrollHeight\n\n    if (isNotStart) {\n      setCurrentIndex(0)\n    } else if (isEnd) {\n      setCurrentIndex(tabs.length - 1)\n    } else {\n      tabs.forEach((el, index) => {\n        const target = document.getElementById(el.anchor)\n        if (target) {\n          const visibleVertical =\n            target.offsetTop >= 0 &&\n            scrollElement.scrollTop + tabHeight + headerHeight >=\n              window.scrollY + target.getBoundingClientRect().top &&\n            scrollElement.scrollTop + tabHeight + headerHeight >\n              window.scrollY +\n                target.getBoundingClientRect().top +\n                target.offsetHeight\n\n          if (visibleVertical) {\n            setCurrentIndex(index)\n          }\n        }\n      })\n    }\n  }, [headerHeight, tabHeight, tabs])\n\n  const handleTabClick = useCallback(\n    (index: number) => {\n      const offsetTop = Math.ceil(\n        window.scrollY +\n          (document.getElementById(tabs[index].anchor)?.getBoundingClientRect()\n            .top || 0) +\n          5,\n      )\n\n      window.scrollTo({\n        top: offsetTop - tabHeight - headerHeight,\n        behavior: 'smooth',\n      })\n    },\n    [headerHeight, tabHeight, tabs],\n  )\n\n  useEffect(() => {\n    window.addEventListener('scroll', handleScroll)\n\n    return () => {\n      window.removeEventListener('scroll', handleScroll)\n    }\n  }, [handleScroll])\n\n  useEffect(() => {\n    const header = document.getElementsByTagName('header')[0]\n\n    const heightHandler = () => {\n      setHeaderHeight(header?.clientHeight || 0)\n\n      if (tabRef && tabRef.current) {\n        setTabHeight(tabRef.current.clientHeight || 0)\n      }\n    }\n\n    const timeout = setTimeout(() => {\n      heightHandler()\n    }, 100)\n\n    window.addEventListener('resize', heightHandler)\n\n    return () => {\n      window.removeEventListener('resize', heightHandler)\n\n      clearTimeout(timeout)\n    }\n  }, [])\n\n  return (\n    <Container\n      ref={tabRef}\n      css={{\n        position: 'sticky',\n        top: headerHeight || 0,\n        left: 0,\n        zIndex: 3,\n        background: '#fff',\n      }}\n    >\n      <Container\n        css={{\n          display: 'flex',\n          flexDirection: 'row',\n        }}\n      >\n        {tabs.map(({ defaultImage, activeImage, anchor }, index) => {\n          return (\n            <TabButton\n              onClick={() => handleTabClick(index)}\n              key={`${anchor}_${index}`}\n            >\n              {defaultImage || activeImage ? (\n                <img\n                  src={\n                    currentIndex === index\n                      ? activeImage?.sizes.full.url\n                      : defaultImage?.sizes.full.url\n                  }\n                  alt=\"\"\n                />\n              ) : null}\n            </TabButton>\n          )\n        })}\n      </Container>\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/table.tsx",
    "content": "import {\n  Container,\n  ContainerProps,\n  Table as TableView,\n  TableProps,\n} from '@titicaca/tds-ui'\n\nexport default function Table({\n  value: { table },\n  ...props\n}: {\n  value: { table: TableProps }\n} & ContainerProps) {\n  return (\n    <Container\n      {...props}\n      css={{\n        margin: '20px 30px',\n      }}\n    >\n      <TableView {...table} />\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/text/headings.tsx",
    "content": "import { ComponentType } from 'react'\nimport { H1, H2, H3, H4 } from '@titicaca/tds-ui'\n\ninterface HeadingProps {\n  href?: string\n  emphasize?: boolean\n  headline?: string\n  children: string\n}\n\nexport const MH1 = tripleDocumentHeading(\n  ({ children, ...props }: HeadingProps) => (\n    <H1\n      {...props}\n      css={{\n        margin: '25px 30px 20px',\n      }}\n    >\n      {children}\n    </H1>\n  ),\n)\n\nexport const MH2 = tripleDocumentHeading(\n  ({ children, ...props }: HeadingProps) => (\n    <H2 margin={{ top: 20, bottom: 20, left: 30, right: 30 }} {...props}>\n      {children}\n    </H2>\n  ),\n)\n\nexport const MH3 = tripleDocumentHeading(\n  ({ compact, children, ...props }: HeadingProps & { compact: boolean }) => (\n    <H3\n      margin={compact ? { top: 13 } : { top: 20, left: 30, right: 30 }}\n      {...props}\n    >\n      {children}\n    </H3>\n  ),\n)\n\nexport const MH4 = tripleDocumentHeading(\n  ({ children, ...props }: HeadingProps) => (\n    <H4 margin={{ top: 20, left: 30, right: 30 }} {...props}>\n      {children}\n    </H4>\n  ),\n)\n\nfunction tripleDocumentHeading<P extends object>(\n  Component: ComponentType<P & HeadingProps>,\n) {\n  return function WrappedHeading({\n    value: { text, href, emphasize, headline },\n    ...props\n  }: {\n    value: {\n      text: string\n      href: string\n      emphasize: boolean\n      headline: string\n    }\n  } & P) {\n    return (\n      <Component\n        href={href}\n        emphasize={emphasize}\n        headline={headline}\n        {...(props as P)}\n      >\n        {text}\n      </Component>\n    )\n  }\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/text/index.ts",
    "content": "export * from './headings'\nexport { default as Text } from './plain'\n"
  },
  {
    "path": "packages/triple-document/src/elements/text/plain.tsx",
    "content": "import { useCallback, SyntheticEvent } from 'react'\nimport { Text, Paragraph } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\n\nimport { useLinkClickHandler } from '../../prop-context/link-click-handler'\n\nconst TextHtml = styled(Text)`\n  line-height: 1.63;\n  white-space: ${({ whiteSpace }) => whiteSpace || 'normal'};\n\n  p {\n    margin: 1.5rem 0 0;\n  }\n\n  p:first-of-type {\n    margin-top: 0;\n  }\n\n  strong {\n    color: var(--color-gray);\n  }\n\n  /* HACK: global-style의 underline 설정보다 우선하도록 수정 */\n  && {\n    a {\n      font-size: 15px;\n      font-weight: bold;\n      color: #2987f0;\n      text-decoration: underline;\n    }\n  }\n`\n\nexport default function TextElement({\n  value: { text, rawHTML },\n  compact,\n  ...props\n}: {\n  value: { text: string; rawHTML: string }\n  compact: boolean\n}) {\n  const onLinkClick = useLinkClickHandler()\n\n  const handleClick = useCallback(\n    (e: SyntheticEvent) => {\n      const target = e.target as HTMLElement\n\n      if (target.tagName === 'A') {\n        e.preventDefault()\n        e.stopPropagation()\n\n        if (!onLinkClick) {\n          // TODO: triple-document 에러 리포팅 로직 설계하기\n          return null\n        }\n\n        onLinkClick(e, {\n          href: target?.getAttribute('href') || undefined,\n          label: (target as HTMLAnchorElement)?.text,\n        })\n      }\n    },\n    [onLinkClick],\n  )\n\n  if (rawHTML) {\n    return (\n      <TextHtml\n        margin={compact ? { top: 4 } : { top: 10, left: 30, right: 30 }}\n        alpha={0.9}\n        dangerouslySetInnerHTML={{ __html: rawHTML }}\n        onClick={handleClick}\n        {...props}\n      />\n    )\n  }\n\n  return (\n    <Paragraph\n      margin={compact ? { top: 4 } : { top: 10, left: 30, right: 30 }}\n      {...props}\n    >\n      {text}\n    </Paragraph>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/tna/index.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { HR2 } from '@titicaca/tds-ui'\nimport { authGuardedFetchers, NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\n\nimport { TnaProductsResponse } from './types'\nimport { Slot } from './slot'\n\nfunction useProducts({ slotId }: { slotId?: number }): TnaProductsResponse {\n  const [response, setProductsList] = useState<TnaProductsResponse>({\n    products: [],\n    title: '',\n  })\n\n  useEffect(() => {\n    async function fetchAndSetProductsList() {\n      if (!slotId) {\n        return\n      }\n\n      const response = await authGuardedFetchers.get<TnaProductsResponse>(\n        `/api/tna-v2/slots/${slotId}`,\n      )\n\n      if (response !== NEED_LOGIN_IDENTIFIER && response.ok) {\n        const {\n          parsedBody: { title, products },\n        } = response\n\n        setProductsList({ title, products })\n      }\n    }\n\n    fetchAndSetProductsList()\n  }, [slotId])\n  return response\n}\n\ninterface TnaProductsListProps {\n  value: {\n    slotId?: number\n  }\n}\n\nexport function TnaProducts({ value: { slotId } }: TnaProductsListProps) {\n  const { products, title } = useProducts({\n    slotId,\n  })\n\n  return products.length > 0 ? (\n    <>\n      <HR2 />\n      <Slot id={slotId} title={title} products={products} />\n    </>\n  ) : null\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/tna/price-policy-coupon-info.tsx",
    "content": "import { Container, Text } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\nimport { useTranslation } from '@titicaca/triple-web'\n\nconst StyledContainer = styled(Container)`\n  margin-top: 2px;\n`\n\nexport function PricePolicyCouponInfo({\n  emphasisColor = 'mint',\n  hasOnlyExpectedApplicableCoupon,\n  hasAmountAfterUsingCouponPrice,\n  displayPricePolicy,\n  ...props\n}: {\n  emphasisColor?: Parameters<typeof Text>[0]['color']\n  hasOnlyExpectedApplicableCoupon?: boolean\n  hasAmountAfterUsingCouponPrice?: boolean | 0\n  displayPricePolicy?: string\n}) {\n  const t = useTranslation()\n\n  return (\n    <StyledContainer {...props}>\n      {hasOnlyExpectedApplicableCoupon ? (\n        <>\n          <Text bold inlineBlock size=\"tiny\" color={emphasisColor}>\n            {t('쿠폰할인')}\n          </Text>\n          <Text\n            bold\n            inlineBlock\n            size=\"tiny\"\n            color=\"gray700\"\n            margin={{ left: 5 }}\n          >\n            {t('가능')}\n          </Text>\n        </>\n      ) : hasAmountAfterUsingCouponPrice ? (\n        <>\n          <Text bold inlineBlock size=\"tiny\" color=\"gray700\">\n            {t('쿠폰할인가')}\n          </Text>\n          <Text\n            bold\n            inlineBlock\n            size=\"tiny\"\n            color={emphasisColor}\n            margin={{ left: 5 }}\n          >\n            {displayPricePolicy}\n          </Text>\n        </>\n      ) : (\n        <>\n          <Text bold inlineBlock size=\"tiny\" color=\"gray700\">\n            {t('쿠폰적용시')}\n          </Text>\n          <Text\n            bold\n            inlineBlock\n            size=\"tiny\"\n            color={emphasisColor}\n            margin={{ left: 5 }}\n          >\n            {t('무료')}\n          </Text>\n        </>\n      )}\n    </StyledContainer>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/tna/product.tsx",
    "content": "import { MouseEventHandler, SyntheticEvent, useCallback } from 'react'\nimport { useTranslation, useClientApp } from '@titicaca/triple-web'\nimport { Text, Tag, Container, Image, Rating } from '@titicaca/tds-ui'\nimport { OverlayScrapButton } from '@titicaca/tds-widget'\nimport { formatNumber } from '@titicaca/view-utilities'\nimport { StaticIntersectionObserver } from '@titicaca/intersection-observer'\n\nimport { TnaProductData, DomesticArea } from './types'\nimport { useGenerateCoupon } from './use-generate-coupon'\nimport { PricePolicyCouponInfo } from './price-policy-coupon-info'\n\nconst PLACEHOLDER_IMAGE_URL =\n  'https://assets.triple.guide/images/ico_blank_see@2x.png'\n\nfunction Pricing({\n  basePrice, // 판매가\n  salePrice, // 표시가\n}: Parameters<typeof Container>[0] & {\n  basePrice?: number\n  salePrice: number\n}) {\n  const t = useTranslation()\n\n  const formattedBasePrice = formatNumber(basePrice)\n  const formattedSalePrice = formatNumber(salePrice)\n\n  const rate = basePrice\n    ? Math.floor(((basePrice - salePrice) / basePrice) * 100)\n    : null\n\n  return (\n    <Container\n      css={{\n        margin: '10px 0 0',\n      }}\n    >\n      {rate ? (\n        <Container\n          css={{\n            margin: '0 0 1px',\n          }}\n        >\n          <Text color=\"red\" bold size={18}>\n            {rate}%\n          </Text>\n        </Container>\n      ) : null}\n\n      <Container>\n        {salePrice > 0 ? (\n          <Text inline bold size={18} color=\"gray\">\n            {t('{{formattedSalePrice}}원', {\n              formattedSalePrice,\n            })}\n          </Text>\n        ) : (\n          <Text inline bold size={18} color=\"gray300\">\n            {t('일시품절')}\n          </Text>\n        )}\n\n        {basePrice ? (\n          <Text\n            inline\n            color=\"gray300\"\n            size=\"mini\"\n            strikethrough\n            margin={{ left: 5 }}\n          >\n            {t('{{formattedBasePrice}}원', {\n              formattedBasePrice,\n            })}\n          </Text>\n        ) : null}\n      </Container>\n    </Container>\n  )\n}\n\nexport function TnaProductWithPrice({\n  product,\n  product: {\n    id,\n    title,\n    heroImage,\n    tags,\n    salePrice: rawSalePrice,\n    basePrice: rawBasePrice,\n    reviewRating,\n    reviewsCount,\n    domesticAreas = [],\n    applicableCoupon,\n    expectedApplicableCoupon,\n    scraped,\n    bestSelfPackageDiscountSpec,\n  },\n  index,\n  onIntersect,\n  onClick,\n}: {\n  index: number\n  product: TnaProductData\n  onClick: (e: SyntheticEvent, product: TnaProductData, index: number) => void\n  onIntersect: (product: TnaProductData, index: number) => void\n}) {\n  const t = useTranslation()\n  const app = useClientApp()\n\n  const salePrice =\n    typeof rawSalePrice === 'string' ? parseInt(rawSalePrice) : rawSalePrice\n  const basePrice =\n    typeof rawBasePrice === 'string' ? parseInt(rawBasePrice) : rawBasePrice\n  const primaryDomesticArea: DomesticArea | undefined =\n    domesticAreas.find(({ representative }) => representative) ||\n    domesticAreas[0]\n  const {\n    hasCoupon,\n    hasOnlyExpectedApplicableCoupon,\n    hasAmountAfterUsingCouponPrice,\n    displayPricePolicy,\n  } = useGenerateCoupon({\n    applicableCoupon,\n    expectedApplicableCoupon,\n  })\n  const hasSelfPackageBenefit = !!bestSelfPackageDiscountSpec\n\n  const handleIntersectionChange = useCallback(\n    ({ isIntersecting }: IntersectionObserverEntry) => {\n      if (isIntersecting) {\n        onIntersect(product, index)\n      }\n    },\n    [index, onIntersect, product],\n  )\n\n  const handleClick: MouseEventHandler<HTMLDivElement> = useCallback(\n    (e) => {\n      onClick(e, product, index)\n    },\n    [index, onClick, product],\n  )\n\n  return (\n    <StaticIntersectionObserver onChange={handleIntersectionChange}>\n      <Container onClick={handleClick} clearing>\n        <Image>\n          <Image.FixedDimensionsFrame size=\"small\" width={90} floated=\"left\">\n            {heroImage ? (\n              <Image.Img\n                src={heroImage}\n                alt={t('{{title}}의 썸네일', { title })}\n              />\n            ) : (\n              <Image.Placeholder src={PLACEHOLDER_IMAGE_URL} />\n            )}\n          </Image.FixedDimensionsFrame>\n        </Image>\n\n        {!app ? (\n          <Container\n            position=\"absolute\"\n            css={{\n              top: '3px',\n              left: '51px',\n            }}\n          >\n            <OverlayScrapButton\n              resource={{ id, scraped, type: 'tna' }}\n              size={36}\n            />\n          </Container>\n        ) : null}\n\n        <Container\n          css={{\n            margin: '0 0 0 104px',\n          }}\n        >\n          <Text bold size=\"large\" color=\"gray\" ellipsis maxLines={2}>\n            {title}\n          </Text>\n\n          {primaryDomesticArea && (\n            <Text color=\"gray400\" size=\"tiny\" margin={{ top: 4 }}>\n              {primaryDomesticArea.displayName}\n            </Text>\n          )}\n\n          {tags && tags.length > 0 && (\n            <Container\n              css={{\n                margin: '3px 0 0',\n              }}\n            >\n              {tags.map(({ text, type, style }, i) => (\n                <Tag\n                  key={i}\n                  type={type}\n                  style={style}\n                  margin={{ top: 4, right: i < tags.length - 1 ? 4 : 0 }}\n                >\n                  {text}\n                </Tag>\n              ))}\n            </Container>\n          )}\n\n          {reviewsCount ? (\n            <Container\n              css={{\n                display: 'flex',\n                alignItems: 'flex-end',\n                margin: '4px 0 0',\n                height: 16,\n              }}\n            >\n              <Rating size=\"tiny\" score={reviewRating} />\n              <Text\n                inlineBlock\n                size=\"tiny\"\n                color=\"gray400\"\n                lineHeight={1.08}\n                margin={{ left: 3 }}\n              >\n                ({reviewsCount})\n              </Text>\n            </Container>\n          ) : null}\n\n          {salePrice !== undefined ? (\n            <Pricing\n              salePrice={salePrice}\n              basePrice={\n                !!basePrice && salePrice < basePrice ? basePrice : undefined\n              }\n            />\n          ) : null}\n\n          {hasCoupon && (\n            <PricePolicyCouponInfo\n              hasOnlyExpectedApplicableCoupon={hasOnlyExpectedApplicableCoupon}\n              hasAmountAfterUsingCouponPrice={hasAmountAfterUsingCouponPrice}\n              displayPricePolicy={displayPricePolicy}\n            />\n          )}\n\n          {hasSelfPackageBenefit && (\n            <Text bold size=\"small\" color=\"gray700\" margin={{ top: 4 }}>\n              {t('셀프패키지 추가할인 가능')}\n            </Text>\n          )}\n        </Container>\n      </Container>\n    </StaticIntersectionObserver>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/tna/slot.tsx",
    "content": "import { SyntheticEvent, useCallback, useState } from 'react'\nimport { useTranslation, useTrackEvent } from '@titicaca/triple-web'\nimport { Container, H1, List, Button } from '@titicaca/tds-ui'\nimport { useNavigate } from '@titicaca/router'\nimport { useTheme } from 'styled-components'\n\nimport { TnaProductData } from './types'\nimport { TnaProductWithPrice } from './product'\n\nexport function Slot({\n  id: slotId,\n  title: slotTitle,\n  products,\n}: {\n  id?: number\n  title: string\n  products: TnaProductData[]\n}) {\n  const t = useTranslation()\n  const { colors } = useTheme()\n  const trackEvent = useTrackEvent()\n  const { navigate } = useNavigate()\n\n  const [showMore, setShowMore] = useState(false)\n\n  const handleClick = useCallback(\n    (e: SyntheticEvent, product: TnaProductData, index: number) => {\n      trackEvent({\n        ga: ['투어티켓_상품선택', `${slotId}_${product.id}_${index}`],\n        fa: {\n          action: '투어티켓_상품선택',\n          slot_id: slotId,\n          tna_id: product.id,\n          position: index,\n        },\n      })\n      navigate(`/tna/products/${product.id}`)\n    },\n    [slotId, trackEvent, navigate],\n  )\n\n  const handleIntersect = useCallback(\n    (product: TnaProductData, index: number) => {\n      trackEvent({\n        fa: {\n          action: '투어티켓_노출',\n          slot_id: slotId,\n          tna_id: product.id,\n          position: index,\n        },\n      })\n    },\n    [trackEvent, slotId],\n  )\n\n  const handleShowMoreClick = useCallback(() => {\n    trackEvent({\n      ga: ['투어티켓_더보기'],\n      fa: {\n        action: '투어티켓_더보기',\n        slot_id: slotId,\n      },\n    })\n\n    setShowMore(true)\n  }, [trackEvent, slotId])\n\n  return (\n    <Container\n      id={`tna-slot-${slotId}`}\n      css={{\n        margin: '30px 30px 0',\n      }}\n    >\n      <H1\n        css={{\n          margin: '0 0 20px',\n        }}\n      >\n        {slotTitle}\n      </H1>\n\n      <List clearing verticalGap={40} divided dividerColor={colors.gray50}>\n        {(showMore ? products : products.slice(0, 3)).map((product, i) => (\n          <List.Item key={i}>\n            <TnaProductWithPrice\n              index={i}\n              product={product}\n              onClick={handleClick}\n              onIntersect={handleIntersect}\n            />\n          </List.Item>\n        ))}\n      </List>\n\n      {!showMore && products.length > 3 ? (\n        <Button\n          basic\n          fluid\n          compact\n          size=\"small\"\n          margin={{ top: 20 }}\n          onClick={handleShowMoreClick}\n        >\n          {t('더보기')}\n        </Button>\n      ) : null}\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/tna/types.ts",
    "content": "import { CSSProperties, SyntheticEvent } from 'react'\nimport { TagColors } from '@titicaca/tds-ui'\n\ntype Price = string | number\n\nexport type TnaProductsFetcher = (slotId: number) => Promise<Response>\n\nexport type TnaProductsClickHandler = (\n  e: SyntheticEvent,\n  product: unknown,\n  slotId?: number,\n  index?: number,\n) => void\n\nexport interface TnaProductsResponse {\n  products: TnaProductData[]\n  title: string\n  description?: string\n}\n\nexport interface DomesticArea {\n  displayName: string\n  id: string\n  name: string\n  representative: boolean\n}\n\nexport interface DiscountPolicy {\n  maxDiscountAmount: number\n  type: 'RATE' | 'AMOUNT'\n  value: number\n}\n\nexport interface TnaCoupon {\n  amountAfterUsingCoupon: number\n  amountBeforeUsingCoupon: number\n  description: string\n  discountAmount: number\n  discountPolicy: DiscountPolicy\n  downloaded: boolean\n  id: string\n  name: string\n}\n\nexport interface TnaProductData {\n  id: string\n  heroImage: string\n  title: string\n  tags: { text: string; type: TagColors; style: CSSProperties }[]\n  salePrice: Price\n  basePrice: Price\n  reviewRating: number\n  reviewsCount: number\n  domesticAreas?: DomesticArea[]\n  applicableCoupon?: TnaCoupon\n  expectedApplicableCoupon?: TnaCoupon\n  description?: string\n  productId: string\n  orderPrice: number\n  supplierType: string\n  eventTags?: string[]\n  userId?: number\n  scraped?: boolean\n  bestSelfPackageDiscountSpec?: {\n    tripId: number\n    level: number\n    schedule: {\n      from: string\n      to: string\n    }\n    destinations: {\n      type: string\n      id: string\n    }[]\n    discountPolicy: {\n      discountRate: number\n      maxDiscountAmount: number\n    }\n    token: string\n  }\n}\n"
  },
  {
    "path": "packages/triple-document/src/elements/tna/use-generate-coupon.ts",
    "content": "import { formatNumber } from '@titicaca/view-utilities'\nimport { useTranslation } from '@titicaca/triple-web'\n\nimport { TnaCoupon } from './types'\n\nexport function useGenerateCoupon({\n  applicableCoupon,\n  expectedApplicableCoupon,\n}: {\n  applicableCoupon?: TnaCoupon\n  expectedApplicableCoupon?: TnaCoupon\n}) {\n  const t = useTranslation()\n\n  const { amountAfterUsingCoupon: applicableAmountAfterUsingCoupon } =\n    applicableCoupon || {}\n\n  const hasCoupon = !!applicableCoupon || !!expectedApplicableCoupon\n  const hasOnlyExpectedApplicableCoupon =\n    !applicableCoupon && !!expectedApplicableCoupon\n  const hasAmountAfterUsingCouponPrice =\n    applicableAmountAfterUsingCoupon && applicableAmountAfterUsingCoupon > 0\n\n  const formattedApplicableAmountAfterUsingCoupon = formatNumber(\n    applicableAmountAfterUsingCoupon,\n  )\n  const displayPricePolicy =\n    applicableCoupon &&\n    t('{{formattedApplicableAmountAfterUsingCoupon}}원', {\n      formattedApplicableAmountAfterUsingCoupon,\n    })\n\n  return {\n    hasCoupon,\n    hasOnlyExpectedApplicableCoupon,\n    hasAmountAfterUsingCouponPrice,\n    displayPricePolicy,\n  }\n}\n"
  },
  {
    "path": "packages/triple-document/src/heading.stories.tsx",
    "content": "import type { Meta, StoryFn } from '@storybook/react'\n\nimport ELEMENTS from './elements'\n\nconst {\n  heading1: Heading1,\n  heading2: Heading2,\n  heading3: Heading3,\n  heading4: Heading4,\n} = ELEMENTS\n\nexport default { title: 'triple-document / heading' } as Meta\n\nconst Heading1Template: StoryFn<{\n  // FIXME: HeadingProps\n  value: { text: string; href?: string; emphasize?: boolean; headline?: string }\n}> = (args) => <Heading1 {...args} />\n\nexport const Heading1Normal = {\n  render: Heading1Template,\n\n  args: {\n    value: { text: '제목1: bold 21' },\n  },\n\n  name: '제목 1 기본',\n}\n\nexport const Heading1Emphasized = {\n  render: Heading1Template,\n\n  args: {\n    value: { emphasize: true, text: '제목0: bold 21 #2987F0' },\n  },\n\n  name: '제목 1 강조',\n}\n\nexport const Heading1WithHeadline = {\n  render: Heading1Template,\n\n  args: {\n    value: {\n      text: '제목1: bold 21',\n      headline: '보조: bold 13 #2987F0',\n    },\n  },\n\n  name: '보조 문구가 있는 제목 1',\n}\n\nexport function Heading2Example() {\n  return <Heading2 value={{ text: '제목2: medium 19' }} />\n}\nHeading2Example.storyName = '제목 2'\n\nexport function Heading3Example() {\n  return <Heading3 value={{ text: '제목3: bold 16' }} />\n}\nHeading3Example.storyName = '제목 3'\n\nexport function Heading4Example() {\n  return <Heading4 value={{ text: '제목4: bold 16 #2987F0' }} />\n}\nHeading4Example.storyName = '제목 4'\n"
  },
  {
    "path": "packages/triple-document/src/hr.stories.tsx",
    "content": "import type { Meta } from '@storybook/react'\n\nimport ELEMENTS from './elements'\n\nconst { hr1: HR1, hr2: HR2, hr3: HR3, hr4: HR4, hr5: HR5, hr6: HR6 } = ELEMENTS\n\nexport default { title: 'triple-document / hr' } as Meta\n\nexport function Hr1Example() {\n  return <HR1 />\n}\nHr1Example.storyName = '구분선 1'\n\nexport function Hr2Example() {\n  return <HR2 />\n}\nHr2Example.storyName = '구분선 2'\n\nexport function Hr3Example() {\n  return <HR3 />\n}\nHr3Example.storyName = '구분선 3'\n\nexport function Hr4Example() {\n  return <HR4 />\n}\nHr4Example.storyName = '구분선 4'\n\nexport function Hr5Example() {\n  return <HR5 />\n}\nHr5Example.storyName = '구분선 5'\n\nexport function Hr6Example() {\n  return <HR6 />\n}\nHr6Example.storyName = '구분선 6'\n"
  },
  {
    "path": "packages/triple-document/src/images.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport ELEMENTS from './elements'\nimport IMAGES_FRAME from './mocks/images-frame.sample.json'\nimport IMAGES from './mocks/images.sample.json'\n\nconst { images: Images } = ELEMENTS\n\nexport default {\n  title: 'triple-document / 이미지',\n  component: Images,\n} as Meta\n\nexport const OneImage: StoryObj = {\n  name: '1개',\n  args: {\n    value: {\n      images: [\n        {\n          ...IMAGES[0],\n          frame: 'small',\n          title: 'TripleDocument 샘플 1',\n          sourceUrl: 'https://triple.guide',\n        },\n      ],\n    },\n  },\n}\n\nexport const OneImageWithFrame: StoryObj = {\n  name: '1개, 프레임',\n  args: {\n    value: {\n      images: [\n        {\n          ...IMAGES_FRAME[0],\n          frame: 'small',\n          title: 'TripleDocument 샘플 1',\n          sourceUrl: 'https://triple.guide',\n        },\n      ],\n    },\n  },\n}\n\nexport const TwoImages: StoryObj = {\n  name: '2개',\n  args: {\n    value: {\n      images: IMAGES.slice(0, 2).map((value) => ({\n        ...value,\n        title: '',\n      })),\n    },\n  },\n}\n\nexport const TwoImagesWithCaption: StoryObj = {\n  name: '2개, 캡션',\n  args: {\n    value: {\n      images: IMAGES.slice(0, 2),\n    },\n  },\n}\n\nexport const TwoBlockImages: StoryObj = {\n  name: '2개, 블록',\n  args: {\n    value: {\n      images: IMAGES.slice(0, 2).map((value) => ({ ...value, title: '' })),\n      display: 'block',\n    },\n  },\n}\n\nexport const TwoGridImages: StoryObj = {\n  name: '2개, 분할',\n  args: {\n    value: {\n      images: IMAGES.slice(0, 2).map((value) => ({ ...value, title: '' })),\n      display: 'grid',\n    },\n  },\n}\n\nexport const FiveGridImages: StoryObj = {\n  name: '5개, 분할',\n  args: {\n    value: {\n      images: IMAGES.map((value) => ({ ...value, title: '' })),\n      display: 'grid',\n    },\n  },\n}\n"
  },
  {
    "path": "packages/triple-document/src/index.ts",
    "content": "import { TripleDocument } from './triple-document'\n\nexport { default as ELEMENTS } from './elements'\nexport type { TripleElementData as TripleDocumentElementData } from './types'\nexport * from './elements/text'\nexport { useGenerateCoupon } from './elements/tna/use-generate-coupon'\nexport { Slot } from './elements/tna/slot'\nexport { PricePolicyCouponInfo } from './elements/tna/price-policy-coupon-info'\n\nexport { useDeepLink } from './prop-context/deep-link'\nexport { useGuestMode } from './prop-context/guest-mode'\nexport { useImageClickHandler } from './prop-context/image-click-handler'\nexport { useImageSource } from './prop-context/image-source'\nexport { useLinkClickHandler } from './prop-context/link-click-handler'\nexport { useMediaConfig } from './prop-context/media-config'\nexport { useResourceClickHandler } from './prop-context/resource-click-handler'\nexport { default as useResourceEventTracker } from './use-resource-event-tracker'\n\nexport default TripleDocument\n"
  },
  {
    "path": "packages/triple-document/src/links.stories.tsx",
    "content": "import type { Meta } from '@storybook/react'\n\nimport ELEMENTS from './elements'\n\nconst MOCK_IMAGE_LINKS_VALUE = {\n  links: [\n    {\n      label: '메가 돈키호테 시부야 본점',\n      image: {\n        sizes: {\n          full: {\n            url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1c22ae37-108f-44a7-b96b-1d70179b0b3f.jpeg',\n          },\n          large: {\n            url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1c22ae37-108f-44a7-b96b-1d70179b0b3f.jpeg',\n          },\n          small_square: {\n            url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1c22ae37-108f-44a7-b96b-1d70179b0b3f.jpeg',\n          },\n        },\n      },\n      description: '관광명소',\n    },\n    {\n      label: '도쿄의 이색 체험',\n      image: {\n        sizes: {\n          full: {\n            url: 'https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/fc59cda3-056b-41ca-9c87-242d6f15074d.jpeg',\n          },\n          large: {\n            url: 'https://media.triple.guide/triple-dev/c_limit,f_auto,h_1024,w_1024/fc59cda3-056b-41ca-9c87-242d6f15074d.jpeg',\n          },\n          small_square: {\n            url: 'https://media.triple.guide/triple-dev/c_fill,f_auto,h_256,w_256/fc59cda3-056b-41ca-9c87-242d6f15074d.jpeg',\n          },\n        },\n      },\n      description: '가이드',\n    },\n  ],\n  display: 'image',\n}\n\nconst { links: Links } = ELEMENTS\n\nexport default { title: 'triple-document / 링크', component: Links } as Meta\n\nexport function Normal() {\n  return (\n    <Links\n      value={{\n        links: [\n          {\n            label: '방콕 3박 4일 가이드',\n          },\n          {\n            label: '도쿄 타워',\n          },\n        ],\n      }}\n    />\n  )\n}\nNormal.storyName = '일반'\n\nexport function Emphasized() {\n  return (\n    <Links\n      value={{\n        links: [\n          {\n            label: '다낭 바로 가기',\n          },\n        ],\n        display: 'button',\n      }}\n    />\n  )\n}\nEmphasized.storyName = '강조'\n\nexport function EmphasizedTwo() {\n  return (\n    <Links\n      value={{\n        links: [\n          {\n            label: '다낭 바로 가기',\n          },\n          {\n            label: '도쿄 바로 가기',\n          },\n        ],\n        display: 'button',\n      }}\n    />\n  )\n}\nEmphasizedTwo.storyName = '강조 (2개)'\n\nexport function Extended() {\n  return (\n    <Links\n      value={{\n        links: [\n          {\n            label: '장소 보기',\n          },\n        ],\n        display: 'block',\n      }}\n    />\n  )\n}\nExtended.storyName = '확장'\n\nexport function ExtendedWithLevel() {\n  return (\n    <Links\n      value={{\n        links: [\n          {\n            label: '장소 보기',\n            level: 'primary',\n          },\n          {\n            label: '장소 보기',\n            level: 'secondary',\n          },\n        ],\n        display: 'block',\n      }}\n    />\n  )\n}\nExtendedWithLevel.storyName = '확장 + Level'\n\nexport function Image() {\n  return <Links value={MOCK_IMAGE_LINKS_VALUE} />\n}\nImage.storyName = '이미지'\n"
  },
  {
    "path": "packages/triple-document/src/list.stories.tsx",
    "content": "import type { Meta } from '@storybook/react'\n\nimport ELEMENTS from './elements'\n\nconst DOT_LIST_MOCK_VALUE = {\n  bulletType: 'oval',\n  items: [\n    {\n      type: 'text',\n      value: {\n        text: '일본 여행! 포켓와이파이 없이 어떻게 가려고요?',\n      },\n    },\n    {\n      type: 'text',\n      value: {\n        text: '와그만의 핑크미가 뿜뿜나는 LTE 포켓와이파이로 더 편한 일본 여행을 즐겨보세요.',\n      },\n    },\n    {\n      type: 'text',\n      value: {\n        text: '일본 지역 어디든 잘 터져서 데이터 걱정이 없어요.',\n      },\n    },\n    {\n      type: 'text',\n      value: {\n        text: '보조배터리도 무료로 하나 더 챙겨드리니 걱정하지 마세요.',\n      },\n    },\n    {\n      type: 'text',\n      value: {\n        text: '일본 공항에서 수령할 수 있는 일본 공항 수령 LTE 포켓와이파이도 있어요.',\n      },\n    },\n    {\n      type: 'text',\n      value: {\n        rich: true,\n        text: '오사카 여행에 주유패스가 빠질 수 없겠죠?\\n',\n        rawHTML:\n          '<p>오사카 여행에 <strong>주유패스가</strong> 빠질 수 없겠죠?</p>\\n',\n        markdownText: '오사카 여행에 **주유패스가** 빠질 수 없겠죠?\\n',\n      },\n    },\n    {\n      type: 'links',\n      value: {\n        display: 'default',\n        links: [\n          {\n            href: 'http://www.waug.com/good/?idx=105245',\n            label: '일본 공항 수령 LTE 포켓와이파이',\n          },\n        ],\n      },\n    },\n  ],\n}\n\nconst CHECK_LIST_MOCK_VALUE = {\n  bulletType: 'check',\n  items: [\n    {\n      type: 'text',\n      value: {\n        text: '일본 여행! 포켓와이파이 없이 어떻게 가려고요?',\n      },\n    },\n    {\n      type: 'text',\n      value: {\n        text: '와그만의 핑크미가 뿜뿜나는 LTE 포켓와이파이로 더 편한 일본 여행을 즐겨보세요.',\n      },\n    },\n    {\n      type: 'text',\n      value: {\n        text: '일본 지역 어디든 잘 터져서 데이터 걱정이 없어요.',\n      },\n    },\n    {\n      type: 'text',\n      value: {\n        text: '보조배터리도 무료로 하나 더 챙겨드리니 걱정하지 마세요.',\n      },\n    },\n    {\n      type: 'text',\n      value: {\n        text: '일본 공항에서 수령할 수 있는 일본 공항 수령 LTE 포켓와이파이도 있어요.',\n      },\n    },\n    {\n      type: 'links',\n      value: {\n        display: 'default',\n        links: [\n          {\n            href: 'http://www.waug.com/good/?idx=105245',\n            label: '일본 공항 수령 LTE 포켓와이파이',\n          },\n        ],\n      },\n    },\n  ],\n}\n\nconst { list: ListElement } = ELEMENTS\n\nexport default {\n  title: 'triple-document / 리스트',\n  component: ListElement,\n} as Meta\n\nexport function Dot() {\n  return <ListElement value={DOT_LIST_MOCK_VALUE} />\n}\nDot.storyName = '점'\n\nexport function Check() {\n  return <ListElement value={CHECK_LIST_MOCK_VALUE} />\n}\nCheck.storyName = '체크'\n"
  },
  {
    "path": "packages/triple-document/src/mocks/hotel.sample.json",
    "content": "{\n  \"id\": \"06598e28-1fb5-496e-a7c7-3b3a85a05f4f\",\n  \"source\": {\n    \"addresses\": {\n      \"ko\": null,\n      \"en\": \"1-2-3 Minatomachi Naniwa-ku\",\n      \"local\": \"〒556-0017浪速区湊町1-2-3\"\n    },\n    \"keywords\": [\n      \"호텔몬토레\",\n      \"몬토레그라스미아\",\n      \"그라스미아오사카\",\n      \"호텔몬토레그라스미아\",\n      \"몬토레그라스미아오사카\",\n      \"호텔몬토레그라스미아오사카\",\n      \"그래스미어 몬테레이 오사카\",\n      \"호텔 그래스미어 오사카\",\n      \"호텔 몬테레이 그래스미어\",\n      \"몬테레이 그래스미어\",\n      \"오사카 그래스미어 호텔\",\n      \"오사카 호텔 몬테레이 그래스미어\",\n      \"오사카 여행\",\n      \"남바 호텔\",\n      \"난바 호텔\",\n      \"오사카 호텔\",\n      \"오사카 난바 호텔\",\n      \"호텔 몬테레이 그래스미어 오사카\",\n      \"몬테레이호텔\",\n      \"몬토레이\"\n    ],\n    \"accommodations\": [\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"냉장고\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/b103f0db-d296-49c1-a1dd-0c4a2e7785cc-1509414680.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_B_1\",\n          \"id\": \"b103f0db-d296-49c1-a1dd-0c4a2e7785cc\"\n        },\n        \"id\": \"076b3643-611c-4bdc-9952-edb144df8e3a\",\n        \"priority\": 501,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"유료 영화\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/8a3141ab-531c-4e3c-8cc3-9f542a26e900-1509418335.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_13\",\n          \"id\": \"8a3141ab-531c-4e3c-8cc3-9f542a26e900\"\n        },\n        \"id\": \"0c179bb8-2fed-4f9c-bc91-c82abe013bb7\",\n        \"priority\": 713,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"헤어드라이어\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/9696da2e-ff1c-4d7b-bc61-563e41dbe75a-1509604461.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_8\",\n          \"id\": \"9696da2e-ff1c-4d7b-bc61-563e41dbe75a\"\n        },\n        \"id\": \"0e04e64c-b320-4371-9868-5062e4596dbc\",\n        \"priority\": 708,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": null,\n        \"name\": \"웨딩 서비스\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/f76b444c-206f-44c5-ba84-85a4a54f90a9-1508379904.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenity_B_24\",\n          \"id\": \"f76b444c-206f-44c5-ba84-85a4a54f90a9\"\n        },\n        \"id\": \"11bcef66-f291-4328-a6a0-06c5e91e3fb8\",\n        \"priority\": 524,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"디지털 TV 서비스\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_4\",\n          \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n        },\n        \"id\": \"238f00ad-621a-4a4f-80c3-77661092b7db\",\n        \"priority\": 704,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"바/라운지\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/2b698a57-ec2d-4734-aca8-4f8f19fe2309-1508380168.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenity_B_25\",\n          \"id\": \"2b698a57-ec2d-4734-aca8-4f8f19fe2309\"\n        },\n        \"id\": \"23f06900-4854-4cc2-9e15-750bdbd2fe65\",\n        \"priority\": 0,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"전신 욕조\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/96d5ccdc-f0e2-44ca-8174-fb4de1a3bb5c-1509442831.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_23\",\n          \"id\": \"96d5ccdc-f0e2-44ca-8174-fb4de1a3bb5c\"\n        },\n        \"id\": \"2bd183d0-d385-4262-b707-2c8d98af3844\",\n        \"priority\": 723,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"무료 WiFi\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/c4efeba5-5422-47fd-af64-7bd95d6c44a8-1509448015.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_S_4\",\n          \"id\": \"c4efeba5-5422-47fd-af64-7bd95d6c44a8\"\n        },\n        \"id\": \"32837370-0a16-4546-ae54-70a815ecdd66\",\n        \"priority\": 104,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": \"e3eafbef-80f7-4347-a046-dd0725fec786\",\n        \"name\": \"셀프 주차(요금 별도)\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/6e72760c-cfc7-4c72-9878-e3b8bf1d6e86-1508224443.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenityFilterParking_S_9\",\n          \"id\": \"6e72760c-cfc7-4c72-9878-e3b8bf1d6e86\"\n        },\n        \"id\": \"36059029-586c-442a-b3a8-0e5237458927\",\n        \"priority\": 109,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": null,\n        \"name\": \"엘리베이터\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/24a76017-d850-4cf6-999d-f7c0a0b7709f-1509602760.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenity_C_6\",\n          \"id\": \"24a76017-d850-4cf6-999d-f7c0a0b7709f\"\n        },\n        \"id\": \"3671af2d-38b2-41a6-a451-0833958b7e12\",\n        \"priority\": 706,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": \"502d1d69-2d26-4a06-a8f4-7e8aae02569c\",\n        \"name\": \"아침 식사 가능(요금 별도)\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/b066cd01-364d-4c8c-b626-1c505484c9a1-1508230544.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenityFilterBreakfast_S_4\",\n          \"id\": \"b066cd01-364d-4c8c-b626-1c505484c9a1\"\n        },\n        \"id\": \"46ed9af9-0dbd-4b57-8633-be3767bef1f7\",\n        \"priority\": 104,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": null,\n        \"name\": \"드라이클리닝/세탁 서비스\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/aecdb9da-ded4-4e6b-a41a-3e99ec5d0076-1508378645.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenity_B_11\",\n          \"id\": \"aecdb9da-ded4-4e6b-a41a-3e99ec5d0076\"\n        },\n        \"id\": \"47bed422-8452-4e93-9630-e291b4b54ed5\",\n        \"priority\": 511,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"전화\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/d5ca424a-e299-4555-860d-ff33086dc3a8-1509604329.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_6\",\n          \"id\": \"d5ca424a-e299-4555-860d-ff33086dc3a8\"\n        },\n        \"id\": \"589e25fd-2b24-4bec-ae68-232dc304dd54\",\n        \"priority\": 706,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"HD TV\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_4\",\n          \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n        },\n        \"id\": \"61b6c7f3-fec2-4a68-9dae-e892b1a6f16c\",\n        \"priority\": 704,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"평면 TV\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/4e528257-3172-4e95-adf4-79a1e83d2431-1509443305.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_4\",\n          \"id\": \"4e528257-3172-4e95-adf4-79a1e83d2431\"\n        },\n        \"id\": \"730f9fe6-fb4a-41ad-80b0-ea95749c810a\",\n        \"priority\": 704,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"전용 욕실\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/51fa9b9c-39d9-404c-8731-f649ae1d9b40-1508807465.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_S_7\",\n          \"id\": \"51fa9b9c-39d9-404c-8731-f649ae1d9b40\"\n        },\n        \"id\": \"7499f081-7e1c-4f38-8b0d-c7bb2baed9f8\",\n        \"priority\": 107,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": null,\n        \"name\": \"지정 흡연구역\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/1bdc56ea-f11c-4510-9652-b75111516b3a-1508378426.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenity_B_10\",\n          \"id\": \"1bdc56ea-f11c-4510-9652-b75111516b3a\"\n        },\n        \"id\": \"80089278-769f-426b-a609-6d4ee1fd0f29\",\n        \"priority\": 510,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": null,\n        \"name\": \"무료 WiFi\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/580a5567-ff28-4ef2-92f0-2ccca87d8da4-1508743354.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenity_S_8\",\n          \"id\": \"580a5567-ff28-4ef2-92f0-2ccca87d8da4\"\n        },\n        \"id\": \"80f82cc6-2822-4e14-aa99-251ebf8e95a9\",\n        \"priority\": 108,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": null,\n        \"name\": \"프런트 데스크의 안전 금고\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/3c155191-32f3-4442-ae86-aa881475ae43-1508742587.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenity_C_3\",\n          \"id\": \"3c155191-32f3-4442-ae86-aa881475ae43\"\n        },\n        \"id\": \"8d1321e9-5f76-4d04-825b-c74843e569ca\",\n        \"priority\": 703,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"매일 하우스키핑\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/2a3d912e-73ae-43f5-90db-04d020c22f3d-1509447539.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_S_9\",\n          \"id\": \"2a3d912e-73ae-43f5-90db-04d020c22f3d\"\n        },\n        \"id\": \"920a8b45-29c1-4cf9-ba81-7d26f3dc0d00\",\n        \"priority\": 109,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": null,\n        \"name\": \"애완동물 동반 불가\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/86cb31fc-24cb-4dbf-a4b7-cbe1adab0471-1508489485.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenity_C_1\",\n          \"id\": \"86cb31fc-24cb-4dbf-a4b7-cbe1adab0471\"\n        },\n        \"id\": \"92c27c97-f024-4bac-a8a2-79797f36b0b4\",\n        \"priority\": 701,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": null,\n        \"name\": \"세탁 시설\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/aecdb9da-ded4-4e6b-a41a-3e99ec5d0076-1508378645.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenity_B_11\",\n          \"id\": \"aecdb9da-ded4-4e6b-a41a-3e99ec5d0076\"\n        },\n        \"id\": \"a06edb33-7d30-4b76-96a7-1f1ef70f922c\",\n        \"priority\": 511,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"다리미/다리미판(요청 시)\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/7c9377fa-5e3a-49f4-b841-bd0b3170a96a-1509604552.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_9\",\n          \"id\": \"7c9377fa-5e3a-49f4-b841-bd0b3170a96a\"\n        },\n        \"id\": \"a09c91a0-9153-4cc1-9309-971295f17e1f\",\n        \"priority\": 709,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"무료 세면용품\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/ec53d9b2-89d8-407d-a2de-b9244eb3acbd-1508808301.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_S_5\",\n          \"id\": \"ec53d9b2-89d8-407d-a2de-b9244eb3acbd\"\n        },\n        \"id\": \"a483459a-4162-4da2-99bf-14f4917a97e2\",\n        \"priority\": 505,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"고급 침구\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/aa88d9b6-f95d-4fb7-b2df-3b328e814f93-1508819875.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_S_6\",\n          \"id\": \"aa88d9b6-f95d-4fb7-b2df-3b328e814f93\"\n        },\n        \"id\": \"b15cc85e-a625-4495-a972-a4d854443239\",\n        \"priority\": 106,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"방음 객실\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/fb8c7e18-ab48-4f6a-8adc-3cbf34018236-1508746456.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_A_3\",\n          \"id\": \"fb8c7e18-ab48-4f6a-8adc-3cbf34018236\"\n        },\n        \"id\": \"c58b32aa-6e7f-4578-81a2-2d12e11354e1\",\n        \"priority\": 303,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelProperty\",\n        \"groupId\": \"799eb6ce-683b-40de-a227-69ec741a9aa4\",\n        \"name\": \"회의 공간\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/c7ed38a2-72c2-4d6e-ba09-2c067fe5f8e1-1508308347.png\"\n            }\n          },\n          \"name\": \"icoHotelAmenityFilterBusiness_A_4\",\n          \"id\": \"c7ed38a2-72c2-4d6e-ba09-2c067fe5f8e1\"\n        },\n        \"id\": \"cb5ad620-4fcc-4d9c-95cf-585d34bac577\",\n        \"priority\": 304,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"무료 유선 인터넷\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/c4efeba5-5422-47fd-af64-7bd95d6c44a8-1509448015.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_S_4\",\n          \"id\": \"c4efeba5-5422-47fd-af64-7bd95d6c44a8\"\n        },\n        \"id\": \"d1d2b055-443a-419f-ad1c-49adcc0bf526\",\n        \"priority\": 104,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"에어컨\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/74a4d9a3-e07b-48fa-a224-64bbb0f33df7-1509418072.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_1\",\n          \"id\": \"74a4d9a3-e07b-48fa-a224-64bbb0f33df7\"\n        },\n        \"id\": \"e4ac8134-adca-4242-ad7f-d0206952293e\",\n        \"priority\": 701,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"샤워기가 있는 욕조\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/c979fe89-0862-4545-b53f-fe2fe28cc304-1509417400.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_B_6\",\n          \"id\": \"c979fe89-0862-4545-b53f-fe2fe28cc304\"\n        },\n        \"id\": \"e7d4d2e5-ec7d-44c7-a4c7-c5295080e7ae\",\n        \"priority\": 506,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"객실 내 온도 조절기\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/74a4d9a3-e07b-48fa-a224-64bbb0f33df7-1509418072.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_1\",\n          \"id\": \"74a4d9a3-e07b-48fa-a224-64bbb0f33df7\"\n        },\n        \"id\": \"f26b428f-31d9-4bc2-ab59-de7275aa44b6\",\n        \"priority\": 701,\n        \"value\": true\n      },\n      {\n        \"subtype\": \"HotelRoom\",\n        \"groupId\": null,\n        \"name\": \"슬리퍼\",\n        \"icon\": {\n          \"sizes\": {\n            \"small\": { \"url\": null },\n            \"large\": {\n              \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/large_image/f079a23e-f581-4d5a-91aa-027fe41f7b78-1509442356.png\"\n            }\n          },\n          \"name\": \"icoHotelRoomAmenity_C_17\",\n          \"id\": \"f079a23e-f581-4d5a-91aa-027fe41f7b78\"\n        },\n        \"id\": \"ff96194b-bdde-4d7a-988f-2923cb5465fd\",\n        \"priority\": 717,\n        \"value\": true\n      }\n    ],\n    \"officialSiteUrl\": \"http://www.hotelmonterey.co.jp/en/htl/grasmere_osaka/\",\n    \"areas\": [{ \"name\": \"난바\", \"id\": \"f24138c7-2b76-49e1-a423-c1e7b83c6e5d\" }],\n    \"saleable\": true,\n    \"type\": \"hotel\",\n    \"pointGeolocation\": {\n      \"type\": \"Point\",\n      \"coordinates\": [135.49615, 34.66753]\n    },\n    \"tips\": null,\n    \"permanentlyClosed\": false,\n    \"reviews\": [\n      {\n        \"thanks\": 1,\n        \"createdAt\": 1507711872755,\n        \"blind\": false,\n        \"attachments\": [],\n        \"rating\": 5,\n        \"comment\": \"ㅎㅎㅎㄹㅎㅎㅎ\",\n        \"location\": { \"lon\": 135.49614, \"lat\": 34.666664 },\n        \"id\": 25983,\n        \"user\": {\n          \"uid\": \"_unreigter_.1522144013925.08._KA534970376\",\n          \"unregister\": true,\n          \"name\": \"user53610\",\n          \"photo\": \"http://res.cloudinary.com/titicaca-imgs/image/upload/w_256,h_256,c_thumb,f_auto/w_256,h_256,c_thumb,f_auto/v1490937645/defaul_profile05_imckmy.png\",\n          \"userBoard\": { \"thanks\": 1, \"reports\": 0, \"reviews\": 3, \"trips\": 0 },\n          \"mileage\": { \"badges\": [], \"level\": 0, \"point\": 4 }\n        }\n      }\n    ],\n    \"internalWeight\": null,\n    \"popularity\": { \"rank\": 160, \"score\": -10 },\n    \"externalLinks\": [\n      {\n        \"imageUrl\": \"http://blogthumb2.naver.net/20140724_295/dearmyboy_1406203574742CUhHh_JPEG/P1010249.JPG?type=w2\",\n        \"publisher\": \"blog.naver.com\",\n        \"title\": \"몬테레이 글라스미어 오사카, 룸컨디션\",\n        \"url\": \"http://blog.naver.com/dearmyboy/220070965584\"\n      },\n      {\n        \"imageUrl\": \"http://blogthumb2.naver.net/20150807_56/jung9223_1438875030339ush0A_JPEG/P1120557.JPG?type=w2\",\n        \"publisher\": \"blog.naver.com\",\n        \"title\": \"교통이 편리한 몬테레이 그레스미어 호텔\",\n        \"url\": \"http://blog.naver.com/jung9223/220444777598\"\n      },\n      {\n        \"imageUrl\": \"http://blogthumb2.naver.net/20150222_108/mogura81_1424603053163EmHFS_JPEG/DSC02817.JPG?type=w2\",\n        \"publisher\": \"blog.naver.com\",\n        \"title\": \"오사카 호텔 후기 : 호텔 몬트레이 그래스미어 오사카\",\n        \"url\": \"http://blog.naver.com/mogura81/220288668879\"\n      }\n    ],\n    \"categories\": [],\n    \"featuredContent\": [\n      {\n        \"type\": \"images\",\n        \"value\": {\n          \"images\": [\n            {\n              \"sourceUrl\": \"http://blog.naver.com/mogura81/220288668879\",\n              \"sizes\": {\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/9f93c1b3-a05d-45d6-9aa8-fca3d9704519.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/9f93c1b3-a05d-45d6-9aa8-fca3d9704519.jpg\"\n                },\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/9f93c1b3-a05d-45d6-9aa8-fca3d9704519.jpg\"\n                }\n              },\n              \"description\": null,\n              \"id\": \"9f93c1b3-a05d-45d6-9aa8-fca3d9704519\",\n              \"title\": null\n            }\n          ],\n          \"display\": \"tile\"\n        }\n      },\n      {\n        \"type\": \"heading2\",\n        \"value\": { \"text\": \"뛰어난 이동성을 자랑하는 호텔\" }\n      },\n      {\n        \"type\": \"text\",\n        \"value\": {\n          \"text\": \"호텔은 오사카 교통의 중심지인 난바역과 연결되어 있어 편리한 이동성을 자랑한다. 뿐만 아니라, 호텔 바로 옆에는 간사이 국제공항까지 한 번에 이동할 수 있는 OCAT 버스 터미널이 있어 공항과의 뛰어난 접근성을 갖추고 있는 곳이다.\"\n        }\n      },\n      {\n        \"type\": \"images\",\n        \"value\": {\n          \"images\": [\n            {\n              \"sourceUrl\": \"https://www.expedia.com/\",\n              \"sizes\": {\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/4e417e8c-caae-400e-9dd7-89ad81d3552f.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/4e417e8c-caae-400e-9dd7-89ad81d3552f.jpg\"\n                },\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/4e417e8c-caae-400e-9dd7-89ad81d3552f.jpg\"\n                }\n              },\n              \"description\": null,\n              \"id\": \"4e417e8c-caae-400e-9dd7-89ad81d3552f\",\n              \"title\": null\n            }\n          ],\n          \"display\": \"tile\"\n        }\n      },\n      {\n        \"type\": \"heading2\",\n        \"value\": { \"text\": \"최첨단 보안 시스템과 멋진 전망의 객실\" }\n      },\n      {\n        \"type\": \"text\",\n        \"value\": {\n          \"text\": \"호텔에서는 객실 카드가 있어야만 복도 출입을 할 수 있는 최첨단 보안 시스템을 갖추고 있어 투숙객들은 더욱 안전한 휴식 취할 수 있다. 또한, 호텔 전 객실은 24층부터 이루어져 있어 오사카의 멋진 시티뷰와 화려한 야경을 감상하며 로맨틱한 하루를 마무리할 수 있다.\"\n        }\n      },\n      {\n        \"type\": \"images\",\n        \"value\": {\n          \"images\": [\n            {\n              \"sourceUrl\": \"https://blog.naver.com/yrlovejg/221069049759\",\n              \"sizes\": {\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d76d9b04-704e-4b20-8d00-18ef71202848.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d76d9b04-704e-4b20-8d00-18ef71202848.jpg\"\n                },\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d76d9b04-704e-4b20-8d00-18ef71202848.jpg\"\n                }\n              },\n              \"description\": null,\n              \"id\": \"d76d9b04-704e-4b20-8d00-18ef71202848\",\n              \"title\": null\n            }\n          ],\n          \"display\": \"tile\"\n        }\n      },\n      {\n        \"type\": \"heading2\",\n        \"value\": { \"text\": \"호텔 지하에 마련된 대형마트\" }\n      },\n      {\n        \"type\": \"text\",\n        \"value\": {\n          \"text\": \"호텔 지하에는 쇼핑센터, 디럭스토어 등이 갖춰진 대형마트가 입점되어 있다. 이곳에서는 여행 중 필요한 생필품은 물론 쇼핑 또한 쉽게 즐길 수 있으며, 자정까지 운영하고 있어 저녁 늦게 여행을 마치고 돌아온 투숙객들도 편리하게 이용할 수 있다.\"\n        }\n      },\n      {\n        \"type\": \"embedded\",\n        \"value\": {\n          \"entries\": [\n            [\n              {\n                \"type\": \"images\",\n                \"value\": {\n                  \"images\": [\n                    {\n                      \"sourceUrl\": null,\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/20815b2a-aaef-4d4f-9053-a3cae22fe4c7.jpg\"\n                        },\n                        \"small_square\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/20815b2a-aaef-4d4f-9053-a3cae22fe4c7.jpg\"\n                        },\n                        \"full\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/20815b2a-aaef-4d4f-9053-a3cae22fe4c7.jpg\"\n                        }\n                      },\n                      \"description\": null,\n                      \"id\": \"20815b2a-aaef-4d4f-9053-a3cae22fe4c7\",\n                      \"title\": null\n                    }\n                  ],\n                  \"display\": \"simple\"\n                }\n              },\n              { \"type\": \"heading2\", \"value\": { \"text\": \"더블룸\" } },\n              { \"type\": \"text\", \"value\": { \"text\": \"더블침대 1, 19m²\" } }\n            ],\n            [\n              {\n                \"type\": \"images\",\n                \"value\": {\n                  \"images\": [\n                    {\n                      \"sourceUrl\": null,\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f4793c00-066d-49f6-a8bc-7d8af2cd19fd.jpg\"\n                        },\n                        \"small_square\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f4793c00-066d-49f6-a8bc-7d8af2cd19fd.jpg\"\n                        },\n                        \"full\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f4793c00-066d-49f6-a8bc-7d8af2cd19fd.jpg\"\n                        }\n                      },\n                      \"description\": null,\n                      \"id\": \"f4793c00-066d-49f6-a8bc-7d8af2cd19fd\",\n                      \"title\": null\n                    }\n                  ],\n                  \"display\": \"simple\"\n                }\n              },\n              { \"type\": \"heading2\", \"value\": { \"text\": \"트윈룸\" } },\n              { \"type\": \"text\", \"value\": { \"text\": \"싱글침대 2, 23m²\" } }\n            ],\n            [\n              {\n                \"type\": \"images\",\n                \"value\": {\n                  \"images\": [\n                    {\n                      \"sourceUrl\": null,\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/9a692ffe-d897-4dff-9db4-a228646c1715.jpg\"\n                        },\n                        \"small_square\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/9a692ffe-d897-4dff-9db4-a228646c1715.jpg\"\n                        },\n                        \"full\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/9a692ffe-d897-4dff-9db4-a228646c1715.jpg\"\n                        }\n                      },\n                      \"description\": null,\n                      \"id\": \"9a692ffe-d897-4dff-9db4-a228646c1715\",\n                      \"title\": null\n                    }\n                  ],\n                  \"display\": \"simple\"\n                }\n              },\n              { \"type\": \"heading2\", \"value\": { \"text\": \"트리플룸\" } },\n              {\n                \"type\": \"text\",\n                \"value\": { \"text\": \"싱글침대 2, 더블사이즈 소파베드 1, 26m²\" }\n              }\n            ],\n            [\n              {\n                \"type\": \"images\",\n                \"value\": {\n                  \"images\": [\n                    {\n                      \"sourceUrl\": null,\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d07bba03-5321-4686-b34a-23d903fd4f8f.jpg\"\n                        },\n                        \"small_square\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d07bba03-5321-4686-b34a-23d903fd4f8f.jpg\"\n                        },\n                        \"full\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d07bba03-5321-4686-b34a-23d903fd4f8f.jpg\"\n                        }\n                      },\n                      \"description\": null,\n                      \"id\": \"d07bba03-5321-4686-b34a-23d903fd4f8f\",\n                      \"title\": null\n                    }\n                  ],\n                  \"display\": \"simple\"\n                }\n              },\n              { \"type\": \"heading2\", \"value\": { \"text\": \"싱글룸\" } },\n              { \"type\": \"text\", \"value\": { \"text\": \"싱글침대 1, 19m²\" } }\n            ],\n            [\n              {\n                \"type\": \"images\",\n                \"value\": {\n                  \"images\": [\n                    {\n                      \"sourceUrl\": null,\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/a5e08dd8-417c-4732-b877-cb58a365be47.jpg\"\n                        },\n                        \"small_square\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/a5e08dd8-417c-4732-b877-cb58a365be47.jpg\"\n                        },\n                        \"full\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/a5e08dd8-417c-4732-b877-cb58a365be47.jpg\"\n                        }\n                      },\n                      \"description\": null,\n                      \"id\": \"a5e08dd8-417c-4732-b877-cb58a365be47\",\n                      \"title\": null\n                    }\n                  ],\n                  \"display\": \"simple\"\n                }\n              },\n              {\n                \"type\": \"heading2\",\n                \"value\": { \"text\": \"이그제큐티브 트윈룸\" }\n              },\n              { \"type\": \"text\", \"value\": { \"text\": \"싱글침대 2, 58m²\" } }\n            ],\n            [\n              {\n                \"type\": \"images\",\n                \"value\": {\n                  \"images\": [\n                    {\n                      \"sourceUrl\": null,\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/514c1394-e14f-46b5-a281-c720a01ad942.jpg\"\n                        },\n                        \"small_square\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/514c1394-e14f-46b5-a281-c720a01ad942.jpg\"\n                        },\n                        \"full\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/514c1394-e14f-46b5-a281-c720a01ad942.jpg\"\n                        }\n                      },\n                      \"description\": null,\n                      \"id\": \"514c1394-e14f-46b5-a281-c720a01ad942\",\n                      \"title\": null\n                    }\n                  ],\n                  \"display\": \"simple\"\n                }\n              },\n              { \"type\": \"heading2\", \"value\": { \"text\": \"이코노미 더블룸\" } },\n              { \"type\": \"text\", \"value\": { \"text\": \"슈퍼싱글침대 1, 19m²\" } }\n            ],\n            [\n              {\n                \"type\": \"images\",\n                \"value\": {\n                  \"images\": [\n                    {\n                      \"sourceUrl\": null,\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/2f07c1ec-54ea-4e98-8fd6-b144963c8d48.jpg\"\n                        },\n                        \"small_square\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/2f07c1ec-54ea-4e98-8fd6-b144963c8d48.jpg\"\n                        },\n                        \"full\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/2f07c1ec-54ea-4e98-8fd6-b144963c8d48.jpg\"\n                        }\n                      },\n                      \"description\": null,\n                      \"id\": \"2f07c1ec-54ea-4e98-8fd6-b144963c8d48\",\n                      \"title\": null\n                    }\n                  ],\n                  \"display\": \"simple\"\n                }\n              },\n              { \"type\": \"heading2\", \"value\": { \"text\": \"디럭스 트윈룸\" } },\n              { \"type\": \"text\", \"value\": { \"text\": \"싱글침대 2, 38m²\" } }\n            ]\n          ]\n        }\n      }\n    ],\n    \"id\": \"06598e28-1fb5-496e-a7c7-3b3a85a05f4f\",\n    \"starRating\": 4,\n    \"updatedAt\": \"2019-03-19T19:02:08.208Z\",\n    \"image\": {\n      \"description\": null,\n      \"id\": \"51956c2a-4400-4fa7-8496-46b6e607d1ac\",\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/51956c2a-4400-4fa7-8496-46b6e607d1ac.jpg\"\n        },\n        \"large\": {\n          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/51956c2a-4400-4fa7-8496-46b6e607d1ac.jpg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/51956c2a-4400-4fa7-8496-46b6e607d1ac.jpg\"\n        }\n      },\n      \"sourceUrl\": \"https://www.expedia.com/\",\n      \"title\": null\n    },\n    \"images\": [\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/51956c2a-4400-4fa7-8496-46b6e607d1ac.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/51956c2a-4400-4fa7-8496-46b6e607d1ac.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/51956c2a-4400-4fa7-8496-46b6e607d1ac.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"51956c2a-4400-4fa7-8496-46b6e607d1ac\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/bf597dc7-43d2-4202-9857-f50b12841d50.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/bf597dc7-43d2-4202-9857-f50b12841d50.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/bf597dc7-43d2-4202-9857-f50b12841d50.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"bf597dc7-43d2-4202-9857-f50b12841d50\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/b5035d94-38df-4925-8776-4b3b31e30d16.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/b5035d94-38df-4925-8776-4b3b31e30d16.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/b5035d94-38df-4925-8776-4b3b31e30d16.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"b5035d94-38df-4925-8776-4b3b31e30d16\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/bcf394ff-5a23-4591-b8e7-47483eb82153.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/bcf394ff-5a23-4591-b8e7-47483eb82153.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/bcf394ff-5a23-4591-b8e7-47483eb82153.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"bcf394ff-5a23-4591-b8e7-47483eb82153\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/91be6513-185f-48e6-8b00-238ee925f178.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/91be6513-185f-48e6-8b00-238ee925f178.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/91be6513-185f-48e6-8b00-238ee925f178.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"91be6513-185f-48e6-8b00-238ee925f178\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/bb333728-2022-44b3-937c-391ef84d04c4.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/bb333728-2022-44b3-937c-391ef84d04c4.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/bb333728-2022-44b3-937c-391ef84d04c4.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"bb333728-2022-44b3-937c-391ef84d04c4\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/104a0bcb-43d6-4215-bc7c-9d3de81f2ed9.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/104a0bcb-43d6-4215-bc7c-9d3de81f2ed9.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/104a0bcb-43d6-4215-bc7c-9d3de81f2ed9.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"104a0bcb-43d6-4215-bc7c-9d3de81f2ed9\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/7c526c60-740d-4d31-a547-75b204a2c287.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/7c526c60-740d-4d31-a547-75b204a2c287.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/7c526c60-740d-4d31-a547-75b204a2c287.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"7c526c60-740d-4d31-a547-75b204a2c287\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/56150570-c24c-4133-9ebc-cc49e9c766cf.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/56150570-c24c-4133-9ebc-cc49e9c766cf.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/56150570-c24c-4133-9ebc-cc49e9c766cf.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"56150570-c24c-4133-9ebc-cc49e9c766cf\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/17aa0896-f502-4ed9-a1c1-015672374bc2.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/17aa0896-f502-4ed9-a1c1-015672374bc2.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/17aa0896-f502-4ed9-a1c1-015672374bc2.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"17aa0896-f502-4ed9-a1c1-015672374bc2\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/0767d051-0b77-4d59-9f4e-dc48365da7b8.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/0767d051-0b77-4d59-9f4e-dc48365da7b8.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/0767d051-0b77-4d59-9f4e-dc48365da7b8.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"0767d051-0b77-4d59-9f4e-dc48365da7b8\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/a90c6696-d01d-4db2-b75c-c51475fc94cd.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/a90c6696-d01d-4db2-b75c-c51475fc94cd.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/a90c6696-d01d-4db2-b75c-c51475fc94cd.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"a90c6696-d01d-4db2-b75c-c51475fc94cd\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/24434899-e3d5-4c7f-9939-4672cc2cd268.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/24434899-e3d5-4c7f-9939-4672cc2cd268.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/24434899-e3d5-4c7f-9939-4672cc2cd268.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"24434899-e3d5-4c7f-9939-4672cc2cd268\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/b1fc8a09-5970-460d-8ac5-cc4bb2bb3e56.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/b1fc8a09-5970-460d-8ac5-cc4bb2bb3e56.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/b1fc8a09-5970-460d-8ac5-cc4bb2bb3e56.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"b1fc8a09-5970-460d-8ac5-cc4bb2bb3e56\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/b022a05e-f97c-4e2e-b39b-42cc175dc151.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/b022a05e-f97c-4e2e-b39b-42cc175dc151.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/b022a05e-f97c-4e2e-b39b-42cc175dc151.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"b022a05e-f97c-4e2e-b39b-42cc175dc151\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/9937061a-cce1-416d-a5e8-851875f881ac.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/9937061a-cce1-416d-a5e8-851875f881ac.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/9937061a-cce1-416d-a5e8-851875f881ac.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"9937061a-cce1-416d-a5e8-851875f881ac\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/cea53be2-fa72-4a0c-809a-23c50eab2bb0.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/cea53be2-fa72-4a0c-809a-23c50eab2bb0.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/cea53be2-fa72-4a0c-809a-23c50eab2bb0.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"cea53be2-fa72-4a0c-809a-23c50eab2bb0\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/8f2cd988-92ee-4f4e-95db-b31793fe83fd.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/8f2cd988-92ee-4f4e-95db-b31793fe83fd.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/8f2cd988-92ee-4f4e-95db-b31793fe83fd.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"8f2cd988-92ee-4f4e-95db-b31793fe83fd\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/6c35962e-7aec-45f0-acf2-b9d50dd72fc6.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/6c35962e-7aec-45f0-acf2-b9d50dd72fc6.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/6c35962e-7aec-45f0-acf2-b9d50dd72fc6.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"6c35962e-7aec-45f0-acf2-b9d50dd72fc6\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/e8516aea-d161-4313-ada2-0801cc991be5.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/e8516aea-d161-4313-ada2-0801cc991be5.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/e8516aea-d161-4313-ada2-0801cc991be5.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"e8516aea-d161-4313-ada2-0801cc991be5\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/9408fbf7-db9b-4615-968b-6035254343a9.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/9408fbf7-db9b-4615-968b-6035254343a9.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/9408fbf7-db9b-4615-968b-6035254343a9.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"9408fbf7-db9b-4615-968b-6035254343a9\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/c9c7b480-8a1f-4c01-8fa5-d1d7982862cc.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/c9c7b480-8a1f-4c01-8fa5-d1d7982862cc.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/c9c7b480-8a1f-4c01-8fa5-d1d7982862cc.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"c9c7b480-8a1f-4c01-8fa5-d1d7982862cc\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/12e7d03b-23ef-4a69-b062-fe4848e7ae55.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/12e7d03b-23ef-4a69-b062-fe4848e7ae55.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/12e7d03b-23ef-4a69-b062-fe4848e7ae55.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"12e7d03b-23ef-4a69-b062-fe4848e7ae55\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/ef76e4aa-8a15-44ea-842a-7542c6d94130.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/ef76e4aa-8a15-44ea-842a-7542c6d94130.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/ef76e4aa-8a15-44ea-842a-7542c6d94130.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"ef76e4aa-8a15-44ea-842a-7542c6d94130\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/1c20d507-992f-42d7-9dea-8d3afe70994e.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/1c20d507-992f-42d7-9dea-8d3afe70994e.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/1c20d507-992f-42d7-9dea-8d3afe70994e.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"1c20d507-992f-42d7-9dea-8d3afe70994e\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/0df2ccd1-9fd9-4544-8b44-64da644756fd.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/0df2ccd1-9fd9-4544-8b44-64da644756fd.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/0df2ccd1-9fd9-4544-8b44-64da644756fd.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"0df2ccd1-9fd9-4544-8b44-64da644756fd\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d0574241-40ed-46d9-89ef-7982cfaffa60.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d0574241-40ed-46d9-89ef-7982cfaffa60.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d0574241-40ed-46d9-89ef-7982cfaffa60.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"d0574241-40ed-46d9-89ef-7982cfaffa60\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/b19eaa2a-d41e-4d50-a1ae-0afd7c36e216.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/b19eaa2a-d41e-4d50-a1ae-0afd7c36e216.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/b19eaa2a-d41e-4d50-a1ae-0afd7c36e216.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"b19eaa2a-d41e-4d50-a1ae-0afd7c36e216\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/c6e75419-addb-42b4-b855-373f538a1161.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/c6e75419-addb-42b4-b855-373f538a1161.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/c6e75419-addb-42b4-b855-373f538a1161.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"c6e75419-addb-42b4-b855-373f538a1161\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/70af27bc-5f8e-47b7-93aa-0ec03d38e1e4.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/70af27bc-5f8e-47b7-93aa-0ec03d38e1e4.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/70af27bc-5f8e-47b7-93aa-0ec03d38e1e4.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"70af27bc-5f8e-47b7-93aa-0ec03d38e1e4\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/7fa82691-6c74-47ee-b2c9-7926f1757dc2.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/7fa82691-6c74-47ee-b2c9-7926f1757dc2.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/7fa82691-6c74-47ee-b2c9-7926f1757dc2.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"7fa82691-6c74-47ee-b2c9-7926f1757dc2\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/2b3c5076-2a0d-49ff-bd3b-870d89f4145d.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/2b3c5076-2a0d-49ff-bd3b-870d89f4145d.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/2b3c5076-2a0d-49ff-bd3b-870d89f4145d.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"2b3c5076-2a0d-49ff-bd3b-870d89f4145d\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/fcf06a64-3c99-4dc1-904b-6c97d783d574.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/fcf06a64-3c99-4dc1-904b-6c97d783d574.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/fcf06a64-3c99-4dc1-904b-6c97d783d574.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"fcf06a64-3c99-4dc1-904b-6c97d783d574\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/94752a81-16e1-4c2d-a430-e91e7eff7a1b.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/94752a81-16e1-4c2d-a430-e91e7eff7a1b.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/94752a81-16e1-4c2d-a430-e91e7eff7a1b.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"94752a81-16e1-4c2d-a430-e91e7eff7a1b\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/69e6ffe6-dc87-4a58-bd57-b0ecdc11658b.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/69e6ffe6-dc87-4a58-bd57-b0ecdc11658b.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/69e6ffe6-dc87-4a58-bd57-b0ecdc11658b.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"69e6ffe6-dc87-4a58-bd57-b0ecdc11658b\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/3dac2679-dd4e-45d9-a317-af74b57eb9ac.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/3dac2679-dd4e-45d9-a317-af74b57eb9ac.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/3dac2679-dd4e-45d9-a317-af74b57eb9ac.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"3dac2679-dd4e-45d9-a317-af74b57eb9ac\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/68d0c040-6916-476b-a729-fa5a5f5dd9f7.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/68d0c040-6916-476b-a729-fa5a5f5dd9f7.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/68d0c040-6916-476b-a729-fa5a5f5dd9f7.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"68d0c040-6916-476b-a729-fa5a5f5dd9f7\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/be8f002f-fc97-4b1b-ab45-5f0745ff21ba.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/be8f002f-fc97-4b1b-ab45-5f0745ff21ba.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/be8f002f-fc97-4b1b-ab45-5f0745ff21ba.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"be8f002f-fc97-4b1b-ab45-5f0745ff21ba\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/c2250870-9119-480c-a7f3-38d2b8435ea7.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/c2250870-9119-480c-a7f3-38d2b8435ea7.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/c2250870-9119-480c-a7f3-38d2b8435ea7.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"c2250870-9119-480c-a7f3-38d2b8435ea7\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/20f57073-e00e-46c9-b96a-c5ac22cbe1ee.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/20f57073-e00e-46c9-b96a-c5ac22cbe1ee.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/20f57073-e00e-46c9-b96a-c5ac22cbe1ee.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"20f57073-e00e-46c9-b96a-c5ac22cbe1ee\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f2a6599b-f675-49c2-adb3-b758aa065aa9.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f2a6599b-f675-49c2-adb3-b758aa065aa9.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f2a6599b-f675-49c2-adb3-b758aa065aa9.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"f2a6599b-f675-49c2-adb3-b758aa065aa9\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/8c55a37b-4004-4bef-96ef-8d5ab4e7b5e6.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/8c55a37b-4004-4bef-96ef-8d5ab4e7b5e6.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/8c55a37b-4004-4bef-96ef-8d5ab4e7b5e6.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"8c55a37b-4004-4bef-96ef-8d5ab4e7b5e6\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/596967fc-bc93-404a-9086-e76a83d9bf66.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/596967fc-bc93-404a-9086-e76a83d9bf66.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/596967fc-bc93-404a-9086-e76a83d9bf66.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"596967fc-bc93-404a-9086-e76a83d9bf66\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/347ebd0d-5a38-4515-bc13-2fad51124c38.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/347ebd0d-5a38-4515-bc13-2fad51124c38.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/347ebd0d-5a38-4515-bc13-2fad51124c38.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"347ebd0d-5a38-4515-bc13-2fad51124c38\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/1e0e8233-00b2-4b84-9f89-a5acbae41298.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/1e0e8233-00b2-4b84-9f89-a5acbae41298.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/1e0e8233-00b2-4b84-9f89-a5acbae41298.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"1e0e8233-00b2-4b84-9f89-a5acbae41298\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/9fef1ff4-17bd-4cb7-bd62-8c1e63497cbb.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/9fef1ff4-17bd-4cb7-bd62-8c1e63497cbb.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/9fef1ff4-17bd-4cb7-bd62-8c1e63497cbb.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"9fef1ff4-17bd-4cb7-bd62-8c1e63497cbb\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/b630b9a8-2b97-4948-ba8a-5bfc2d6ea243.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/b630b9a8-2b97-4948-ba8a-5bfc2d6ea243.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/b630b9a8-2b97-4948-ba8a-5bfc2d6ea243.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"b630b9a8-2b97-4948-ba8a-5bfc2d6ea243\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/bb5e6862-153c-43f0-95fe-056d01834524.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/bb5e6862-153c-43f0-95fe-056d01834524.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/bb5e6862-153c-43f0-95fe-056d01834524.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"bb5e6862-153c-43f0-95fe-056d01834524\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/3a239542-8244-49a8-8e56-2d4481ee7d8c.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/3a239542-8244-49a8-8e56-2d4481ee7d8c.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/3a239542-8244-49a8-8e56-2d4481ee7d8c.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"3a239542-8244-49a8-8e56-2d4481ee7d8c\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/e3756642-f42e-4c7d-a898-297822650e4f.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/e3756642-f42e-4c7d-a898-297822650e4f.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/e3756642-f42e-4c7d-a898-297822650e4f.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"e3756642-f42e-4c7d-a898-297822650e4f\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d2dd62f6-8b41-404e-a6d0-e615dd549f15.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d2dd62f6-8b41-404e-a6d0-e615dd549f15.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d2dd62f6-8b41-404e-a6d0-e615dd549f15.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"d2dd62f6-8b41-404e-a6d0-e615dd549f15\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/7629a288-46a5-4ba5-9d5a-4e982443c210.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/7629a288-46a5-4ba5-9d5a-4e982443c210.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/7629a288-46a5-4ba5-9d5a-4e982443c210.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"7629a288-46a5-4ba5-9d5a-4e982443c210\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/a9ba1857-ce84-4326-872d-153ae7419143.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/a9ba1857-ce84-4326-872d-153ae7419143.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/a9ba1857-ce84-4326-872d-153ae7419143.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"a9ba1857-ce84-4326-872d-153ae7419143\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/c786aeaf-55cf-49c8-bf10-398c9923502e.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/c786aeaf-55cf-49c8-bf10-398c9923502e.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/c786aeaf-55cf-49c8-bf10-398c9923502e.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"c786aeaf-55cf-49c8-bf10-398c9923502e\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/87466c74-bc96-4b6d-8376-9652b4beef80.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/87466c74-bc96-4b6d-8376-9652b4beef80.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/87466c74-bc96-4b6d-8376-9652b4beef80.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"87466c74-bc96-4b6d-8376-9652b4beef80\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/18047fb1-ed22-4b59-8eb9-16d1a2ad66a3.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/18047fb1-ed22-4b59-8eb9-16d1a2ad66a3.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/18047fb1-ed22-4b59-8eb9-16d1a2ad66a3.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"18047fb1-ed22-4b59-8eb9-16d1a2ad66a3\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d736eac1-94c7-41c2-8e46-b7858c871162.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d736eac1-94c7-41c2-8e46-b7858c871162.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d736eac1-94c7-41c2-8e46-b7858c871162.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"d736eac1-94c7-41c2-8e46-b7858c871162\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/4e417e8c-caae-400e-9dd7-89ad81d3552f.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/4e417e8c-caae-400e-9dd7-89ad81d3552f.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/4e417e8c-caae-400e-9dd7-89ad81d3552f.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"4e417e8c-caae-400e-9dd7-89ad81d3552f\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/1031fb21-9299-4366-995f-0dcbec7a28b1.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/1031fb21-9299-4366-995f-0dcbec7a28b1.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/1031fb21-9299-4366-995f-0dcbec7a28b1.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"1031fb21-9299-4366-995f-0dcbec7a28b1\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/35ed68e9-d451-4de9-a049-d718a08da223.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/35ed68e9-d451-4de9-a049-d718a08da223.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/35ed68e9-d451-4de9-a049-d718a08da223.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"35ed68e9-d451-4de9-a049-d718a08da223\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/b50e6c81-180f-4fb8-b97b-6a78f18b164b.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/b50e6c81-180f-4fb8-b97b-6a78f18b164b.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/b50e6c81-180f-4fb8-b97b-6a78f18b164b.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"b50e6c81-180f-4fb8-b97b-6a78f18b164b\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/0cb5ed7d-6016-4bf5-ab0b-96d13f50cb7a.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/0cb5ed7d-6016-4bf5-ab0b-96d13f50cb7a.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/0cb5ed7d-6016-4bf5-ab0b-96d13f50cb7a.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"0cb5ed7d-6016-4bf5-ab0b-96d13f50cb7a\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/dearmyboy/220070965584\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d3c1ba09-69cb-4047-afbd-0688677a509b.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d3c1ba09-69cb-4047-afbd-0688677a509b.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d3c1ba09-69cb-4047-afbd-0688677a509b.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"d3c1ba09-69cb-4047-afbd-0688677a509b\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/dearmyboy/220070965584\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f20fea32-fb27-499f-a452-395bca71467e.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f20fea32-fb27-499f-a452-395bca71467e.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f20fea32-fb27-499f-a452-395bca71467e.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"f20fea32-fb27-499f-a452-395bca71467e\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/dearmyboy/220070965584\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/32b99576-d1f5-4ab3-b4fa-d6e20d206d95.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/32b99576-d1f5-4ab3-b4fa-d6e20d206d95.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/32b99576-d1f5-4ab3-b4fa-d6e20d206d95.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"32b99576-d1f5-4ab3-b4fa-d6e20d206d95\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/dearmyboy/220070965584\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/22bcaf94-0d98-4594-80e1-0e1ce947205b.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/22bcaf94-0d98-4594-80e1-0e1ce947205b.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/22bcaf94-0d98-4594-80e1-0e1ce947205b.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"22bcaf94-0d98-4594-80e1-0e1ce947205b\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/dearmyboy/220070965584\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f0aa036a-fc86-4e2a-882e-dd2a75e279f5.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f0aa036a-fc86-4e2a-882e-dd2a75e279f5.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f0aa036a-fc86-4e2a-882e-dd2a75e279f5.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"f0aa036a-fc86-4e2a-882e-dd2a75e279f5\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/dearmyboy/220070965584\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/94299ed0-7971-4c50-b64f-11bac974ccc0.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/94299ed0-7971-4c50-b64f-11bac974ccc0.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/94299ed0-7971-4c50-b64f-11bac974ccc0.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"94299ed0-7971-4c50-b64f-11bac974ccc0\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/jung9223/220444777598\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/e086ae95-4762-4597-9caf-be9ff2488d1f.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/e086ae95-4762-4597-9caf-be9ff2488d1f.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/e086ae95-4762-4597-9caf-be9ff2488d1f.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"e086ae95-4762-4597-9caf-be9ff2488d1f\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/jung9223/220444777598\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/c3de77f4-99bc-4e3a-895b-8199b505c871.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/c3de77f4-99bc-4e3a-895b-8199b505c871.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/c3de77f4-99bc-4e3a-895b-8199b505c871.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"c3de77f4-99bc-4e3a-895b-8199b505c871\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/jung9223/220444777598\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/2f2c5395-02cf-48fd-aada-d7c9b666ef00.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/2f2c5395-02cf-48fd-aada-d7c9b666ef00.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/2f2c5395-02cf-48fd-aada-d7c9b666ef00.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"2f2c5395-02cf-48fd-aada-d7c9b666ef00\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/mogura81/220288668879\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/b88f16c5-e9b5-4172-bd58-617047ac4861.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/b88f16c5-e9b5-4172-bd58-617047ac4861.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/b88f16c5-e9b5-4172-bd58-617047ac4861.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"b88f16c5-e9b5-4172-bd58-617047ac4861\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/mogura81/220288668879\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/9f93c1b3-a05d-45d6-9aa8-fca3d9704519.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/9f93c1b3-a05d-45d6-9aa8-fca3d9704519.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/9f93c1b3-a05d-45d6-9aa8-fca3d9704519.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"9f93c1b3-a05d-45d6-9aa8-fca3d9704519\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/mogura81/220288668879\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/a1a2c051-743e-446d-8db8-557349d4b7bd.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/a1a2c051-743e-446d-8db8-557349d4b7bd.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/a1a2c051-743e-446d-8db8-557349d4b7bd.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"a1a2c051-743e-446d-8db8-557349d4b7bd\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/mogura81/220288668879\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/daf1958f-0ac5-47be-b5a7-b53c0901de5b.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/daf1958f-0ac5-47be-b5a7-b53c0901de5b.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/daf1958f-0ac5-47be-b5a7-b53c0901de5b.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"daf1958f-0ac5-47be-b5a7-b53c0901de5b\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": \"http://blog.naver.com/mogura81/220288668879\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/479b5ee1-417f-4f9d-9b80-cc1f4a04b996.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/479b5ee1-417f-4f9d-9b80-cc1f4a04b996.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/479b5ee1-417f-4f9d-9b80-cc1f4a04b996.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"479b5ee1-417f-4f9d-9b80-cc1f4a04b996\",\n        \"title\": null\n      },\n      {\n        \"sourceUrl\": null,\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/ef64411d-2783-497d-b64f-af64e81a196f.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/ef64411d-2783-497d-b64f-af64e81a196f.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/ef64411d-2783-497d-b64f-af64e81a196f.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"ef64411d-2783-497d-b64f-af64e81a196f\",\n        \"title\": \"더블룸\"\n      },\n      {\n        \"sourceUrl\": null,\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/9a692ffe-d897-4dff-9db4-a228646c1715.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/9a692ffe-d897-4dff-9db4-a228646c1715.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/9a692ffe-d897-4dff-9db4-a228646c1715.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"9a692ffe-d897-4dff-9db4-a228646c1715\",\n        \"title\": \"트리플룸\"\n      },\n      {\n        \"sourceUrl\": null,\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d07bba03-5321-4686-b34a-23d903fd4f8f.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d07bba03-5321-4686-b34a-23d903fd4f8f.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d07bba03-5321-4686-b34a-23d903fd4f8f.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"d07bba03-5321-4686-b34a-23d903fd4f8f\",\n        \"title\": \"싱글룸\"\n      },\n      {\n        \"sourceUrl\": null,\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/2f07c1ec-54ea-4e98-8fd6-b144963c8d48.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/2f07c1ec-54ea-4e98-8fd6-b144963c8d48.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/2f07c1ec-54ea-4e98-8fd6-b144963c8d48.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"2f07c1ec-54ea-4e98-8fd6-b144963c8d48\",\n        \"title\": \"디럭스 트윈룸\"\n      },\n      {\n        \"sourceUrl\": null,\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/514c1394-e14f-46b5-a281-c720a01ad942.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/514c1394-e14f-46b5-a281-c720a01ad942.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/514c1394-e14f-46b5-a281-c720a01ad942.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"514c1394-e14f-46b5-a281-c720a01ad942\",\n        \"title\": \"이코노미 더블룸\"\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/20815b2a-aaef-4d4f-9053-a3cae22fe4c7.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/20815b2a-aaef-4d4f-9053-a3cae22fe4c7.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/20815b2a-aaef-4d4f-9053-a3cae22fe4c7.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"20815b2a-aaef-4d4f-9053-a3cae22fe4c7\",\n        \"title\": \"더블룸\"\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f4793c00-066d-49f6-a8bc-7d8af2cd19fd.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f4793c00-066d-49f6-a8bc-7d8af2cd19fd.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f4793c00-066d-49f6-a8bc-7d8af2cd19fd.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"f4793c00-066d-49f6-a8bc-7d8af2cd19fd\",\n        \"title\": \"트윈룸\"\n      },\n      {\n        \"sourceUrl\": \"https://www.expedia.com/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/a5e08dd8-417c-4732-b877-cb58a365be47.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/a5e08dd8-417c-4732-b877-cb58a365be47.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/a5e08dd8-417c-4732-b877-cb58a365be47.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"a5e08dd8-417c-4732-b877-cb58a365be47\",\n        \"title\": \"이그제큐티브 트윈룸\"\n      },\n      {\n        \"sourceUrl\": \"https://blog.naver.com/yrlovejg/221069049759\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d76d9b04-704e-4b20-8d00-18ef71202848.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d76d9b04-704e-4b20-8d00-18ef71202848.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d76d9b04-704e-4b20-8d00-18ef71202848.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"d76d9b04-704e-4b20-8d00-18ef71202848\",\n        \"title\": null\n      }\n    ],\n    \"relationshipCounts\": { \"equipped_with\": 3 },\n    \"publishedAt\": \"2016-11-30T18:54:30.000+09:00\",\n    \"extraProperties\": [],\n    \"tags\": [\n      {\n        \"name\": \"지하철역 5분거리\",\n        \"id\": \"4328808b-b7c9-49f1-81ce-b88e99bf1f03\"\n      },\n      { \"name\": \"역과 연결\", \"id\": \"5f00ab39-4e4a-413e-b374-b56c2d9dcf8e\" },\n      { \"name\": \"리무진버스이용\", \"id\": \"efdc27bf-57a1-4af6-8710-11e49d790d2f\" }\n    ],\n    \"deletedAt\": null,\n    \"names\": {\n      \"ko\": \"호텔 몬토레 그라스미아 오사카\",\n      \"en\": \"Hotel Monterey Grasmere Osaka\",\n      \"local\": \"ホテルモントレ グラスミア大阪\"\n    },\n    \"phoneNumber\": \"+81666457111\",\n    \"checkIn\": \"3 PM\",\n    \"directions\": \"난바 역에서 연결\",\n    \"regionId\": \"71476976-cf9a-4ae8-a60f-76e6fb26900d\",\n    \"originalStarRating\": \"4.0\",\n    \"grade\": 20,\n    \"comment\": \"난바역과 연결된 편리한 교통편을 자랑하는 멋진 전망의 고급 호텔\",\n    \"location\": [135.49615, 34.66753],\n    \"permalink\": \"https://triple-dev.titicaca-corp.com/regions/71476976-cf9a-4ae8-a60f-76e6fb26900d/hotels/06598e28-1fb5-496e-a7c7-3b3a85a05f4f\",\n    \"checkOut\": \"11 AM\",\n    \"remarks\": [\n      \"난바역 30번 출구와 연결된 호텔\",\n      \"간사이 국제공항을 한 번에 가는 버스 터미널 옆\",\n      \"카드키를 통한 완벽한 보안성\",\n      \"탁 트인 전경을 감상할 수 있는 객실\",\n      \"호텔 지하에 위치한 대형 마트\"\n    ],\n    \"pricing\": {\n      \"nightlyBasePrice\": 258964,\n      \"nightlyPrice\": 191736,\n      \"promoText\": \"최대 25%\",\n      \"nightlyPriceHotelPromotionApplied\": 193890,\n      \"clubPromotionRate\": 1,\n      \"clubPromotionType\": \"STATIC\",\n      \"clubMemberOnly\": false,\n      \"clubPromotionTarget\": true\n    }\n  },\n  \"type\": \"hotel\",\n  \"region\": {\n    \"id\": \"71476976-cf9a-4ae8-a60f-76e6fb26900d\",\n    \"type\": \"region\",\n    \"source\": {\n      \"weatherForecastSpots\": [\n        {\n          \"name\": \"오사카\",\n          \"id\": \"31e3a86f-1683-46c7-992a-bc5bdf4b6170\",\n          \"geolocation\": {\n            \"coordinates\": [135.50260942822263, 34.6685201467736],\n            \"type\": \"Point\"\n          }\n        },\n        {\n          \"name\": \"고베\",\n          \"id\": \"f78aecb6-5d9f-4e45-bed6-fdd4649306b2\",\n          \"geolocation\": {\n            \"coordinates\": [135.19464891796872, 34.688001254190986],\n            \"type\": \"Point\"\n          }\n        },\n        {\n          \"name\": \"교토\",\n          \"id\": \"83e04abf-4a5e-40f6-8882-57cbb697d3f8\",\n          \"geolocation\": {\n            \"coordinates\": [135.76868456249997, 35.00919873767235],\n            \"type\": \"Point\"\n          }\n        },\n        {\n          \"name\": \"나라\",\n          \"id\": \"ff3bd751-69da-4dc6-a774-918a7d93657f\",\n          \"geolocation\": {\n            \"coordinates\": [135.81895988827512, 34.68089047526345],\n            \"type\": \"Point\"\n          }\n        }\n      ],\n      \"preferences\": {\n        \"default_search_radius\": 1000,\n        \"recommended_duration\": 2,\n        \"time_zone\": \"Asia/Tokyo\",\n        \"currencies\": [\"JPY\"]\n      },\n      \"restaurantCategoryListings\": [\n        {\n          \"description\": null,\n          \"category\": {\n            \"name\": \"음식점\",\n            \"id\": \"280a5533-c9d0-48e8-8cad-b8c651a53d86\",\n            \"icons\": {\n              \"off\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/20b2e3de-2208-416c-9fb2-8a37f3719699-1487926560.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              },\n              \"on\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/650ce7bd-0ef7-4ffc-9816-7aa5dd729108-1487926580.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              }\n            }\n          },\n          \"picture\": null\n        },\n        {\n          \"description\": null,\n          \"category\": {\n            \"name\": \"카페/디저트\",\n            \"id\": \"55530454-0f37-4d35-a476-5d5100d2b449\",\n            \"icons\": {\n              \"off\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/57b156c4-8644-4622-8984-7dbfe528fafe-1487926526.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              },\n              \"on\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/d0f2cbaa-bedd-4571-8273-6e5f852410da-1487926543.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              }\n            }\n          },\n          \"picture\": null\n        },\n        {\n          \"description\": null,\n          \"category\": {\n            \"name\": \"술집/바\",\n            \"id\": \"b60013ce-5fd8-4e10-a309-0e50f47ed66e\",\n            \"icons\": {\n              \"off\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/546a044a-b80e-430b-87a3-814c491b6189-1487926594.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              },\n              \"on\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/f441338d-47df-4f6d-bc74-04c575736489-1487926612.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              }\n            }\n          },\n          \"picture\": null\n        }\n      ],\n      \"visible\": true,\n      \"articleCategoryListings\": [\n        {\n          \"description\": \"여행 전,\\n필수 체크사항\",\n          \"category\": {\n            \"name\": \"준비\",\n            \"id\": \"b1462717-952f-400c-901a-0b85d13c1331\",\n            \"icons\": {}\n          },\n          \"picture\": {\n            \"sourceUrl\": null,\n            \"sizes\": {\n              \"large\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/807bea5c-4cd6-4beb-a378-b473d0c3987b.jpg\"\n              },\n              \"small_square\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/807bea5c-4cd6-4beb-a378-b473d0c3987b.jpg\"\n              },\n              \"full\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/807bea5c-4cd6-4beb-a378-b473d0c3987b.jpg\"\n              }\n            },\n            \"description\": null,\n            \"id\": \"807bea5c-4cd6-4beb-a378-b473d0c3987b\",\n            \"title\": null\n          }\n        },\n        {\n          \"description\": \"알면 쓸모있는\\n오사카 정보와 팁\",\n          \"category\": {\n            \"name\": \"정보\",\n            \"id\": \"d1fa8256-a8f2-4a86-b293-27a7b65695bc\",\n            \"icons\": null\n          },\n          \"picture\": {\n            \"sourceUrl\": null,\n            \"sizes\": {\n              \"large\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/9ecb7aee-8951-4ce3-bbac-c8d0a0acc6da.jpg\"\n              },\n              \"small_square\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/9ecb7aee-8951-4ce3-bbac-c8d0a0acc6da.jpg\"\n              },\n              \"full\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/9ecb7aee-8951-4ce3-bbac-c8d0a0acc6da.jpg\"\n              }\n            },\n            \"description\": null,\n            \"id\": \"9ecb7aee-8951-4ce3-bbac-c8d0a0acc6da\",\n            \"title\": null\n          }\n        },\n        {\n          \"description\": \"볼거리, 즐길거리의 \\n모든 것\",\n          \"category\": {\n            \"name\": \"관광\",\n            \"id\": \"91e59cd2-b45b-4cfe-8b7b-69dd1a546fe0\",\n            \"icons\": null\n          },\n          \"picture\": {\n            \"sourceUrl\": null,\n            \"sizes\": {\n              \"large\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/0fadaed0-805d-452d-b1a9-e8842768cc0c.jpg\"\n              },\n              \"small_square\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/0fadaed0-805d-452d-b1a9-e8842768cc0c.jpg\"\n              },\n              \"full\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/0fadaed0-805d-452d-b1a9-e8842768cc0c.jpg\"\n              }\n            },\n            \"description\": null,\n            \"id\": \"0fadaed0-805d-452d-b1a9-e8842768cc0c\",\n            \"title\": null\n          }\n        },\n        {\n          \"description\": \"오사카\\n먹킷리스트\",\n          \"category\": {\n            \"name\": \"맛집\",\n            \"id\": \"5563700e-b3e8-482d-af61-616e8c40620f\",\n            \"icons\": null\n          },\n          \"picture\": {\n            \"sourceUrl\": null,\n            \"sizes\": {\n              \"large\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/15c619ad-d18e-4588-a19a-d3a4310a039b.jpg\"\n              },\n              \"small_square\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/15c619ad-d18e-4588-a19a-d3a4310a039b.jpg\"\n              },\n              \"full\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/15c619ad-d18e-4588-a19a-d3a4310a039b.jpg\"\n              }\n            },\n            \"description\": null,\n            \"id\": \"15c619ad-d18e-4588-a19a-d3a4310a039b\",\n            \"title\": null\n          }\n        }\n      ],\n      \"publishedAt\": \"2017-04-19T12:00:00.000+09:00\",\n      \"areas\": [\n        {\n          \"attractionsCount\": 212,\n          \"hotelArticleId\": \"dbb7edba-18cc-4560-be0d-87c8322fb831\",\n          \"name\": \"난바\",\n          \"restaurantsCount\": 1678,\n          \"id\": \"f24138c7-2b76-49e1-a423-c1e7b83c6e5d\",\n          \"hotelsCount\": 166\n        },\n        {\n          \"attractionsCount\": 143,\n          \"hotelArticleId\": \"faffdc84-0083-4c99-b002-77637ef133fc\",\n          \"name\": \"우메다\",\n          \"restaurantsCount\": 1998,\n          \"id\": \"e71b4afe-5b26-4c6f-a73a-332f32cb386e\",\n          \"hotelsCount\": 47\n        },\n        {\n          \"attractionsCount\": 32,\n          \"hotelArticleId\": \"4c15d12b-6a65-49d6-a08f-bb483df7949c\",\n          \"name\": \"베이에어리어\",\n          \"restaurantsCount\": 147,\n          \"id\": \"e3e80a44-7cc0-4549-8321-5c2538d29bbf\",\n          \"hotelsCount\": 15\n        },\n        {\n          \"attractionsCount\": 45,\n          \"hotelArticleId\": null,\n          \"name\": \"교토역\",\n          \"restaurantsCount\": 369,\n          \"id\": \"2dd3b53e-919e-4fb6-bb18-bcef37bc05ed\",\n          \"hotelsCount\": 0\n        },\n        {\n          \"attractionsCount\": 42,\n          \"hotelArticleId\": null,\n          \"name\": \"교토 기온\",\n          \"restaurantsCount\": 348,\n          \"id\": \"b056d6e1-53cf-4249-9519-49f16db40b63\",\n          \"hotelsCount\": 0\n        },\n        {\n          \"attractionsCount\": 1096,\n          \"hotelArticleId\": \"f6e0560f-141b-4e30-88d6-b4c2be871e1d\",\n          \"name\": \"교토 전체\",\n          \"restaurantsCount\": 5265,\n          \"id\": \"866d1a92-050a-4eee-a63c-6b6100f8ee80\",\n          \"hotelsCount\": 963\n        },\n        {\n          \"attractionsCount\": 362,\n          \"hotelArticleId\": \"9aad1665-6fcf-4691-8143-99dbdadfe60a\",\n          \"name\": \"고베\",\n          \"restaurantsCount\": 2749,\n          \"id\": \"82f34ba7-99c8-4a72-9092-3e09ed45e160\",\n          \"hotelsCount\": 101\n        },\n        {\n          \"attractionsCount\": 185,\n          \"hotelArticleId\": null,\n          \"name\": \"나라\",\n          \"restaurantsCount\": 1907,\n          \"id\": \"12b81e70-3e26-4c87-8495-1f81dc224d7d\",\n          \"hotelsCount\": 0\n        }\n      ],\n      \"regionCategory\": {\n        \"id\": \"0532bef9-9127-4419-9817-560ad2230eb4\",\n        \"name\": \"일본\",\n        \"priority\": 100\n      },\n      \"type\": \"region\",\n      \"priority\": 7200,\n      \"attractionCategoryListings\": [\n        {\n          \"description\": null,\n          \"category\": {\n            \"name\": \"관광명소\",\n            \"id\": \"abf9191a-fe28-4b36-8a9f-52a5e6d5666a\",\n            \"icons\": {\n              \"off\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/da7f5342-4474-4a28-812a-ca64d8525729-1487925715.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              },\n              \"on\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/a22144ce-9cbe-46f5-a8a9-615d9e51b62c-1487926449.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              }\n            }\n          },\n          \"picture\": null\n        },\n        {\n          \"description\": null,\n          \"category\": {\n            \"name\": \"테마/체험\",\n            \"id\": \"b7e3aaee-4a0e-40b2-8ffa-99b3ec3cdff5\",\n            \"icons\": {\n              \"off\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/1d2ae3ee-af99-4098-bf08-51b665607fb1-1487926477.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              },\n              \"on\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/c594a106-2b2b-4ba2-8b7e-f96a0d0559c4-1487926499.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              }\n            }\n          },\n          \"picture\": null\n        },\n        {\n          \"description\": null,\n          \"category\": {\n            \"name\": \"쇼핑\",\n            \"id\": \"95556107-682f-4335-bdd6-43175ba34ef4\",\n            \"icons\": {\n              \"off\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/f1f3d698-3b59-45a6-a93c-bf86b36fc137-1487926642.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              },\n              \"on\": {\n                \"sizes\": {\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/9b7facf3-fca9-4e19-ae16-6c89ad04e3b5-1487926656.png\"\n                  },\n                  \"large\": { \"url\": null }\n                }\n              }\n            }\n          },\n          \"picture\": null\n        }\n      ],\n      \"terminals\": [\n        {\n          \"id\": \"c81f2a51-e83a-46fb-8f47-e7c93dc9a218\",\n          \"source\": {\n            \"image\": {\n              \"sourceUrl\": \"http://anniversary.kawada.jp/detail/no1001427.html\",\n              \"sizes\": {\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/e1a543d6-7eb2-459d-9e90-59eaf98319a0.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/e1a543d6-7eb2-459d-9e90-59eaf98319a0.jpg\"\n                },\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/e1a543d6-7eb2-459d-9e90-59eaf98319a0.jpg\"\n                }\n              },\n              \"description\": null,\n              \"id\": \"e1a543d6-7eb2-459d-9e90-59eaf98319a0\",\n              \"title\": null\n            },\n            \"names\": {\n              \"ko\": \"간사이국제공항\",\n              \"en\": \"Kansai International Airport\",\n              \"local\": \"関西国際空港\"\n            },\n            \"regionId\": \"71476976-cf9a-4ae8-a60f-76e6fb26900d\",\n            \"grade\": 1000,\n            \"comment\": \"오사카, 교토, 고베를 여행할 때 주로 이용하는 국제 공항\",\n            \"location\": [135.2303939, 34.4320024],\n            \"id\": \"c81f2a51-e83a-46fb-8f47-e7c93dc9a218\",\n            \"categories\": [{ \"name\": \"관광명소\" }],\n            \"type\": \"attraction\",\n            \"pointGeolocation\": {\n              \"coordinates\": [135.2303939, 34.4320024],\n              \"type\": \"Point\"\n            }\n          },\n          \"type\": \"attraction\"\n        }\n      ],\n      \"names\": { \"ko\": \"오사카\", \"en\": \"Osaka\", \"local\": null },\n      \"style\": {\n        \"backgroundImageUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/background_image/71476976-cf9a-4ae8-a60f-76e6fb26900d-1489569587.png\",\n        \"backgroundVideoUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/background_video/71476976-cf9a-4ae8-a60f-76e6fb26900d-1489569602.mp4\",\n        \"blurredBackgroundImageUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/blurred_background_image/71476976-cf9a-4ae8-a60f-76e6fb26900d-1490177218.png\",\n        \"logoImageUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/logo_image/71476976-cf9a-4ae8-a60f-76e6fb26900d-1522055056.png\"\n      },\n      \"id\": \"71476976-cf9a-4ae8-a60f-76e6fb26900d\",\n      \"hotelTagListings\": [\n        {\n          \"tag\": {\n            \"name\": \"지하철역 5분거리\",\n            \"id\": \"4328808b-b7c9-49f1-81ce-b88e99bf1f03\"\n          }\n        },\n        {\n          \"tag\": {\n            \"name\": \"쇼핑하기 편한\",\n            \"id\": \"df93d846-5a6c-42a5-a067-6301b851e7ca\"\n          }\n        },\n        {\n          \"tag\": {\n            \"name\": \"역과 연결\",\n            \"id\": \"5f00ab39-4e4a-413e-b374-b56c2d9dcf8e\"\n          }\n        },\n        {\n          \"tag\": {\n            \"name\": \"료칸\",\n            \"id\": \"d0693b11-2aac-4c6b-a912-5aba0fa23083\"\n          }\n        },\n        {\n          \"tag\": {\n            \"name\": \"룸 컨디션이 좋은\",\n            \"id\": \"96682de7-527b-4a8d-9cc9-501c23ecd2b1\"\n          }\n        },\n        {\n          \"tag\": {\n            \"name\": \"리무진버스이용\",\n            \"id\": \"efdc27bf-57a1-4af6-8710-11e49d790d2f\"\n          }\n        },\n        {\n          \"tag\": {\n            \"name\": \"가성비 좋은 비즈니스호텔\",\n            \"id\": \"2f3766f6-e80f-4417-87c7-b070de392875\"\n          }\n        }\n      ]\n    }\n  },\n  \"reviewed\": false,\n  \"scraped\": false\n}\n"
  },
  {
    "path": "packages/triple-document/src/mocks/images-frame.sample.json",
    "content": "[\n  {\n    \"id\": \"07f5ed9c-1102-4ec0-b07c-{7b1b098311b2\",\n    \"frame\": \"original\",\n    \"sizes\": {\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/b24a62a0-348d-4c02-bac2-a09027ff2a63.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/b24a62a0-348d-4c02-bac2-a09027ff2a63.jpeg\"\n      },\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/b24a62a0-348d-4c02-bac2-a09027ff2a63.jpeg\"\n      }\n    },\n    \"description\": \"\",\n    \"sourceUrl\": \"https://triple.guide\"\n  }\n]\n"
  },
  {
    "path": "packages/triple-document/src/mocks/images.sample.json",
    "content": "[\n  {\n    \"id\": \"07f5ed9c-1102-4ec0-b07c-{7b1b098311b2\",\n    \"frame\": \"small\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://res.cloudinary.com/triple-entry/image/upload/w_2048,h_2048,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg\"\n      },\n      \"large\": {\n        \"url\": \"https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_limit,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://res.cloudinary.com/triple-entry/image/upload/w_256,h_256,c_fill,f_auto/07f5ed9c-1102-4ec0-b07c-7b1b098311b2.jpg\"\n      }\n    },\n    \"title\": \"TripleDocument 샘플 1\",\n    \"width\": 1024,\n    \"height\": 1544,\n    \"description\": \"\",\n    \"sourceUrl\": \"https://triple.guide\"\n  },\n  {\n    \"id\": \"07f5ed9c-1102-4ec0-b07c-{7b1b098311b2\",\n    \"frame\": \"small\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://res.cloudinary.com/triple-entry/image/upload/w_2048,h_2048,c_fill,f_auto/9163f25c-edb5-4321-82d8-e28f797908d5.jpg\"\n      },\n      \"large\": {\n        \"url\": \"https://res.cloudinary.com/triple-entry/image/upload/w_1024,h_1024,c_fill,f_auto/9163f25c-edb5-4321-82d8-e28f797908d5.jpg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://res.cloudinary.com/triple-entry/image/upload/w_256,h_256,c_fill,f_auto/9163f25c-edb5-4321-82d8-e28f797908d5.jpg\"\n      }\n    },\n    \"title\": \"TripleDocument 샘플 2\",\n    \"width\": 1024,\n    \"height\": 1544,\n    \"description\": \"\",\n    \"sourceUrl\": \"https://triple.guide\"\n  },\n  {\n    \"id\": \"07f5ed9c-1102-4ec0-b07c-{7b1b098311b2\",\n    \"frame\": \"small\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea.jpg\"\n      },\n      \"large\": {\n        \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea.jpg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea.jpg\"\n      }\n    },\n    \"title\": \"TripleDocument 샘플 3\",\n    \"width\": 1024,\n    \"height\": 1544,\n    \"description\": \"\",\n    \"sourceUrl\": \"https://triple.guide\"\n  },\n  {\n    \"id\": \"07f5ed9c-1102-4ec0-b07c-{7b1b098311b2\",\n    \"frame\": \"small\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n      },\n      \"large\": {\n        \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n      }\n    },\n    \"title\": \"TripleDocument 샘플 4\",\n    \"width\": 1024,\n    \"height\": 1544,\n    \"description\": \"\",\n    \"sourceUrl\": \"https://triple.guide\"\n  },\n  {\n    \"id\": \"07f5ed9c-1102-4ec0-b07c-{7b1b098311b2\",\n    \"frame\": \"small\",\n    \"sizes\": {\n      \"full\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/6d795778-6586-4a2e-a841-af3198cb1603.jpeg\"\n      },\n      \"large\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/6d795778-6586-4a2e-a841-af3198cb1603.jpeg\"\n      },\n      \"small_square\": {\n        \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/6d795778-6586-4a2e-a841-af3198cb1603.jpeg\"\n      }\n    },\n    \"title\": \"TripleDocument 샘플 5\",\n    \"width\": 1024,\n    \"height\": 1544,\n    \"description\": \"\",\n    \"sourceUrl\": \"https://triple.guide\"\n  }\n]\n"
  },
  {
    "path": "packages/triple-document/src/mocks/pois.sample.json",
    "content": "[\n  {\n    \"id\": \"f72d2f50-2efb-4469-a903-47ad6b0c0740\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://www.our-thailand-vacations.com/reclining-buddha-bangkok.html\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f9ff3ed6-990e-4270-a810-d9409befa31f.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f9ff3ed6-990e-4270-a810-d9409befa31f.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f9ff3ed6-990e-4270-a810-d9409befa31f.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"f9ff3ed6-990e-4270-a810-d9409befa31f\",\n        \"title\": null\n      },\n      \"scrapsCount\": 4,\n      \"areas\": [],\n      \"reviewsCount\": 6,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.493298, 13.746523],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 3.5,\n      \"names\": {\n        \"ko\": \"노보텔 방콕 수완나폼 에어포트 호텔 - HOTEL TEST \",\n        \"en\": \"Temple of the Reclining Buddha (Wat Pho)\",\n        \"local\": \"วัดโพธิ์\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"comment\": \"아유타야 양식으로 지어진 방콕 최대 규모의 유서깊은 사원\",\n      \"location\": [100.493298, 13.746523],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"f72d2f50-2efb-4469-a903-47ad6b0c0740\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"0b436475-eead-4a82-8486-5b1c659c90ba\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": null,\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d9705590-7480-4ed5-adee-34c01463d1d2.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d9705590-7480-4ed5-adee-34c01463d1d2.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d9705590-7480-4ed5-adee-34c01463d1d2.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"d9705590-7480-4ed5-adee-34c01463d1d2\",\n        \"title\": null\n      },\n      \"scrapsCount\": 19,\n      \"areas\": [\n        {\n          \"name\": \"셩완, 센트럴, 빅토리아피크\"\n        }\n      ],\n      \"reviewsCount\": 12,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.145457, 22.275908],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 4.5,\n      \"names\": {\n        \"ko\": \"빅토리아 피크\",\n        \"en\": \"Victoria Peak (The Peak)\",\n        \"local\": \"太平山山頂\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"파노라마 뷰로 즐기는 홍콩 야경의 하이라이트\",\n      \"location\": [114.145457, 22.275908],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"0b436475-eead-4a82-8486-5b1c659c90ba\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"3d32908d-72e3-4db1-8376-dc707c3d5112\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://kindcandy.blog.me/220863061922\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/1a89e821-c22e-4ba5-ae02-b3e2059f1b67.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/1a89e821-c22e-4ba5-ae02-b3e2059f1b67.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/1a89e821-c22e-4ba5-ae02-b3e2059f1b67.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"1a89e821-c22e-4ba5-ae02-b3e2059f1b67\",\n        \"title\": null\n      },\n      \"names\": {\n        \"ko\": \"카오산 로드\",\n        \"en\": \"Khao San Road\",\n        \"local\": \"ถนนข้าวสาร\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"areas\": [\n        {\n          \"name\": \"올드시티\"\n        }\n      ],\n      \"comment\": \"방콕  배낭 여행의 중심지\",\n      \"location\": [100.497176, 13.758948],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"3d32908d-72e3-4db1-8376-dc707c3d5112\",\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.497176, 13.758948],\n        \"type\": \"Point\"\n      }\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"6ca7e0ad-acec-4d32-9107-b77f2516681e\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://www.laservision.com.au/portfolio/symphony-of-lights/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/85eb8700-d8a4-4a0b-a7ef-a78ec5104269.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/85eb8700-d8a4-4a0b-a7ef-a78ec5104269.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/85eb8700-d8a4-4a0b-a7ef-a78ec5104269.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"85eb8700-d8a4-4a0b-a7ef-a78ec5104269\",\n        \"title\": null\n      },\n      \"scrapsCount\": 9,\n      \"areas\": [\n        {\n          \"name\": \"침사추이\"\n        }\n      ],\n      \"reviewsCount\": 6,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.174771, 22.293186],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": \"4.333333333333333\",\n      \"names\": {\n        \"ko\": \"심포니 오브 라이트\",\n        \"en\": \"Symphony of Lights\",\n        \"local\": \"幻彩詠香江燈光錶演\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"매일 밤 8시에 펼쳐지는 환상의 레이저 쇼\",\n      \"location\": [114.174771, 22.293186],\n      \"categories\": [\n        {\n          \"name\": \"테마/체험\"\n        }\n      ],\n      \"id\": \"6ca7e0ad-acec-4d32-9107-b77f2516681e\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"fe700d6a-2066-4c31-ab64-75da86acb756\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://www.lifebeyondtourism.org/puntointeresse/2285/Wat-Arun-\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/65fcb462-bfdf-447b-a815-21d4f34bff5d.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/65fcb462-bfdf-447b-a815-21d4f34bff5d.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/65fcb462-bfdf-447b-a815-21d4f34bff5d.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"65fcb462-bfdf-447b-a815-21d4f34bff5d\",\n        \"title\": null\n      },\n      \"names\": {\n        \"ko\": \"왓 아룬\",\n        \"en\": \"Temple of Dawn (Wat Arun)\",\n        \"local\": \"วัดอรุณ\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"areas\": [\n        {\n          \"name\": \"올드시티\"\n        }\n      ],\n      \"comment\": \"방콕의 새벽을 밝히는 사원\",\n      \"location\": [100.489006, 13.743707],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"fe700d6a-2066-4c31-ab64-75da86acb756\",\n      \"hasTnaProducts\": true,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.489006, 13.743707],\n        \"type\": \"Point\"\n      }\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"9a21b2ac-cc5f-48c0-bd62-58f88b577a14\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://blog.naver.com/lioooolioooo/220812572460 \",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/56b8ace0-a8ef-46c8-8cf8-3ea5f80f7903.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/56b8ace0-a8ef-46c8-8cf8-3ea5f80f7903.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/56b8ace0-a8ef-46c8-8cf8-3ea5f80f7903.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"56b8ace0-a8ef-46c8-8cf8-3ea5f80f7903\",\n        \"title\": null\n      },\n      \"scrapsCount\": 7,\n      \"areas\": [\n        {\n          \"name\": \"셩완, 센트럴, 빅토리아피크\"\n        }\n      ],\n      \"reviewsCount\": 7,\n      \"hasTnaProducts\": true,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.152844, 22.281572],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 4.571428571428571,\n      \"names\": {\n        \"ko\": \"소호\",\n        \"en\": \"Soho Test\",\n        \"local\": \"蘇豪區\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"홍콩 제일의 트렌디한 거리에서 즐기는 낮과 밤\",\n      \"location\": [114.152844, 22.281572],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"9a21b2ac-cc5f-48c0-bd62-58f88b577a14\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"cd9b3a6e-7034-4d8d-9005-dfb1b1accbcd\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://blog.naver.com/yeontech/220815008594\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/54e4fbf9-239f-4294-bfba-cb72003397a2.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/54e4fbf9-239f-4294-bfba-cb72003397a2.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/54e4fbf9-239f-4294-bfba-cb72003397a2.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"54e4fbf9-239f-4294-bfba-cb72003397a2\",\n        \"title\": null\n      },\n      \"scrapsCount\": 2,\n      \"areas\": [\n        {\n          \"name\": \"올드시티\"\n        }\n      ],\n      \"reviewsCount\": 5,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.491253, 13.749977],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 3.4,\n      \"names\": {\n        \"ko\": \"왕궁\",\n        \"en\": \"The Grand Palace\",\n        \"local\": \"พระบรมมหาราชวัง\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"comment\": \"230년 짜끄리 왕조과 함께 해온 방콕의 대표 명소\",\n      \"location\": [100.491253, 13.749977],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"cd9b3a6e-7034-4d8d-9005-dfb1b1accbcd\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"c95c117f-f1ee-4261-badd-2ea8e32e088f\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://blog.naver.com/qkrtmdqja/220762393209\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/0c784694-59a4-4b3b-9a01-e0cbd3090a4e.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/0c784694-59a4-4b3b-9a01-e0cbd3090a4e.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/0c784694-59a4-4b3b-9a01-e0cbd3090a4e.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"0c784694-59a4-4b3b-9a01-e0cbd3090a4e\",\n        \"title\": null\n      },\n      \"names\": {\n        \"ko\": \"짜뚜짝 주말시장\",\n        \"en\": \"Chatuchak Weekend Market\",\n        \"local\": null\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"comment\": \"보는 재미, 먹는 재미로 활기찬 주말시장\",\n      \"location\": [100.550899, 13.799974],\n      \"categories\": [\n        {\n          \"name\": \"쇼핑\"\n        }\n      ],\n      \"id\": \"c95c117f-f1ee-4261-badd-2ea8e32e088f\",\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.550899, 13.799974],\n        \"type\": \"Point\"\n      }\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"ffec5c01-ad40-4817-8732-5e11e1216f34\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"https://utrip.com/plan-travel/china/hong-kong/hong-kong-lan-kwai-fong/\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/cb3b1eb2-ca99-418d-a2ad-6cf19fdee88b.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/cb3b1eb2-ca99-418d-a2ad-6cf19fdee88b.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/cb3b1eb2-ca99-418d-a2ad-6cf19fdee88b.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"cb3b1eb2-ca99-418d-a2ad-6cf19fdee88b\",\n        \"title\": null\n      },\n      \"scrapsCount\": 3,\n      \"areas\": [\n        {\n          \"name\": \"셩완, 센트럴, 빅토리아피크\"\n        }\n      ],\n      \"reviewsCount\": 6,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.15567, 22.280837],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 4.5,\n      \"names\": {\n        \"ko\": \"란콰이퐁\",\n        \"en\": \"Lan Kwai Fong\",\n        \"local\": \"蘭桂坊\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"핫한 나이트 라이프로 유명한 거리\",\n      \"location\": [114.15567, 22.280837],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"ffec5c01-ad40-4817-8732-5e11e1216f34\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"a8f8d4e5-c4b0-4d00-829a-db4378a51e1d\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://blog.naver.com/superkbh/220957368617\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/02e405f4-e2a9-40cc-934b-0751e3e640c5.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/02e405f4-e2a9-40cc-934b-0751e3e640c5.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/02e405f4-e2a9-40cc-934b-0751e3e640c5.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"02e405f4-e2a9-40cc-934b-0751e3e640c5\",\n        \"title\": null\n      },\n      \"names\": {\n        \"ko\": \"씨암 스퀘어\",\n        \"en\": \"Siam Square\",\n        \"local\": \"สยามสแควร์\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"areas\": [\n        {\n          \"name\": \"씨암, 칫롬\"\n        }\n      ],\n      \"comment\": \"방콕 유행을 선도하는 핫플레이스\",\n      \"location\": [100.53433877546308, 13.745084118890189],\n      \"categories\": [\n        {\n          \"name\": \"쇼핑\"\n        }\n      ],\n      \"id\": \"a8f8d4e5-c4b0-4d00-829a-db4378a51e1d\",\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.53433877546308, 13.745084118890189],\n        \"type\": \"Point\"\n      }\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"07f9a261-d3c3-43a6-b014-f057b59ec879\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://www.camemberu.com/2013/06/hong-kong-disneyland-park-attractions.html\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/95cb40a3-78c3-430b-8d04-894ecbbb2a89.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/95cb40a3-78c3-430b-8d04-894ecbbb2a89.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/95cb40a3-78c3-430b-8d04-894ecbbb2a89.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"95cb40a3-78c3-430b-8d04-894ecbbb2a89\",\n        \"title\": null\n      },\n      \"scrapsCount\": 7,\n      \"areas\": [\n        {\n          \"name\": \"란타우섬\"\n        }\n      ],\n      \"reviewsCount\": 7,\n      \"hasTnaProducts\": true,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.041281, 22.312962],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 4.571428571428571,\n      \"names\": {\n        \"ko\": \"홍콩 디즈니랜드\",\n        \"en\": \"Hong Kong Disneyland\",\n        \"local\": \"香港迪士尼樂園\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"홍콩 여행에서 빼 놓을 수 없는 테마파크\",\n      \"location\": [114.041281, 22.312962],\n      \"categories\": [\n        {\n          \"name\": \"테마/체험\"\n        }\n      ],\n      \"id\": \"07f9a261-d3c3-43a6-b014-f057b59ec879\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"e43610f5-2345-4a48-a905-1eb5f66892ef\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://vietravelasia.com/en/news/top-5-things-to-do-in-bangkok-292.html\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f7ba0e55-5c7b-479f-a44b-acb3396311cc.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f7ba0e55-5c7b-479f-a44b-acb3396311cc.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f7ba0e55-5c7b-479f-a44b-acb3396311cc.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"f7ba0e55-5c7b-479f-a44b-acb3396311cc\",\n        \"title\": null\n      },\n      \"names\": {\n        \"ko\": \"짐 톰슨 하우스\",\n        \"en\": \"The Jim Thompson House\",\n        \"local\": \"บ้านจิมทอมป์สัน\"\n      },\n      \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"grade\": 10,\n      \"areas\": [\n        {\n          \"name\": \"씨암, 칫롬\"\n        }\n      ],\n      \"comment\": \"태국 최고의 실크 브랜드를 만든 짐 톰슨의 집\",\n      \"location\": [100.528324, 13.749251],\n      \"categories\": [\n        {\n          \"name\": \"관광명소\"\n        }\n      ],\n      \"id\": \"e43610f5-2345-4a48-a905-1eb5f66892ef\",\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [100.528324, 13.749251],\n        \"type\": \"Point\"\n      }\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  },\n  {\n    \"id\": \"d6269a4c-c7cd-4786-89d3-756e9ae76d7c\",\n    \"source\": {\n      \"image\": {\n        \"sourceUrl\": \"http://cafe.naver.com/hkmyhome/19590\",\n        \"sizes\": {\n          \"large\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/635446f3-235f-4e06-b504-b8db1244918a.jpg\"\n          },\n          \"small_square\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/635446f3-235f-4e06-b504-b8db1244918a.jpg\"\n          },\n          \"full\": {\n            \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/635446f3-235f-4e06-b504-b8db1244918a.jpg\"\n          }\n        },\n        \"description\": null,\n        \"id\": \"635446f3-235f-4e06-b504-b8db1244918a\",\n        \"title\": null\n      },\n      \"scrapsCount\": 4,\n      \"areas\": [\n        {\n          \"name\": \"셩완, 센트럴, 빅토리아피크\"\n        }\n      ],\n      \"reviewsCount\": 4,\n      \"hasTnaProducts\": false,\n      \"type\": \"attraction\",\n      \"pointGeolocation\": {\n        \"coordinates\": [114.158127, 22.285878],\n        \"type\": \"Point\"\n      },\n      \"reviewsRating\": 4.0,\n      \"names\": {\n        \"ko\": \"IFC 몰\",\n        \"en\": \"IFC mall\",\n        \"local\": \"國際金融中心商場\"\n      },\n      \"regionId\": \"84685a5a-a0ee-47b5-b84c-4d76dffad76d\",\n      \"grade\": 10,\n      \"comment\": \"홍콩의 중심에서 홍콩의 모든것을 즐길 수 있는 쇼핑몰\",\n      \"location\": [114.158127, 22.285878],\n      \"categories\": [\n        {\n          \"name\": \"쇼핑\"\n        }\n      ],\n      \"id\": \"d6269a4c-c7cd-4786-89d3-756e9ae76d7c\"\n    },\n    \"type\": \"attraction\",\n    \"reviewed\": false,\n    \"scraped\": false\n  }\n]\n"
  },
  {
    "path": "packages/triple-document/src/mocks/slots.sample.json",
    "content": "{\n  \"id\": 1561,\n  \"title\": \"[경기 용인] 에버랜드+플라이스테이션 패키지★\",\n  \"products\": [\n    {\n      \"id\": \"cb144134-401d-4450-aa1f-346686cb0f7b\",\n      \"title\": \"[경기 용인] 표시가보다 판매가가 큰 경우 할인율과 표시가가 미노출\",\n      \"tags\": [],\n      \"salePrice\": 116000,\n      \"heroImage\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/f90fe57e-d2eb-45d2-b204-a652f093e2bf.jpeg\",\n      \"supplierType\": \"YANOLJA\",\n      \"basePrice\": 60900,\n      \"reviewRating\": 0,\n      \"reviewsCount\": 0,\n      \"domesticAreas\": [\n        {\n          \"id\": \"4146100000\",\n          \"name\": \"경기도 용인시 처인구\",\n          \"displayName\": \"경기 용인시\",\n          \"representative\": true\n        },\n        {\n          \"id\": \"4146125000\",\n          \"name\": \"경기도 용인시 처인구 포곡읍\",\n          \"displayName\": \"용인시 처인구 포곡읍\",\n          \"representative\": false\n        },\n        {\n          \"id\": \"4146000000\",\n          \"name\": \"경기도 용인시\",\n          \"displayName\": \"경기 용인시\",\n          \"representative\": true\n        },\n        {\n          \"id\": \"4100000000\",\n          \"name\": \"경기도\",\n          \"displayName\": \"경기\",\n          \"representative\": false\n        }\n      ],\n      \"productId\": \"cb144134-401d-4450-aa1f-346686cb0f7b\",\n      \"eventTags\": [],\n      \"orderPrice\": 60900,\n      \"bestSelfPackageDiscountSpec\": {\n        \"tripId\": 241624,\n        \"level\": 1,\n        \"schedule\": {\n          \"from\": \"2022-04-17\",\n          \"to\": \"2022-04-19\"\n        },\n        \"destinations\": [\n          {\n            \"type\": \"triple-region\",\n            \"id\": \"759174cc-0814-4400-a420-5668a0517edd\"\n          }\n        ],\n        \"discountPolicy\": {\n          \"discountRate\": 2.0,\n          \"maxDiscountAmount\": 100000\n        },\n        \"token\": \"token\"\n      }\n    },\n    {\n      \"id\": \"cb144134-401d-4450-aa1f-346686cb0f7b\",\n      \"title\": \"[경기 용인] 표시가보다 판매가가 작은 경우 할인율과 표시가 노출\",\n      \"tags\": [],\n      \"salePrice\": 60900,\n      \"heroImage\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/f90fe57e-d2eb-45d2-b204-a652f093e2bf.jpeg\",\n      \"supplierType\": \"YANOLJA\",\n      \"basePrice\": 116000,\n      \"reviewRating\": 4,\n      \"reviewsCount\": 3,\n      \"domesticAreas\": [\n        {\n          \"id\": \"4146100000\",\n          \"name\": \"경기도 용인시 처인구\",\n          \"displayName\": \"경기 용인시\",\n          \"representative\": true\n        },\n        {\n          \"id\": \"4146125000\",\n          \"name\": \"경기도 용인시 처인구 포곡읍\",\n          \"displayName\": \"용인시 처인구 포곡읍\",\n          \"representative\": false\n        },\n        {\n          \"id\": \"4146000000\",\n          \"name\": \"경기도 용인시\",\n          \"displayName\": \"경기 용인시\",\n          \"representative\": true\n        },\n        {\n          \"id\": \"4100000000\",\n          \"name\": \"경기도\",\n          \"displayName\": \"경기\",\n          \"representative\": false\n        }\n      ],\n      \"productId\": \"cb144134-401d-4450-aa1f-346686cb0f7b\",\n      \"eventTags\": [],\n      \"orderPrice\": 60900\n    },\n    {\n      \"id\": \"cb144134-401d-4450-aa1f-346686cb0f7b\",\n      \"title\": \"[경기 용인] 0원일 때 일시품절 텍스트로 노출\",\n      \"tags\": [],\n      \"salePrice\": 0,\n      \"heroImage\": \"https://media.triple.guide/triple-dev/c_limit,f_auto,h_2048,w_2048/f90fe57e-d2eb-45d2-b204-a652f093e2bf.jpeg\",\n      \"supplierType\": \"YANOLJA\",\n      \"basePrice\": 0,\n      \"reviewRating\": 0,\n      \"reviewsCount\": 0,\n      \"domesticAreas\": [\n        {\n          \"id\": \"4146100000\",\n          \"name\": \"경기도 용인시 처인구\",\n          \"displayName\": \"경기 용인시\",\n          \"representative\": true\n        },\n        {\n          \"id\": \"4146125000\",\n          \"name\": \"경기도 용인시 처인구 포곡읍\",\n          \"displayName\": \"용인시 처인구 포곡읍\",\n          \"representative\": false\n        },\n        {\n          \"id\": \"4146000000\",\n          \"name\": \"경기도 용인시\",\n          \"displayName\": \"경기 용인시\",\n          \"representative\": true\n        },\n        {\n          \"id\": \"4100000000\",\n          \"name\": \"경기도\",\n          \"displayName\": \"경기\",\n          \"representative\": false\n        }\n      ],\n      \"productId\": \"cb144134-401d-4450-aa1f-346686cb0f7b\",\n      \"eventTags\": [],\n      \"orderPrice\": 60900\n    }\n  ]\n}\n"
  },
  {
    "path": "packages/triple-document/src/mocks/triple-document.embedded.json",
    "content": "{\n  \"entries\": [\n    [\n      {\n        \"type\": \"images\",\n        \"value\": {\n          \"images\": [\n            {\n              \"id\": \"578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea\",\n              \"frame\": \"large\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea.jpg\"\n                }\n              },\n              \"title\": \"\",\n              \"width\": 960,\n              \"height\": 640,\n              \"sourceUrl\": \"http://www.naver.com/\",\n              \"description\": \"\"\n            }\n          ]\n        }\n      },\n      {\n        \"type\": \"heading3\",\n        \"value\": {\n          \"text\": \"제목 한줄로 보이게 한 줄 맞나 맞나?\"\n        }\n      },\n      {\n        \"type\": \"text\",\n        \"value\": {\n          \"text\": \"짧은 설명이 들어가면 좋겠습니다. 모든 임베딩에 균일하게 최대 3줄 정도면 적당하겠군요.\"\n        }\n      },\n      {\n        \"type\": \"links\",\n        \"value\": {\n          \"links\": [\n            {\n              \"href\": \"dev-soto:///outlink?url=https%3A%2F%2Ftriple.guide%2Fentry%2Fposts%2F04ba5bd9-1a75-4ddb-bb3c-516657e9ae0c%3F_triple_no_navbar%26_triple_swipe_to_close\",\n              \"label\": \"방콕 3박 4일 가이드\"\n            },\n            {\n              \"href\": \"dev-soto:///regions/23c5965b-01ad-486b-a694-a2ced15f245c/attractions/c3d2ef37-f0ef-42b4-a210-039dc08143bf\",\n              \"label\": \"도쿄 타워\"\n            }\n          ],\n          \"display\": \"default\"\n        }\n      }\n    ],\n    [\n      {\n        \"type\": \"images\",\n        \"value\": {\n          \"images\": [\n            {\n              \"id\": \"bfb5dd63-696c-4f1c-b98c-8e7f86fe069f\",\n              \"frame\": \"large\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n                }\n              },\n              \"title\": \"\",\n              \"width\": 4287,\n              \"height\": 2706,\n              \"description\": \"\"\n            }\n          ]\n        }\n      },\n      {\n        \"type\": \"heading3\",\n        \"value\": {\n          \"text\": \"제목\"\n        }\n      },\n      {\n        \"type\": \"text\",\n        \"value\": {\n          \"text\": \"짧은 설명이 들어가면 좋겠습니 다. 모든 임베딩에 균일하게 최대 3줄 정도면 적당하겠군요.\"\n        }\n      }\n    ],\n    [\n      {\n        \"type\": \"heading3\",\n        \"value\": {\n          \"text\": \"제목\"\n        }\n      },\n      {\n        \"type\": \"text\",\n        \"value\": {\n          \"text\": \"짧은 설명이 들어가면 좋겠습니 다. 모든 임베딩에 균일하게 최대 3줄 정도면 적당하겠군요.\"\n        }\n      },\n      {\n        \"type\": \"images\",\n        \"value\": {\n          \"images\": [\n            {\n              \"id\": \"bfb5dd63-696c-4f1c-b98c-8e7f86fe069f\",\n              \"frame\": \"large\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n                }\n              },\n              \"title\": \"\",\n              \"width\": 4287,\n              \"height\": 2706,\n              \"description\": \"\"\n            }\n          ]\n        }\n      }\n    ]\n  ]\n}\n"
  },
  {
    "path": "packages/triple-document/src/mocks/triple-document.itinerary.json",
    "content": "{\n  \"article\": {\n    \"id\": \"abc8b2a8-7bf2-4632-a8fd-e00b84ff7f1e\",\n    \"type\": \"post\",\n    \"texts\": [\n      \"[미정] 50일간의 베트남 일주\",\n      \"미정\",\n      \"미\",\n      \"50일간의\",\n      \"50일간\",\n      \"50일\",\n      \"50\",\n      \"5\",\n      \"베트남\",\n      \"베트\",\n      \"베\",\n      \"일주\",\n      \"일\",\n      \"실비아\"\n    ],\n    \"updatedAt\": \"2021-01-12T06:27:50.927Z\",\n    \"createdAt\": \"2020-04-20T08:12:19.139Z\",\n    \"source\": {\n      \"body\": [\n        {\n          \"type\": \"text\",\n          \"value\": {\n            \"text\": \"한 나라를 50일 동안 여행한다면\\n무엇을 볼 수 있을까?\\n\\n대학생 시절,\\n부지런히 아르바이트한 돈을 모아 \\n과감히 떠났던 베트남.\\n\\n여행자의 마음에서 \\n한 걸음 더 나아가 바라본,\\n베트남의 일상들을 소개한다.\"\n          }\n        },\n        {\n          \"type\": \"itinerary\",\n          \"value\": {\n            \"geotag\": {\n              \"type\": \"triple-region\",\n              \"id\": \"be72875a-8b3f-41ad-98d4-42b2e0939bea\"\n            },\n            \"itinerary\": {\n              \"day\": 1,\n              \"items\": [\n                {\n                  \"memo\": \"\",\n                  \"schedule\": \"11:00\",\n                  \"transportation\": [\n                    {\n                      \"type\": \"transporation\",\n                      \"value\": {\n                        \"transportation\": \"walk\",\n                        \"duration\": \"1\\\"\"\n                      }\n                    }\n                  ],\n                  \"poi\": {\n                    \"id\": \"0bd36137-711a-49ad-af68-95c4219d7ac4\",\n                    \"type\": \"attraction\",\n                    \"categories\": [\n                      {\n                        \"id\": \"abf9191a-fe28-4b36-8a9f-52a5e6d5666a\",\n                        \"name\": \"관광명소\"\n                      }\n                    ],\n                    \"region\": {\n                      \"source\": {\n                        \"names\": {\n                          \"ko\": \"강릉·속초\",\n                          \"en\": \"Gangneung·Sokcho\",\n                          \"local\": null\n                        }\n                      }\n                    },\n                    \"source\": {\n                      \"names\": {\n                        \"ko\": \"삼척 해수욕장\"\n                      },\n                      \"areas\": [],\n                      \"regionId\": \"be72875a-8b3f-41ad-98d4-42b2e0939bea\",\n                      \"geolocation\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [129.1675186243286, 37.46888269903159]\n                      },\n                      \"vicinity\": \"강원 삼척시\"\n                    }\n                  }\n                },\n                {\n                  \"memo\": \"\",\n                  \"schedule\": \"12:00\",\n                  \"transportation\": [\n                    {\n                      \"type\": \"transporation\",\n                      \"value\": {\n                        \"transportation\": \"car\",\n                        \"duration\": \"45\\\"\"\n                      }\n                    }\n                  ],\n                  \"poi\": {\n                    \"id\": \"abc5d7a4-aa96-4706-8d68-c2e2f48f9687\",\n                    \"type\": \"restaurant\",\n                    \"categories\": [\n                      {\n                        \"id\": \"280a5533-c9d0-48e8-8cad-b8c651a53d86\",\n                        \"name\": \"음식점\"\n                      }\n                    ],\n                    \"region\": {\n                      \"source\": {\n                        \"names\": {\n                          \"ko\": \"강릉·속초\",\n                          \"en\": \"Gangneung·Sokcho\",\n                          \"local\": null\n                        }\n                      }\n                    },\n                    \"source\": {\n                      \"names\": {\n                        \"ko\": \"일미어담\"\n                      },\n                      \"areas\": [],\n                      \"regionId\": \"be72875a-8b3f-41ad-98d4-42b2e0939bea\",\n                      \"geolocation\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [129.1705044, 37.4666384]\n                      },\n                      \"vicinity\": \"강원 삼척시\"\n                    }\n                  }\n                },\n                {\n                  \"memo\": \"삼척 환선굴 메모\",\n                  \"schedule\": \"13:45\",\n                  \"transportation\": [\n                    {\n                      \"type\": \"transporation\",\n                      \"value\": {\n                        \"transportation\": \"car\",\n                        \"duration\": \"45\\\"\"\n                      }\n                    }\n                  ],\n                  \"poi\": {\n                    \"id\": \"f79eb963-2151-47ed-97ff-6374fd34ea71\",\n                    \"type\": \"attraction\",\n                    \"categories\": [\n                      {\n                        \"id\": \"abf9191a-fe28-4b36-8a9f-52a5e6d5666a\",\n                        \"name\": \"관광명소\"\n                      }\n                    ],\n                    \"region\": {\n                      \"source\": {\n                        \"names\": {\n                          \"ko\": \"강릉·속초\",\n                          \"en\": \"Gangneung·Sokcho\",\n                          \"local\": null\n                        }\n                      }\n                    },\n                    \"source\": {\n                      \"names\": {\n                        \"ko\": \"환선굴\"\n                      },\n                      \"areas\": [],\n                      \"regionId\": \"be72875a-8b3f-41ad-98d4-42b2e0939bea\",\n                      \"geolocation\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [129.01968714109194, 37.32534004078134]\n                      },\n                      \"vicinity\": \"강원 삼척시\",\n\n                      \"comment\": \"모노레일을 타고 다채로운 풍경을 볼 수 있는 큰 규모의 동굴\"\n                    }\n                  }\n                },\n                {\n                  \"memo\": \"삼척 포토 스팟\",\n                  \"schedule\": \"16:30\",\n                  \"transportation\": [\n                    {\n                      \"type\": \"transporation\",\n                      \"value\": {\n                        \"transportation\": \"car\",\n                        \"duration\": \"15\\\"\"\n                      }\n                    }\n                  ],\n                  \"poi\": {\n                    \"id\": \"e732743d-42df-4f6f-b142-ecfaba3d60b8\",\n                    \"type\": \"attraction\",\n                    \"categories\": [\n                      {\n                        \"id\": \"abf9191a-fe28-4b36-8a9f-52a5e6d5666a\",\n                        \"name\": \"관광명소\"\n                      }\n                    ],\n                    \"region\": {\n                      \"source\": {\n                        \"names\": {\n                          \"ko\": \"강릉·속초\",\n                          \"en\": \"Gangneung·Sokcho\",\n                          \"local\": null\n                        }\n                      }\n                    },\n                    \"source\": {\n                      \"names\": {\n                        \"ko\": \"미인 폭포\"\n                      },\n                      \"areas\": [],\n                      \"regionId\": \"be72875a-8b3f-41ad-98d4-42b2e0939bea\",\n                      \"geolocation\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [129.04686755791934, 37.17824355761976]\n                      },\n                      \"vicinity\": \"강원 삼척시\"\n                    }\n                  }\n                },\n                {\n                  \"memo\": \"물닭갈비 맛집\",\n                  \"schedule\": \"18:00\",\n                  \"transportation\": [],\n                  \"poi\": {\n                    \"id\": \"b64815ca-dcae-44a1-98f1-3dc5c0f3d46e\",\n                    \"type\": \"restaurant\",\n                    \"categories\": [\n                      {\n                        \"id\": \"280a5533-c9d0-48e8-8cad-b8c651a53d86\",\n                        \"name\": \"음식점\"\n                      }\n                    ],\n                    \"region\": {\n                      \"source\": {\n                        \"names\": {\n                          \"ko\": \"강릉·속초\",\n                          \"en\": \"Gangneung·Sokcho\",\n                          \"local\": null\n                        }\n                      }\n                    },\n                    \"source\": {\n                      \"names\": {\n                        \"ko\": \"텃밭에 노는 닭\"\n                      },\n                      \"areas\": [],\n                      \"regionId\": \"be72875a-8b3f-41ad-98d4-42b2e0939bea\",\n                      \"geolocation\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [129.04568579999997, 37.238738819453296]\n                      },\n                      \"vicinity\": \"강원 삼척시\"\n                    }\n                  }\n                }\n              ]\n            }\n          }\n        },\n        {\n          \"type\": \"hr1\"\n        },\n        {\n          \"type\": \"heading1\",\n          \"value\": {\n            \"text\": \"오토바이\",\n            \"headline\": \"삶의 현장이자 일터였던\",\n            \"emphasize\": false,\n            \"href\": \"otobai\"\n          }\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"big\",\n                \"cloudinaryId\": \"be7cc357-4da7-4715-a4c6-1b22c6c7a83b\",\n                \"id\": \"be7cc357-4da7-4715-a4c6-1b22c6c7a83b\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/be7cc357-4da7-4715-a4c6-1b22c6c7a83b.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be7cc357-4da7-4715-a4c6-1b22c6c7a83b.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/be7cc357-4da7-4715-a4c6-1b22c6c7a83b.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"'베트남'하면 가장 먼저 떠오르는 풍경.\\n끝이 보이지 않을 만큼 늘어선 오토바이 행렬이다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"large\",\n                \"cloudinaryId\": \"775b31f3-b7dc-47d1-957f-c705fd201ff9\",\n                \"id\": \"775b31f3-b7dc-47d1-957f-c705fd201ff9\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/775b31f3-b7dc-47d1-957f-c705fd201ff9.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/775b31f3-b7dc-47d1-957f-c705fd201ff9.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/775b31f3-b7dc-47d1-957f-c705fd201ff9.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 851,\n                \"height\": 563,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"처음엔 사람보다 많은 오토바이가 무섭기도 했지만, \\n가만히 보면 볼수록 대단했다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"1c2b36a3-c17a-49bf-b4c3-73b9b14ac9bd\",\n                \"id\": \"1c2b36a3-c17a-49bf-b4c3-73b9b14ac9bd\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1c2b36a3-c17a-49bf-b4c3-73b9b14ac9bd.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1c2b36a3-c17a-49bf-b4c3-73b9b14ac9bd.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1c2b36a3-c17a-49bf-b4c3-73b9b14ac9bd.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"베트남 사람들에게 오토바이는 \\n단순한 이동 수단을 넘어,\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"large\",\n                \"cloudinaryId\": \"53b47f0b-666a-4475-ab01-ce6bd9aac998\",\n                \"id\": \"53b47f0b-666a-4475-ab01-ce6bd9aac998\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/53b47f0b-666a-4475-ab01-ce6bd9aac998.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/53b47f0b-666a-4475-ab01-ce6bd9aac998.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/53b47f0b-666a-4475-ab01-ce6bd9aac998.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"사람을 태우거나 다양한 물건을 운반하는,\\n삶의 현장이자 일터이기도 했으니까.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"883c0037-9f98-4dac-b8a8-faabb233f905\",\n                \"id\": \"883c0037-9f98-4dac-b8a8-faabb233f905\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/883c0037-9f98-4dac-b8a8-faabb233f905.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/883c0037-9f98-4dac-b8a8-faabb233f905.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/883c0037-9f98-4dac-b8a8-faabb233f905.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"어딜 가나 보이던 오토바이 용품들.\\n오토바이는 이미 베트남의 문화이자, 삶의 일부였다.\"\n          }\n        },\n        {\n          \"type\": \"hr1\"\n        },\n        {\n          \"type\": \"heading1\",\n          \"value\": {\n            \"text\": \"전통 시장\",\n            \"headline\": \"먹고 사는 이야기\",\n            \"emphasize\": false,\n            \"href\": \"jeontong-sijang\"\n          }\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"large\",\n                \"cloudinaryId\": \"f017ec93-4398-4372-8360-48ce1de1d16a\",\n                \"id\": \"f017ec93-4398-4372-8360-48ce1de1d16a\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/f017ec93-4398-4372-8360-48ce1de1d16a.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/f017ec93-4398-4372-8360-48ce1de1d16a.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/f017ec93-4398-4372-8360-48ce1de1d16a.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"여행하면서 가장 많이 들른 장소를 꼽으라면\\n아무래도 전통 시장이 될 것 같다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"large\",\n                \"cloudinaryId\": \"537a96b3-b40c-4ccc-864b-b87aa68f6d87\",\n                \"id\": \"537a96b3-b40c-4ccc-864b-b87aa68f6d87\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/537a96b3-b40c-4ccc-864b-b87aa68f6d87.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/537a96b3-b40c-4ccc-864b-b87aa68f6d87.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/537a96b3-b40c-4ccc-864b-b87aa68f6d87.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"시장에는 태어나서 처음 들어보는 이름을 가진, \\n생긴 것 또한 신기한 과일이 가득했고\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"b8c9f8c3-6867-4d02-9384-bfd8a72087f3\",\n                \"id\": \"b8c9f8c3-6867-4d02-9384-bfd8a72087f3\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/b8c9f8c3-6867-4d02-9384-bfd8a72087f3.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/b8c9f8c3-6867-4d02-9384-bfd8a72087f3.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/b8c9f8c3-6867-4d02-9384-bfd8a72087f3.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"원한다면 바로 그 자리에서 먹을 수 있도록\\n테이블까지 준비되어 있었다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"1c9e6aa3-6389-4657-a69b-7b684eb6dfff\",\n                \"id\": \"1c9e6aa3-6389-4657-a69b-7b684eb6dfff\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/1c9e6aa3-6389-4657-a69b-7b684eb6dfff.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/1c9e6aa3-6389-4657-a69b-7b684eb6dfff.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/1c9e6aa3-6389-4657-a69b-7b684eb6dfff.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"어떤 시장은 한국의 시장과\\n크게 다를 것이 없어 보이기도 했지만\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"a042a63f-89a5-4a5c-9ed4-44d5a59710d3\",\n                \"id\": \"a042a63f-89a5-4a5c-9ed4-44d5a59710d3\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/a042a63f-89a5-4a5c-9ed4-44d5a59710d3.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/a042a63f-89a5-4a5c-9ed4-44d5a59710d3.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/a042a63f-89a5-4a5c-9ed4-44d5a59710d3.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 640,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"더 많은 시장을 다닐수록, 베트남에서만 볼 수 있는\\n독특한 매력들을 발견할 수 있었다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"51ad0cf9-a3c4-4c58-ba7d-462025892588\",\n                \"id\": \"51ad0cf9-a3c4-4c58-ba7d-462025892588\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/51ad0cf9-a3c4-4c58-ba7d-462025892588.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/51ad0cf9-a3c4-4c58-ba7d-462025892588.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/51ad0cf9-a3c4-4c58-ba7d-462025892588.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\",\n                \"link\": {\n                  \"href\": \"\"\n                }\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"현지인들의 문화가 그대로 묻어난 시장의 모습은,\\n여행이 끝난 지금도 선명하게 기억되고 있다.\"\n          }\n        },\n        {\n          \"type\": \"hr1\"\n        },\n        {\n          \"type\": \"heading1\",\n          \"value\": {\n            \"text\": \"집, 학교\",\n            \"headline\": \"보다 가까이 바라본 일상\",\n            \"emphasize\": false,\n            \"href\": \"jib-haggyo\"\n          }\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"ce68689a-221c-4bc6-8a3a-1dd164a75988\",\n                \"id\": \"ce68689a-221c-4bc6-8a3a-1dd164a75988\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/ce68689a-221c-4bc6-8a3a-1dd164a75988.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/ce68689a-221c-4bc6-8a3a-1dd164a75988.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/ce68689a-221c-4bc6-8a3a-1dd164a75988.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 965,\n                \"height\": 655,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"여행을 통해 만난 베트남 친구의 집에 머무르는 동안엔,\\n이곳 사람들의 일상을 더 가까이 들여다볼 수 있었다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"62cb0293-dedb-442c-a1d6-e955c1f1ac11\",\n                \"id\": \"62cb0293-dedb-442c-a1d6-e955c1f1ac11\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/62cb0293-dedb-442c-a1d6-e955c1f1ac11.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/62cb0293-dedb-442c-a1d6-e955c1f1ac11.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/62cb0293-dedb-442c-a1d6-e955c1f1ac11.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"빨래를 널고, 요리를 하고.\\n평범하게 흘러가는 소소한 일상들.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"large\",\n                \"cloudinaryId\": \"40b59890-78d0-4895-9d40-ddd8af087702\",\n                \"id\": \"40b59890-78d0-4895-9d40-ddd8af087702\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/40b59890-78d0-4895-9d40-ddd8af087702.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/40b59890-78d0-4895-9d40-ddd8af087702.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/40b59890-78d0-4895-9d40-ddd8af087702.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"사람 사는 풍경은 \\n시골 마을이라고 다르지 않았다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"a512c375-552f-44b5-98a5-5b3b919aa1c2\",\n                \"id\": \"a512c375-552f-44b5-98a5-5b3b919aa1c2\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/a512c375-552f-44b5-98a5-5b3b919aa1c2.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/a512c375-552f-44b5-98a5-5b3b919aa1c2.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/a512c375-552f-44b5-98a5-5b3b919aa1c2.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 625,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"또 어떤 날엔, 베트남 대학생들이 궁금해\\n무작정 대학교에 가보기도 했다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"56b573da-fb87-4b40-88ee-c546c887fddc\",\n                \"id\": \"56b573da-fb87-4b40-88ee-c546c887fddc\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/56b573da-fb87-4b40-88ee-c546c887fddc.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/56b573da-fb87-4b40-88ee-c546c887fddc.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/56b573da-fb87-4b40-88ee-c546c887fddc.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"학교라고는 믿기지 않을 만큼\\n정원에 둘러싸인 아름다운 학교도 있었고,\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"354fb639-ce0d-471c-a498-f46b45bf78e2\",\n                \"id\": \"354fb639-ce0d-471c-a498-f46b45bf78e2\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/354fb639-ce0d-471c-a498-f46b45bf78e2.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/354fb639-ce0d-471c-a498-f46b45bf78e2.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/354fb639-ce0d-471c-a498-f46b45bf78e2.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 2500,\n                \"height\": 1677,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"여기서 공부하면 영감이 솟아날 것만 같은,\\n예쁜 모양과 색을 가진 학교도 있었다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"bd44848c-aee4-4ace-93af-45447bf19300\",\n                \"id\": \"bd44848c-aee4-4ace-93af-45447bf19300\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/bd44848c-aee4-4ace-93af-45447bf19300.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/bd44848c-aee4-4ace-93af-45447bf19300.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/bd44848c-aee4-4ace-93af-45447bf19300.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"학생들과 이런저런 대화도 나누었는데,\\n당시 대학생이었던 나의 일상과 그들의 일상이\\n크게 다르지 않다는 걸 느꼈다.\"\n          }\n        },\n        {\n          \"type\": \"hr1\"\n        },\n        {\n          \"type\": \"heading1\",\n          \"value\": {\n            \"text\": \"자연 풍경\",\n            \"headline\": \"언제나 경이로운\",\n            \"emphasize\": false,\n            \"href\": \"jayeon-punggyeong\"\n          }\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"large\",\n                \"cloudinaryId\": \"c8510ac2-7cfb-4186-b59b-bbd598e21ea6\",\n                \"id\": \"c8510ac2-7cfb-4186-b59b-bbd598e21ea6\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/c8510ac2-7cfb-4186-b59b-bbd598e21ea6.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/c8510ac2-7cfb-4186-b59b-bbd598e21ea6.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/c8510ac2-7cfb-4186-b59b-bbd598e21ea6.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 645,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"50일 동안 담은 풍경 중,\\n가장 오래 기억에 남은 건 결국 자연이었다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"medium\",\n                \"cloudinaryId\": \"e68d2b39-7abd-4c91-ad5e-fcb6e895c78d\",\n                \"id\": \"e68d2b39-7abd-4c91-ad5e-fcb6e895c78d\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/e68d2b39-7abd-4c91-ad5e-fcb6e895c78d.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/e68d2b39-7abd-4c91-ad5e-fcb6e895c78d.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/e68d2b39-7abd-4c91-ad5e-fcb6e895c78d.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"볼록하게 고개를 세운 산과,\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"big\",\n                \"cloudinaryId\": \"0503fc4d-6510-4710-8e61-fca3d392aa68\",\n                \"id\": \"0503fc4d-6510-4710-8e61-fca3d392aa68\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/0503fc4d-6510-4710-8e61-fca3d392aa68.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/0503fc4d-6510-4710-8e61-fca3d392aa68.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/0503fc4d-6510-4710-8e61-fca3d392aa68.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 690,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"야자수가 펼쳐진 해변.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"big\",\n                \"cloudinaryId\": \"eb833659-af88-4b3a-9b77-45d4dd0f6039\",\n                \"id\": \"eb833659-af88-4b3a-9b77-45d4dd0f6039\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/eb833659-af88-4b3a-9b77-45d4dd0f6039.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/eb833659-af88-4b3a-9b77-45d4dd0f6039.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/eb833659-af88-4b3a-9b77-45d4dd0f6039.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"고요한 바다부터,\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"big\",\n                \"cloudinaryId\": \"aac6ffca-dbe9-497b-b0bf-3c93c48236ed\",\n                \"id\": \"aac6ffca-dbe9-497b-b0bf-3c93c48236ed\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/aac6ffca-dbe9-497b-b0bf-3c93c48236ed.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/aac6ffca-dbe9-497b-b0bf-3c93c48236ed.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/aac6ffca-dbe9-497b-b0bf-3c93c48236ed.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"광활한 사막까지.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"medium\",\n                \"cloudinaryId\": \"aad41e0a-4a3c-4957-b57d-248d05929ea0\",\n                \"id\": \"aad41e0a-4a3c-4957-b57d-248d05929ea0\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/aad41e0a-4a3c-4957-b57d-248d05929ea0.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/aad41e0a-4a3c-4957-b57d-248d05929ea0.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/aad41e0a-4a3c-4957-b57d-248d05929ea0.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"그 모든 순간을 카메라에 담으면서,\\n기대와 설렘으로 여행을 이어갈 수 있었다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"medium\",\n                \"cloudinaryId\": \"6b94c062-0df6-40d6-9830-a573080d8abb\",\n                \"id\": \"6b94c062-0df6-40d6-9830-a573080d8abb\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/6b94c062-0df6-40d6-9830-a573080d8abb.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/6b94c062-0df6-40d6-9830-a573080d8abb.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/6b94c062-0df6-40d6-9830-a573080d8abb.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"그렇게 50일간의 여행은 \\n베트남의 다양한 풍경으로 채워졌다.\"\n          }\n        },\n        {\n          \"type\": \"hr1\"\n        },\n        {\n          \"type\": \"heading1\",\n          \"value\": {\n            \"text\": \"여행을 마친 지금, \\n이 순간\",\n            \"headline\": \"\",\n            \"emphasize\": false,\n            \"href\": \"yeohaengeul-macin-jigeum-i-sungan\"\n          }\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"medium\",\n                \"cloudinaryId\": \"692f7a11-691e-4f88-980e-a6e01f504363\",\n                \"id\": \"692f7a11-691e-4f88-980e-a6e01f504363\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/692f7a11-691e-4f88-980e-a6e01f504363.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/692f7a11-691e-4f88-980e-a6e01f504363.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/692f7a11-691e-4f88-980e-a6e01f504363.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 2500,\n                \"height\": 1677,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"50일간의 긴 여행을 마치고 돌아오니 \\n무엇이 제일 좋았냐는 질문을 많이 받았다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"medium\",\n                \"cloudinaryId\": \"8208f512-cf3b-42e9-9fe1-9658f4283840\",\n                \"id\": \"8208f512-cf3b-42e9-9fe1-9658f4283840\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/8208f512-cf3b-42e9-9fe1-9658f4283840.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/8208f512-cf3b-42e9-9fe1-9658f4283840.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/8208f512-cf3b-42e9-9fe1-9658f4283840.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 2500,\n                \"height\": 1677,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"그때마다 머릿속엔 \\n'여행하며 보낸 일상'이 떠올랐다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"large\",\n                \"cloudinaryId\": \"6b531987-2c77-454d-9ceb-90cdf7f02780\",\n                \"id\": \"6b531987-2c77-454d-9ceb-90cdf7f02780\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/6b531987-2c77-454d-9ceb-90cdf7f02780.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/6b531987-2c77-454d-9ceb-90cdf7f02780.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/6b531987-2c77-454d-9ceb-90cdf7f02780.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 648,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"화려한 도시보다는, 현지에서의 소소한 일상과 \\n자연 풍경들이 오랫동안 잊히지 않았다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"original\",\n                \"cloudinaryId\": \"8bf70caf-cd97-4426-a50f-32fc6fef2423\",\n                \"id\": \"8bf70caf-cd97-4426-a50f-32fc6fef2423\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/8bf70caf-cd97-4426-a50f-32fc6fef2423.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/8bf70caf-cd97-4426-a50f-32fc6fef2423.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/8bf70caf-cd97-4426-a50f-32fc6fef2423.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 966,\n                \"height\": 651,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"일상을 벗어나고자 떠난 여행이었지만, \\n결국 내가 마주한 모습은 한국에서의 모습과 \\n크게 다르지 않았다.\"\n          }\n        },\n        {\n          \"type\": \"hr3\"\n        },\n        {\n          \"type\": \"images\",\n          \"value\": {\n            \"images\": [\n              {\n                \"title\": \"\",\n                \"frame\": \"large\",\n                \"cloudinaryId\": \"a4ed6e4d-151c-4367-9939-28618f869596\",\n                \"id\": \"a4ed6e4d-151c-4367-9939-28618f869596\",\n                \"type\": \"image\",\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/a4ed6e4d-151c-4367-9939-28618f869596.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/a4ed6e4d-151c-4367-9939-28618f869596.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/a4ed6e4d-151c-4367-9939-28618f869596.jpeg\"\n                  }\n                },\n                \"source\": {},\n                \"width\": 628,\n                \"height\": 629,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"sourceUrl\": \"실버라인 님의 사진\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"itinerary\",\n          \"value\": {\n            \"itinerary\": {\n              \"day\": 1,\n              \"items\": [\n                {\n                  \"memo\": \"디즈니랜드메모\",\n                  \"schedule\": \"12:00\",\n                  \"poi\": {\n                    \"id\": \"e889ae22-0336-4cf9-8fbb-742b95fd09d0\",\n                    \"type\": \"attraction\",\n                    \"source\": {\n                      \"id\": \"e889ae22-0336-4cf9-8fbb-742b95fd09d0\",\n                      \"type\": \"attraction\",\n                      \"regionId\": \"23c5965b-01ad-486b-a694-a2ced15f245c\",\n                      \"names\": {\n                        \"ko\": \"도쿄 디즈니 랜드\",\n                        \"en\": \"Tokyo Disney land\",\n                        \"local\": \"東京ディズニーランド\"\n                      },\n                      \"areas\": [],\n                      \"categories\": [\n                        {\n                          \"name\": \"관광명소\"\n                        }\n                      ],\n                      \"comment\": \"어른과 아이 모두 즐길 수 있는 대형 테마파크\",\n                      \"grade\": 10,\n                      \"image\": {\n                        \"cloudinaryId\": \"26e4defd-0d0a-487a-b37b-18aea486377e\",\n                        \"id\": \"b49a9379-124e-4611-a152-21f3c6f73e77\",\n                        \"type\": \"image\",\n                        \"sizes\": {\n                          \"full\": {\n                            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/26e4defd-0d0a-487a-b37b-18aea486377e.jpeg\"\n                          },\n                          \"large\": {\n                            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/26e4defd-0d0a-487a-b37b-18aea486377e.jpeg\"\n                          },\n                          \"small_square\": {\n                            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/26e4defd-0d0a-487a-b37b-18aea486377e.jpeg\"\n                          }\n                        },\n                        \"source\": {},\n                        \"cloudinaryBucket\": \"triple-cms\",\n                        \"sourceUrl\": \"https://allabout-japan.com/en/article/2512/\"\n                      },\n                      \"location\": [139.884576, 35.635587],\n                      \"pointGeolocation\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [139.884576, 35.635587]\n                      },\n                      \"tags\": [],\n                      \"permanentlyClosed\": false\n                    }\n                  },\n                  \"transportation\": [\n                    {\n                      \"type\": \"transportation\",\n                      \"value\": {\n                        \"transportation\": \"walk\",\n                        \"duration\": \"1'\"\n                      }\n                    }\n                  ]\n                },\n                {\n                  \"memo\": \"트리플 2021 최고의 관광지\",\n                  \"schedule\": \"14:00\",\n                  \"transportation\": [\n                    {\n                      \"type\": \"transportation\",\n                      \"value\": {\n                        \"transportation\": \"car\",\n                        \"duration\": \"3'10\\\"\"\n                      }\n                    }\n                  ],\n                  \"poi\": {\n                    \"id\": \"9a248cd6-edc3-43e0-bc8f-4ef15f58ed7c\",\n                    \"type\": \"attraction\",\n                    \"source\": {\n                      \"id\": \"9a248cd6-edc3-43e0-bc8f-4ef15f58ed7c\",\n                      \"type\": \"attraction\",\n                      \"regionId\": \"23c5965b-01ad-486b-a694-a2ced15f245c\",\n                      \"names\": {\n                        \"ko\": \"도쿄 디즈니 씨\",\n                        \"en\": \"Tokyo Disney Sea\",\n                        \"local\": \"東京ディズニーシー\"\n                      },\n                      \"areas\": [],\n                      \"categories\": [\n                        {\n                          \"name\": \"테마/체험\"\n                        }\n                      ],\n                      \"comment\": \"세계에서 유일한 바다 콘셉트의 테마 공원\",\n                      \"grade\": 10,\n                      \"image\": {\n                        \"cloudinaryId\": \"bb1eebaf-43f1-45d8-a180-878273fdfd3f\",\n                        \"id\": \"4a4a8737-2e6d-4d26-a036-4a08ba5da8a4\",\n                        \"type\": \"image\",\n                        \"sizes\": {\n                          \"full\": {\n                            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/bb1eebaf-43f1-45d8-a180-878273fdfd3f.jpeg\"\n                          },\n                          \"large\": {\n                            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/bb1eebaf-43f1-45d8-a180-878273fdfd3f.jpeg\"\n                          },\n                          \"small_square\": {\n                            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/bb1eebaf-43f1-45d8-a180-878273fdfd3f.jpeg\"\n                          }\n                        },\n                        \"source\": {},\n                        \"cloudinaryBucket\": \"triple-cms\",\n                        \"sourceUrl\": \"https://travel.gaijinpot.com/tokyo-disneysea/\"\n                      },\n                      \"location\": [139.8850779, 35.6267108],\n                      \"pointGeolocation\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [139.8850779, 35.6267108]\n                      },\n                      \"tags\": [],\n                      \"permanentlyClosed\": false\n                    }\n                  }\n                },\n                {\n                  \"schedule\": \"16:00\",\n                  \"poi\": {\n                    \"id\": \"e80acfe5-1514-403c-ae19-80aec7979a5c\",\n                    \"type\": \"hotel\",\n                    \"source\": {\n                      \"id\": \"e80acfe5-1514-403c-ae19-80aec7979a5c\",\n                      \"type\": \"hotel\",\n                      \"regionId\": \"23c5965b-01ad-486b-a694-a2ced15f245c\",\n                      \"names\": {\n                        \"ko\": \"도쿄 디즈니랜드 호텔\",\n                        \"en\": \"Tokyo Disneyland Hotel\",\n                        \"local\": \"東京ディズニーランド®ホテル\"\n                      },\n                      \"areas\": [],\n                      \"grade\": 10000,\n                      \"image\": {\n                        \"cloudinaryId\": \"fb2219b1-0b4e-4237-b814-f3fd056d948a\",\n                        \"id\": \"e767ebb1-31ab-47b2-92d9-b60a72db314f\",\n                        \"type\": \"image\",\n                        \"sizes\": {\n                          \"full\": {\n                            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/fb2219b1-0b4e-4237-b814-f3fd056d948a.jpeg\"\n                          },\n                          \"large\": {\n                            \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/fb2219b1-0b4e-4237-b814-f3fd056d948a.jpeg\"\n                          },\n                          \"small_square\": {\n                            \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/fb2219b1-0b4e-4237-b814-f3fd056d948a.jpeg\"\n                          }\n                        },\n                        \"source\": {},\n                        \"cloudinaryBucket\": \"triple-cms\",\n                        \"sourceUrl\": \"https://www.expedia.com/\"\n                      },\n                      \"location\": [139.880216, 35.637339],\n                      \"pointGeolocation\": {\n                        \"type\": \"Point\",\n                        \"coordinates\": [139.880216, 35.637339]\n                      },\n                      \"tags\": [],\n                      \"permanentlyClosed\": false,\n                      \"starRating\": 4\n                    }\n                  }\n                }\n              ]\n            }\n          }\n        },\n        {\n          \"type\": \"note\",\n          \"value\": {\n            \"title\": \"\",\n            \"body\": \"밥을 먹고, 사람을 만나고, 책을 읽고, 글을 쓰고. \\n평범한 일상의 소중함을 깨닫게 해준,\\n소중한 50일간의 여행이었다.\"\n          }\n        },\n        {\n          \"type\": \"hr5\"\n        },\n        {\n          \"type\": \"regions\",\n          \"value\": {\n            \"regions\": [\n              {\n                \"id\": \"5eb828fe-cb69-482c-bf37-e166d6cce259\",\n                \"type\": \"region\",\n                \"source\": {\n                  \"preferences\": {\n                    \"recommended_duration\": 5,\n                    \"languages\": [\"vi\", \"en\", \"ko\"],\n                    \"tna\": true,\n                    \"time_zone\": \"Asia/Ho_Chi_Minh\",\n                    \"default_search_radius\": 500,\n                    \"currencies\": [\"VND\", \"USD\"]\n                  },\n                  \"visible\": true,\n                  \"names\": {\n                    \"ko\": \"하노이\",\n                    \"en\": \"Hanoi\",\n                    \"local\": null\n                  },\n                  \"publishedAt\": \"2017-08-07T11:00:00.000+09:00\",\n                  \"style\": {\n                    \"backgroundImageThumbUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_image/thumb_5eb828fe-cb69-482c-bf37-e166d6cce259-1573715597.jpg\",\n                    \"backgroundVideoUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_video/5eb828fe-cb69-482c-bf37-e166d6cce259-1498544427.mp4\",\n                    \"logoImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/logo_image/5eb828fe-cb69-482c-bf37-e166d6cce259-1504575557.png\",\n                    \"backgroundImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_image/5eb828fe-cb69-482c-bf37-e166d6cce259-1573715597.jpg\",\n                    \"blurredBackgroundImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/blurred_background_image/5eb828fe-cb69-482c-bf37-e166d6cce259-1498546432.png\"\n                  },\n                  \"regionCategory\": {\n                    \"name\": \"동남아시아\",\n                    \"id\": \"08023914-1b50-4869-b2f0-6e78f35c256e\",\n                    \"priority\": 60\n                  },\n                  \"id\": \"5eb828fe-cb69-482c-bf37-e166d6cce259\",\n                  \"type\": \"region\",\n                  \"priority\": 8900\n                }\n              },\n              {\n                \"id\": \"00aab92a-b38a-42b5-bad7-db203c89c5ef\",\n                \"type\": \"region\",\n                \"source\": {\n                  \"preferences\": {\n                    \"recommended_duration\": 0,\n                    \"languages\": [\"vi\", \"en\", \"ko\"],\n                    \"tna\": true,\n                    \"time_zone\": \"Asia/Ho_Chi_Minh\",\n                    \"default_search_radius\": 500,\n                    \"currencies\": [\"VND\", \"USD\"]\n                  },\n                  \"visible\": true,\n                  \"names\": {\n                    \"ko\": \"호치민\",\n                    \"en\": \"Ho Chi Minh\",\n                    \"local\": null\n                  },\n                  \"publishedAt\": \"2017-07-14T11:00:00.000+09:00\",\n                  \"style\": {\n                    \"backgroundImageThumbUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_image/thumb_00aab92a-b38a-42b5-bad7-db203c89c5ef-1573715627.jpg\",\n                    \"backgroundVideoUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_video/00aab92a-b38a-42b5-bad7-db203c89c5ef-1497850555.mp4\",\n                    \"logoImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/logo_image/00aab92a-b38a-42b5-bad7-db203c89c5ef-1504575408.png\",\n                    \"backgroundImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_image/00aab92a-b38a-42b5-bad7-db203c89c5ef-1573715627.jpg\",\n                    \"blurredBackgroundImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/blurred_background_image/00aab92a-b38a-42b5-bad7-db203c89c5ef-1498546170.png\"\n                  },\n                  \"regionCategory\": {\n                    \"name\": \"동남아시아\",\n                    \"id\": \"08023914-1b50-4869-b2f0-6e78f35c256e\",\n                    \"priority\": 60\n                  },\n                  \"id\": \"00aab92a-b38a-42b5-bad7-db203c89c5ef\",\n                  \"type\": \"region\",\n                  \"priority\": 9100\n                }\n              },\n              {\n                \"id\": \"22b60e7e-afc7-40e1-9237-8f31ed8a842d\",\n                \"type\": \"region\",\n                \"source\": {\n                  \"preferences\": {\n                    \"recommended_duration\": 5,\n                    \"languages\": [\"vi\", \"en\", \"ko\"],\n                    \"tna\": true,\n                    \"time_zone\": \"Asia/Ho_Chi_Minh\",\n                    \"default_search_radius\": 500,\n                    \"currencies\": [\"VND\", \"USD\"]\n                  },\n                  \"visible\": true,\n                  \"names\": {\n                    \"ko\": \"다낭\",\n                    \"en\": \"Danang\",\n                    \"local\": null\n                  },\n                  \"publishedAt\": \"2017-07-26T11:00:00.000+09:00\",\n                  \"style\": {\n                    \"backgroundImageThumbUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_image/thumb_22b60e7e-afc7-40e1-9237-8f31ed8a842d-1573713560.jpg\",\n                    \"backgroundVideoUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_video/22b60e7e-afc7-40e1-9237-8f31ed8a842d-1498546814.mp4\",\n                    \"logoImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/logo_image/22b60e7e-afc7-40e1-9237-8f31ed8a842d-1504575417.png\",\n                    \"backgroundImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_image/22b60e7e-afc7-40e1-9237-8f31ed8a842d-1573713560.jpg\",\n                    \"blurredBackgroundImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/blurred_background_image/22b60e7e-afc7-40e1-9237-8f31ed8a842d-1498546680.png\"\n                  },\n                  \"regionCategory\": {\n                    \"name\": \"동남아시아\",\n                    \"id\": \"08023914-1b50-4869-b2f0-6e78f35c256e\",\n                    \"priority\": 60\n                  },\n                  \"id\": \"22b60e7e-afc7-40e1-9237-8f31ed8a842d\",\n                  \"type\": \"region\",\n                  \"priority\": 7400\n                }\n              },\n              {\n                \"id\": \"6f0a0cf2-766d-49db-907c-283c9636c9aa\",\n                \"type\": \"region\",\n                \"source\": {\n                  \"preferences\": {\n                    \"recommended_duration\": 3,\n                    \"languages\": [\"vi\", \"en\", \"ko\"],\n                    \"tna\": true,\n                    \"time_zone\": \"Asia/Bangkok\",\n                    \"default_search_radius\": 1000,\n                    \"currencies\": [\"VND\", \"USD\"]\n                  },\n                  \"visible\": true,\n                  \"names\": {\n                    \"ko\": \"나트랑\",\n                    \"en\": \"Nha Trang\",\n                    \"local\": null\n                  },\n                  \"publishedAt\": \"2018-10-30T15:00:00.000+09:00\",\n                  \"style\": {\n                    \"backgroundImageThumbUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_image/thumb_6f0a0cf2-766d-49db-907c-283c9636c9aa-1573713534.jpg\",\n                    \"backgroundVideoUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_video/6f0a0cf2-766d-49db-907c-283c9636c9aa-1537508067.mp4\",\n                    \"logoImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/logo_image/6f0a0cf2-766d-49db-907c-283c9636c9aa-1537508038.png\",\n                    \"backgroundImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_image/6f0a0cf2-766d-49db-907c-283c9636c9aa-1573713534.jpg\",\n                    \"blurredBackgroundImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/blurred_background_image/6f0a0cf2-766d-49db-907c-283c9636c9aa-1537508059.png\"\n                  },\n                  \"regionCategory\": {\n                    \"name\": \"동남아시아\",\n                    \"id\": \"08023914-1b50-4869-b2f0-6e78f35c256e\",\n                    \"priority\": 60\n                  },\n                  \"id\": \"6f0a0cf2-766d-49db-907c-283c9636c9aa\",\n                  \"type\": \"region\",\n                  \"priority\": 0\n                }\n              },\n              {\n                \"id\": \"f72998d9-5eea-4ed7-9040-30e9d59c05c7\",\n                \"type\": \"region\",\n                \"source\": {\n                  \"preferences\": {\n                    \"recommended_duration\": 3,\n                    \"languages\": [\"vi\", \"en\", \"ko\"],\n                    \"time_zone\": \"Asia/Ho_Chi_Minh\",\n                    \"default_search_radius\": 1000,\n                    \"currencies\": [\"VND\", \"USD\"]\n                  },\n                  \"visible\": true,\n                  \"names\": {\n                    \"ko\": \"푸꾸옥\",\n                    \"en\": \"Phu Quoc\",\n                    \"local\": \"Phú Quốc\"\n                  },\n                  \"publishedAt\": \"2019-10-02T15:00:00.000+09:00\",\n                  \"style\": {\n                    \"backgroundImageThumbUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_image/thumb_f72998d9-5eea-4ed7-9040-30e9d59c05c7-1573715530.jpg\",\n                    \"backgroundVideoUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_video/f72998d9-5eea-4ed7-9040-30e9d59c05c7-1564997554.mp4\",\n                    \"logoImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/logo_image/f72998d9-5eea-4ed7-9040-30e9d59c05c7-1564985026.png\",\n                    \"backgroundImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/background_image/f72998d9-5eea-4ed7-9040-30e9d59c05c7-1573715530.jpg\",\n                    \"blurredBackgroundImageUrl\": \"https://triple-cms.s3-ap-northeast-1.amazonaws.com/region_media/blurred_background_image/f72998d9-5eea-4ed7-9040-30e9d59c05c7-1564997540.png\"\n                  },\n                  \"regionCategory\": {\n                    \"name\": \"동남아시아\",\n                    \"id\": \"08023914-1b50-4869-b2f0-6e78f35c256e\",\n                    \"priority\": 60\n                  },\n                  \"id\": \"f72998d9-5eea-4ed7-9040-30e9d59c05c7\",\n                  \"type\": \"region\",\n                  \"priority\": 0\n                }\n              }\n            ]\n          }\n        }\n      ],\n      \"name\": \"[2000520-에세이] 50일간의 베트남 일주\",\n      \"metadata\": {\n        \"title\": \"50일간의 베트남 일주\",\n        \"author\": {\n          \"id\": \"f6fa4f59-2d03-4fbb-8cb4-4135bff62b51\",\n          \"source\": {\n            \"name\": \"실버라인\",\n            \"bio\": \"기록하는 사람\",\n            \"intro\": {\n              \"text\": \"다양한 방법으로 시간을 기록합니다. @silverlining.pic\"\n            },\n            \"image\": {\n              \"cloudinaryId\": \"580ec9a7-af71-49de-8eb5-d6b3d0f84209\",\n              \"id\": \"580ec9a7-af71-49de-8eb5-d6b3d0f84209\",\n              \"type\": \"image\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/580ec9a7-af71-49de-8eb5-d6b3d0f84209.jpeg\"\n                },\n                \"large\": {\n                  \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/580ec9a7-af71-49de-8eb5-d6b3d0f84209.jpeg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/580ec9a7-af71-49de-8eb5-d6b3d0f84209.jpeg\"\n                }\n              },\n              \"source\": {},\n              \"width\": 810,\n              \"height\": 718,\n              \"cloudinaryBucket\": \"triple-cms\"\n            }\n          },\n          \"updatedAt\": \"2020-05-04T06:01:18.838Z\",\n          \"createdAt\": \"2020-04-22T00:49:52.799Z\",\n          \"bioOverride\": \"기록하는 사람\"\n        },\n        \"recommendable\": true,\n        \"readableTimestamp\": \"5월 3주차\",\n        \"description\": \"여행하는 마음을 멈추고서야\\n비로소 보이는 것들\",\n        \"image\": {\n          \"cloudinaryId\": \"74ed00a4-57c9-4b96-ae52-d953cbb1341b\",\n          \"id\": \"74ed00a4-57c9-4b96-ae52-d953cbb1341b\",\n          \"type\": \"image\",\n          \"sizes\": {\n            \"full\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/74ed00a4-57c9-4b96-ae52-d953cbb1341b.jpeg\"\n            },\n            \"large\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/74ed00a4-57c9-4b96-ae52-d953cbb1341b.jpeg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/74ed00a4-57c9-4b96-ae52-d953cbb1341b.jpeg\"\n            }\n          },\n          \"source\": {},\n          \"width\": 1260,\n          \"height\": 1500,\n          \"cloudinaryBucket\": \"triple-cms\",\n          \"sourceUrl\": \"실버라인 님의 사진\"\n        },\n        \"template\": {\n          \"items\": [\n            {\n              \"type\": \"featuredImage\",\n              \"value\": \"fullSquare\"\n            },\n            {\n              \"type\": \"title\",\n              \"value\": \"default\"\n            },\n            {\n              \"type\": \"document\",\n              \"value\": \"default\"\n            },\n            {\n              \"type\": \"tags\",\n              \"value\": \"default\"\n            },\n            {\n              \"type\": \"exposure\",\n              \"value\": \"excuse\"\n            },\n            {\n              \"type\": \"author\",\n              \"value\": \"default\"\n            },\n            {\n              \"type\": \"share\",\n              \"value\": \"default\"\n            },\n            {\n              \"type\": \"recommendations\",\n              \"value\": \"default\"\n            }\n          ]\n        },\n        \"exposedAt\": \"2020-05-20T01:00:00.000Z\",\n        \"priority\": null,\n        \"tags\": [\n          {\n            \"id\": \"4a37663d-9cb0-4aef-a321-bb0bb5726bfd\",\n            \"type\": \"theme\",\n            \"articleType\": \"post\",\n            \"name\": \"여행기\",\n            \"metadata\": {\n              \"priority\": 900\n            },\n            \"updatedAt\": \"2020-04-01T08:47:04.299Z\",\n            \"createdAt\": \"2019-10-01T09:29:12.920Z\",\n            \"articlesCount\": 42,\n            \"exposedAt\": \"2019-09-01T03:00:00.000Z\"\n          },\n          {\n            \"id\": \"30404f79-629d-4f13-928a-2c08e1a80a44\",\n            \"type\": \"theme\",\n            \"articleType\": \"post\",\n            \"name\": \"여행지추천\",\n            \"metadata\": {\n              \"priority\": 200\n            },\n            \"updatedAt\": \"2020-04-01T08:47:03.667Z\",\n            \"createdAt\": \"2019-10-01T09:29:12.899Z\",\n            \"exposedAt\": \"2019-09-01T03:00:00.000Z\",\n            \"articlesCount\": 99\n          },\n          {\n            \"id\": \"81e79187-3003-437e-bb0a-24c2b93fc139\",\n            \"type\": \"regionCategory\",\n            \"articleType\": \"post\",\n            \"name\": \"동남아시아\",\n            \"metadata\": {\n              \"regionCategoryId\": \"08023914-1b50-4869-b2f0-6e78f35c256e\",\n              \"priority\": 0\n            },\n            \"updatedAt\": \"2020-04-01T08:47:03.408Z\",\n            \"createdAt\": \"2019-10-16T09:16:55.498Z\",\n            \"exposedAt\": \"2019-10-16T09:16:55.498Z\",\n            \"articlesCount\": 105\n          }\n        ],\n        \"keywords\": [\"베트남\", \"일주\", \"실비아\"]\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/triple-document/src/mocks/triple-document.regions.json",
    "content": "[\n  {\n    \"id\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n    \"type\": \"region\",\n    \"source\": {\n      \"id\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n      \"type\": \"region\",\n      \"areas\": [\n        {\n          \"id\": \"af16bd5f-c629-440c-af03-39ca0fc68a02\",\n          \"name\": \"수쿰빗\",\n          \"hotelsCount\": 476,\n          \"hotelArticleId\": \"c7c6175a-c84b-43fd-aed1-59ffb8ad6b64\",\n          \"attractionsCount\": 362,\n          \"restaurantsCount\": 1716\n        },\n        {\n          \"id\": \"2be2e9c8-322a-4be0-9451-b826b66960a0\",\n          \"name\": \"씨암, 칫롬\",\n          \"hotelsCount\": 298,\n          \"hotelArticleId\": \"bfbeca76-e271-4912-b238-bc528dfac320\",\n          \"attractionsCount\": 422,\n          \"restaurantsCount\": 4395\n        },\n        {\n          \"id\": \"3c6f853c-ee6f-4fb0-a93b-d1335cc48c9d\",\n          \"name\": \"올드시티\",\n          \"hotelsCount\": 275,\n          \"hotelArticleId\": \"5949e605-4e99-4bd9-ab50-60d7a68e126f\",\n          \"attractionsCount\": 230,\n          \"restaurantsCount\": 546\n        },\n        {\n          \"id\": \"c92fd762-7588-4d94-a2ac-bef79dbe2535\",\n          \"name\": \"사톤, 실롬, 리버사이드\",\n          \"hotelsCount\": 317,\n          \"hotelArticleId\": \"f9cf056d-f263-4033-8115-cdad061d1ee5\",\n          \"attractionsCount\": 208,\n          \"restaurantsCount\": 871\n        },\n        {\n          \"id\": \"848ff90e-5fc6-4c3c-9ea6-354f35e4e1cf\",\n          \"name\": \"공항 근처\",\n          \"hotelsCount\": 25,\n          \"hotelArticleId\": \"dc6e370b-46ec-4de4-98b4-c842f5a856b0\",\n          \"attractionsCount\": 3,\n          \"restaurantsCount\": 12\n        },\n        {\n          \"id\": \"17b4bb3f-975e-4516-816c-1375c8b4b4a1\",\n          \"name\": \"파타야\",\n          \"hotelsCount\": 1204,\n          \"hotelArticleId\": \"498c1020-405d-424d-bc49-7521842029e6\",\n          \"attractionsCount\": 120,\n          \"restaurantsCount\": 947\n        }\n      ],\n      \"names\": {\n        \"en\": \"Bangkok\",\n        \"ko\": \"방콕\",\n        \"local\": null\n      },\n      \"style\": {\n        \"logoImageUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/logo_image/edf1982d-c835-43a7-b06b-af43acbb6f38-1504575746.png\",\n        \"backgroundImageUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/background_image/edf1982d-c835-43a7-b06b-af43acbb6f38-1489569899.png\",\n        \"backgroundVideoUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/background_video/edf1982d-c835-43a7-b06b-af43acbb6f38-1489569905.mp4\",\n        \"blurredBackgroundImageUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/blurred_background_image/edf1982d-c835-43a7-b06b-af43acbb6f38-1490177274.png\"\n      },\n      \"visible\": true,\n      \"priority\": 7700,\n      \"terminals\": [\n        {\n          \"id\": \"22feaa3e-79f5-4eff-86e2-4fa70bdb748f\",\n          \"type\": \"attraction\",\n          \"source\": {\n            \"id\": \"22feaa3e-79f5-4eff-86e2-4fa70bdb748f\",\n            \"type\": \"attraction\",\n            \"areas\": [\n              {\n                \"name\": \"공항 근처\"\n              }\n            ],\n            \"grade\": 1000,\n            \"image\": {\n              \"id\": \"3c528ce4-3146-427c-bbd2-e9b6e68306a9\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/3c528ce4-3146-427c-bbd2-e9b6e68306a9.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/3c528ce4-3146-427c-bbd2-e9b6e68306a9.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/3c528ce4-3146-427c-bbd2-e9b6e68306a9.jpg\"\n                }\n              },\n              \"title\": null,\n              \"sourceUrl\": \"http://www.e-architect.co.uk/thailand/suvarnabhumi-airport\",\n              \"description\": null\n            },\n            \"names\": {\n              \"en\": \"Suvarnabhumi International Airport\",\n              \"ko\": \"수완나품 국제공항\",\n              \"local\": \"ท่าอากาศยานสุวรรณภูมิ\"\n            },\n            \"comment\": null,\n            \"location\": [100.7501124, 13.6899991],\n            \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n            \"categories\": [\n              {\n                \"name\": \"관광명소\"\n              }\n            ],\n            \"pointGeolocation\": {\n              \"type\": \"Point\",\n              \"coordinates\": [100.7501124, 13.6899991]\n            }\n          }\n        },\n        {\n          \"id\": \"083ba341-aba3-4226-8de0-4ca6f484a756\",\n          \"type\": \"attraction\",\n          \"source\": {\n            \"id\": \"083ba341-aba3-4226-8de0-4ca6f484a756\",\n            \"type\": \"attraction\",\n            \"grade\": 1000,\n            \"image\": {\n              \"id\": \"b6fd7b8f-5a98-4ef9-85e8-e03ed85a5d1c\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/b6fd7b8f-5a98-4ef9-85e8-e03ed85a5d1c.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/b6fd7b8f-5a98-4ef9-85e8-e03ed85a5d1c.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/b6fd7b8f-5a98-4ef9-85e8-e03ed85a5d1c.jpg\"\n                }\n              },\n              \"title\": null,\n              \"sourceUrl\": \"http://www.bangkok-maps.com/bangkokairport.htm\",\n              \"description\": null\n            },\n            \"names\": {\n              \"en\": \"Donmueang International Airport\",\n              \"ko\": \"돈므앙 국제공항\",\n              \"local\": \"ท่าอากาศยานดอนเมือง\"\n            },\n            \"comment\": null,\n            \"location\": [100.6041987, 13.9132602],\n            \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n            \"categories\": [\n              {\n                \"name\": \"관광명소\"\n              }\n            ],\n            \"pointGeolocation\": {\n              \"type\": \"Point\",\n              \"coordinates\": [100.6041987, 13.9132602]\n            }\n          }\n        }\n      ],\n      \"preferences\": {\n        \"time_zone\": \"Asia/Bangkok\",\n        \"currencies\": [\"THB\", \"USD\"],\n        \"recommended_duration\": 3,\n        \"default_search_radius\": 1000\n      },\n      \"hotelTagListings\": [\n        {\n          \"tag\": {\n            \"id\": \"59e7e070-2c7a-4087-9751-c07f482002e2\",\n            \"name\": \"교통이 편리한\"\n          }\n        },\n        {\n          \"tag\": {\n            \"id\": \"aea83659-f4d8-4231-bc30-c7050447ebed\",\n            \"name\": \"수영장이 좋은\"\n          }\n        },\n        {\n          \"tag\": {\n            \"id\": \"df93d846-5a6c-42a5-a067-6301b851e7ca\",\n            \"name\": \"쇼핑하기 편한\"\n          }\n        },\n        {\n          \"tag\": {\n            \"id\": \"1259446d-d7ac-46ea-ac8f-87e4935e8851\",\n            \"name\": \"뷰가 좋은\"\n          }\n        },\n        {\n          \"tag\": {\n            \"id\": \"064a7031-d1da-4bb2-ae10-e111c0d8e103\",\n            \"name\": \"조식이 맛있는\"\n          }\n        },\n        {\n          \"tag\": {\n            \"id\": \"6b107177-473b-442a-b595-ed78bde5cad2\",\n            \"name\": \"부티크 호텔\"\n          }\n        },\n        {\n          \"tag\": {\n            \"id\": \"1d6641d3-444c-4922-9acf-6bddefb7116c\",\n            \"name\": \"아이와 함께\"\n          }\n        },\n        {\n          \"tag\": {\n            \"id\": \"73ee800e-c5b8-4517-80d7-92a87546cd07\",\n            \"name\": \"전용비치\"\n          }\n        }\n      ],\n      \"weatherForecastSpots\": [\n        {\n          \"id\": \"5c29d743-8b27-456a-9029-69cad2b83a67\",\n          \"name\": \"방콕\",\n          \"geolocation\": {\n            \"type\": \"Point\",\n            \"coordinates\": [100.4930274, 13.7248946]\n          }\n        },\n        {\n          \"id\": \"80a07e7d-6fba-4d44-851a-d568cf464e56\",\n          \"name\": \"파타야\",\n          \"geolocation\": {\n            \"type\": \"Point\",\n            \"coordinates\": [100.90927999859616, 12.940290614215169]\n          }\n        }\n      ],\n      \"articleCategoryListings\": [\n        {\n          \"picture\": {\n            \"id\": \"a9464183-b992-4bc8-9ddd-5219a9239406\",\n            \"sizes\": {\n              \"full\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/a9464183-b992-4bc8-9ddd-5219a9239406.jpg\"\n              },\n              \"large\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/a9464183-b992-4bc8-9ddd-5219a9239406.jpg\"\n              },\n              \"small_square\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/a9464183-b992-4bc8-9ddd-5219a9239406.jpg\"\n              }\n            },\n            \"title\": null,\n            \"sourceUrl\": null,\n            \"description\": null\n          },\n          \"category\": {\n            \"id\": \"b1462717-952f-400c-901a-0b85d13c1331\",\n            \"name\": \"준비\",\n            \"icons\": {}\n          },\n          \"description\": \"여행 전,\\n필수 체크사항\"\n        },\n        {\n          \"picture\": {\n            \"id\": \"f3a48937-7fc0-453e-8a0e-2f34d068ece1\",\n            \"sizes\": {\n              \"full\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f3a48937-7fc0-453e-8a0e-2f34d068ece1.jpg\"\n              },\n              \"large\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f3a48937-7fc0-453e-8a0e-2f34d068ece1.jpg\"\n              },\n              \"small_square\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f3a48937-7fc0-453e-8a0e-2f34d068ece1.jpg\"\n              }\n            },\n            \"title\": null,\n            \"sourceUrl\": null,\n            \"description\": null\n          },\n          \"category\": {\n            \"id\": \"d1fa8256-a8f2-4a86-b293-27a7b65695bc\",\n            \"name\": \"정보\",\n            \"icons\": null\n          },\n          \"description\": \"알면 쓸모있는\\n방콕 정보와 팁\"\n        },\n        {\n          \"picture\": {\n            \"id\": \"ee3eb05a-ea02-4b5e-bb44-a0aabf5173bf\",\n            \"sizes\": {\n              \"full\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/ee3eb05a-ea02-4b5e-bb44-a0aabf5173bf.jpg\"\n              },\n              \"large\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/ee3eb05a-ea02-4b5e-bb44-a0aabf5173bf.jpg\"\n              },\n              \"small_square\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/ee3eb05a-ea02-4b5e-bb44-a0aabf5173bf.jpg\"\n              }\n            },\n            \"title\": null,\n            \"sourceUrl\": null,\n            \"description\": null\n          },\n          \"category\": {\n            \"id\": \"91e59cd2-b45b-4cfe-8b7b-69dd1a546fe0\",\n            \"name\": \"관광\",\n            \"icons\": null\n          },\n          \"description\": \"볼거리, 즐길거리의\\n모든 것\"\n        },\n        {\n          \"picture\": {\n            \"id\": \"5de75dca-3fa7-44c1-8b97-5c9f320c0171\",\n            \"sizes\": {\n              \"full\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/5de75dca-3fa7-44c1-8b97-5c9f320c0171.jpg\"\n              },\n              \"large\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/5de75dca-3fa7-44c1-8b97-5c9f320c0171.jpg\"\n              },\n              \"small_square\": {\n                \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/5de75dca-3fa7-44c1-8b97-5c9f320c0171.jpg\"\n              }\n            },\n            \"title\": null,\n            \"sourceUrl\": null,\n            \"description\": null\n          },\n          \"category\": {\n            \"id\": \"5563700e-b3e8-482d-af61-616e8c40620f\",\n            \"name\": \"맛집\",\n            \"icons\": null\n          },\n          \"description\": \"방콕\\n먹킷리스트\"\n        }\n      ],\n      \"attractionCategoryListings\": [\n        {\n          \"picture\": null,\n          \"category\": {\n            \"id\": \"abf9191a-fe28-4b36-8a9f-52a5e6d5666a\",\n            \"name\": \"관광명소\",\n            \"icons\": {\n              \"on\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/a22144ce-9cbe-46f5-a8a9-615d9e51b62c-1487926449.png\"\n                  }\n                }\n              },\n              \"off\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/da7f5342-4474-4a28-812a-ca64d8525729-1487925715.png\"\n                  }\n                }\n              }\n            }\n          },\n          \"description\": null\n        },\n        {\n          \"picture\": null,\n          \"category\": {\n            \"id\": \"b7e3aaee-4a0e-40b2-8ffa-99b3ec3cdff5\",\n            \"name\": \"테마/체험\",\n            \"icons\": {\n              \"on\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/c594a106-2b2b-4ba2-8b7e-f96a0d0559c4-1487926499.png\"\n                  }\n                }\n              },\n              \"off\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/1d2ae3ee-af99-4098-bf08-51b665607fb1-1487926477.png\"\n                  }\n                }\n              }\n            }\n          },\n          \"description\": null\n        },\n        {\n          \"picture\": null,\n          \"category\": {\n            \"id\": \"95556107-682f-4335-bdd6-43175ba34ef4\",\n            \"name\": \"쇼핑\",\n            \"icons\": {\n              \"on\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/9b7facf3-fca9-4e19-ae16-6c89ad04e3b5-1487926656.png\"\n                  }\n                }\n              },\n              \"off\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/f1f3d698-3b59-45a6-a93c-bf86b36fc137-1487926642.png\"\n                  }\n                }\n              }\n            }\n          },\n          \"description\": null\n        }\n      ],\n      \"restaurantCategoryListings\": [\n        {\n          \"picture\": null,\n          \"category\": {\n            \"id\": \"280a5533-c9d0-48e8-8cad-b8c651a53d86\",\n            \"name\": \"음식점\",\n            \"icons\": {\n              \"on\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/650ce7bd-0ef7-4ffc-9816-7aa5dd729108-1487926580.png\"\n                  }\n                }\n              },\n              \"off\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/20b2e3de-2208-416c-9fb2-8a37f3719699-1487926560.png\"\n                  }\n                }\n              }\n            }\n          },\n          \"description\": null\n        },\n        {\n          \"picture\": null,\n          \"category\": {\n            \"id\": \"55530454-0f37-4d35-a476-5d5100d2b449\",\n            \"name\": \"카페/디저트\",\n            \"icons\": {\n              \"on\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/d0f2cbaa-bedd-4571-8273-6e5f852410da-1487926543.png\"\n                  }\n                }\n              },\n              \"off\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/57b156c4-8644-4622-8984-7dbfe528fafe-1487926526.png\"\n                  }\n                }\n              }\n            }\n          },\n          \"description\": null\n        },\n        {\n          \"picture\": null,\n          \"category\": {\n            \"id\": \"b60013ce-5fd8-4e10-a309-0e50f47ed66e\",\n            \"name\": \"술집/바\",\n            \"icons\": {\n              \"on\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/f441338d-47df-4f6d-bc74-04c575736489-1487926612.png\"\n                  }\n                }\n              },\n              \"off\": {\n                \"sizes\": {\n                  \"large\": {\n                    \"url\": null\n                  },\n                  \"small\": {\n                    \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/546a044a-b80e-430b-87a3-814c491b6189-1487926594.png\"\n                  }\n                }\n              }\n            }\n          },\n          \"description\": null\n        }\n      ]\n    },\n    \"nameOverride\": null\n  }\n]\n"
  },
  {
    "path": "packages/triple-document/src/mocks/triple-document.sample.json",
    "content": "[\n  {\n    \"type\": \"embedded\",\n    \"value\": {\n      \"entries\": [\n        [\n          {\n            \"type\": \"images\",\n            \"value\": {\n              \"images\": [\n                {\n                  \"id\": \"578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea\",\n                  \"frame\": \"large\",\n                  \"sizes\": {\n                    \"full\": {\n                      \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea.jpg\"\n                    },\n                    \"large\": {\n                      \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea.jpg\"\n                    },\n                    \"small_square\": {\n                      \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/578bbb87-9ed3-443a-8cb3-b0b4a2b3e1ea.jpg\"\n                    }\n                  },\n                  \"title\": \"\",\n                  \"width\": 960,\n                  \"height\": 640,\n                  \"sourceUrl\": \"http://www.naver.com/\",\n                  \"description\": \"\"\n                }\n              ]\n            }\n          },\n          {\n            \"type\": \"heading3\",\n            \"value\": {\n              \"text\": \"제목 한줄로 보이게 한 줄 맞나 맞나?\"\n            }\n          },\n          {\n            \"type\": \"text\",\n            \"value\": {\n              \"text\": \"짧은 설명이 들어가면 좋겠습니다. 모든 임베딩에 균일하게 최대 3줄 정도면 적당하겠군요.\"\n            }\n          },\n          {\n            \"type\": \"links\",\n            \"value\": {\n              \"links\": [\n                {\n                  \"href\": \"dev-soto:///outlink?url=https%3A%2F%2Ftriple.guide%2Fentry%2Fposts%2F04ba5bd9-1a75-4ddb-bb3c-516657e9ae0c%3F_triple_no_navbar%26_triple_swipe_to_close\",\n                  \"label\": \"방콕 3박 4일 가이드\"\n                },\n                {\n                  \"href\": \"dev-soto:///regions/23c5965b-01ad-486b-a694-a2ced15f245c/attractions/c3d2ef37-f0ef-42b4-a210-039dc08143bf\",\n                  \"label\": \"도쿄 타워\"\n                }\n              ],\n              \"display\": \"default\"\n            }\n          }\n        ],\n        [\n          {\n            \"type\": \"images\",\n            \"value\": {\n              \"images\": [\n                {\n                  \"id\": \"bfb5dd63-696c-4f1c-b98c-8e7f86fe069f\",\n                  \"frame\": \"large\",\n                  \"sizes\": {\n                    \"full\": {\n                      \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n                    },\n                    \"large\": {\n                      \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n                    },\n                    \"small_square\": {\n                      \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/bfb5dd63-696c-4f1c-b98c-8e7f86fe069f.jpg\"\n                    }\n                  },\n                  \"title\": \"\",\n                  \"width\": 4287,\n                  \"height\": 2706,\n                  \"description\": \"\"\n                }\n              ]\n            }\n          },\n          {\n            \"type\": \"heading3\",\n            \"value\": {\n              \"text\": \"제목\"\n            }\n          },\n          {\n            \"type\": \"text\",\n            \"value\": {\n              \"text\": \"짧은 설명이 들어가면 좋겠습니 다. 모든 임베딩에 균일하게 최대 3줄 정도면 적당하겠군요.\"\n            }\n          }\n        ]\n      ]\n    }\n  },\n  {\n    \"type\": \"images\",\n    \"value\": {\n      \"images\": [\n        {\n          \"title\": \"\",\n          \"frame\": \"original\",\n          \"cloudinaryId\": \"dd5bc1ab-24b4-4d2a-ad42-9b885740f9ef\",\n          \"id\": \"dd5bc1ab-24b4-4d2a-ad42-9b885740f9ef\",\n          \"type\": \"image\",\n          \"sizes\": {\n            \"full\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/dd5bc1ab-24b4-4d2a-ad42-9b885740f9ef.jpeg\"\n            },\n            \"large\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/dd5bc1ab-24b4-4d2a-ad42-9b885740f9ef.jpeg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/dd5bc1ab-24b4-4d2a-ad42-9b885740f9ef.jpeg\"\n            }\n          },\n          \"source\": {},\n          \"width\": 1080,\n          \"height\": 1080,\n          \"cloudinaryBucket\": \"triple-cms\"\n        },\n        {\n          \"title\": \"\",\n          \"frame\": \"original\",\n          \"cloudinaryId\": \"23f458de-46a4-44fd-aa63-374a2edf3bca\",\n          \"id\": \"23f458de-46a4-44fd-aa63-374a2edf3bca\",\n          \"type\": \"image\",\n          \"sizes\": {\n            \"full\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/23f458de-46a4-44fd-aa63-374a2edf3bca.jpeg\"\n            },\n            \"large\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/23f458de-46a4-44fd-aa63-374a2edf3bca.jpeg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/23f458de-46a4-44fd-aa63-374a2edf3bca.jpeg\"\n            }\n          },\n          \"source\": {},\n          \"width\": 1080,\n          \"height\": 1078,\n          \"cloudinaryBucket\": \"triple-cms\"\n        },\n        {\n          \"title\": \"\",\n          \"frame\": \"original\",\n          \"cloudinaryId\": \"6d795778-6586-4a2e-a841-af3198cb1603\",\n          \"id\": \"6d795778-6586-4a2e-a841-af3198cb1603\",\n          \"type\": \"image\",\n          \"sizes\": {\n            \"full\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/6d795778-6586-4a2e-a841-af3198cb1603.jpeg\"\n            },\n            \"large\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/6d795778-6586-4a2e-a841-af3198cb1603.jpeg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/6d795778-6586-4a2e-a841-af3198cb1603.jpeg\"\n            }\n          },\n          \"source\": {},\n          \"width\": 2048,\n          \"height\": 1366,\n          \"cloudinaryBucket\": \"triple-cms\"\n        }\n      ],\n      \"display\": \"gapless-block\"\n    }\n  },\n  {\n    \"type\": \"video\",\n    \"value\": {\n      \"provider\": \"youtube\",\n      \"identifier\": \"hYIe4VrfHoA\"\n    }\n  },\n  {\n    \"type\": \"hr4\"\n  },\n  {\n    \"type\": \"hr5\"\n  },\n  {\n    \"type\": \"hr6\"\n  },\n  {\n    \"type\": \"links\",\n    \"value\": {\n      \"links\": [\n        {\n          \"href\": \"https://triple-dev.titicaca-corp.com/entry/posts/f4ce0a73-10e0-406c-ae07-f812eb11e0fe?_triple_no_navbar\",\n          \"label\": \"다른포스트\"\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"pois\",\n    \"value\": {\n      \"pois\": [\n        {\n          \"id\": \"1239f853-d0b3-40e3-9c96-1ae7bb0c24ef\",\n          \"type\": \"restaurant\",\n          \"price\": \"₩100100\",\n          \"source\": {\n            \"id\": \"1239f853-d0b3-40e3-9c96-1ae7bb0c24ef\",\n            \"type\": \"restaurant\",\n            \"areas\": [\n              {\n                \"name\": \"하카타\"\n              }\n            ],\n            \"grade\": 30,\n            \"image\": {\n              \"id\": \"ec56bd12-9dd3-4826-904a-e4877ea1eab1\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/ec56bd12-9dd3-4826-904a-e4877ea1eab1.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/ec56bd12-9dd3-4826-904a-e4877ea1eab1.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/ec56bd12-9dd3-4826-904a-e4877ea1eab1.jpg\"\n                }\n              },\n              \"title\": null,\n              \"sourceUrl\": \"http://blog.naver.com/99s_asia/40168557367\",\n              \"description\": null\n            },\n            \"names\": {\n              \"en\": \"Hakata Famous Yoshizuka Eel Restaurant\",\n              \"ko\": \"요시즈카 우나기야\",\n              \"local\": \"博多名代 吉塚うなぎ屋\"\n            },\n            \"comment\": \"140년 전통을 잇는 장어덮밥을 맛볼 수 있는 곳\",\n            \"location\": [130.408868, 33.592528],\n            \"regionId\": \"92ba56af-a93e-4877-a46a-3b84cf1af0cf\",\n            \"categories\": [\n              {\n                \"name\": \"음식점\"\n              }\n            ],\n            \"pointGeolocation\": {\n              \"type\": \"Point\",\n              \"coordinates\": [130.408868, 33.592528]\n            }\n          },\n          \"buttonType\": \"price\",\n          \"nameOverride\": null\n        },\n        {\n          \"id\": \"0d11433e-db37-4168-bb3c-3cf20f3ddfbb\",\n          \"type\": \"hotel\",\n          \"source\": {\n            \"id\": \"0d11433e-db37-4168-bb3c-3cf20f3ddfbb\",\n            \"tags\": [\n              {\n                \"name\": \"아이와 함께\"\n              },\n              {\n                \"name\": \"수영장이 좋은\"\n              }\n            ],\n            \"type\": \"hotel\",\n            \"areas\": [\n              {\n                \"name\": \"화이트 비치 북쪽\"\n              }\n            ],\n            \"grade\": 10,\n            \"image\": {\n              \"id\": \"f54226e0-dc8e-430e-aa8a-316b71dd5609\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f54226e0-dc8e-430e-aa8a-316b71dd5609.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f54226e0-dc8e-430e-aa8a-316b71dd5609.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f54226e0-dc8e-430e-aa8a-316b71dd5609.jpg\"\n                }\n              },\n              \"title\": null,\n              \"sourceUrl\": \"https://www.facebook.com/altavistadeboracay/photos/a.312816762002.159781.267575457002/10153848660612003/?type=3&theater\",\n              \"description\": null\n            },\n            \"names\": {\n              \"en\": \"Alta Vista De Boracay\",\n              \"ko\": \"알타 비스타 데 보라카이\",\n              \"local\": \"Alta Vista De Boracay\"\n            },\n            \"comment\": \"수상스포츠의 천국 야팍 지역에 위치한 실용적인 객실을 갖춘 리조트\",\n            \"location\": [121.91238, 11.98796],\n            \"regionId\": \"22d7cfbd-2210-4ccb-94c4-cf1d97614dfe\",\n            \"starRating\": 4,\n            \"pointGeolocation\": {\n              \"type\": \"Point\",\n              \"coordinates\": [121.91238, 11.98796]\n            }\n          }\n        },\n        {\n          \"id\": \"06b4bbb2-aacc-4b46-ac53-d9ccca29be9d\",\n          \"type\": \"hotel\",\n          \"source\": {\n            \"id\": \"06b4bbb2-aacc-4b46-ac53-d9ccca29be9d\",\n            \"type\": \"hotel\",\n            \"grade\": 10,\n            \"image\": {\n              \"id\": \"45b25738-858c-49c4-ae39-75f6628d3d7c\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/45b25738-858c-49c4-ae39-75f6628d3d7c.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/45b25738-858c-49c4-ae39-75f6628d3d7c.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/45b25738-858c-49c4-ae39-75f6628d3d7c.jpg\"\n                }\n              },\n              \"title\": null,\n              \"sourceUrl\": \"https://www.expedia.com/\",\n              \"description\": null\n            },\n            \"names\": {\n              \"en\": \"The Bicycle Hotel & Casino\",\n              \"ko\": \"더 바이시클 호텔 & 카지노\",\n              \"local\": \"The Bicycle Hotel & Casino\"\n            },\n            \"comment\": \"라이브 공연부터 파티까지 다양한 즐길 거리와 넓은 객실을 갖춘 호텔\",\n            \"pricing\": {\n              \"promoText\": \"최대 16%\",\n              \"nightlyPrice\": 252685,\n              \"nightlyBasePrice\": 303000,\n              \"clubPromotionRate\": 3,\n              \"nightlyPriceHotelPromotionApplied\": 260500\n            },\n            \"location\": [-118.16585, 33.96587],\n            \"regionId\": \"25f11d9d-7267-43da-a3ae-cb12b1042380\",\n            \"starRating\": 4,\n            \"pointGeolocation\": {\n              \"type\": \"Point\",\n              \"coordinates\": [-118.16585, 33.96587]\n            }\n          }\n        }\n      ],\n      \"display\": \"default\"\n    }\n  },\n  {\n    \"type\": \"text\",\n    \"value\": {\n      \"rich\": true,\n      \"text\": \"우리는 하고 싶은 일보다는 해야하는 일에 훨씬 많은 일상을 보낸다. 그런 일상에서 벗어나고자 떠나는 여행 역시 ‘여기는 가봐야지, 이건 먹어봐야지’의 해야할 일들로 가득 채워져 있다. 이번 휴가만은, 빡빡한 일정의 패키지같은 여행 대신, 진짜 로망했던 순간들이 실현되는 시간으로 만들어보는건 어떨까? 그 특별한 휴가를 위한 출발점은, ‘어디로 갈까' 대신 ‘어떻게 보낼까'를 먼저 상상하는 것! 그 상상이 시작 되는 순간, 당신의 완벽한 휴가도 시작된다.\\n\",\n      \"rawHTML\": \"<p>우리는 하고 싶은 일보다는 해야하는 일에 훨씬 많은 일상을 보낸다. 그런 일상에서 벗어나고자 떠나는 여행 역시 ‘여기는 가봐야지, 이건 먹어봐야지’의 해야할 일들로 가득 채워져 있다. 이번 휴가만은, 빡빡한 일정의 패키지같은 여행 대신, 진짜 <strong>로망했던 순간</strong>들이 실현되는 시간으로 만들어보는건 어떨까? 그 특별한 휴가를 위한 출발점은, ‘어디로 갈까' 대신 ‘어떻게 보낼까'를 먼저 상상하는 것! 그 상상이 시작 되는 순간, 당신의 완벽한 휴가도 시작된다.</p>\\n\",\n      \"markdownText\": \"우리는 하고 싶은 일보다는 해야하는 일에 훨씬 많은 일상을 보낸다. 그런 일상에서 벗어나고자 떠나는 여행 역시 ‘여기는 가봐야지, 이건 먹어봐야지’의 해야할 일들로 가득 채워져 있다. 이번 휴가만은, 빡빡한 일정의 패키지같은 여행 대신, 진짜 **로망했던 순간**들이 실현되는 시간으로 만들어보는건 어떨까? 그 특별한 휴가를 위한 출발점은, ‘어디로 갈까' 대신 ‘어떻게 보낼까'를 먼저 상상하는 것! 그 상상이 시작 되는 순간, 당신의 완벽한 휴가도 시작된다.\"\n    }\n  },\n  {\n    \"type\": \"note\",\n    \"value\": {\n      \"body\": \"목적지로 바로 가지 않고, 중간 지점에서 잠시 머무는 단기 체류를 뜻한다. 보통 경유 시간인 3-4시간 정도가 아니라 24시간 이상을 뜻하기 때문에 관광과 숙박이 가능한 것이 특징. 일부 항공사는 스탑오버시 무료 관광을 제공하니 참고할것!\",\n      \"title\": \"잠깐! 스탑오버(Stopover)란?\"\n    }\n  },\n  {\n    \"type\": \"hr1\"\n  },\n  {\n    \"type\": \"images\",\n    \"value\": {\n      \"images\": []\n    }\n  },\n  {\n    \"type\": \"heading1\",\n    \"value\": {\n      \"text\": \"스탑오버 추천 여행지 Best 3\",\n      \"headline\": \"\",\n      \"emphasize\": true\n    }\n  },\n  {\n    \"type\": \"heading1\",\n    \"value\": {\n      \"text\": \"홍콩 w. 캐세이퍼시픽\",\n      \"headline\": \"\"\n    }\n  },\n  {\n    \"type\": \"text\",\n    \"value\": {\n      \"text\": \"페리, MTR, 버스, 트램 등 교통이 잘 발달되어 있어 이동하기 쉬움. 공항에서 도심까지의 이동이 간단하고 빠름. 침사추이까지 19분이면 갈 수 있음. \\n공항으로 돌아갈때 홍콩역, 구룡역에서 도심 무료 체크인 서비스를 제공하며, 한국인은 자동 출입국 심사 서비스를 받을 수 있어 수속이 빠르고 편하다.\"\n    }\n  },\n  {\n    \"type\": \"text\",\n    \"value\": {\n      \"text\": \"페리, MTR, 버스, 트램 등 교통이 잘 발달되어 있어 이동하기 쉬움. 공항에서 도심까지의 이동이 간단하고 빠름. 침사추이까지 19분이면 갈 수 있음. \\n공항으로 돌아갈때 홍콩역, 구룡역에서 도심 무료 체크인 서비스를 제공하며, 한국인은 자동 출입국 심사 서비스를 받을 수 있어 수속이 빠르고 편하다.\"\n    }\n  },\n  {\n    \"type\": \"heading3\",\n    \"value\": {\n      \"text\": \"왜 홍콩일까?\"\n    }\n  },\n  {\n    \"type\": \"text\",\n    \"value\": {\n      \"text\": \"・ MTR(지하철), 버스, 트램 그리고 페리까지! 편리한 교통으로 쉽게 이동\\n・ 공항에서 도심인 침사추이까지 공항철도로 단 19분 소요\\n・ 홍콩역, 구룡역에서 제공되는 무료 체크인 서비스. 한국인은 자동 출입국 심사 서비스로 더욱 빠르게\"\n    }\n  },\n  {\n    \"type\": \"heading4\",\n    \"value\": {\n      \"text\": \"알아두면 좋아요!\"\n    }\n  },\n  {\n    \"type\": \"text\",\n    \"value\": {\n      \"text\": \"캐세이퍼시픽 항공사에서는 1회 무료 스톱오버 서 비스를 제공하며, 출국편/귀국편 중 선택할 수 있다.\"\n    }\n  },\n  {\n    \"type\": \"hr3\"\n  },\n  {\n    \"type\": \"hr1\"\n  },\n  {\n    \"type\": \"images\",\n    \"value\": {\n      \"images\": [\n        {\n          \"id\": \"aedabd71-174b-40e2-95e0-b47d2d9f5132\",\n          \"frame\": \"small\",\n          \"sizes\": {\n            \"full\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/aedabd71-174b-40e2-95e0-b47d2d9f5132.jpg\"\n            },\n            \"large\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/aedabd71-174b-40e2-95e0-b47d2d9f5132.jpg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/aedabd71-174b-40e2-95e0-b47d2d9f5132.jpg\"\n            }\n          },\n          \"title\": \"\",\n          \"width\": 3072,\n          \"height\": 2048,\n          \"description\": \"\"\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"links\",\n    \"value\": {\n      \"links\": [\n        {\n          \"href\": \"https://triple-dev.titicaca-corp.com/entry/posts/f4ce0a73-10e0-406c-ae07-f812eb11e0fe?_triple_no_navbar\",\n          \"label\": \"다른포스트\"\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"heading1\",\n    \"value\": {\n      \"text\": \"방콕\",\n      \"headline\": \"복잡한 도심속 화려한 하루\"\n    }\n  },\n  {\n    \"type\": \"text\",\n    \"value\": {\n      \"text\": \"높은 건물들 사이, 차들과 릭샤가 클락션을 울려 대는 소리에 깬다. 5성급 호텔, 뽀송한 침대 속에서 일어나, vip 클럽 라운지에서 커피 한잔 후, 인스타제닉을 장식할 유명 브런치 카페로 간다. 오후에는 스파/마사지로 쌓였던 피로를 날려버려야지. 다시 호텔로 돌아와 드레스업! 평소와는 달리, 조금은 과감한 원피스를 입고, 예약해둔 미슐랭 레스토랑으로 출발. 돌아오는 길엔, 이 동네 멋진 언니, 오빠들 모두 모인다는 바로 그 루프탑에서 칵테일 한잔으로 마무리!\"\n    }\n  },\n  {\n    \"type\": \"links\",\n    \"value\": {\n      \"links\": [\n        {\n          \"href\": \"https://www.naver.com/\",\n          \"label\": \"방콕 3박 4일 가이드\"\n        },\n        {\n          \"href\": \"dev-soto:///regions/23c5965b-01ad-486b-a694-a2ced15f245c/attractions/c3d2ef37-f0ef-42b4-a210-039dc08143bf\",\n          \"label\": \"도쿄 타워\"\n        },\n        {\n          \"href\": \"dev-soto:///regions/71476976-cf9a-4ae8-a60f-76e6fb26900d/attractions/9cd99dd6-fe46-42a5-ad19-4ad277aec569\",\n          \"label\": \"신사이바시\"\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"links\",\n    \"value\": {\n      \"links\": [\n        {\n          \"href\": \"dev-soto:///regions/23c5965b-01ad-486b-a694-a2ced15f245c\",\n          \"label\": \"도쿄 바로가기\"\n        }\n      ],\n      \"display\": \"button\"\n    }\n  },\n  {\n    \"type\": \"regions\",\n    \"value\": {\n      \"regions\": [\n        {\n          \"id\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n          \"type\": \"region\",\n          \"source\": {\n            \"id\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n            \"type\": \"region\",\n            \"areas\": [\n              {\n                \"id\": \"af16bd5f-c629-440c-af03-39ca0fc68a02\",\n                \"name\": \"수쿰빗\",\n                \"hotelsCount\": 476,\n                \"hotelArticleId\": \"c7c6175a-c84b-43fd-aed1-59ffb8ad6b64\",\n                \"attractionsCount\": 362,\n                \"restaurantsCount\": 1716\n              },\n              {\n                \"id\": \"2be2e9c8-322a-4be0-9451-b826b66960a0\",\n                \"name\": \"씨암, 칫롬\",\n                \"hotelsCount\": 298,\n                \"hotelArticleId\": \"bfbeca76-e271-4912-b238-bc528dfac320\",\n                \"attractionsCount\": 422,\n                \"restaurantsCount\": 4395\n              },\n              {\n                \"id\": \"3c6f853c-ee6f-4fb0-a93b-d1335cc48c9d\",\n                \"name\": \"올드시티\",\n                \"hotelsCount\": 275,\n                \"hotelArticleId\": \"5949e605-4e99-4bd9-ab50-60d7a68e126f\",\n                \"attractionsCount\": 230,\n                \"restaurantsCount\": 546\n              },\n              {\n                \"id\": \"c92fd762-7588-4d94-a2ac-bef79dbe2535\",\n                \"name\": \"사톤, 실롬, 리버사이드\",\n                \"hotelsCount\": 317,\n                \"hotelArticleId\": \"f9cf056d-f263-4033-8115-cdad061d1ee5\",\n                \"attractionsCount\": 208,\n                \"restaurantsCount\": 871\n              },\n              {\n                \"id\": \"848ff90e-5fc6-4c3c-9ea6-354f35e4e1cf\",\n                \"name\": \"공항 근처\",\n                \"hotelsCount\": 25,\n                \"hotelArticleId\": \"dc6e370b-46ec-4de4-98b4-c842f5a856b0\",\n                \"attractionsCount\": 3,\n                \"restaurantsCount\": 12\n              },\n              {\n                \"id\": \"17b4bb3f-975e-4516-816c-1375c8b4b4a1\",\n                \"name\": \"파타야\",\n                \"hotelsCount\": 1204,\n                \"hotelArticleId\": \"498c1020-405d-424d-bc49-7521842029e6\",\n                \"attractionsCount\": 120,\n                \"restaurantsCount\": 947\n              }\n            ],\n            \"names\": {\n              \"en\": \"Bangkok\",\n              \"ko\": \"방콕\",\n              \"local\": null\n            },\n            \"style\": {\n              \"logoImageUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/logo_image/edf1982d-c835-43a7-b06b-af43acbb6f38-1504575746.png\",\n              \"backgroundImageUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/background_image/edf1982d-c835-43a7-b06b-af43acbb6f38-1489569899.png\",\n              \"backgroundVideoUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/background_video/edf1982d-c835-43a7-b06b-af43acbb6f38-1489569905.mp4\",\n              \"blurredBackgroundImageUrl\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/region_media/blurred_background_image/edf1982d-c835-43a7-b06b-af43acbb6f38-1490177274.png\"\n            },\n            \"visible\": true,\n            \"priority\": 7700,\n            \"terminals\": [\n              {\n                \"id\": \"22feaa3e-79f5-4eff-86e2-4fa70bdb748f\",\n                \"type\": \"attraction\",\n                \"source\": {\n                  \"id\": \"22feaa3e-79f5-4eff-86e2-4fa70bdb748f\",\n                  \"type\": \"attraction\",\n                  \"areas\": [\n                    {\n                      \"name\": \"공항 근처\"\n                    }\n                  ],\n                  \"grade\": 1000,\n                  \"image\": {\n                    \"id\": \"3c528ce4-3146-427c-bbd2-e9b6e68306a9\",\n                    \"sizes\": {\n                      \"full\": {\n                        \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/3c528ce4-3146-427c-bbd2-e9b6e68306a9.jpg\"\n                      },\n                      \"large\": {\n                        \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/3c528ce4-3146-427c-bbd2-e9b6e68306a9.jpg\"\n                      },\n                      \"small_square\": {\n                        \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/3c528ce4-3146-427c-bbd2-e9b6e68306a9.jpg\"\n                      }\n                    },\n                    \"title\": null,\n                    \"sourceUrl\": \"http://www.e-architect.co.uk/thailand/suvarnabhumi-airport\",\n                    \"description\": null\n                  },\n                  \"names\": {\n                    \"en\": \"Suvarnabhumi International Airport\",\n                    \"ko\": \"수완나품 국제공항\",\n                    \"local\": \"ท่าอากาศยานสุวรรณภูมิ\"\n                  },\n                  \"comment\": null,\n                  \"location\": [100.7501124, 13.6899991],\n                  \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n                  \"categories\": [\n                    {\n                      \"name\": \"관광명소\"\n                    }\n                  ],\n                  \"pointGeolocation\": {\n                    \"type\": \"Point\",\n                    \"coordinates\": [100.7501124, 13.6899991]\n                  }\n                }\n              },\n              {\n                \"id\": \"083ba341-aba3-4226-8de0-4ca6f484a756\",\n                \"type\": \"attraction\",\n                \"source\": {\n                  \"id\": \"083ba341-aba3-4226-8de0-4ca6f484a756\",\n                  \"type\": \"attraction\",\n                  \"grade\": 1000,\n                  \"image\": {\n                    \"id\": \"b6fd7b8f-5a98-4ef9-85e8-e03ed85a5d1c\",\n                    \"sizes\": {\n                      \"full\": {\n                        \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/b6fd7b8f-5a98-4ef9-85e8-e03ed85a5d1c.jpg\"\n                      },\n                      \"large\": {\n                        \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/b6fd7b8f-5a98-4ef9-85e8-e03ed85a5d1c.jpg\"\n                      },\n                      \"small_square\": {\n                        \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/b6fd7b8f-5a98-4ef9-85e8-e03ed85a5d1c.jpg\"\n                      }\n                    },\n                    \"title\": null,\n                    \"sourceUrl\": \"http://www.bangkok-maps.com/bangkokairport.htm\",\n                    \"description\": null\n                  },\n                  \"names\": {\n                    \"en\": \"Donmueang International Airport\",\n                    \"ko\": \"돈므앙 국제공항\",\n                    \"local\": \"ท่าอากาศยานดอนเมือง\"\n                  },\n                  \"comment\": null,\n                  \"location\": [100.6041987, 13.9132602],\n                  \"regionId\": \"edf1982d-c835-43a7-b06b-af43acbb6f38\",\n                  \"categories\": [\n                    {\n                      \"name\": \"관광명소\"\n                    }\n                  ],\n                  \"pointGeolocation\": {\n                    \"type\": \"Point\",\n                    \"coordinates\": [100.6041987, 13.9132602]\n                  }\n                }\n              }\n            ],\n            \"preferences\": {\n              \"time_zone\": \"Asia/Bangkok\",\n              \"currencies\": [\"THB\", \"USD\"],\n              \"recommended_duration\": 3,\n              \"default_search_radius\": 1000\n            },\n            \"hotelTagListings\": [\n              {\n                \"tag\": {\n                  \"id\": \"59e7e070-2c7a-4087-9751-c07f482002e2\",\n                  \"name\": \"교통이 편리한\"\n                }\n              },\n              {\n                \"tag\": {\n                  \"id\": \"aea83659-f4d8-4231-bc30-c7050447ebed\",\n                  \"name\": \"수영장이 좋은\"\n                }\n              },\n              {\n                \"tag\": {\n                  \"id\": \"df93d846-5a6c-42a5-a067-6301b851e7ca\",\n                  \"name\": \"쇼핑하기 편한\"\n                }\n              },\n              {\n                \"tag\": {\n                  \"id\": \"1259446d-d7ac-46ea-ac8f-87e4935e8851\",\n                  \"name\": \"뷰가 좋은\"\n                }\n              },\n              {\n                \"tag\": {\n                  \"id\": \"064a7031-d1da-4bb2-ae10-e111c0d8e103\",\n                  \"name\": \"조식이 맛있는\"\n                }\n              },\n              {\n                \"tag\": {\n                  \"id\": \"6b107177-473b-442a-b595-ed78bde5cad2\",\n                  \"name\": \"부티크 호텔\"\n                }\n              },\n              {\n                \"tag\": {\n                  \"id\": \"1d6641d3-444c-4922-9acf-6bddefb7116c\",\n                  \"name\": \"아이와 함께\"\n                }\n              },\n              {\n                \"tag\": {\n                  \"id\": \"73ee800e-c5b8-4517-80d7-92a87546cd07\",\n                  \"name\": \"전용비치\"\n                }\n              }\n            ],\n            \"weatherForecastSpots\": [\n              {\n                \"id\": \"5c29d743-8b27-456a-9029-69cad2b83a67\",\n                \"name\": \"방콕\",\n                \"geolocation\": {\n                  \"type\": \"Point\",\n                  \"coordinates\": [100.4930274, 13.7248946]\n                }\n              },\n              {\n                \"id\": \"80a07e7d-6fba-4d44-851a-d568cf464e56\",\n                \"name\": \"파타야\",\n                \"geolocation\": {\n                  \"type\": \"Point\",\n                  \"coordinates\": [100.90927999859616, 12.940290614215169]\n                }\n              }\n            ],\n            \"articleCategoryListings\": [\n              {\n                \"picture\": {\n                  \"id\": \"a9464183-b992-4bc8-9ddd-5219a9239406\",\n                  \"sizes\": {\n                    \"full\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/a9464183-b992-4bc8-9ddd-5219a9239406.jpg\"\n                    },\n                    \"large\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/a9464183-b992-4bc8-9ddd-5219a9239406.jpg\"\n                    },\n                    \"small_square\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/a9464183-b992-4bc8-9ddd-5219a9239406.jpg\"\n                    }\n                  },\n                  \"title\": null,\n                  \"sourceUrl\": null,\n                  \"description\": null\n                },\n                \"category\": {\n                  \"id\": \"b1462717-952f-400c-901a-0b85d13c1331\",\n                  \"name\": \"준비\",\n                  \"icons\": {}\n                },\n                \"description\": \"여행 전,\\n필수 체크사항\"\n              },\n              {\n                \"picture\": {\n                  \"id\": \"f3a48937-7fc0-453e-8a0e-2f34d068ece1\",\n                  \"sizes\": {\n                    \"full\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f3a48937-7fc0-453e-8a0e-2f34d068ece1.jpg\"\n                    },\n                    \"large\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f3a48937-7fc0-453e-8a0e-2f34d068ece1.jpg\"\n                    },\n                    \"small_square\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f3a48937-7fc0-453e-8a0e-2f34d068ece1.jpg\"\n                    }\n                  },\n                  \"title\": null,\n                  \"sourceUrl\": null,\n                  \"description\": null\n                },\n                \"category\": {\n                  \"id\": \"d1fa8256-a8f2-4a86-b293-27a7b65695bc\",\n                  \"name\": \"정보\",\n                  \"icons\": null\n                },\n                \"description\": \"알면 쓸모있는\\n방콕 정보와 팁\"\n              },\n              {\n                \"picture\": {\n                  \"id\": \"ee3eb05a-ea02-4b5e-bb44-a0aabf5173bf\",\n                  \"sizes\": {\n                    \"full\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/ee3eb05a-ea02-4b5e-bb44-a0aabf5173bf.jpg\"\n                    },\n                    \"large\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/ee3eb05a-ea02-4b5e-bb44-a0aabf5173bf.jpg\"\n                    },\n                    \"small_square\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/ee3eb05a-ea02-4b5e-bb44-a0aabf5173bf.jpg\"\n                    }\n                  },\n                  \"title\": null,\n                  \"sourceUrl\": null,\n                  \"description\": null\n                },\n                \"category\": {\n                  \"id\": \"91e59cd2-b45b-4cfe-8b7b-69dd1a546fe0\",\n                  \"name\": \"관광\",\n                  \"icons\": null\n                },\n                \"description\": \"볼거리, 즐길거리의\\n모든 것\"\n              },\n              {\n                \"picture\": {\n                  \"id\": \"5de75dca-3fa7-44c1-8b97-5c9f320c0171\",\n                  \"sizes\": {\n                    \"full\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/5de75dca-3fa7-44c1-8b97-5c9f320c0171.jpg\"\n                    },\n                    \"large\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/5de75dca-3fa7-44c1-8b97-5c9f320c0171.jpg\"\n                    },\n                    \"small_square\": {\n                      \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/5de75dca-3fa7-44c1-8b97-5c9f320c0171.jpg\"\n                    }\n                  },\n                  \"title\": null,\n                  \"sourceUrl\": null,\n                  \"description\": null\n                },\n                \"category\": {\n                  \"id\": \"5563700e-b3e8-482d-af61-616e8c40620f\",\n                  \"name\": \"맛집\",\n                  \"icons\": null\n                },\n                \"description\": \"방콕\\n먹킷리스트\"\n              }\n            ],\n            \"attractionCategoryListings\": [\n              {\n                \"picture\": null,\n                \"category\": {\n                  \"id\": \"abf9191a-fe28-4b36-8a9f-52a5e6d5666a\",\n                  \"name\": \"관광명소\",\n                  \"icons\": {\n                    \"on\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/a22144ce-9cbe-46f5-a8a9-615d9e51b62c-1487926449.png\"\n                        }\n                      }\n                    },\n                    \"off\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/da7f5342-4474-4a28-812a-ca64d8525729-1487925715.png\"\n                        }\n                      }\n                    }\n                  }\n                },\n                \"description\": null\n              },\n              {\n                \"picture\": null,\n                \"category\": {\n                  \"id\": \"b7e3aaee-4a0e-40b2-8ffa-99b3ec3cdff5\",\n                  \"name\": \"테마/체험\",\n                  \"icons\": {\n                    \"on\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/c594a106-2b2b-4ba2-8b7e-f96a0d0559c4-1487926499.png\"\n                        }\n                      }\n                    },\n                    \"off\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/1d2ae3ee-af99-4098-bf08-51b665607fb1-1487926477.png\"\n                        }\n                      }\n                    }\n                  }\n                },\n                \"description\": null\n              },\n              {\n                \"picture\": null,\n                \"category\": {\n                  \"id\": \"95556107-682f-4335-bdd6-43175ba34ef4\",\n                  \"name\": \"쇼핑\",\n                  \"icons\": {\n                    \"on\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/9b7facf3-fca9-4e19-ae16-6c89ad04e3b5-1487926656.png\"\n                        }\n                      }\n                    },\n                    \"off\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/f1f3d698-3b59-45a6-a93c-bf86b36fc137-1487926642.png\"\n                        }\n                      }\n                    }\n                  }\n                },\n                \"description\": null\n              }\n            ],\n            \"restaurantCategoryListings\": [\n              {\n                \"picture\": null,\n                \"category\": {\n                  \"id\": \"280a5533-c9d0-48e8-8cad-b8c651a53d86\",\n                  \"name\": \"음식점\",\n                  \"icons\": {\n                    \"on\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/650ce7bd-0ef7-4ffc-9816-7aa5dd729108-1487926580.png\"\n                        }\n                      }\n                    },\n                    \"off\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/20b2e3de-2208-416c-9fb2-8a37f3719699-1487926560.png\"\n                        }\n                      }\n                    }\n                  }\n                },\n                \"description\": null\n              },\n              {\n                \"picture\": null,\n                \"category\": {\n                  \"id\": \"55530454-0f37-4d35-a476-5d5100d2b449\",\n                  \"name\": \"카페/디저트\",\n                  \"icons\": {\n                    \"on\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/d0f2cbaa-bedd-4571-8273-6e5f852410da-1487926543.png\"\n                        }\n                      }\n                    },\n                    \"off\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/57b156c4-8644-4622-8984-7dbfe528fafe-1487926526.png\"\n                        }\n                      }\n                    }\n                  }\n                },\n                \"description\": null\n              },\n              {\n                \"picture\": null,\n                \"category\": {\n                  \"id\": \"b60013ce-5fd8-4e10-a309-0e50f47ed66e\",\n                  \"name\": \"술집/바\",\n                  \"icons\": {\n                    \"on\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/f441338d-47df-4f6d-bc74-04c575736489-1487926612.png\"\n                        }\n                      }\n                    },\n                    \"off\": {\n                      \"sizes\": {\n                        \"large\": {\n                          \"url\": null\n                        },\n                        \"small\": {\n                          \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/icons/small_image/546a044a-b80e-430b-87a3-814c491b6189-1487926594.png\"\n                        }\n                      }\n                    }\n                  }\n                },\n                \"description\": null\n              }\n            ]\n          },\n          \"nameOverride\": null\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"hr1\"\n  },\n  {\n    \"type\": \"images\",\n    \"value\": {\n      \"images\": [\n        {\n          \"id\": \"11cc2df3-6841-4764-a648-9ee4026fb4d2\",\n          \"frame\": \"small\",\n          \"sizes\": {\n            \"full\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/11cc2df3-6841-4764-a648-9ee4026fb4d2.jpg\"\n            },\n            \"large\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/11cc2df3-6841-4764-a648-9ee4026fb4d2.jpg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/11cc2df3-6841-4764-a648-9ee4026fb4d2.jpg\"\n            }\n          },\n          \"title\": \"이미지 설명은 한줄로 간략하게!\",\n          \"width\": 1280,\n          \"height\": 768,\n          \"sourceUrl\": \"https://blog.naver.com/userid/imageimageimageimage\",\n          \"description\": \"\"\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"hr3\"\n  },\n  {\n    \"type\": \"text\",\n    \"value\": {\n      \"text\": \"애초에 타로에게 관심이 생긴 것은 그의 어머니 오카모토 가노코 때문이었다. 그녀는 재능 넘치는 작가였지만 어머니로서는 최악이었다고 한다. 남편(당대의 유명 만화가 오카모토 잇페이)의 용인하에 집에 대학생 애인을 끌어들여 셋이 함께 산 적도 있으며(하지만 잇페이 역시 방탕하기로 유명한 남자였으니 가노코만 탓할 수는 없으리라), 서너 살이었던 타로가 놀아달라며 글을 쓰는 자신을 방해하면 허리띠로 옷장에 동여매기도 했다 한다. 그런 어머니를 둔 남자의 손끝에서 태어난 작품은 과연 어떨지 나는 궁금했다.\"\n    }\n  },\n  {\n    \"type\": \"hr2\"\n  },\n  {\n    \"type\": \"heading3\",\n    \"value\": {\n      \"text\": \"여기 가보면 어때요?\"\n    }\n  },\n  {\n    \"type\": \"pois\",\n    \"value\": {\n      \"pois\": [\n        {\n          \"id\": \"f79d6799-e5f0-4485-afb1-2c57c5eca661\",\n          \"type\": \"restaurant\",\n          \"source\": {\n            \"id\": \"f79d6799-e5f0-4485-afb1-2c57c5eca661\",\n            \"type\": \"restaurant\",\n            \"areas\": [\n              {\n                \"name\": \"호이안\"\n              }\n            ],\n            \"grade\": 10,\n            \"image\": {\n              \"id\": \"a20a9c41-9537-4431-94e0-2cfdb473d82e\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/a20a9c41-9537-4431-94e0-2cfdb473d82e.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/a20a9c41-9537-4431-94e0-2cfdb473d82e.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/a20a9c41-9537-4431-94e0-2cfdb473d82e.jpg\"\n                }\n              },\n              \"title\": null,\n              \"sourceUrl\": \"http://culinaryvietnam.com/2014/08/restaurants-in-hoi-an/\",\n              \"description\": null\n            },\n            \"names\": {\n              \"en\": \"Morning Glory Restaurant\",\n              \"ko\": \"모닝 글로리 레스토랑\",\n              \"local\": \"Morning Glory Restaurant\"\n            },\n            \"comment\": \"호이안의 분위기가 물씬 느껴지는 로컬 레스토랑\",\n            \"location\": [108.32762500305842, 15.876622855227817],\n            \"regionId\": \"22b60e7e-afc7-40e1-9237-8f31ed8a842d\",\n            \"categories\": [\n              {\n                \"name\": \"음식점\"\n              }\n            ],\n            \"pointGeolocation\": {\n              \"type\": \"Point\",\n              \"coordinates\": [108.32762500305842, 15.876622855227817]\n            }\n          }\n        },\n        {\n          \"id\": \"0997e895-37a0-47a4-a7c5-fa403a2b2fbe\",\n          \"type\": \"restaurant\",\n          \"source\": {\n            \"id\": \"0997e895-37a0-47a4-a7c5-fa403a2b2fbe\",\n            \"type\": \"restaurant\",\n            \"areas\": [\n              {\n                \"name\": \"차이나타운\"\n              }\n            ],\n            \"grade\": 10,\n            \"image\": {\n              \"id\": \"f4a8126b-8b3c-4bce-abaa-fb58fdf61532\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/f4a8126b-8b3c-4bce-abaa-fb58fdf61532.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/f4a8126b-8b3c-4bce-abaa-fb58fdf61532.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/f4a8126b-8b3c-4bce-abaa-fb58fdf61532.jpg\"\n                }\n              },\n              \"title\": null,\n              \"sourceUrl\": \"http://blog.naver.com/assamlike/220840334433\",\n              \"description\": null\n            },\n            \"names\": {\n              \"en\": \"Ya Kun Kaya Toast Far East Square\",\n              \"ko\": \"야 쿤 카야 토스트 파 이스트 스퀘어 점\",\n              \"local\": \"Ya Kun Kaya Toast Far East Square\"\n            },\n            \"comment\": \"싱가포르 국민 간식인 카야 토스트의 원조를 맛볼 수 있는 레스토랑\",\n            \"location\": [103.84793527116392, 1.283797277525225],\n            \"regionId\": \"5119daab-5538-433b-b593-1b96978ed7ca\",\n            \"categories\": [\n              {\n                \"name\": \"음식점\"\n              }\n            ],\n            \"pointGeolocation\": {\n              \"type\": \"Point\",\n              \"coordinates\": [103.84793527116392, 1.283797277525225]\n            }\n          }\n        },\n        {\n          \"id\": \"88b8b505-6569-4e2f-b31c-bbae3462e7d2\",\n          \"type\": \"restaurant\",\n          \"source\": {\n            \"id\": \"88b8b505-6569-4e2f-b31c-bbae3462e7d2\",\n            \"type\": \"restaurant\",\n            \"areas\": [\n              {\n                \"name\": \"시부야\"\n              }\n            ],\n            \"grade\": 10,\n            \"image\": {\n              \"id\": \"98797a3c-9972-4a4e-ae00-46c319255dbc\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/98797a3c-9972-4a4e-ae00-46c319255dbc.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/98797a3c-9972-4a4e-ae00-46c319255dbc.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/98797a3c-9972-4a4e-ae00-46c319255dbc.jpg\"\n                }\n              },\n              \"title\": null,\n              \"sourceUrl\": \"https://tabelog.com/kr/tokyo/A1303/A130301/13004624/dtlphotolst/4/\",\n              \"description\": null\n            },\n            \"names\": {\n              \"en\": \"Umegaoka Sushino Midori Shibuya\",\n              \"ko\": \"우메가오카 스시노 미도리 시부야 점\",\n              \"local\": \"梅丘寿司の美登利 渋谷店\"\n            },\n            \"comment\": \"가성비 최고의 유명 초밥집\",\n            \"location\": [139.698986, 35.658345],\n            \"regionId\": \"23c5965b-01ad-486b-a694-a2ced15f245c\",\n            \"categories\": [\n              {\n                \"name\": \"음식점\"\n              }\n            ],\n            \"pointGeolocation\": {\n              \"type\": \"Point\",\n              \"coordinates\": [139.698986, 35.658345]\n            }\n          }\n        }\n      ],\n      \"display\": \"list\"\n    }\n  },\n  {\n    \"type\": \"hr1\"\n  },\n  {\n    \"type\": \"hr3\"\n  },\n  {\n    \"type\": \"heading1\",\n    \"value\": {\n      \"text\": \"요코하마 주요 축제\",\n      \"headline\": \"\"\n    }\n  },\n  {\n    \"type\": \"hr1\"\n  },\n  {\n    \"type\": \"images\",\n    \"value\": {\n      \"images\": [\n        {\n          \"id\": \"abd7bfda-893a-4719-bffe-132d0afd84b2\",\n          \"frame\": \"huge\",\n          \"sizes\": {\n            \"full\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/abd7bfda-893a-4719-bffe-132d0afd84b2.jpg\"\n            },\n            \"large\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/abd7bfda-893a-4719-bffe-132d0afd84b2.jpg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/abd7bfda-893a-4719-bffe-132d0afd84b2.jpg\"\n            }\n          },\n          \"title\": \"\",\n          \"width\": 3072,\n          \"height\": 2048,\n          \"sourceUrl\": \"http://blog.naver.com/PostView.nhn?blogId=cmr7321&logNo=221048430836&redirect=Dlog&widgetTypeCall=true&directAccess=false\",\n          \"description\": \"\"\n        },\n        {\n          \"id\": \"f95e56d8-1341-4e0a-946d-c022f63c64c6\",\n          \"frame\": \"huge\",\n          \"sizes\": {\n            \"full\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/f95e56d8-1341-4e0a-946d-c022f63c64c6.jpg\"\n            },\n            \"large\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/f95e56d8-1341-4e0a-946d-c022f63c64c6.jpg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/f95e56d8-1341-4e0a-946d-c022f63c64c6.jpg\"\n            }\n          },\n          \"title\": \"\",\n          \"width\": 457,\n          \"height\": 155,\n          \"description\": \"\"\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"images\",\n    \"value\": {\n      \"images\": []\n    }\n  },\n  {\n    \"type\": \"images\",\n    \"value\": {\n      \"images\": [\n        {\n          \"id\": \"36b8e30e-34b7-454b-b575-dd61324e545c\",\n          \"frame\": \"medium\",\n          \"sizes\": {\n            \"full\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/36b8e30e-34b7-454b-b575-dd61324e545c.jpg\"\n            },\n            \"large\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/36b8e30e-34b7-454b-b575-dd61324e545c.jpg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/36b8e30e-34b7-454b-b575-dd61324e545c.jpg\"\n            }\n          },\n          \"title\": \"캡션이당\",\n          \"width\": 1728,\n          \"height\": 768,\n          \"description\": \"\"\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"images\",\n    \"value\": {\n      \"images\": [\n        {\n          \"id\": \"28c7b1ed-6f9e-4baa-935b-7d1bd9478a5b\",\n          \"frame\": \"large\",\n          \"sizes\": {\n            \"full\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/28c7b1ed-6f9e-4baa-935b-7d1bd9478a5b.jpg\"\n            },\n            \"large\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/28c7b1ed-6f9e-4baa-935b-7d1bd9478a5b.jpg\"\n            },\n            \"small_square\": {\n              \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/28c7b1ed-6f9e-4baa-935b-7d1bd9478a5b.jpg\"\n            }\n          },\n          \"title\": \"\",\n          \"width\": 1500,\n          \"height\": 1500,\n          \"description\": \"\"\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"hr1\"\n  },\n  {\n    \"type\": \"links\",\n    \"value\": {\n      \"links\": [\n        {\n          \"href\": \"ㅇㅇ\",\n          \"label\": \"얍\"\n        }\n      ],\n      \"display\": \"block\"\n    }\n  },\n  {\n    \"type\": \"hr1\"\n  },\n  {\n    \"type\": \"embedded\",\n    \"value\": {\n      \"entries\": [\n        [\n          {\n            \"type\": \"images\",\n            \"value\": {\n              \"images\": [\n                {\n                  \"id\": \"16e30680-ea16-402e-86b0-f781d48fb90b\",\n                  \"frame\": \"small\",\n                  \"sizes\": {\n                    \"full\": {\n                      \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_2048,h_2048,c_limit/16e30680-ea16-402e-86b0-f781d48fb90b.jpg\"\n                    },\n                    \"large\": {\n                      \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_1024,h_1024,c_limit/16e30680-ea16-402e-86b0-f781d48fb90b.jpg\"\n                    },\n                    \"small_square\": {\n                      \"url\": \"https://res.cloudinary.com/triple-dev/image/upload/w_256,h_256,c_fill/16e30680-ea16-402e-86b0-f781d48fb90b.jpg\"\n                    }\n                  },\n                  \"title\": \"\",\n                  \"width\": 1728,\n                  \"height\": 768,\n                  \"description\": \"\"\n                }\n              ]\n            }\n          },\n          {\n            \"type\": \"links\",\n            \"value\": {\n              \"links\": [\n                {\n                  \"href\": \"\",\n                  \"label\": \"스시잔마이\"\n                }\n              ],\n              \"display\": \"block\"\n            }\n          },\n          {\n            \"type\": \"text\",\n            \"value\": {\n              \"text\": \"도쿄에 여행가기에 가장 좋은 시기는 언제 도쿄의 월별 기온과 강우량\"\n            }\n          }\n        ]\n      ]\n    }\n  },\n  {\n    \"type\": \"buttons\",\n    \"value\": {\n      \"buttons\": [\n        {\n          \"href\": \"\",\n          \"label\": \"도쿄\"\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"pois\",\n    \"value\": {\n      \"pois\": [\n        {\n          \"id\": \"edcbad50-3581-4b8e-a36e-0de5379b5bd6\",\n          \"type\": \"hotel\",\n          \"source\": {\n            \"id\": \"edcbad50-3581-4b8e-a36e-0de5379b5bd6\",\n            \"type\": \"hotel\",\n            \"areas\": [\n              {\n                \"name\": \"에이샴플라\"\n              }\n            ],\n            \"grade\": 10,\n            \"image\": {\n              \"id\": \"d6b67fd8-b415-4d76-a666-c8983556fd3f\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/d6b67fd8-b415-4d76-a666-c8983556fd3f.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/d6b67fd8-b415-4d76-a666-c8983556fd3f.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/d6b67fd8-b415-4d76-a666-c8983556fd3f.jpg\"\n                }\n              },\n              \"title\": null,\n              \"sourceUrl\": \"https://www.expedia.com/\",\n              \"description\": null\n            },\n            \"names\": {\n              \"en\": \"Hotel Granvia\",\n              \"ko\": \"호텔 그란비아\",\n              \"local\": \"Hotel Granvia\"\n            },\n            \"comment\": \"고풍스러움과 럭셔리함을 살린 19세기 궁전 호텔\",\n            \"pricing\": {\n              \"promoText\": \"최대 25%\",\n              \"nightlyPrice\": 236486,\n              \"nightlyBasePrice\": 317600,\n              \"clubPromotionRate\": 3,\n              \"nightlyPriceHotelPromotionApplied\": 243800\n            },\n            \"location\": [2.169573, 41.389972],\n            \"regionId\": \"c883cd34-f5c2-4f0a-9960-5f963f9b2dbb\",\n            \"starRating\": 3,\n            \"pointGeolocation\": {\n              \"type\": \"Point\",\n              \"coordinates\": [2.169573, 41.389972]\n            }\n          }\n        },\n        {\n          \"id\": \"c2420b96-b610-4a7f-9916-aa6843608604\",\n          \"type\": \"attraction\",\n          \"source\": {\n            \"id\": \"c2420b96-b610-4a7f-9916-aa6843608604\",\n            \"type\": \"attraction\",\n            \"grade\": 40,\n            \"image\": {\n              \"id\": \"723963f9-1bcb-4b38-9c64-5dcf11199479\",\n              \"sizes\": {\n                \"full\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/full/723963f9-1bcb-4b38-9c64-5dcf11199479.jpg\"\n                },\n                \"large\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/large/723963f9-1bcb-4b38-9c64-5dcf11199479.jpg\"\n                },\n                \"small_square\": {\n                  \"url\": \"https://triple-cms-development.s3-ap-northeast-1.amazonaws.com/pictures/small_square/723963f9-1bcb-4b38-9c64-5dcf11199479.jpg\"\n                }\n              },\n              \"title\": null,\n              \"sourceUrl\": \"https://plus.google.com/photos/photo/113047214681234370860/6444403242559464882\",\n              \"description\": null\n            },\n            \"names\": {\n              \"en\": \"Maricar\",\n              \"ko\": \"마리오 카트\",\n              \"local\": \"マリカー\"\n            },\n            \"comment\": \"슈퍼 마리오 게임 속 복장을 하고 체험하는 도쿄 도심 드라이빙\",\n            \"location\": [139.74154911073924, 35.620995000000015],\n            \"regionId\": \"23c5965b-01ad-486b-a694-a2ced15f245c\",\n            \"categories\": [\n              {\n                \"name\": \"테마/체험\"\n              }\n            ],\n            \"pointGeolocation\": {\n              \"type\": \"Point\",\n              \"coordinates\": [139.74154911073924, 35.620995000000015]\n            }\n          }\n        }\n      ],\n      \"display\": \"list\"\n    }\n  },\n  {\n    \"type\": \"list\",\n    \"value\": {\n      \"bulletType\": \"check\",\n      \"items\": [\n        {\n          \"type\": \"text\",\n          \"value\": {\n            \"text\": \"일본 여행! 포켓와이파이 없이 어떻게 가려고요?\"\n          }\n        },\n        {\n          \"type\": \"text\",\n          \"value\": {\n            \"text\": \"와그만의 핑크미가 뿜뿜나는 LTE 포켓와이파이로 더 편한 일본 여행을 즐겨보세요.\"\n          }\n        },\n        {\n          \"type\": \"text\",\n          \"value\": {\n            \"text\": \"일본 지역 어디든 잘 터져서 데이터 걱정이 없어요.\"\n          }\n        },\n        {\n          \"type\": \"text\",\n          \"value\": {\n            \"text\": \"보조배터리도 무료로 하나 더 챙겨드리니 걱정하지 마세요.\"\n          }\n        },\n        {\n          \"type\": \"text\",\n          \"value\": {\n            \"text\": \"일본 공항에서 수령할 수 있는 일본 공항 수령 LTE 포켓와이파이도 있어요.\"\n          }\n        },\n        {\n          \"type\": \"text\",\n          \"value\": {\n            \"rich\": true,\n            \"text\": \"오사카 여행에 주유패스가 빠질 수 없겠죠?\\n\",\n            \"rawHTML\": \"<p>오사카 여행에 <strong>주유패스가</strong> 빠질 수 없겠죠?</p>\\n\",\n            \"markdownText\": \"오사카 여행에 **주유패스가** 빠질 수 없겠죠?\\n\"\n          }\n        },\n        {\n          \"type\": \"links\",\n          \"value\": {\n            \"display\": \"default\",\n            \"links\": [\n              {\n                \"href\": \"http://www.waug.com/good/?idx=105245\",\n                \"label\": \"일본 공항 수령 LTE 포켓와이파이\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"text\",\n          \"value\": {\n            \"text\": \"일본 공항 수령 LTE 포켓와이파이\"\n          }\n        }\n      ]\n    }\n  },\n  {\n    \"type\": \"table\",\n    \"value\": {\n      \"table\": {\n        \"type\": \"horizontal\",\n        \"head\": [\n          { \"text\": \"목적지\" },\n          { \"text\": \"요금 / 소요시간\" },\n          { \"text\": \"운행간격\" }\n        ],\n        \"body\": [\n          [\n            { \"text\": \"난바 OCAT\" },\n            { \"text\": \"1,050엔 / 45분\" },\n            { \"text\": \"30분\" }\n          ],\n          [\n            { \"text\": \"오사카\" },\n            { \"text\": \"1,550엔 / 70분\" },\n            { \"text\": \"30분\" }\n          ],\n          [\n            { \"text\": \"신우메다시티\" },\n            { \"text\": \"1,550엔 / 75분\" },\n            { \"text\": \"30분\" }\n          ],\n          [\n            { \"text\": \"신사이바시\" },\n            { \"text\": \"1,550엔 / 70분\" },\n            { \"text\": \"30분\" }\n          ],\n          [{ \"text\": \"난코\" }, { \"text\": \"1,550엔 / 45분\" }, { \"text\": \"70분\" }]\n        ]\n      }\n    }\n  },\n  {\n    \"type\": \"table\",\n    \"value\": {\n      \"table\": {\n        \"type\": \"vertical\",\n        \"head\": [\n          { \"text\": \"루트\" },\n          { \"text\": \"요금\" },\n          { \"text\": \"소요시간\" },\n          { \"text\": \"운행간격\" }\n        ],\n        \"body\": [\n          [{ \"text\": \"간사이 공항 → JR 교토역\" }],\n          [{ \"text\": \"하루카 편도 3,370엔 이코카 & 하루카 편도 3,600엔\" }],\n          [{ \"text\": \"1시간 15분\" }],\n          [{ \"text\": \"시간당 급행 2대 / 일반 3대\" }]\n        ]\n      }\n    }\n  }\n]\n"
  },
  {
    "path": "packages/triple-document/src/pois.stories.tsx",
    "content": "import type { Meta } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\nimport { ScrapsProvider } from '@titicaca/tds-widget'\n\nimport POIS from './mocks/pois.sample.json'\nimport HOTEL from './mocks/hotel.sample.json'\nimport ELEMENTS from './elements'\n\nconst { pois: Pois } = ELEMENTS\n\nexport default {\n  title: 'triple-document / POI',\n  component: Pois,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <ScrapsProvider>\n          <Story />\n        </ScrapsProvider>\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta\n\nexport function Normal() {\n  return (\n    <Pois\n      resourceScraps={{}}\n      value={{\n        pois: POIS,\n        display: 'default',\n      }}\n    />\n  )\n}\nNormal.storyName = '일반'\n\nexport function NormalWithImagePlaceholder() {\n  return (\n    <Pois\n      resourceScraps={{}}\n      value={{\n        // image를 제외해서 placeholder 확인\n        pois: POIS.map(({ source: { image, ...source }, ...rest }) => ({\n          source,\n          ...rest,\n        })),\n        display: 'default',\n      }}\n    />\n  )\n}\nNormalWithImagePlaceholder.storyName = '일반 w/ 이미지 Placeholder'\n\nexport function List() {\n  return (\n    <Pois\n      resourceScraps={{}}\n      value={{\n        pois: POIS,\n        display: 'list',\n      }}\n    />\n  )\n}\nList.storyName = '리스트'\n\nexport function ListWithImagePlaceholder() {\n  return (\n    <Pois\n      resourceScraps={{}}\n      value={{\n        // image를 제외해서 placeholder 확인\n        pois: POIS.map(({ source: { image, ...source }, ...rest }) => ({\n          source,\n          ...rest,\n        })),\n        display: 'list',\n      }}\n    />\n  )\n}\nListWithImagePlaceholder.storyName = '리스트 w/ 이미지 Placeholder'\n\nexport function HotelListWithPrice() {\n  return (\n    <Pois\n      resourceScraps={{}}\n      value={{\n        pois: [HOTEL],\n        display: 'list',\n      }}\n    />\n  )\n}\nHotelListWithPrice.storyName = '리스트 (호텔 w/ 가격)'\n"
  },
  {
    "path": "packages/triple-document/src/prop-context/deep-link.ts",
    "content": "import { createContext, useContext } from 'react'\n\nconst DeepLinkContext = createContext<string | undefined>(undefined)\n\nexport const DeepLinkProvider = DeepLinkContext.Provider\n\nexport function useDeepLink() {\n  return useContext(DeepLinkContext)\n}\n"
  },
  {
    "path": "packages/triple-document/src/prop-context/guest-mode.ts",
    "content": "import { GuestModeType } from '@titicaca/type-definitions'\nimport { createContext, useContext } from 'react'\n\nconst GuestModeContext = createContext<GuestModeType | undefined>(undefined)\n\nexport const GuestModeProvider = GuestModeContext.Provider\n\nexport function useGuestMode() {\n  return useContext(GuestModeContext)\n}\n"
  },
  {
    "path": "packages/triple-document/src/prop-context/image-click-handler.ts",
    "content": "import { createContext, useContext } from 'react'\n\nimport { ImageEventHandler } from '../types'\n\nconst ImageClickHandlerContext = createContext<ImageEventHandler | undefined>(\n  undefined,\n)\n\nexport const ImageClickHandlerProvider = ImageClickHandlerContext.Provider\n\nexport function useImageClickHandler() {\n  return useContext(ImageClickHandlerContext)\n}\n"
  },
  {
    "path": "packages/triple-document/src/prop-context/image-source.ts",
    "content": "import { createContext, useContext } from 'react'\nimport { ImageSource } from '@titicaca/tds-widget'\n\nconst ImageSourceContext = createContext<typeof ImageSource | undefined>(\n  undefined,\n)\n\nexport const ImageSourceProvider = ImageSourceContext.Provider\n\nexport function useImageSource() {\n  return useContext(ImageSourceContext)\n}\n"
  },
  {
    "path": "packages/triple-document/src/prop-context/link-click-handler.ts",
    "content": "import { createContext, useContext } from 'react'\n\nimport { LinkEventHandler } from '../types'\n\nconst LinkClickHandlerContext = createContext<LinkEventHandler | undefined>(\n  undefined,\n)\n\nexport const LinkClickHandlerProvider = LinkClickHandlerContext.Provider\n\nexport function useLinkClickHandler() {\n  return useContext(LinkClickHandlerContext)\n}\n"
  },
  {
    "path": "packages/triple-document/src/prop-context/media-config.tsx",
    "content": "import { createContext, PropsWithChildren, useContext } from 'react'\n\nexport interface MediaConfig {\n  videoAutoPlay?: boolean\n  hideVideoControls?: boolean\n  optimized?: boolean\n}\n\nconst MediaConfigContext = createContext<MediaConfig>({})\n\nexport function MediaConfigProvider({\n  children,\n  ...mediaConfig\n}: PropsWithChildren<MediaConfig>) {\n  return (\n    <MediaConfigContext.Provider value={mediaConfig}>\n      {children}\n    </MediaConfigContext.Provider>\n  )\n}\n\nexport function useMediaConfig() {\n  return useContext(MediaConfigContext)\n}\n"
  },
  {
    "path": "packages/triple-document/src/prop-context/resource-click-handler.ts",
    "content": "import { createContext, SyntheticEvent, useContext } from 'react'\n\nexport type ResourceClickHandler = (\n  e: SyntheticEvent,\n  resource: {\n    id: string\n    type: string\n    source: unknown\n  },\n) => void\n\nconst ResourceClickHandlerContext = createContext<\n  ResourceClickHandler | undefined\n>(undefined)\nexport const ResourceClickHandlerProvider = ResourceClickHandlerContext.Provider\n\nexport function useResourceClickHandler() {\n  return useContext(ResourceClickHandlerContext)\n}\n"
  },
  {
    "path": "packages/triple-document/src/tna-slot.stories.tsx",
    "content": "import type { Meta } from '@storybook/react'\nimport { http, HttpResponse } from 'msw'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\nimport { ScrapsProvider } from '@titicaca/tds-widget'\n\nimport ELEMENTS from './elements'\nimport SLOTS from './mocks/slots.sample.json'\n\nconst { tnaProducts: TnaProducts } = ELEMENTS\n\nexport default {\n  title: 'triple-document / T&A Slot',\n  component: TnaProducts,\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <ScrapsProvider>\n          <Story />\n        </ScrapsProvider>\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta\n\nexport function InTripleDocument() {\n  return (\n    <TnaProducts\n      value={{\n        slotId: 1546,\n      }}\n    />\n  )\n}\nInTripleDocument.storyName = 'Triple-document에 포함된 Slot'\nInTripleDocument.parameters = {\n  msw: {\n    handlers: [\n      http.get('/api/tna-v2/slots/:slotId', () => {\n        return HttpResponse.json(SLOTS)\n      }),\n    ],\n  },\n}\n"
  },
  {
    "path": "packages/triple-document/src/triple-document.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\nimport { Container } from '@titicaca/tds-ui'\nimport { useScrollToAnchor } from '@titicaca/react-hooks'\nimport { http, HttpResponse } from 'msw'\nimport { useEffect } from 'react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\nimport { ScrapsProvider } from '@titicaca/tds-widget'\n\nimport ELEMENTS from './elements'\nimport MOCK_EMBEDDED from './mocks/triple-document.embedded.json'\nimport MOCK_ITINERARY from './mocks/triple-document.itinerary.json'\nimport MOCK_REGIONS from './mocks/triple-document.regions.json'\nimport SAMPLE from './mocks/triple-document.sample.json'\nimport { DeepLinkProvider } from './prop-context/deep-link'\nimport { TripleDocument } from './triple-document'\nimport { TripleElementData } from './types'\n\nconst {\n  text: Text,\n  note: Note,\n  video: Video,\n  table: Table,\n  regions: Regions,\n  embedded: Embedded,\n  anchor: Anchor,\n  itinerary: Itinerary,\n  coupon: Coupon,\n  stickyTabs: StickyTabs,\n} = ELEMENTS\n\nexport default {\n  title: 'triple-document / TripleDocument',\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <ScrapsProvider>\n          <Story />\n        </ScrapsProvider>\n      </EventTrackingProvider>\n    ),\n  ],\n} as Meta\n\nexport const Sample: StoryObj<typeof TripleDocument> = {\n  name: '샘플',\n  render: () => (\n    <Container centered css={{ maxWidth: 768 }}>\n      <TripleDocument>\n        {SAMPLE as TripleElementData<string, unknown>[]}\n      </TripleDocument>\n    </Container>\n  ),\n}\n\nexport const TextExample: StoryObj<typeof Text> = {\n  name: '텍스트',\n  render: () => (\n    <>\n      <Text value={{ text: '텍스트: medium 16 80%' }} />\n      <Text\n        value={{\n          rawHTML: '텍스트 <a href=\"/regions/:regionId\">Inline link</a>',\n        }}\n      />\n      <Text bold value={{ text: '강조 텍스트: bold 16 100%' }} alpha={1} />\n    </>\n  ),\n}\n\nexport const NoteExample: StoryObj<typeof Note> = {\n  name: '노트',\n  render: (args) => <Note value={args.value} />,\n  args: {\n    value: {\n      body: '목적지로 바로 가지 않고, 중간 지점에서 잠시 머무는 단기 체류를 뜻한다. 보통 경유 시간인 3-4시간 정도가 아니라 24시간 이상을 뜻하기 때문에 관광과 숙박이 가능한 것이 특징. 일부 항공사는 스탑오버시 무료 관광을 제공하니 참고할것!',\n      title: '잠깐! 스탑오버(Stopover)란?',\n    },\n  },\n}\n\nexport const VideoExample: StoryObj<typeof Video> = {\n  name: '비디오',\n  render: (args) => <Video value={args.value} />,\n  args: {\n    value: {\n      provider: 'youtube',\n      identifier: 'hYIe4VrfHoA',\n    },\n  },\n}\n\nexport const TableExample: StoryObj<typeof Table> = {\n  name: '표',\n  render: (args) => <Table value={args.value} />,\n  args: {\n    value: {\n      table: {\n        type: 'horizontal',\n        head: [{ text: '취소 시점' }, { text: '취소 수수료' }],\n        body: [\n          [{ text: '구매 당일 (No-show 제외)' }, { text: '0원' }],\n          [{ text: '구매 익일 ~ 출발 61일 전' }, { text: '1,000원' }],\n          [{ text: '출발 60일 전 ~ 출발 31일 전' }, { text: '3,000원' }],\n          [{ text: '출발 30일 전 ~ 출발 8일 전' }, { text: '4,000원' }],\n          [{ text: '출발 7일 전 ~ 출발 2일 전' }, { text: '6,000원' }],\n          [{ text: '출발 1일 전 ~ 출발시간 전' }, { text: '12,000원' }],\n          [{ text: '출발시간 이후 (No-show)' }, { text: '15,000원' }],\n        ],\n      },\n    },\n  },\n}\n\nexport const RegionExample: StoryObj<typeof Regions> = {\n  name: '리전',\n  render: (args) => <Regions value={args.value} />,\n  args: {\n    value: { regions: MOCK_REGIONS },\n  },\n}\n\nexport const EmbeddedExample: StoryObj<typeof Embedded> = {\n  name: '임베딩',\n  render: (args) => <Embedded value={args.value} />,\n  args: {\n    value: MOCK_EMBEDDED,\n  },\n}\n\nexport const AnchorExample: StoryObj<typeof Anchor> = {\n  name: '앵커',\n  render: function Render() {\n    useEffect(() => {\n      window.history.pushState(null, '', '#android')\n    }, [])\n\n    useScrollToAnchor({ delayTime: 0 })\n\n    return (\n      <div>\n        <div style={{ height: '200vh' }} />\n        <div>Android</div>\n        <Anchor value={{ href: 'android' }} />\n        <div style={{ height: '200vh' }} />\n        <div>ios</div>\n        <Anchor value={{ href: 'ios' }} />\n      </div>\n    )\n  },\n}\n\nexport const DocumentItinerary: StoryObj<typeof Itinerary> = {\n  name: '추천코스 기본',\n  render: (args) => <Itinerary value={args.value} />,\n  args: {\n    value: MOCK_ITINERARY.article.source.body[1].value,\n  },\n}\n\nexport const CouponExample: StoryObj<typeof Coupon> = {\n  name: '쿠폰',\n  render: (args) => (\n    <DeepLinkProvider value=\"https://triple-dev.onelink.me\">\n      <Coupon value={args.value} />\n    </DeepLinkProvider>\n  ),\n  args: {\n    value: {\n      identifier: 'TEST_IDENTIFIER',\n      description:\n        '쿠폰은 최초 1회만 지급되며, 이미 쿠폰을 받았다면  ‘쿠폰함’에서 확인 할 수 있습니다.',\n      enabledAt: '2023-07-03',\n      color: {\n        background: undefined,\n        buttonBackground: '#368fff',\n        buttonText: '#ffffff',\n        description: '#3a3a3a80',\n      },\n    },\n  },\n  parameters: {\n    msw: {\n      handlers: [\n        http.get('/api/benefit/coupons/:id', () => {\n          return HttpResponse.json({\n            downloaded: true,\n          })\n        }),\n        http.get('/api/users/smscert', () => {\n          return HttpResponse.json({\n            verified: true,\n          })\n        }),\n      ],\n    },\n  },\n}\n\nexport const StickyTabsExample: StoryObj<typeof StickyTabs> = {\n  name: '고정 탭',\n  render: function Render(args) {\n    return (\n      <div>\n        <StickyTabs value={args.value} />\n        <Anchor value={{ href: 'tab1' }} />\n        <div style={{ height: '500vh', border: '1px solid green' }} />\n        <Anchor value={{ href: 'tab2' }} />\n        <div style={{ height: '100px', border: '1px solid blue' }} />\n        <Anchor value={{ href: 'tab3' }} />\n        <div style={{ height: '100vh', border: '1px solid red' }} />\n      </div>\n    )\n  },\n  args: {\n    value: {\n      tabs: [\n        {\n          defaultImage: {\n            cloudinaryId: '44d96f0f-1c9d-44cf-8a04-bdee865e7311',\n            id: '46dc4371-e8bf-485e-b00a-77c67024279d',\n            type: 'image',\n            sizes: {\n              full: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/44d96f0f-1c9d-44cf-8a04-bdee865e7311.jpeg',\n              },\n              large: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/44d96f0f-1c9d-44cf-8a04-bdee865e7311.jpeg',\n              },\n              small_square: {\n                url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/44d96f0f-1c9d-44cf-8a04-bdee865e7311.jpeg',\n              },\n            },\n            source: {},\n            width: 303,\n            height: 168,\n            cloudinaryBucket: 'triple-cms',\n            metadata: {\n              format: 'png',\n            },\n          },\n          activeImage: {\n            cloudinaryId: 'c3855a72-94d9-4d16-8125-c11a3f55470d',\n            id: 'de31e21f-08f5-496f-ac9b-bd9c47f5359a',\n            type: 'image',\n            sizes: {\n              full: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/c3855a72-94d9-4d16-8125-c11a3f55470d.jpeg',\n              },\n              large: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/c3855a72-94d9-4d16-8125-c11a3f55470d.jpeg',\n              },\n              small_square: {\n                url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/c3855a72-94d9-4d16-8125-c11a3f55470d.jpeg',\n              },\n            },\n            source: {},\n            width: 303,\n            height: 168,\n            cloudinaryBucket: 'triple-cms',\n            metadata: {\n              format: 'png',\n            },\n          },\n          anchor: 'tab1',\n        },\n        {\n          defaultImage: {\n            cloudinaryId: 'd3dd3b38-ca9d-47aa-9d6d-9fbdf7052772',\n            id: 'a59bb952-f6e1-447a-9e90-9fbb412562fb',\n            type: 'image',\n            sizes: {\n              full: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/d3dd3b38-ca9d-47aa-9d6d-9fbdf7052772.jpeg',\n              },\n              large: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/d3dd3b38-ca9d-47aa-9d6d-9fbdf7052772.jpeg',\n              },\n              small_square: {\n                url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/d3dd3b38-ca9d-47aa-9d6d-9fbdf7052772.jpeg',\n              },\n            },\n            source: {},\n            width: 303,\n            height: 168,\n            cloudinaryBucket: 'triple-cms',\n            metadata: {\n              format: 'png',\n            },\n          },\n          activeImage: {\n            cloudinaryId: '3ecbd0cb-5679-4ca1-9486-642e56f06cff',\n            id: '7698d07a-3498-4fc9-889c-169619f4ec15',\n            type: 'image',\n            sizes: {\n              full: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/3ecbd0cb-5679-4ca1-9486-642e56f06cff.jpeg',\n              },\n              large: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/3ecbd0cb-5679-4ca1-9486-642e56f06cff.jpeg',\n              },\n              small_square: {\n                url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/3ecbd0cb-5679-4ca1-9486-642e56f06cff.jpeg',\n              },\n            },\n            source: {},\n            width: 303,\n            height: 168,\n            cloudinaryBucket: 'triple-cms',\n            metadata: {\n              format: 'png',\n            },\n          },\n          anchor: 'tab2',\n        },\n        {\n          defaultImage: {\n            cloudinaryId: 'b3738eee-fcea-457c-943a-281a06d32ca6',\n            id: '26d05644-1669-4b73-a5a4-9314511b9a5c',\n            type: 'image',\n            sizes: {\n              full: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/b3738eee-fcea-457c-943a-281a06d32ca6.jpeg',\n              },\n              large: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/b3738eee-fcea-457c-943a-281a06d32ca6.jpeg',\n              },\n              small_square: {\n                url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/b3738eee-fcea-457c-943a-281a06d32ca6.jpeg',\n              },\n            },\n            source: {},\n            width: 303,\n            height: 168,\n            cloudinaryBucket: 'triple-cms',\n            metadata: {\n              format: 'png',\n            },\n          },\n          activeImage: {\n            cloudinaryId: '5e236073-4d57-4677-bdc8-70e5685c46b7',\n            id: '60adf45f-4dba-4173-9fbe-7ee8f147e16f',\n            type: 'image',\n            sizes: {\n              full: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/5e236073-4d57-4677-bdc8-70e5685c46b7.jpeg',\n              },\n              large: {\n                url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/5e236073-4d57-4677-bdc8-70e5685c46b7.jpeg',\n              },\n              small_square: {\n                url: 'https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/5e236073-4d57-4677-bdc8-70e5685c46b7.jpeg',\n              },\n            },\n            source: {},\n            width: 303,\n            height: 168,\n            cloudinaryBucket: 'triple-cms',\n            metadata: {\n              format: 'png',\n            },\n          },\n          anchor: 'tab3',\n        },\n      ],\n    },\n  },\n}\n"
  },
  {
    "path": "packages/triple-document/src/triple-document.tsx",
    "content": "import { useCallback } from 'react'\nimport { useTrackEventWithMetadata } from '@titicaca/triple-web'\nimport { useStandardActionHandler } from '@titicaca/standard-action-handler'\n\nimport {\n  TripleElementData,\n  ElementSet,\n  TripleDocumentContext,\n  LinkEventHandler,\n} from './types'\nimport {\n  ResourceClickHandler,\n  ResourceClickHandlerProvider,\n} from './prop-context/resource-click-handler'\nimport { ImageClickHandlerProvider } from './prop-context/image-click-handler'\nimport { LinkClickHandlerProvider } from './prop-context/link-click-handler'\nimport { ImageSourceProvider } from './prop-context/image-source'\nimport { DeepLinkProvider } from './prop-context/deep-link'\nimport { MediaConfigProvider } from './prop-context/media-config'\nimport ELEMENTS from './elements'\nimport useEventResourceTracker from './use-resource-event-tracker'\nimport { GuestModeProvider } from './prop-context/guest-mode'\n\nexport function TripleDocument({\n  children,\n  customElements = {},\n  onResourceClick,\n  onImageClick,\n  onLinkClick,\n  imageSourceComponent,\n  deepLink,\n  cta,\n  videoAutoPlay,\n  hideVideoControls,\n  optimized = false,\n  guestMode,\n}: {\n  customElements?: ElementSet\n  children: TripleElementData[]\n  cta?: string\n} & TripleDocumentContext) {\n  const trackEventWithMetadata = useTrackEventWithMetadata()\n  const trackResourceEvent = useEventResourceTracker()\n\n  const handleAction = useStandardActionHandler({ cta })\n\n  const defaultHandleLinkClick: LinkEventHandler = useCallback(\n    (e, { href, target }) => {\n      if (!href) {\n        // TODO: triple-document 에러 처리 방법 설계\n        return\n      }\n      trackEventWithMetadata({\n        fa: {\n          action: '링크선택',\n          url: href,\n        },\n        ga: ['링크선택', href],\n      })\n      handleAction(href, { target })\n    },\n    [handleAction, trackEventWithMetadata],\n  )\n\n  const defaultHandleResourceClick: ResourceClickHandler = useCallback(\n    (e, { id, type, source }) => {\n      const url = composeResourceUrl({ id, type, source })\n\n      trackResourceEvent({ id, type, source })\n\n      url && handleAction(url)\n    },\n    [handleAction, trackResourceEvent],\n  )\n\n  const resourceClickHandler = onResourceClick || defaultHandleResourceClick\n  const linkClickHandler = onLinkClick || defaultHandleLinkClick\n\n  return (\n    <ResourceClickHandlerProvider value={resourceClickHandler}>\n      <ImageClickHandlerProvider value={onImageClick}>\n        <LinkClickHandlerProvider value={linkClickHandler}>\n          <ImageSourceProvider value={imageSourceComponent}>\n            <DeepLinkProvider value={deepLink}>\n              <MediaConfigProvider\n                videoAutoPlay={videoAutoPlay}\n                hideVideoControls={hideVideoControls}\n                optimized={optimized}\n              >\n                <GuestModeProvider value={guestMode}>\n                  {children.map(({ type, value }, i) => {\n                    const RegularElement = ELEMENTS[type]\n                    const CustomElement = customElements[type]\n\n                    const Element = CustomElement || RegularElement\n\n                    return (\n                      Element && (\n                        <Element\n                          key={i}\n                          value={value}\n                          {...(CustomElement\n                            ? {\n                                onResourceClick: resourceClickHandler,\n                                onImageClick,\n                                onLinkClick: linkClickHandler,\n                                ImageSource: imageSourceComponent,\n                                deepLink,\n                                videoAutoPlay,\n                                hideVideoControls,\n                                optimized,\n                                guestMode,\n                              }\n                            : {})}\n                        />\n                      )\n                    )\n                  })}\n                </GuestModeProvider>\n              </MediaConfigProvider>\n            </DeepLinkProvider>\n          </ImageSourceProvider>\n        </LinkClickHandlerProvider>\n      </ImageClickHandlerProvider>\n    </ResourceClickHandlerProvider>\n  )\n}\n\nfunction composeResourceUrl(resource: Parameters<ResourceClickHandler>[1]) {\n  switch (resource.type) {\n    case 'attraction':\n      return `/inlink?path=${encodeURIComponent(\n        `/attractions/${resource.id}?_triple_no_navbar`,\n      )}`\n    case 'restaurant':\n      return `/inlink?path=${encodeURIComponent(\n        `/restaurants/${resource.id}?_triple_no_navbar`,\n      )}`\n    case 'hotel':\n      return `/inlink?path=${encodeURIComponent(\n        `/hotels/${resource.id}?_triple_no_navbar`,\n      )}`\n    case 'article':\n      return `/inlink?path=${encodeURIComponent(\n        `/articles/${resource.id}?_triple_no_navbar`,\n      )}`\n    case 'region':\n      return `/regions/${resource.id}`\n    default:\n      return null\n  }\n}\n"
  },
  {
    "path": "packages/triple-document/src/types.ts",
    "content": "import { ComponentType, SyntheticEvent } from 'react'\nimport {\n  GuestModeType,\n  TranslatedProperty,\n  ImageMeta,\n} from '@titicaca/type-definitions'\nimport { ImageSource } from '@titicaca/tds-widget'\n\nimport { MediaConfig } from './prop-context/media-config'\nimport { ResourceClickHandler } from './prop-context/resource-click-handler'\nimport {\n  TnaProductsClickHandler,\n  TnaProductsFetcher,\n} from './elements/tna/types'\n\nexport interface RegionData {\n  id: string\n  type: string\n  nameOverride: string | null\n  source: {\n    id: string\n    names: TranslatedProperty\n    style?: {\n      backgroundImageUrl: string\n    }\n  }\n}\n\nexport interface TripleElementData<T = string, Value = unknown> {\n  type: T\n  value: Value\n}\n\nexport type ImageEventHandler = (e: SyntheticEvent, image: ImageMeta) => void\n\nexport interface Link {\n  href?: string\n  label?: string\n  level?: string\n  target?: 'browser'\n}\n\nexport type LinkEventHandler = (e: SyntheticEvent, link: Link) => void\n\nexport type TripleDocumentContext = {\n  onResourceClick?: ResourceClickHandler\n  onImageClick?: ImageEventHandler\n  onLinkClick?: LinkEventHandler\n  onTNAProductClick?: TnaProductsClickHandler\n  onTNAProductsFetch?: TnaProductsFetcher\n  imageSourceComponent?: typeof ImageSource\n  deepLink?: string\n  guestMode?: GuestModeType\n} & MediaConfig\n\nexport interface ElementSet {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  [type: string]: ComponentType<any>\n}\n\nexport interface CouponData {\n  id: string\n  name: string\n  description: string\n  publicationPeriod?: {\n    startAt: string\n    endAt: string\n  }\n  validityPeriod?: {\n    startAt: string\n    endAt: string\n  }\n  status?: string\n  expired?: true\n  maxDiscountAmount: number\n  discountPolicy: {\n    type: string\n    value: number\n  }\n  useConditions: [\n    {\n      type: string\n      value: string\n    },\n  ]\n  downloaded?: true\n}\n"
  },
  {
    "path": "packages/triple-document/src/use-resource-event-tracker.ts",
    "content": "import { useCallback } from 'react'\nimport { useTrackEventWithMetadata } from '@titicaca/triple-web'\n\nenum Resource {\n  Region = 'region',\n  Hotel = 'hotel',\n  Restaurant = 'restaurant',\n  Attraction = 'attraction',\n}\n\nfunction getObjectNamesProperty(source: unknown) {\n  if (\n    typeof source === 'object' &&\n    source !== null &&\n    Object.prototype.hasOwnProperty.call(source, 'names')\n  ) {\n    const { names } = source as { names: unknown }\n\n    if (typeof names === 'object' && names !== null) {\n      const { ko, en } = names as { ko?: unknown; en?: unknown }\n\n      return ko || en\n    }\n  }\n}\n\nexport default function useResourceEventTracker() {\n  const trackEventWithMetadata = useTrackEventWithMetadata()\n\n  return useCallback(\n    ({ id, type, source }: { id: string; type: string; source: unknown }) => {\n      switch (type) {\n        case Resource.Region:\n          return trackEventWithMetadata({\n            fa: {\n              action: '도시선택',\n              region_id: id,\n              button_name: getObjectNamesProperty(source),\n              content_type: type,\n            },\n          })\n\n        case Resource.Hotel:\n        case Resource.Restaurant:\n        case Resource.Attraction:\n          return trackEventWithMetadata({\n            fa: {\n              action: 'POI선택',\n              item_id: id,\n              button_name: getObjectNamesProperty(source),\n              content_type: type,\n            },\n          })\n        default:\n          break\n      }\n    },\n    [trackEventWithMetadata],\n  )\n}\n"
  },
  {
    "path": "packages/triple-document/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/triple-document/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/triple-document/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/triple-email-document/package.json",
    "content": "{\n  \"name\": \"@titicaca/triple-email-document\",\n  \"version\": \"14.2.3\",\n  \"description\": \"EmailDocument: Formatted Email System\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/triple-email-document\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/tds-ui\": \"workspace:*\",\n    \"@titicaca/type-definitions\": \"workspace:*\"\n  },\n  \"devDependencies\": {\n    \"react\": \"^18.3.1\",\n    \"styled-components\": \"^6.1.15\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^18.0\",\n    \"styled-components\": \"^6.0\"\n  }\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/common/box.ts",
    "content": "import { styled } from 'styled-components'\nimport { MarginPadding } from '@titicaca/tds-ui'\n\nconst Box = styled.td<{ $padding?: MarginPadding }>`\n  /* stylelint-disable function-whitespace-after */\n  padding: ${({ $padding }) => {\n    const { top, bottom, left, right } = $padding || {}\n    return [top, right, bottom, left].map(addUnit).join(' ')\n  }};\n`\n\nfunction addUnit(value: string | number | undefined) {\n  return value !== undefined && value !== 0 ? `${value}px` : 0\n}\n\nexport default Box\n"
  },
  {
    "path": "packages/triple-email-document/src/common/fluid-table.tsx",
    "content": "import { styled } from 'styled-components'\nimport { marginMixin, MarginPadding } from '@titicaca/tds-ui'\n\nconst StyledTable = styled.table<{ $margin?: MarginPadding }>`\n  width: 100%;\n  border-collapse: collapse;\n\n  ${({ $margin }) => marginMixin({ margin: $margin })}\n`\n\nexport default function FluidTable({\n  id,\n  children,\n  margin,\n}: {\n  id?: string\n  children?: React.ReactNode\n  margin?: MarginPadding\n}) {\n  return (\n    <StyledTable id={id} $margin={margin}>\n      {children}\n    </StyledTable>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/common/handlebars-anchor.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nexport default function HandlebarsAnchor({\n  linkId,\n  className,\n  children,\n}: PropsWithChildren<{\n  linkId: string\n  className?: string\n}>) {\n  return (\n    <a\n      className={className}\n      href={`{{${linkId}}}`}\n      {...{ 'ses:tags': `link:${linkId}` }}\n    >\n      {children}\n    </a>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/common/index.ts",
    "content": "export { default as Box } from './box'\nexport { default as FluidTable } from './fluid-table'\nexport { default as HandlebarsAnchor } from './handlebars-anchor'\nexport {\n  RecommendedReset,\n  CustomReset,\n  ClientSpecificWorkaround,\n} from './reset'\n"
  },
  {
    "path": "packages/triple-email-document/src/common/reset.ts",
    "content": "import { createGlobalStyle } from 'styled-components'\n\n/**\n *  Mailchimp에서 권장하는 reset 스타일\n */\nexport const RecommendedReset = createGlobalStyle`\n  /* stylelint-disable selector-class-pattern */\n  body {\n    margin: 0;\n    padding: 0;\n  }\n\n  img {\n    border: 0 none;\n    height: auto;\n    line-height: 100%;\n    outline: none;\n    text-decoration: none;\n  }\n\n  a img {\n    border: 0 none;\n  }\n\n  .imageFix {\n    display: block;\n  }\n\n  table,\n  td {\n    border-collapse: collapse;\n  }\n\n  /* for gmail */\n  /* stylelint-disable-next-line selector-id-pattern */\n  #bodyTable {\n    height: 100% !important;\n    margin: 0;\n    padding: 0;\n    width: 100% !important;\n  }\n`\n\nexport const CustomReset = createGlobalStyle`\n  body {\n   font-family: Arial, sans-serif;\n  }\n\n  table td {\n    padding: 0;\n  }\n\n  a {\n    text-decoration: none;\n  }\n`\n\nexport const ClientSpecificWorkaround = createGlobalStyle`\n  .ExternalClass {\n    width: 100%;\n  }\n\n  .ExternalClass,\n  .ExternalClass p,\n  .ExternalClass span,\n  .ExternalClass font,\n  .ExternalClass td,\n  .ExternalClass div {\n    line-height: 100%;\n  }\n\n  #outlook a {\n    padding: 0;\n  }\n\n  table {\n    /* stylelint-disable-next-line property-no-unknown */\n    mso-table-lspace: 0;\n    /* stylelint-disable-next-line property-no-unknown */\n    mso-table-rspace: 0;\n  }\n\n  img {\n    /* stylelint-disable-next-line property-no-vendor-prefix */\n    -ms-interpolation-mode: bicubic;\n  }\n\n  body {\n    /* stylelint-disable-next-line property-no-vendor-prefix */\n    -webkit-text-size-adjust: 100%;\n    /* stylelint-disable-next-line property-no-vendor-prefix */\n    -ms-text-size-adjust: 100%;\n  }\n`\n"
  },
  {
    "path": "packages/triple-email-document/src/components/index.ts",
    "content": "export { default as EmailPreview, type PreviewDocument } from './preview'\n"
  },
  {
    "path": "packages/triple-email-document/src/components/preview.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport EmailPreview from './preview'\n\ntest('EmailPreview를 렌더링합니다.', () => {\n  const mockedPreviewValue = {\n    phrase: 'Cheking Email',\n  }\n\n  render(<EmailPreview value={mockedPreviewValue} />)\n\n  const previewElement = screen.getByText(mockedPreviewValue.phrase)\n\n  expect(previewElement).toBeInTheDocument()\n})\n"
  },
  {
    "path": "packages/triple-email-document/src/components/preview.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { FluidTable, Box } from '../common'\n\nexport interface PreviewDocument {\n  type: 'preview'\n  value: {\n    phrase: string\n  }\n}\n\nconst PreviewStyled = styled.div`\n  font-size: 1px;\n  display: none;\n  max-height: 0;\n  max-width: 0;\n  opacity: 0;\n  overflow: hidden;\n`\n\nexport default function EmailPreview({\n  value: { phrase },\n}: {\n  value: PreviewDocument['value']\n}) {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 0, bottom: 0, left: 0, right: 0 }}>\n            <PreviewStyled>{phrase}</PreviewStyled>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/dividers.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { ELEMENTS } from '../index'\n\ntest('높이 1px, 배경 색 gray 인 구분선 1 Element를 렌더링합니다.', () => {\n  const Divider1 = ELEMENTS.hr1\n\n  render(<Divider1 value={undefined} />)\n\n  const dividerElement = screen.getByRole('separator')\n\n  expect(dividerElement).toHaveStyleRule('height', '1px')\n  expect(dividerElement).toHaveStyleRule(\n    'background-color',\n    'rgba(239,239,239,1)',\n  )\n})\n\ntest('높이 10px, 배경 색은 gray 인 구분선 2 Element를 렌더링합니다.', () => {\n  const Divider2 = ELEMENTS.hr2\n\n  render(<Divider2 value={undefined} />)\n\n  const dividerElement = screen.getByRole('separator')\n\n  expect(dividerElement).toHaveStyleRule('height', '10px')\n  expect(dividerElement).toHaveStyleRule(\n    'background-color',\n    'rgba(239,239,239,1)',\n  )\n})\n\ntest('높이 10px, 배경 색은 transparent 구분선 3 Element를 렌더링합니다.', () => {\n  const Divider3 = ELEMENTS.hr3\n\n  render(<Divider3 value={undefined} />)\n\n  const dividerElement = screen.getByRole('separator')\n\n  expect(dividerElement).toHaveStyleRule('height', '10px')\n  expect(dividerElement).toHaveStyleRule('background-color', 'transparent')\n})\n\ntest('사선(/)으로 표시하는 구분선 4 Element를 렌더링합니다.', () => {\n  const Divider4 = ELEMENTS.hr4\n\n  render(<Divider4 value={undefined} />)\n\n  const dividerImgElement = screen.getByRole('img')\n\n  expect(dividerImgElement).toHaveAttribute(\n    'src',\n    'https://assets.triple.guide/images/img-line1@2x.png',\n  )\n})\n\ntest('점 3개(. . .)로 표시하는 구분선 5 Element를 렌더링합니다.', () => {\n  const Divider5 = ELEMENTS.hr5\n\n  render(<Divider5 value={undefined} />)\n\n  const dividerImgElement = screen.getByRole('img')\n\n  expect(dividerImgElement).toHaveAttribute(\n    'src',\n    'https://assets.triple.guide/images/img-line2@2x.png',\n  )\n})\n\ntest('점과 가로선(ㅡ . ㅡ)으로 표시하는 구분선 6 Element를 렌더링합니다.', () => {\n  const Divider6 = ELEMENTS.hr6\n\n  render(<Divider6 value={undefined} />)\n\n  const dividerImgElement = screen.getByRole('img')\n\n  expect(dividerImgElement).toHaveAttribute(\n    'src',\n    'https://assets.triple.guide/images/img-line3@2x.png',\n  )\n})\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/dividers.tsx",
    "content": "import { styled, css } from 'styled-components'\n\nimport { FluidTable, Box } from '../common'\n\nexport interface Divider1Document {\n  type: 'hr1'\n  value: undefined\n}\n\nexport interface Divider2Document {\n  type: 'hr2'\n  value: undefined\n}\n\nexport interface Divider3Document {\n  type: 'hr3'\n  value: undefined\n}\n\nexport interface Divider4Document {\n  type: 'hr4'\n  value: undefined\n}\n\nexport interface Divider5Document {\n  type: 'hr5'\n  value: undefined\n}\n\nexport interface Divider6Document {\n  type: 'hr6'\n  value: undefined\n}\n\nconst resetHr = css`\n  margin: 0;\n  border: none;\n`\n\nconst Hr = styled.hr<{ $backgroundColor: string; $height: number }>`\n  ${resetHr}\n\n  height: ${({ $height }) => $height}px;\n  background-color: ${({ $backgroundColor }) => $backgroundColor};\n`\nconst ImgContainer = styled.div`\n  width: 100%;\n  text-align: center;\n`\n\nconst HrImg = styled.img`\n  width: 130px;\n  height: 37px;\n`\n\nexport function Divider1View() {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 50, bottom: 50, left: 30, right: 30 }}>\n            <Hr $height={1} $backgroundColor=\"rgba(239, 239, 239, 1)\" />\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n\nexport function Divider2View() {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 50, bottom: 50, left: 0, right: 0 }}>\n            <Hr $height={10} $backgroundColor=\"rgba(239, 239, 239, 1)\" />\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n\nexport function Divider3View() {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 0, bottom: 0, left: 0, right: 0 }}>\n            <Hr $height={10} $backgroundColor=\"transparent\" />\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n\nexport function Divider4View() {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 40, bottom: 40, left: 0, right: 0 }}>\n            <ImgContainer>\n              <HrImg\n                src=\"https://assets.triple.guide/images/img-line1@2x.png\"\n                alt=\"***\"\n              />\n            </ImgContainer>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n\nexport function Divider5View() {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 40, bottom: 40, left: 0, right: 0 }}>\n            <ImgContainer>\n              <HrImg\n                src=\"https://assets.triple.guide/images/img-line2@2x.png\"\n                alt=\"***\"\n              />\n            </ImgContainer>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n\nexport function Divider6View() {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 40, bottom: 40, left: 0, right: 0 }}>\n            <ImgContainer>\n              <HrImg\n                src=\"https://assets.triple.guide/images/img-line3@2x.png\"\n                alt=\"***\"\n              />\n            </ImgContainer>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/embedded.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { ELEMENTS } from '../index'\n\nimport { EmbeddedDocument } from './embedded'\n\nconst IMAGE = {\n  id: 'IMAGE_ID',\n  sizes: {\n    full: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n    large: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n    small_square: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n    smallSquare: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n  },\n}\n\nconst ENTRIES = [\n  [\n    {\n      type: 'images',\n      value: {\n        display: 'gapless-block',\n        images: [IMAGE],\n      },\n    },\n    {\n      type: 'heading',\n      value: {\n        text: '임베디드의 타이틀 영역입니다.',\n      },\n    },\n    {\n      type: 'text',\n      value: {\n        text: '임베디드의 본문 영역입니다.',\n      },\n    },\n    {\n      type: 'links',\n      value: {\n        links: [\n          {\n            id: 'Link_ID',\n            label: '박스 디자인 형식',\n            href: '',\n          },\n        ],\n        display: 'block',\n      },\n    },\n  ],\n]\n\ntest('이미지, 타이틀, 본문, 버튼의 조합을 렌더링합니다.', () => {\n  const Embedded = ELEMENTS.embedded\n\n  const value = {\n    entries: ENTRIES,\n  } as EmbeddedDocument['value']\n\n  render(<Embedded value={value} />)\n\n  const imageElement = screen.getByRole('img')\n  const titleElement = screen.getAllByRole('table')[3]\n  const textElement = screen.getAllByRole('table')[4]\n  const linkElement = screen.getByRole('link')\n\n  expect(imageElement).toHaveAttribute(\n    'src',\n    'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n  )\n  expect(titleElement).toHaveTextContent('임베디드의 타이틀 영역입니다.')\n  expect(textElement).toHaveTextContent('임베디드의 본문 영역입니다.')\n\n  expect(linkElement).toHaveTextContent('박스 디자인 형식')\n  expect(linkElement).toHaveStyleRule('background-color', 'rgba(255,255,255,1)')\n  expect(linkElement).toHaveStyleRule('display', 'block')\n})\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/embedded.tsx",
    "content": "import { ComponentType } from 'react'\nimport { Text } from '@titicaca/tds-ui'\n\nimport { FluidTable, Box } from '../common'\n\nimport ImageView, { ImageDocument } from './images'\nimport TextView, { TextDocument } from './text'\nimport LinksView, { LinksDocument } from './links'\n\ninterface ElementSet {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  [type: string]: ComponentType<any>\n}\n\ntype EntriesElement =\n  | EmbeddedHeadingDocument\n  | ImageDocument\n  | TextDocument\n  | LinksDocument\n\nexport interface EmbeddedHeadingDocument {\n  type: 'heading'\n  value: {\n    text: string\n  }\n}\n\nexport interface EmbeddedDocument {\n  type: 'embedded'\n  value: {\n    entries: EntriesElement[][]\n  }\n}\n\nconst EMBEDDED_ELEMENTS: ElementSet = {\n  heading: EmbeddedHeading,\n  images: ImageView,\n  text: TextView,\n  links: LinksView,\n}\n\nexport default function EmbeddedView({\n  value: { entries },\n}: {\n  value: EmbeddedDocument['value']\n}) {\n  return (\n    <>\n      {entries.map((elements) =>\n        elements.map(({ type, value }, index) => {\n          const Element = EMBEDDED_ELEMENTS[type]\n\n          return Element && <Element key={index} value={value} />\n        }),\n      )}\n    </>\n  )\n}\n\nfunction EmbeddedHeading({\n  value: { text },\n}: {\n  value: EmbeddedHeadingDocument['value']\n}) {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={{ left: 30, right: 30 }}>\n            <Text bold lineHeight=\"24px\" size={16} color=\"gray\">\n              {text}\n            </Text>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/heading.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport {\n  Heading1View,\n  Heading2View,\n  Heading3View,\n  Heading4View,\n} from './heading'\n\nconst mockedHeadingValue = {\n  text: 'This is heading',\n}\n\ntest('헤드라인이 있는 제목 1 Element를 렌더링합니다.', () => {\n  const mockedHeading1Value = {\n    headline: 'This is headline',\n    ...mockedHeadingValue,\n  }\n\n  render(<Heading1View value={mockedHeading1Value} />)\n\n  const headlineElement = screen.getByText(/This is headline/i)\n  const headingElement = screen.getByText(/This is heading/i)\n\n  expect(headlineElement).toBeInTheDocument()\n  expect(headlineElement).toHaveStyleRule('font-size', '13px')\n\n  expect(headingElement).toBeInTheDocument()\n  expect(headingElement).toHaveStyleRule('font-size', '21px')\n})\n\ntest('헤드라인이 없는 제목 1 Element를 렌더링합니다.', () => {\n  render(<Heading1View value={mockedHeadingValue} />)\n\n  const headingElement = screen.getByText(/This is heading/i)\n\n  expect(headingElement).toBeInTheDocument()\n  expect(headingElement).toHaveStyleRule('font-size', '21px')\n})\n\ntest('글자크기 19px, 색상 gray 인 제목 2 Element를 렌더링합니다.', () => {\n  render(<Heading2View value={mockedHeadingValue} />)\n\n  const headingElement = screen.getByText(/This is heading/i)\n\n  expect(headingElement).toBeInTheDocument()\n  expect(headingElement).toHaveStyleRule('font-size', '19px')\n})\n\ntest('글자크기 16px, 색상은 gray 인 제목 3 Element를 렌더링합니다.', () => {\n  render(<Heading3View value={mockedHeadingValue} />)\n\n  const headingElement = screen.getByText(/This is heading/i)\n\n  expect(headingElement).toBeInTheDocument()\n  expect(headingElement).toHaveStyleRule('font-size', '16px')\n})\n\ntest('글자크기 16px, 색상은 #2987f0 인 제목 4 Element를 렌더링합니다.', () => {\n  render(<Heading4View value={mockedHeadingValue} />)\n\n  const headingElement = screen.getByText(/This is heading/i)\n\n  expect(headingElement).toBeInTheDocument()\n  expect(headingElement).toHaveStyleRule('font-size', '16px')\n  expect(headingElement).toHaveStyleRule('color', '#2987f0')\n})\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/heading.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { FluidTable, Box } from '../common'\n\nexport interface Heading1Document {\n  type: 'heading1'\n  value: {\n    headline?: string\n    text: string\n  }\n}\n\nexport interface Heading2Document {\n  type: 'heading2'\n  value: {\n    text: string\n  }\n}\n\nexport interface Heading3Document {\n  type: 'heading3'\n  value: {\n    text: string\n  }\n}\n\nexport interface Heading4Document {\n  type: 'heading4'\n  value: {\n    text: string\n  }\n}\n\nconst H1Container = styled.h1`\n  margin: 0;\n  font-size: 21px;\n  font-weight: bold;\n  color: rgba(58, 58, 58, 1);\n  white-space: pre-line;\n`\n\nconst HeadlineContainer = styled.p`\n  margin: 0;\n  font-size: 13px;\n  font-weight: bold;\n  color: #2987f0;\n`\n\nconst H2Container = styled.h2`\n  margin: 0;\n  font-size: 19px;\n  font-weight: 500;\n  color: rgba(58, 58, 58, 1);\n  white-space: pre-line;\n`\n\nconst H3Container = styled.h3`\n  margin: 0;\n  font-size: 16px;\n  font-weight: bold;\n  color: rgba(58, 58, 58, 1);\n  white-space: pre-line;\n`\n\nconst H4Container = styled.h4`\n  margin: 0;\n  font-size: 16px;\n  font-weight: bold;\n  color: #2987f0;\n`\n\nexport function Heading1View({\n  id,\n  value: { text, headline },\n}: {\n  id?: string\n  value: Heading1Document['value']\n}) {\n  return (\n    <FluidTable id={id}>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 25, bottom: 20, left: 30, right: 30 }}>\n            {headline && <HeadlineContainer>{headline}</HeadlineContainer>}\n            <H1Container>{text}</H1Container>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n\nexport function Heading2View({\n  id,\n  value: { text },\n}: {\n  id?: string\n  value: Heading2Document['value']\n}) {\n  return (\n    <FluidTable id={id}>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 20, bottom: 20, left: 30, right: 30 }}>\n            <H2Container>{text}</H2Container>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n\nexport function Heading3View({\n  id,\n  value: { text },\n}: {\n  id?: string\n  value: Heading3Document['value']\n}) {\n  return (\n    <FluidTable id={id}>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 20, left: 30, right: 30 }}>\n            <H3Container>{text}</H3Container>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n\nexport function Heading4View({\n  id,\n  value: { text },\n}: {\n  id?: string\n  value: Heading4Document['value']\n}) {\n  return (\n    <FluidTable id={id}>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 20, left: 30, right: 30 }}>\n            <H4Container>{text}</H4Container>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/images.test.tsx",
    "content": "/* eslint-disable testing-library/no-node-access */\nimport { render, screen } from '@testing-library/react'\n\nimport { ELEMENTS } from '../index'\n\nimport { ExtendedImageMeta } from './images'\n\nconst SAMPLE_IMAGE =\n  'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg'\n\nconst SIZES = {\n  full: {\n    url: SAMPLE_IMAGE,\n  },\n  large: {\n    url: SAMPLE_IMAGE,\n  },\n  small_square: {\n    url: SAMPLE_IMAGE,\n  },\n  smallSquare: {\n    url: SAMPLE_IMAGE,\n  },\n}\n\ndescribe('이미지의 여백을 조절합니다.', () => {\n  const Images = ELEMENTS.images\n  const images = generateSampleImages()\n\n  test('여백없는 이미지 1개를 렌더링합니다.', () => {\n    render(\n      <Images\n        value={{\n          display: 'gapless-block',\n          images,\n        }}\n      />,\n    )\n\n    const wrapperBoxElement = screen.getAllByRole('cell')[0]\n    const firstImgBoxElement = screen.getAllByRole('cell')[1]\n    const imgElement = screen.getByRole('img')\n\n    expect(wrapperBoxElement).toHaveStyleRule('padding', '0 0 0 0')\n    expect(firstImgBoxElement).toHaveStyleRule('padding', '0 0 0 0')\n    expect(imgElement).toHaveAttribute('src', SAMPLE_IMAGE)\n  })\n\n  test('여백있는 이미지 1개를 렌더링합니다.', () => {\n    render(\n      <Images\n        value={{\n          display: 'default',\n          images,\n        }}\n      />,\n    )\n\n    const wrapperBoxElement = screen.getAllByRole('cell')[0]\n    const firstImgBoxElement = screen.getAllByRole('cell')[1]\n    const imgElement = screen.getByRole('img')\n\n    expect(wrapperBoxElement).toHaveStyleRule('padding', '40px 0 30px 0')\n    expect(firstImgBoxElement).toHaveStyleRule('padding', '0 30px 0 30px')\n    expect(imgElement).toHaveAttribute('src', SAMPLE_IMAGE)\n  })\n\n  test('여백이 있으면서 테두리가 굴곡인 이미지 1개를 렌더링합니다.', () => {\n    render(\n      <Images\n        value={{\n          display: 'default-v2',\n          images,\n        }}\n      />,\n    )\n\n    const wrapperBoxElement = screen.getAllByRole('cell')[0]\n    const firstImgBoxElement = screen.getAllByRole('cell')[1]\n    const imgElement = screen.getByRole('img')\n\n    expect(wrapperBoxElement).toHaveStyleRule('padding', '20px 0 20px 0')\n    expect(firstImgBoxElement).toHaveStyleRule('padding', '0 30px 0 30px')\n    expect(imgElement).toHaveStyleRule('border-radius', '6px')\n    expect(imgElement).toHaveAttribute('src', SAMPLE_IMAGE)\n  })\n})\n\ndescribe('이미지 크기를 비율에 따라 조절합니다.', () => {\n  const Images = ELEMENTS.images\n\n  test('4:1 비율로 조절합니다.', () => {\n    const images = generateSampleImages('mini')\n    render(\n      <Images\n        value={{\n          display: 'gapless-block',\n          images,\n        }}\n      />,\n    )\n\n    const backgroundImgElement = screen.getAllByRole('cell')[2].firstChild\n\n    expect(backgroundImgElement).toHaveStyleRule('padding-top', '25%')\n    expect(backgroundImgElement).toHaveAttribute('src', SAMPLE_IMAGE)\n  })\n\n  test('5:3 비율로 조절합니다.', () => {\n    const images = generateSampleImages('small')\n    render(\n      <Images\n        value={{\n          display: 'gapless-block',\n          images,\n        }}\n      />,\n    )\n\n    const backgroundImgElement = screen.getAllByRole('cell')[2].firstChild\n\n    expect(backgroundImgElement).toHaveStyleRule('padding-top', '60%')\n    expect(backgroundImgElement).toHaveAttribute('src', SAMPLE_IMAGE)\n  })\n\n  test('4:3 비율로 조절합니다.', () => {\n    const images = generateSampleImages('medium')\n    render(\n      <Images\n        value={{\n          display: 'gapless-block',\n          images,\n        }}\n      />,\n    )\n\n    const backgroundImgElement = screen.getAllByRole('cell')[2].firstChild\n\n    expect(backgroundImgElement).toHaveStyleRule('padding-top', '75%')\n    expect(backgroundImgElement).toHaveAttribute('src', SAMPLE_IMAGE)\n  })\n\n  test('1:1 비율로 조절합니다.', () => {\n    const images = generateSampleImages('large')\n    render(\n      <Images\n        value={{\n          display: 'gapless-block',\n          images,\n        }}\n      />,\n    )\n\n    const backgroundImgElement = screen.getAllByRole('cell')[2].firstChild\n\n    expect(backgroundImgElement).toHaveStyleRule('padding-top', '100%')\n    expect(backgroundImgElement).toHaveAttribute('src', SAMPLE_IMAGE)\n  })\n\n  test('10:11 비율로 조절합니다.', () => {\n    const images = generateSampleImages('big')\n    render(\n      <Images\n        value={{\n          display: 'gapless-block',\n          images,\n        }}\n      />,\n    )\n\n    const backgroundImgElement = screen.getAllByRole('cell')[2].firstChild\n\n    expect(backgroundImgElement).toHaveStyleRule('padding-top', '110%')\n    expect(backgroundImgElement).toHaveAttribute('src', SAMPLE_IMAGE)\n  })\n\n  test('5:8 비율로 조절합니다.', () => {\n    const images = generateSampleImages('huge')\n    render(\n      <Images\n        value={{\n          display: 'gapless-block',\n          images,\n        }}\n      />,\n    )\n\n    const backgroundImgElement = screen.getAllByRole('cell')[2].firstChild\n\n    expect(backgroundImgElement).toHaveStyleRule('padding-top', '160%')\n    expect(backgroundImgElement).toHaveAttribute('src', SAMPLE_IMAGE)\n  })\n})\n\nfunction generateSampleImages(\n  frame?: ExtendedImageMeta['frame'],\n): ExtendedImageMeta[] {\n  const images = [\n    {\n      id: 'image_id',\n      sizes: SIZES,\n      ...(frame && { frame }),\n    },\n  ]\n\n  return images\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/images.tsx",
    "content": "import { Children, PropsWithChildren } from 'react'\nimport { FrameRatioAndSizes, ImageMeta } from '@titicaca/type-definitions'\nimport { styled, css } from 'styled-components'\n\nimport { FluidTable, Box } from '../common'\n\ntype ImageFrameRatio = Extract<\n  FrameRatioAndSizes,\n  'mini' | 'small' | 'medium' | 'large' | 'big' | 'huge' | 'original'\n>\n\nexport type ExtendedImageMeta = ImageMeta & {\n  link?: ImageMeta['link'] & {\n    id?: string\n  }\n  frame?: ImageFrameRatio\n}\n\nexport interface ImageDocument {\n  type: 'images'\n  value: {\n    images: ExtendedImageMeta[]\n    display: 'default' | 'gapless-block' | 'default-v2'\n  }\n}\n\nexport const MEDIA_FRAME_OPTIONS: {\n  [key in Exclude<ImageFrameRatio, 'original'>]: string\n} & { original: undefined } = {\n  mini: '25%',\n  small: '60%',\n  medium: '75%',\n  large: '100%',\n  big: '110%',\n  huge: '160%',\n  original: undefined,\n}\n\nconst FrameImg = styled.div<{\n  $src: string\n  $borderRadius: number\n  $frame: ImageFrameRatio\n}>`\n  width: 100%;\n\n  ${({ $src }) =>\n    $src &&\n    css`\n      background: url(${$src});\n      background-repeat: no-repeat;\n      background-size: cover;\n      background-position: center;\n    `}\n\n  ${({ $borderRadius }) =>\n    $borderRadius &&\n    css`\n      border-radius: ${$borderRadius}px;\n    `};\n\n  ${({ $frame }) =>\n    $frame &&\n    css`\n      padding-top: ${MEDIA_FRAME_OPTIONS[$frame]};\n    `}\n`\n\nconst DefaultImg = styled.img<{ $borderRadius: number }>`\n  width: 100%;\n  display: block;\n\n  ${({ $borderRadius }) =>\n    $borderRadius &&\n    css`\n      border-radius: ${$borderRadius}px;\n    `};\n`\n\nconst Tr = styled.tr<{ $tdWidth: number }>`\n  > td {\n    width: ${({ $tdWidth }) => `${$tdWidth}%`};\n  }\n`\n\nconst ImageCaption = styled.div`\n  font-size: 13px;\n  font-weight: 500;\n  text-align: center;\n  color: rgba(58, 58, 58, 0.7);\n  white-space: pre-wrap;\n`\n\nconst ImageLink = styled.a`\n  img {\n    border: 0 none;\n  }\n`\n\nexport default function Images({\n  value: {\n    display,\n    images: [first, second],\n  },\n}: {\n  value: ImageDocument['value']\n}) {\n  const paddings = {\n    default: { top: 40, bottom: 30 },\n    'default-v2': { top: 20, bottom: 20 },\n    'gapless-block': undefined,\n  }\n  const firstImagePaddings = {\n    default: { left: 30, right: second !== undefined ? 15 : 30 },\n    'default-v2': { left: 30, right: second !== undefined ? 5 : 30 },\n    'gapless-block': undefined,\n  }\n  const secondImagePaddings = {\n    default: { left: 15, right: 30 },\n    'default-v2': { left: 5, right: 30 },\n    'gapless-block': undefined,\n  }\n\n  if (first === undefined) {\n    return null\n  }\n\n  const borderRadius = {\n    default: 0,\n    'default-v2': 6,\n    'gapless-block': 0,\n  }\n\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={paddings[display]}>\n            <FluidTable>\n              <tbody>\n                <ImagesRow>\n                  <Box $padding={firstImagePaddings[display]}>\n                    <Image image={first} borderRadius={borderRadius[display]} />\n                  </Box>\n\n                  {second !== undefined ? (\n                    <Box $padding={secondImagePaddings[display]}>\n                      <Image\n                        image={second}\n                        borderRadius={borderRadius[display]}\n                      />\n                    </Box>\n                  ) : null}\n                </ImagesRow>\n              </tbody>\n            </FluidTable>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n\nfunction ImagesRow({ children }: PropsWithChildren<unknown>) {\n  const count = Children.toArray(children).filter((child) => !!child).length\n\n  return <Tr $tdWidth={100 / count}>{children}</Tr>\n}\n\nfunction Image({\n  image: {\n    title,\n    link,\n    sizes: {\n      full: { url },\n    },\n    frame,\n  },\n  borderRadius,\n}: {\n  image: ExtendedImageMeta\n  borderRadius: number\n}) {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box>\n            {link ? (\n              <ImageLink\n                href={link.href}\n                {...{ 'ses:tags': `link:${link.id}` }}\n              >\n                <Img imageUrl={url} borderRadius={borderRadius} frame={frame} />\n              </ImageLink>\n            ) : (\n              <Img imageUrl={url} borderRadius={borderRadius} frame={frame} />\n            )}\n          </Box>\n        </tr>\n\n        {title ? (\n          <tr>\n            <Box $padding={{ top: 8, bottom: 8 }}>\n              <ImageCaption>{title}</ImageCaption>\n            </Box>\n          </tr>\n        ) : null}\n      </tbody>\n    </FluidTable>\n  )\n}\n\nfunction Img({\n  imageUrl,\n  borderRadius,\n  frame,\n}: {\n  imageUrl: string\n  borderRadius: number\n  frame?: ImageFrameRatio\n}) {\n  return frame && frame !== 'original' ? (\n    <FrameImg $src={imageUrl} $borderRadius={borderRadius} $frame={frame} />\n  ) : (\n    <DefaultImg src={imageUrl} $borderRadius={borderRadius} />\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/index.ts",
    "content": "import { ComponentType } from 'react'\n\nimport {\n  Heading1Document,\n  Heading2Document,\n  Heading3Document,\n  Heading4Document,\n  Heading1View,\n  Heading2View,\n  Heading3View,\n  Heading4View,\n} from './heading'\nimport {\n  Divider1Document,\n  Divider1View,\n  Divider2Document,\n  Divider2View,\n  Divider3Document,\n  Divider3View,\n  Divider4Document,\n  Divider4View,\n  Divider5Document,\n  Divider5View,\n  Divider6Document,\n  Divider6View,\n} from './dividers'\nimport TextView, { TextDocument } from './text'\nimport LinksView, { LinksDocument } from './links'\nimport NoteView, { NoteDocument } from './note'\nimport ImagesView, { ImageDocument } from './images'\nimport EmbeddedView, { EmbeddedDocument } from './embedded'\n\nexport type TripleEmailElementData =\n  | Heading1Document\n  | Heading2Document\n  | Heading3Document\n  | Heading4Document\n  | Divider1Document\n  | Divider2Document\n  | Divider3Document\n  | Divider4Document\n  | Divider5Document\n  | Divider6Document\n  | TextDocument\n  | LinksDocument\n  | NoteDocument\n  | ImageDocument\n  | EmbeddedDocument\n// 여기에 데이터 구조 타입을 추가하세요\n\nexport type TripleEmailElementType = TripleEmailElementData['type']\n\nexport type GetValue<Key extends TripleEmailElementType> = Extract<\n  TripleEmailElementData,\n  { type: Key }\n>['value']\n\nconst ELEMENTS: {\n  [key in TripleEmailElementType]: ComponentType<{\n    id?: string\n    value: GetValue<key>\n  }>\n} = {\n  heading1: Heading1View,\n  heading2: Heading2View,\n  heading3: Heading3View,\n  heading4: Heading4View,\n  hr1: Divider1View,\n  hr2: Divider2View,\n  hr3: Divider3View,\n  hr4: Divider4View,\n  hr5: Divider5View,\n  hr6: Divider6View,\n  text: TextView,\n  links: LinksView,\n  note: NoteView,\n  images: ImagesView,\n  embedded: EmbeddedView,\n  // 여기에 컴포넌트를 추가하세요.\n}\n\nexport default ELEMENTS\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/links.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { ELEMENTS } from '../index'\n\ntest('디폴트형 링크 Element를 렌더링합니다.', () => {\n  const Links = ELEMENTS.links\n\n  render(\n    <Links\n      value={{\n        links: [{ id: '', label: 'Default Styled Link', href: 'Test Href' }],\n        display: 'default',\n      }}\n    />,\n  )\n\n  const anchorElement = screen.getByRole('link')\n\n  expect(anchorElement).toHaveAttribute('href', 'Test Href')\n  expect(anchorElement).toHaveStyleRule('color', '#2987f0')\n  expect(anchorElement).toHaveStyleRule('text-decoration', 'underline')\n  expect(anchorElement).toHaveTextContent('Default Styled Link')\n})\n\ntest('버튼형 링크 Element를 렌더링합니다.', () => {\n  const Links = ELEMENTS.links\n\n  render(\n    <Links\n      value={{\n        links: [\n          {\n            id: '',\n            label: 'Button Styled Link',\n            href: 'Test Href',\n          },\n        ],\n        display: 'button',\n      }}\n    />,\n  )\n\n  const anchorElement = screen.getByRole('link')\n\n  expect(anchorElement).toHaveAttribute('href', 'Test Href')\n  expect(anchorElement).toHaveStyleRule(\n    'background-color',\n    'rgba(54,143,255,1)',\n  )\n  expect(anchorElement).toHaveTextContent('Button Styled Link')\n})\n\ntest('블락형 링크 Element를 렌더링합니다.', () => {\n  const Link = ELEMENTS.links\n\n  render(\n    <Link\n      value={{\n        links: [{ id: '', label: 'Block Styled Link', href: 'Test Href' }],\n        display: 'block',\n      }}\n    />,\n  )\n\n  const anchorElement = screen.getByRole('link')\n\n  expect(anchorElement).toHaveAttribute('href', 'Test Href')\n  expect(anchorElement).toHaveStyleRule(\n    'background-color',\n    'rgba(255,255,255,1)',\n  )\n  expect(anchorElement).toHaveTextContent('Block Styled Link')\n})\n\ntest('대형 버튼형 링크 Element를 렌더링합니다.', () => {\n  const Link = ELEMENTS.links\n\n  render(\n    <Link\n      value={{\n        links: [\n          {\n            id: '',\n            label: 'Large Button Styled Link',\n            href: 'Test Href',\n          },\n        ],\n        display: 'largeButton',\n      }}\n    />,\n  )\n\n  const anchorElement = screen.getByRole('link')\n\n  expect(anchorElement).toHaveAttribute('href', 'Test Href')\n  expect(anchorElement).toHaveStyleRule(\n    'background-color',\n    'rgba(54,143,255,1)',\n  )\n  expect(anchorElement).toHaveStyleRule('border-radius', '4px')\n  expect(anchorElement).toHaveTextContent('Large Button Styled Link')\n})\n\ntest('대형 컴팩트 버튼형 링크 Element를 렌더링합니다.', () => {\n  const Link = ELEMENTS.links\n\n  render(\n    <Link\n      value={{\n        links: [\n          {\n            id: '',\n            label: 'Large Compact Button Styled Link',\n            href: 'Test Href',\n          },\n        ],\n        display: 'largeCompactButton',\n      }}\n    />,\n  )\n\n  const anchorElement = screen.getByRole('link')\n\n  expect(anchorElement).toHaveAttribute('href', 'Test Href')\n  expect(anchorElement).toHaveStyleRule(\n    'background-color',\n    'rgba(54,143,255,1)',\n  )\n  expect(anchorElement).toHaveStyleRule('border-radius', '21px')\n  expect(anchorElement).toHaveTextContent('Large Compact Button Styled Link')\n})\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/links.tsx",
    "content": "import { PropsWithChildren } from 'react'\nimport { Container } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\n\nimport { FluidTable, Box as DefaultBox } from '../common'\n\ninterface Link {\n  id?: string\n  label?: string\n  href: string\n  target?: string\n}\n\nexport interface LinksDocument {\n  type: 'links'\n  value: {\n    links: Link[]\n    display:\n      | 'default'\n      | 'button'\n      | 'block'\n      | 'largeButton'\n      | 'largeCompactButton'\n  }\n}\n\nconst DefaultLink = styled.a`\n  display: inline-block;\n  margin-right: 20px;\n  font-size: 15px;\n  font-weight: bold;\n  color: #2987f0;\n  text-decoration: underline;\n  overflow-wrap: break-word;\n  white-space: pre-line;\n\n  &:hover {\n    color: #2987f0;\n    text-decoration: underline;\n  }\n`\n\nconst ButtonLink = styled.a`\n  padding: 13px 25px;\n  display: inline-block;\n  font-size: 13px;\n  font-weight: bold;\n  text-align: center;\n  text-decoration: none;\n  outline: none;\n  cursor: pointer;\n  border: 0;\n  color: rgba(255, 255, 255, 1);\n  background-color: rgba(54, 143, 255, 1);\n  float: none;\n  border-radius: 21px;\n`\n\nconst BlockLink = styled.a`\n  padding: 7px 12px 8px;\n  display: block;\n  font-size: 14px;\n  font-weight: bold;\n  text-align: center;\n  text-decoration: none;\n  outline: none;\n  line-height: 17px;\n  color: rgba(58, 58, 58, 1);\n  border: 1px solid rgba(58, 58, 58, 0.2);\n  border-radius: 4px;\n  background-color: rgba(255, 255, 255, 1);\n`\n\nconst LargeLink = styled.a`\n  padding: 17px 12px 16px;\n  display: block;\n  font-size: 14px;\n  font-weight: bold;\n  text-align: center;\n  text-decoration: none;\n  outline: none;\n  line-height: 17px;\n  color: rgba(255, 255, 255, 1);\n  background-color: rgba(54, 143, 255, 1);\n  border-radius: 4px;\n`\n\nconst LargeCompactLink = styled(LargeLink)`\n  border-radius: 21px;\n`\n\nconst LINK_BOXES = {\n  default: DefaultLinkBox,\n  button: ButtonBox,\n  block: BlockBox,\n  largeButton: LargeBox,\n  largeCompactButton: LargeBox,\n}\n\nconst LINK_ELEMENTS = {\n  default: DefaultLink,\n  button: ButtonLink,\n  block: BlockLink,\n  largeButton: LargeLink,\n  largeCompactButton: LargeCompactLink,\n}\n\nexport default function LinksView({\n  value: { display, links },\n}: {\n  value: LinksDocument['value']\n}) {\n  const Box = LINK_BOXES[display] || ButtonBox\n  const Element = LINK_ELEMENTS[display] || ButtonLink\n\n  return (\n    <FluidTable>\n      <tbody>\n        {links.map((link, index) => {\n          return (\n            <tr key={index}>\n              <Box>\n                <Container\n                  css={{\n                    textAlign: 'center',\n                  }}\n                >\n                  <Element\n                    href={link.href}\n                    {...{ 'ses:tags': `link:${link.id}` }}\n                  >\n                    {link.label}\n                  </Element>\n                </Container>\n              </Box>\n            </tr>\n          )\n        })}\n      </tbody>\n    </FluidTable>\n  )\n}\n\nfunction DefaultLinkBox({ children }: PropsWithChildren<unknown>) {\n  return (\n    <DefaultBox $padding={{ top: 10, left: 30, right: 30 }}>\n      {children}\n    </DefaultBox>\n  )\n}\n\nfunction ButtonBox({ children }: PropsWithChildren<unknown>) {\n  return (\n    <DefaultBox $padding={{ top: 55, left: 30, right: 30 }}>\n      {children}\n    </DefaultBox>\n  )\n}\n\nfunction BlockBox({ children }: PropsWithChildren<unknown>) {\n  return (\n    <DefaultBox $padding={{ top: 15, left: 30, right: 30 }}>\n      {children}\n    </DefaultBox>\n  )\n}\n\nfunction LargeBox({ children }: PropsWithChildren<unknown>) {\n  return (\n    <DefaultBox $padding={{ top: 30, left: 30, right: 30 }}>\n      {children}\n    </DefaultBox>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/note.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { ELEMENTS } from '../index'\n\ntest('노트 Element를 렌더링합니다.', () => {\n  const mockeNoteValue = {\n    title: '잠깐! 스탑오버(Stopover)란?',\n    body: '목적지로 바로 가지 않고, 중간 지점에서 잠시 머무는 단기 체류를 뜻한다. 보통 경유 시간인 3-4시간 정도가 아니라 24시간 이상을 뜻하기 때문에 관광과 숙박이 가능한 것이 특징. 일부 항공사는 스탑오버시 무료 관광을 제공하니 참고할것!',\n  }\n\n  const Note = ELEMENTS.note\n\n  render(<Note value={mockeNoteValue} />)\n\n  const noteTitleElement = screen.getByText(mockeNoteValue.title)\n  const noteBodyElement = screen.getByText(mockeNoteValue.body)\n\n  expect(noteTitleElement).toBeInTheDocument()\n  expect(noteBodyElement).toBeInTheDocument()\n})\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/note.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport { FluidTable, Box } from '../common'\n\nexport interface NoteDocument {\n  type: 'note'\n  value: {\n    title: string\n    body: string\n  }\n}\n\nconst SegmentStlyed = styled.div`\n  padding: 20px;\n  border-radius: 6px;\n  background-color: #fafafa;\n\n  &::after {\n    content: '';\n    display: block;\n    clear: both;\n  }\n`\n\nconst NoteTextStyled = styled.div`\n  font-size: 14px;\n  line-height: 1.57;\n  white-space: pre-line;\n`\n\nconst TitleStyled = styled(NoteTextStyled)`\n  font-weight: 700;\n  color: rgba(54, 143, 255, 1);\n`\n\nconst BodyStyled = styled(NoteTextStyled)`\n  color: rgba(58, 58, 58, 0.8);\n`\n\nexport default function NoteView({\n  value: { title, body },\n}: {\n  value: NoteDocument['value']\n}) {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={{ top: 30, bottom: 30, left: 30, right: 30 }}>\n            <SegmentStlyed>\n              <TitleStyled>{title}</TitleStyled>\n              <BodyStyled>{body}</BodyStyled>\n            </SegmentStlyed>\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/text.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport { ELEMENTS } from '../index'\n\ntest('rawHTML을 렌더링합니다.', () => {\n  const mockedTextValue = {\n    rawHTML: '<a href=\"/regions/:regionId\">rawHtml Inline link</a>',\n  }\n\n  const Text = ELEMENTS.text\n\n  render(<Text value={mockedTextValue} />)\n\n  const anchorElement = screen.getByText(/Inline link/i).outerHTML\n\n  expect(anchorElement).toBe(mockedTextValue.rawHTML)\n})\n\ntest('text를 렌더링합니다.', () => {\n  const mockedTextValue = {\n    text: 'Default Text',\n  }\n\n  const Text = ELEMENTS.text\n\n  render(<Text value={mockedTextValue} />)\n\n  const textElement = screen.getByText(/Default Text/i)\n\n  expect(textElement).toBeInTheDocument()\n})\n"
  },
  {
    "path": "packages/triple-email-document/src/elements/text.tsx",
    "content": "import { styled, css } from 'styled-components'\n\nimport { FluidTable, Box } from '../common'\n\nexport interface TextDocument {\n  type: 'text'\n  value: {\n    text?: string\n    rawHTML?: string\n  }\n}\n\nconst textStyle = css`\n  font-size: 16px;\n  line-height: 1.63;\n  font-weight: 500;\n  color: rgba(58, 58, 58, 0.9);\n  white-space: pre-line;\n`\n\nconst HtmlContainer = styled.div`\n  p {\n    margin: 24px 0 0;\n    ${textStyle}\n\n    &:first-of-type {\n      margin-top: 0;\n    }\n  }\n\n  strong {\n    ${textStyle}\n    font-weight: bold;\n    color: rgba(58, 58, 58, 1);\n  }\n\n  && {\n    a {\n      ${textStyle}\n      font-weight: bold;\n      color: #2987f0;\n      text-decoration: underline;\n    }\n  }\n`\n\nconst TextContainer = styled.div`\n  ${textStyle}\n`\n\nexport default function TextView({\n  value: { text, rawHTML },\n}: {\n  value: TextDocument['value']\n}) {\n  return (\n    <FluidTable>\n      <tbody>\n        <tr>\n          <Box $padding={{ left: 30, right: 30 }}>\n            {rawHTML ? (\n              <HtmlContainer dangerouslySetInnerHTML={{ __html: rawHTML }} />\n            ) : (\n              <TextContainer>{text}</TextContainer>\n            )}\n          </Box>\n        </tr>\n      </tbody>\n    </FluidTable>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/embedded.stories.tsx",
    "content": "import type { Meta } from '@storybook/react'\nimport { ComponentProps } from 'react'\nimport { styled } from 'styled-components'\n\nimport ELEMENTS from './elements'\n\nconst { embedded: EmbeddedView } = ELEMENTS\n\nconst Container = styled.div`\n  max-width: 600px;\n`\n\nconst ImageContainer = styled.div`\n  margin-bottom: 30px;\n\n  &:last-child {\n    margin-bottom: 0;\n  }\n`\n\nconst Border = styled.div`\n  border: 2px solid black;\n`\n\nexport default {\n  title: 'triple-email-document / Embedded',\n  component: EmbeddedView,\n} as Meta<typeof EmbeddedView>\n\nconst IMAGE = {\n  id: 'IMAGE_ID',\n  sizes: {\n    full: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n    large: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n    small_square: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n    smallSquare: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n  },\n}\n\nconst EmbeddedTemplate: {\n  (args: ComponentProps<typeof EmbeddedView>): JSX.Element\n  storyName?: string\n  args?: ComponentProps<typeof EmbeddedView>\n} = (args) => {\n  return (\n    <Container>\n      <ImageContainer>\n        <Border>\n          <EmbeddedView value={args.value} />\n        </Border>\n      </ImageContainer>\n    </Container>\n  )\n}\n\nexport const DefaultEmbeddedElement = {\n  render: EmbeddedTemplate,\n  name: '기본 (이미지 여백 X)',\n\n  args: {\n    value: {\n      entries: [\n        [\n          {\n            type: 'images',\n            value: {\n              display: 'gapless-block',\n              images: [IMAGE],\n            },\n          },\n          {\n            type: 'heading',\n            value: {\n              text: '임베디드의 타이틀 영역입니다.',\n            },\n          },\n          {\n            type: 'text',\n            value: {\n              text: '임베디드의 본문 영역입니다.',\n            },\n          },\n          {\n            type: 'links',\n            value: {\n              links: [\n                {\n                  id: 'Link_ID',\n                  label: '박스 디자인 형식',\n                  href: '',\n                },\n              ],\n              display: 'block',\n            },\n          },\n        ],\n      ],\n    },\n  },\n}\n\nexport const withPaddingImageEmbeddedElement = {\n  render: EmbeddedTemplate,\n  name: '기본 (이미지 여백 O)',\n\n  args: {\n    value: {\n      entries: [\n        [\n          {\n            type: 'images',\n            value: {\n              display: 'default',\n              images: [IMAGE],\n            },\n          },\n          {\n            type: 'heading',\n            value: {\n              text: '임베디드의 타이틀 영역입니다.',\n            },\n          },\n          {\n            type: 'text',\n            value: {\n              text: '임베디드의 본문 영역입니다.',\n            },\n          },\n          {\n            type: 'links',\n            value: {\n              links: [\n                {\n                  id: 'Link_ID',\n                  label: '박스 디자인 형식',\n                  href: '',\n                },\n              ],\n              display: 'block',\n            },\n          },\n        ],\n      ],\n    },\n  },\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/full-email-template.tsx",
    "content": "import { styled } from 'styled-components'\n\nimport {\n  RecommendedReset,\n  CustomReset,\n  ClientSpecificWorkaround,\n} from './common'\nimport { EmailPreview, PreviewDocument } from './components'\nimport { TripleEmailElementData } from './elements'\nimport { TripleEmailDocument } from './triple-email-document'\n\nconst RootTable = styled.table`\n  width: 100%;\n  border-collapse: collapse;\n  max-width: 600px;\n  margin-left: auto;\n  margin-right: auto;\n`\n\nexport default function FullEmailTemplate({\n  preview,\n  document,\n}: {\n  preview: PreviewDocument\n  document: TripleEmailElementData[]\n}) {\n  return (\n    <>\n      <RecommendedReset />\n      <CustomReset />\n      <ClientSpecificWorkaround />\n\n      <div id=\"bodyTable\">\n        <RootTable>\n          <tbody>\n            <tr>\n              <td>\n                <EmailPreview value={preview.value} />\n              </td>\n            </tr>\n            <tr>\n              <td>\n                <TripleEmailDocument elements={document} />\n              </td>\n            </tr>\n          </tbody>\n        </RootTable>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/heading.stories.tsx",
    "content": "import type { Meta, StoryFn } from '@storybook/react'\n\nimport ELEMENTS from './elements'\n\nconst {\n  heading1: Heading1,\n  heading2: Heading2,\n  heading3: Heading3,\n  heading4: Heading4,\n} = ELEMENTS\n\nexport default {\n  title: 'triple-email-document / Heading',\n} as Meta\n\nconst Heading1Template: StoryFn<typeof Heading1> = (args) => (\n  <Heading1 {...args} />\n)\n\nexport const Heading1Normal = {\n  render: Heading1Template,\n\n  args: {\n    value: {\n      text: '제목 1: bold 21px',\n    },\n  },\n\n  name: '제목 1 기본',\n}\n\nexport const Heading1WithHeadline = {\n  render: Heading1Template,\n\n  args: {\n    value: {\n      headline: '헤드라인: bold 13px #2987F0',\n      text: '제목 1: bold 21px',\n    },\n  },\n\n  name: '제목 1 헤드라인 포함',\n}\n\nexport function Heading2Example() {\n  return <Heading2 value={{ text: '제목2: medium 19px' }} />\n}\nHeading2Example.storyName = '제목 2'\n\nexport function Heading3Example() {\n  return <Heading3 value={{ text: '제목3: bold 16px' }} />\n}\nHeading3Example.storyName = '제목 3'\n\nexport function Heading4Example() {\n  return <Heading4 value={{ text: '제목4: bold 16px #2987F0' }} />\n}\nHeading4Example.storyName = '제목 4'\n"
  },
  {
    "path": "packages/triple-email-document/src/hr.stories.tsx",
    "content": "import type { Meta } from '@storybook/react'\n\nimport ELEMENTS from './elements'\n\nconst { hr1: HR1, hr2: HR2, hr3: HR3, hr4: HR4, hr5: HR5, hr6: HR6 } = ELEMENTS\n\nexport default {\n  title: 'triple-email-document / HR',\n} as Meta\n\nexport function Hr1Example() {\n  return <HR1 value={undefined} />\n}\nHr1Example.storyName = '구분선 1'\n\nexport function Hr2Example() {\n  return <HR2 value={undefined} />\n}\nHr2Example.storyName = '구분선 2'\n\nexport function Hr3Example() {\n  return <HR3 value={undefined} />\n}\nHr3Example.storyName = '구분선 3'\n\nexport function Hr4Example() {\n  return <HR4 value={undefined} />\n}\nHr4Example.storyName = '구분선 4'\n\nexport function Hr5Example() {\n  return <HR5 value={undefined} />\n}\nHr5Example.storyName = '구분선 5'\n\nexport function Hr6Example() {\n  return <HR6 value={undefined} />\n}\nHr6Example.storyName = '구분선 6'\n"
  },
  {
    "path": "packages/triple-email-document/src/images.stories.tsx",
    "content": "import type { Meta } from '@storybook/react'\nimport { ComponentProps } from 'react'\nimport { styled } from 'styled-components'\n\nimport ELEMENTS from './elements'\n\nconst { images: Images } = ELEMENTS\n\nexport default {\n  title: 'triple-email-document / Images',\n  component: Images,\n} as Meta<typeof Images>\n\nconst Container = styled.div`\n  max-width: 600px;\n`\n\nconst ImageContainer = styled.div`\n  margin-bottom: 30px;\n\n  &:last-child {\n    margin-bottom: 0;\n  }\n`\n\nconst Title = styled.div`\n  margin-bottom: 10px;\n`\n\nconst Border = styled.div`\n  border: 2px solid black;\n`\n\nconst TITIES: { [key: string]: string } = {\n  0: '사이즈만 있는 이미지 데이터 구조',\n  1: '사이즈, 제목이 있는 데이터 구조',\n  2: '사이즈, 링크가 있는 데이터 구조',\n  3: '사이즈, 링크, 제목이 있는 데이터 구조',\n}\n\nconst IMAGE = {\n  id: 'IMAGE_ID',\n  sizes: {\n    full: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n    large: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n    small_square: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n    smallSquare: {\n      url: 'https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/be33afd8-c14b-4508-b1f9-8b36bfb29f64.jpeg',\n    },\n  },\n}\n\nconst IMAGE_WITH_TITLE = {\n  ...IMAGE,\n  title: '이미지 제목입니다.',\n}\n\nconst IMAGE_WITH_LINK = {\n  ...IMAGE,\n  link: {\n    href: 'https://triple.guide',\n    label: '여기를 눌러 확인하세요.',\n  },\n}\n\nconst IMAGE_WITH_TITLE_AND_LINK = {\n  ...IMAGE_WITH_TITLE,\n  ...IMAGE_WITH_LINK,\n}\n\nconst ImageTemplate: {\n  (args: { propList: ComponentProps<typeof Images>[] }): JSX.Element\n  storyName?: string\n  args?: {\n    propList: ComponentProps<typeof Images>[]\n  }\n} = (args) => {\n  return (\n    <Container>\n      {args.propList.map((props, index) => (\n        <ImageContainer key={index}>\n          <Title>{TITIES[index]}</Title>\n          <Border>\n            <Images {...props} />\n          </Border>\n        </ImageContainer>\n      ))}\n    </Container>\n  )\n}\n\nexport const Default = {\n  render: ImageTemplate,\n  name: '간격 없는 이미지 1개',\n\n  args: {\n    propList: generateSampleImages('gapless-block'),\n  },\n}\n\nexport const TwoImages = {\n  render: ImageTemplate,\n  name: '간격 없는 이미지 2개',\n\n  args: {\n    propList: generateSampleImagesTwo('gapless-block'),\n  },\n}\n\nexport const OneImageWithPadding = {\n  render: ImageTemplate,\n  name: '간격 있는 이미지 1개',\n\n  args: {\n    propList: generateSampleImages('default'),\n  },\n}\n\nexport const TwoImagesWithPadding = {\n  render: ImageTemplate,\n  name: '간격 있는 이미지 2개',\n\n  args: {\n    propList: generateSampleImagesTwo('default'),\n  },\n}\n\nexport const OneImageWithPaddingV2 = {\n  render: ImageTemplate,\n  name: '간격 있는 이미지 1개 V2',\n\n  args: {\n    propList: generateSampleImagesTwo('default-v2'),\n  },\n}\n\nexport const TwoImagesWithPaddingV2 = {\n  render: ImageTemplate,\n  name: '간격 있는 이미지 2개 V2',\n\n  args: {\n    propList: generateSampleImagesTwo('default-v2'),\n  },\n}\n\ntype ImageDisply = 'default' | 'gapless-block' | 'default-v2'\n\nfunction generateSampleImagesTwo(type: ImageDisply) {\n  return [\n    {\n      value: {\n        display: type,\n        images: [IMAGE, IMAGE],\n      },\n    },\n    {\n      value: {\n        display: type,\n        images: [IMAGE_WITH_TITLE, IMAGE_WITH_TITLE],\n      },\n    },\n    {\n      value: {\n        display: type,\n        images: [IMAGE_WITH_LINK, IMAGE_WITH_LINK],\n      },\n    },\n    {\n      value: {\n        display: type,\n        images: [IMAGE_WITH_TITLE_AND_LINK, IMAGE_WITH_TITLE_AND_LINK],\n      },\n    },\n  ]\n}\n\nfunction generateSampleImages(type: ImageDisply) {\n  return [\n    {\n      value: {\n        display: type,\n        images: [IMAGE],\n      },\n    },\n    {\n      value: {\n        display: type,\n        images: [IMAGE_WITH_TITLE],\n      },\n    },\n    {\n      value: {\n        display: type,\n        images: [IMAGE_WITH_LINK],\n      },\n    },\n    {\n      value: {\n        display: type,\n        images: [IMAGE_WITH_TITLE_AND_LINK],\n      },\n    },\n  ]\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/index.ts",
    "content": "import { TripleEmailDocument } from './triple-email-document'\n\nexport { default as ELEMENTS } from './elements'\nexport type { TripleEmailElementData as TripleEmailDocumentElement } from './elements'\nexport type { ExtendedImageMeta, ImageDocument } from './elements/images'\nexport { EmailPreview, type PreviewDocument } from './components'\nexport { default as FullEmailTemplate } from './full-email-template'\nexport { FluidTable, HandlebarsAnchor } from './common'\n\nexport default TripleEmailDocument\n"
  },
  {
    "path": "packages/triple-email-document/src/links.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport ELEMENTS from './elements'\n\nconst { links: Links } = ELEMENTS\n\nexport default {\n  title: 'triple-email-document / Links',\n  component: Links,\n  argTypes: {\n    value: {\n      links: {\n        id: {\n          type: 'string',\n          require: true,\n        },\n        label: {\n          type: 'string',\n        },\n        href: {\n          type: 'string',\n          require: true,\n        },\n      },\n      display: {\n        type: 'string',\n        require: true,\n      },\n    },\n  },\n} as Meta\n\nexport const StyledDefaultLinkElement: StoryObj = {\n  name: '디폴트',\n  args: generateSampleData('default'),\n}\n\nexport const StyledButtonLinkElement: StoryObj = {\n  name: '버튼',\n  args: generateSampleData('button'),\n}\n\nexport const StyledBlockLinkElement: StoryObj = {\n  name: '블락',\n  args: generateSampleData('block'),\n}\n\nexport const StyledLargeButtonLinkElement: StoryObj = {\n  name: '대형 버튼',\n  args: generateSampleData('largeButton'),\n}\n\nexport const StyledCompactLargeButtonLinkElement: StoryObj = {\n  name: 'compact한 대형 버튼',\n  args: generateSampleData('largeCompactButton'),\n}\n\ntype LinkDisplay =\n  | 'default'\n  | 'button'\n  | 'block'\n  | 'largeButton'\n  | 'largeCompactButton'\n\nfunction generateSampleData(type: LinkDisplay) {\n  return {\n    value: {\n      links: [\n        {\n          id: 'Link_ID',\n          label: `${type} 디자인 형식`,\n          href: '',\n        },\n      ],\n      display: type,\n    },\n    webUrlBase: 'https://triple-dev.titicaca-corp.com',\n  }\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/note.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport ELEMENTS from './elements'\n\nconst { note: Note } = ELEMENTS\n\nexport default {\n  title: 'triple-email-document / Note',\n  component: Note,\n  argTypes: {\n    value: {\n      title: {\n        type: 'string',\n        require: true,\n      },\n      body: {\n        type: 'string',\n        require: true,\n      },\n    },\n  },\n} as Meta<typeof Note>\n\nexport const NoteElement: StoryObj = {\n  name: '노트',\n  args: {\n    value: {\n      title: '잠깐! 스탑오버(Stopover)란?',\n      body: '목적지로 바로 가지 않고, 중간 지점에서 잠시 머무는 단기 체류를 뜻한다. 보통 경유 시간인 3-4시간 정도가 아니라 24시간 이상을 뜻하기 때문에 관광과 숙박이 가능한 것이 특징. 일부 항공사는 스탑오버시 무료 관광을 제공하니 참고할것!',\n    },\n  },\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/preview.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport { EmailPreview } from './components'\n\nexport default {\n  title: 'triple-email-document / Preview',\n  component: EmailPreview,\n  argTypes: {\n    value: {\n      phrase: {\n        type: 'string',\n        require: true,\n      },\n    },\n  },\n} as Meta<typeof EmailPreview>\n\nexport const DefaultPreview: StoryObj<typeof EmailPreview> = {\n  name: '미리보기',\n  args: {\n    value: {\n      phrase: '지금 10만원 받고, 트리플로 새해여행 어때요?',\n    },\n  },\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/text.stories.tsx",
    "content": "import type { Meta, StoryObj } from '@storybook/react'\n\nimport ELEMENTS from './elements'\n\nconst { text: Text } = ELEMENTS\n\nexport default {\n  title: 'triple-email-document / Text',\n  component: Text,\n  argTypes: {\n    value: {\n      rawHTML: {\n        type: 'string',\n        require: true,\n      },\n    },\n  },\n} as Meta<typeof Text>\n\nexport const TextElement: StoryObj = {\n  name: '텍스트',\n  args: {\n    value: {\n      rawHTML: '텍스트 <a href=\"/regions/:regionId\">Inline link</a>',\n    },\n  },\n}\n"
  },
  {
    "path": "packages/triple-email-document/src/triple-email-document.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\n\nimport TripleEmailDocument, { TripleEmailDocumentElement } from '.'\n\ntype ExtendedTripleEmailDocumentElement =\n  | TripleEmailDocumentElement\n  | UndefinedDocument\n\ninterface UndefinedDocument {\n  type: undefined\n  value: undefined\n}\n\ntest('children이 빈 배열이면 Email Document Element가 렌더링되지 않습니다.', () => {\n  const mockedChildren: TripleEmailDocumentElement[] = []\n\n  render(<TripleEmailDocument elements={mockedChildren} />)\n\n  const TrElement = screen.queryByRole('tr')\n\n  expect(TrElement).not.toBeInTheDocument()\n})\n\ntest('type이 정의되지 않은 Element는 렌더링되지 않습니다.', () => {\n  const mockedChildren: ExtendedTripleEmailDocumentElement[] = [\n    {\n      type: undefined,\n      value: undefined,\n    },\n  ]\n\n  render(\n    <TripleEmailDocument\n      elements={mockedChildren as TripleEmailDocumentElement[]}\n    />,\n  )\n\n  const TrElement = screen.queryByRole('tr')\n\n  expect(TrElement).not.toBeInTheDocument()\n})\n\ntest('Heading1 Element가 렌더링됩니다.', () => {\n  const mockedChildren: TripleEmailDocumentElement[] = [\n    {\n      type: 'heading1',\n      value: {\n        headline: 'This is headline',\n        text: 'This is heading',\n      },\n    },\n  ]\n\n  render(<TripleEmailDocument elements={mockedChildren} />)\n\n  const createdTextInHeadingComponent = screen.getByText(/This is headline/i)\n\n  expect(createdTextInHeadingComponent).toBeInTheDocument()\n})\n"
  },
  {
    "path": "packages/triple-email-document/src/triple-email-document.tsx",
    "content": "import { ComponentType } from 'react'\n\nimport ELEMENTS, { TripleEmailElementData, GetValue } from './elements'\nimport { FluidTable } from './common'\n\ninterface ElementSet {\n  [key: string]: ComponentType<{\n    value: GetValue<TripleEmailElementData['type']>\n  }>\n}\n\nexport function TripleEmailDocument({\n  elements,\n  customElements = {},\n}: {\n  elements: TripleEmailElementData[]\n  customElements?: ElementSet\n}) {\n  return (\n    <FluidTable>\n      <tbody>\n        {elements.map(({ type, value }, index) => {\n          const RegularElement = ELEMENTS[type] as ComponentType<{\n            value: GetValue<typeof type>\n          }>\n          const CustomElement = customElements[type]\n\n          const Element = CustomElement || RegularElement\n\n          if (Element === undefined) {\n            return null\n          }\n\n          return (\n            <tr key={index}>\n              <td>\n                <Element value={value} />\n              </td>\n            </tr>\n          )\n        })}\n      </tbody>\n    </FluidTable>\n  )\n}\n"
  },
  {
    "path": "packages/triple-email-document/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/triple-email-document/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/triple-email-document/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/triple-fallback-action/README.md",
    "content": "# `@titicaca/triple-fallback-action`\n\nHTML 페이지는 로드했으나 Javascript 파일을 로드하지 못했을 때 페이지를 벗어나는 기능을 제공합니다.\n\n## 작동 원리\n\n특정 클래스의 엘리먼트를 클릭했을 때 페이지를 닫거나 뒤로 가기하는 스크립트가 있습니다.\n이 스크립트를 서버에서 페이지를 만들 때 HTML 문서에 인라인 시킵니다.\n따라서, JS 애셋을 로드하지 못했더라도 특정 클래스의 엘리먼트를 클릭하면 페이지를 닫거나 뒤로 갈 수 있습니다.\n이 특정 클래스는 뒤로 가기 버튼이나, 화면 전체를 덮는 요소에 추가합니다.\n\n## 적용 방법\n\n`pages/_document.tsx`에 다음 코드를 추가하세요.\n\n<!-- prettier-ignore-start -->\n```tsx\nimport { TripleFallbackActionScript } from '@titicaca/triple-fallback-action'\n\n// class MyDocument extends Document {\n  // public render() {\n    // return (\n      // <body>\n        // ...\n        <TripleFallbackActionScript />\n      // </body>\n    // )\n  // }\n// }\n```\n<!-- prettier-ignore-end -->\n\n`pages/_app.tsx`에 다음 코드를 추가하세요.\n\n<!-- prettier-ignore-start -->\n```ts\nimport { useTripleFallbackActionRemover } from '@titicaca/triple-fallback-action'\n\n// function MyApp() {\n  // ...\n\n  useTripleFallbackActionRemover()\n\n  // ...\n// }\n```\n<!-- prettier-ignore-end -->\n\n`MyApp`이 클래스 컴포넌트라면 컴포넌트를 추가하세요.\n\n<!-- prettier-ignore-start -->\n```tsx\nimport { TripleFallbackActionRemover } from '@titicaca/triple-fallback-action'\n\n// class MyApp extends App {\n  // public render() {\n    // return (\n      // ...\n      <TripleFallbackActionRemover />\n    // )\n  // }\n// }\n```\n<!-- prettier-ignore-end -->\n"
  },
  {
    "path": "packages/triple-fallback-action/package.json",
    "content": "{\n  \"name\": \"@titicaca/triple-fallback-action\",\n  \"version\": \"14.2.3\",\n  \"description\": \"Escape hatch for Javascript file loading failure in web pages\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/triple-fallback-action\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"devDependencies\": {\n    \"react\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/triple-fallback-action/src/constant.ts",
    "content": "export const TRIPLE_FALLBACK_ACTION_CLASS_NAME = '-triple-fallback-action'\n"
  },
  {
    "path": "packages/triple-fallback-action/src/index.ts",
    "content": "export * from './constant'\nexport * from './triple-fallback-action-remover'\nexport * from './triple-fallback-action'\nexport * from './use-triple-fallback-action-remover'\n"
  },
  {
    "path": "packages/triple-fallback-action/src/triple-fallback-action-remover.tsx",
    "content": "import { useTripleFallbackActionRemover } from './use-triple-fallback-action-remover'\n\nexport function TripleFallbackActionRemover() {\n  useTripleFallbackActionRemover()\n\n  return null\n}\n"
  },
  {
    "path": "packages/triple-fallback-action/src/triple-fallback-action.test.tsx",
    "content": "import { fireEvent, render, screen, waitFor } from '@testing-library/react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport { ReactElement } from 'react'\n\nimport { TripleFallbackActionScript } from './triple-fallback-action'\nimport { TRIPLE_FALLBACK_ACTION_CLASS_NAME } from './constant'\nimport { TripleFallbackActionRemover } from './triple-fallback-action-remover'\n\nconst back = jest.spyOn(history, 'back')\n\nafterEach(() => {\n  back.mockClear()\n})\n\ntest('Fallback Action 클래스를 가진 요소를 클릭하면 페이지를 닫습니다.', () => {\n  renderScriptTag(<TripleFallbackActionScript />)\n\n  render(<button className={TRIPLE_FALLBACK_ACTION_CLASS_NAME}>클릭</button>)\n\n  const button = screen.getByText('클릭')\n\n  fireEvent.click(button)\n\n  expect(back).toHaveBeenCalled()\n})\n\ntest('핸들러를 제거하는 컴포넌트를 함께 렌더링하면 페이지를 닫지 않습니다.', async () => {\n  renderScriptTag(<TripleFallbackActionScript />)\n\n  render(\n    <>\n      <button className={TRIPLE_FALLBACK_ACTION_CLASS_NAME}>클릭</button>\n      <TripleFallbackActionRemover />\n    </>,\n  )\n\n  const button = screen.getByText('클릭')\n\n  await waitFor(() => expect(window.__DISASTER_FALLBACK_HANDLER__).toBeNull())\n\n  fireEvent.click(button)\n\n  expect(back).not.toHaveBeenCalled()\n})\n\nfunction renderScriptTag(el: ReactElement) {\n  const scriptContainer = document.createElement('div')\n  scriptContainer.innerHTML = renderToStaticMarkup(el)\n  const script = document.createElement('script')\n  // eslint-disable-next-line testing-library/no-node-access\n  script.innerHTML = scriptContainer.getElementsByTagName('script')[0].innerHTML\n  document.body.appendChild(script)\n}\n"
  },
  {
    "path": "packages/triple-fallback-action/src/triple-fallback-action.tsx",
    "content": "import { TRIPLE_FALLBACK_ACTION_CLASS_NAME } from './constant'\n\nexport const FALLBACK_HANDLER_KEY = '__DISASTER_FALLBACK_HANDLER__'\n\ndeclare global {\n  interface Window {\n    [FALLBACK_HANDLER_KEY]: ((e: MouseEvent) => void) | null\n  }\n}\n\nexport function TripleFallbackActionScript() {\n  return (\n    <script\n      dangerouslySetInnerHTML={{\n        __html: `\n          if (!window.${FALLBACK_HANDLER_KEY}) {\n            window.${FALLBACK_HANDLER_KEY} = function (e) {\n              if (e.target.className.indexOf('${TRIPLE_FALLBACK_ACTION_CLASS_NAME}') > -1) {\n                try {\n                  if (typeof Soto !== 'undefined' && Soto !== null) {\n                    Soto.postMessage(JSON.stringify({ command: 'backOrClose' }))\n                  } else if (typeof window !== 'undefined' &&\n                    typeof window.webkit !== 'undefined' &&\n                    window.webkit !== null &&\n                    window.webkit.messageHandlers &&\n                    window.webkit.messageHandlers.Soto) {\n\n                    window.webkit.messageHandlers.Soto.postMessage({ command: 'backOrClose' })\n                  } else {\n                    history.back()\n                  }\n                } catch (e) {\n                  /* do nothing */\n                }\n              }\n            }\n\n            document.body.addEventListener('click', window.${FALLBACK_HANDLER_KEY})\n          }\n        `,\n      }}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/triple-fallback-action/src/use-triple-fallback-action-remover.ts",
    "content": "import { useEffect, useLayoutEffect } from 'react'\n\nimport { FALLBACK_HANDLER_KEY } from './triple-fallback-action'\n\nconst useLayoutEffectSafeInSsr =\n  typeof window === 'undefined' ? useEffect : useLayoutEffect\n\nfunction removeDisasterFallback() {\n  if (typeof window === 'undefined') {\n    return\n  }\n\n  const fallbackHandler = window[FALLBACK_HANDLER_KEY]\n\n  if (!fallbackHandler) {\n    return\n  }\n\n  document.body.removeEventListener('click', fallbackHandler)\n\n  window[FALLBACK_HANDLER_KEY] = null\n}\n\nexport function useTripleFallbackActionRemover() {\n  useLayoutEffectSafeInSsr(() => {\n    removeDisasterFallback()\n  }, [])\n}\n"
  },
  {
    "path": "packages/triple-fallback-action/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/triple-fallback-action/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/triple-fallback-action/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/triple-header/package.json",
    "content": "{\n  \"name\": \"@titicaca/triple-header\",\n  \"version\": \"14.2.3\",\n  \"description\": \"TripleHeader\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/triple-header\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/fetcher\": \"workspace:*\",\n    \"@titicaca/react-hooks\": \"workspace:*\",\n    \"@titicaca/router\": \"workspace:*\",\n    \"@titicaca/standard-action-handler\": \"workspace:*\",\n    \"@titicaca/tds-ui\": \"workspace:*\",\n    \"@titicaca/type-definitions\": \"workspace:*\",\n    \"framer-motion\": \"^10.18.0\",\n    \"lottie-web\": \"^5.13.0\"\n  },\n  \"devDependencies\": {\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"react\": \"^18.3.1\",\n    \"styled-components\": \"^6.1.15\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/triple-web\": \"*\",\n    \"react\": \"^18.0\",\n    \"styled-components\": \"^6.0\"\n  }\n}\n"
  },
  {
    "path": "packages/triple-header/src/frame/common.ts",
    "content": "import { SyntheticEvent } from 'react'\n\nimport { Link, LinkEventHandler } from '../types'\n\nexport function generateLinkClickHandler(\n  onLinkClick?: LinkEventHandler,\n): (e: SyntheticEvent, link?: Link) => void {\n  return (e, link) => {\n    if (link && onLinkClick) {\n      return onLinkClick(e, link)\n    }\n  }\n}\n"
  },
  {
    "path": "packages/triple-header/src/frame/effects/common.ts",
    "content": "import { Transition } from 'framer-motion'\n\nimport { InitialEffectOptions } from './types'\n\nexport function stringifyTransition(transition: Transition) {\n  return Object.entries(transition)\n    .map(([key, value]) => `${key}_${value}`)\n    .join(',')\n}\n\nconst COMMON_TRANSITION = {\n  ease: 'linear',\n  duration: 3,\n}\n\nexport function generateTransition<T>(\n  initialOptions: T & InitialEffectOptions & { duration?: number },\n  index: number,\n  frameCount: number,\n) {\n  const {\n    infinity,\n    repeatType,\n    duration = COMMON_TRANSITION.duration,\n    ...options\n  } = initialOptions\n\n  const transition = {\n    ...COMMON_TRANSITION,\n    duration,\n    ...options,\n    ...(infinity && { repeat: Infinity }),\n    ...(repeatType && {\n      repeatType,\n    }),\n    ...(index && { delay: COMMON_TRANSITION.duration * index }),\n    ...(frameCount && {\n      repeatDelay: COMMON_TRANSITION.duration * frameCount - duration,\n    }),\n  }\n\n  return transition\n}\n"
  },
  {
    "path": "packages/triple-header/src/frame/effects/flying.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { MotionContainer } from '../../motion-container'\n\nimport { InitialEffectOptions } from './types'\nimport { generateTransition, stringifyTransition } from './common'\n\ntype ExtendedEffectOptions = InitialEffectOptions & {\n  degree?: number\n}\n\nexport type FlyingEffect = { type: 'flying' } & Omit<\n  FlyingProps,\n  'index' | 'frameCount'\n>\n\ninterface FlyingProps {\n  options?: ExtendedEffectOptions\n  index: number\n  frameCount: number\n}\n\nexport function Flying({\n  children,\n  options = {},\n  index,\n  frameCount,\n}: PropsWithChildren<FlyingProps>) {\n  const transition = generateTransition(\n    { ...options, duration: 0.3 },\n    index,\n    frameCount,\n  )\n\n  return (\n    <MotionContainer\n      key={`flying_${stringifyTransition(transition)}`}\n      initial={{ x: '150%', rotate: 0 }}\n      animate={{ x: 0, rotate: options.degree || 0 }}\n      transition={transition}\n    >\n      {children}\n    </MotionContainer>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/frame/effects/index.ts",
    "content": "import { Zoom, ZoomEffect } from './zoom'\nimport { Rotate, RotateEffect } from './rotate'\nimport { Flying, FlyingEffect } from './flying'\n\nexport const EFFECTS = {\n  zoom: Zoom,\n  rotate: Rotate,\n  flying: Flying,\n}\n\nexport type Effect = ZoomEffect | RotateEffect | FlyingEffect\n"
  },
  {
    "path": "packages/triple-header/src/frame/effects/rotate.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { MotionContainer } from '../../motion-container'\n\nimport { InitialEffectOptions } from './types'\nimport { generateTransition, stringifyTransition } from './common'\n\ntype ExtendedEffectOptions = InitialEffectOptions & {\n  degree?: number\n}\n\nexport type RotateEffect = { type: 'rotate' } & Omit<\n  RotateProps,\n  'index' | 'frameCount'\n>\n\ninterface RotateProps {\n  options?: ExtendedEffectOptions\n  index: number\n  frameCount: number\n}\n\nexport function Rotate({\n  children,\n  options = {},\n  index,\n  frameCount,\n}: PropsWithChildren<RotateProps>) {\n  const transition = generateTransition(options, index, frameCount)\n\n  return (\n    <MotionContainer\n      key={`rotate_${stringifyTransition(transition)}`}\n      animate={{ rotate: options.degree || 0 }}\n      transition={transition}\n    >\n      {children}\n    </MotionContainer>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/frame/effects/types.ts",
    "content": "type RepeatType = 'loop' | 'reverse' | 'mirror'\n\nexport interface InitialEffectOptions {\n  infinity?: boolean\n  repeatType?: RepeatType\n}\n"
  },
  {
    "path": "packages/triple-header/src/frame/effects/zoom.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport { MotionContainer } from '../../motion-container'\n\nimport { InitialEffectOptions } from './types'\nimport { generateTransition, stringifyTransition } from './common'\n\nexport type ZoomEffect = { type: 'zoom' } & Omit<\n  ZoomProps,\n  'index' | 'frameCount'\n>\n\ninterface ZoomProps {\n  options?: InitialEffectOptions\n  index: number\n  frameCount: number\n}\n\nexport function Zoom({\n  children,\n  options = {},\n  index,\n  frameCount,\n}: PropsWithChildren<ZoomProps>) {\n  const transition = generateTransition(options, index, frameCount)\n\n  return (\n    <MotionContainer\n      key={`zoom_${stringifyTransition(transition)}`}\n      animate={{ scale: 1.2 }}\n      transition={transition}\n    >\n      {children}\n    </MotionContainer>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/frame/frame.tsx",
    "content": "import { ComponentType, useCallback } from 'react'\nimport { Container } from '@titicaca/tds-ui'\nimport { styled, css } from 'styled-components'\nimport { useStandardActionHandler } from '@titicaca/standard-action-handler'\nimport { useTrackEventWithMetadata } from '@titicaca/triple-web'\n\nimport { FrameData, LinkEventHandler } from '../types'\nimport { FRAMES } from '../frame'\n\nconst FrameContainer = styled(Container)<{\n  $widthRatio: number\n  $heightRatio: number\n}>`\n  width: 100%;\n  height: 0;\n  margin: 0 auto;\n  flex: 0 0 auto;\n\n  ${({ $heightRatio }) =>\n    $heightRatio &&\n    css`\n      padding: ${$heightRatio}% 0 0 0;\n      position: relative;\n    `}\n\n  ${({ $widthRatio }) =>\n    $widthRatio &&\n    css`\n      max-width: ${$widthRatio}%;\n    `}\n`\n\nexport function Frame({\n  frame: { type, width, height, value, effect },\n  index,\n  calculateFrameRatio,\n  frameCount,\n  onLinkClick,\n}: {\n  frame: FrameData\n  index: number\n  calculateFrameRatio: (length?: number) => number\n  frameCount: number\n  onLinkClick?: LinkEventHandler\n}) {\n  const FrameElement = FRAMES[type] as ComponentType<\n    Omit<FrameData, 'type'> & { index: number; frameCount: number }\n  >\n\n  const widthRatio = calculateFrameRatio(width)\n  const heightRatio = calculateFrameRatio(height)\n\n  const trackEventWithMetadata = useTrackEventWithMetadata()\n\n  const handleAction = useStandardActionHandler()\n\n  const defaultHandleLinkClick: LinkEventHandler = useCallback(\n    (e, { href, target }) => {\n      if (!href) {\n        return\n      }\n      trackEventWithMetadata({\n        fa: {\n          action: '링크선택',\n          url: href,\n        },\n        ga: ['링크선택', href],\n      })\n      handleAction(href, { target })\n    },\n    [handleAction, trackEventWithMetadata],\n  )\n\n  const linkClickHandler = onLinkClick || defaultHandleLinkClick\n\n  return (\n    <FrameContainer $widthRatio={widthRatio} $heightRatio={heightRatio}>\n      <FrameElement\n        value={value}\n        effect={effect}\n        index={index}\n        frameCount={frameCount}\n        onLinkClick={linkClickHandler}\n      />\n    </FrameContainer>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/frame/image.tsx",
    "content": "import { ImageMeta } from '@titicaca/type-definitions'\nimport { styled } from 'styled-components'\n\nimport { MotionContainer } from '../motion-container'\nimport { LinkEventHandler } from '../types'\n\nimport { EFFECTS, Effect } from './effects'\nimport { generateLinkClickHandler } from './common'\n\nexport type ImageFrame = { type: 'image' } & Omit<\n  ImageFrameProps,\n  'index' | 'frameCount'\n>\n\ninterface ImageFrameProps {\n  value: {\n    image: ImageMeta\n  }\n  width?: number\n  height?: number\n  effect?: Effect\n  index: number\n  frameCount: number\n  onLinkClick?: LinkEventHandler\n}\n\nconst Image = styled.img`\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n`\n\nexport function ImageFrame({\n  value: { image },\n  effect,\n  index,\n  frameCount,\n  onLinkClick,\n}: ImageFrameProps) {\n  const EffectElement = effect ? EFFECTS[effect.type] : MotionContainer\n\n  return Object.keys(image).length > 0 ? (\n    <EffectElement\n      options={effect?.options}\n      index={index}\n      frameCount={frameCount}\n    >\n      <Image\n        src={image.sizes.full.url}\n        onClick={(e) => generateLinkClickHandler(onLinkClick)(e, image.link)}\n        width=\"100%\"\n        height=\"100%\"\n      />\n    </EffectElement>\n  ) : null\n}\n"
  },
  {
    "path": "packages/triple-header/src/frame/index.ts",
    "content": "import { ImageFrame } from './image'\nimport { TextFrame } from './text'\n\nexport const FRAMES = {\n  image: ImageFrame,\n  text: TextFrame,\n}\n\nexport { Frame } from './frame'\n"
  },
  {
    "path": "packages/triple-header/src/frame/text.tsx",
    "content": "import { Text } from '@titicaca/tds-ui'\n\nimport { MotionContainer } from '../motion-container'\nimport { Link, LinkEventHandler } from '../types'\n\nimport { generateLinkClickHandler } from './common'\nimport { EFFECTS, Effect } from './effects'\n\nexport type TextFrame = { type: 'text' } & Omit<\n  TextFrameProps,\n  'index' | 'frameCount'\n>\n\ninterface TextFrameProps {\n  value: {\n    text: {\n      content: string\n      link?: Link\n    }\n  }\n  width?: number\n  height?: number\n  effect?: Effect\n  index: number\n  frameCount: number\n  onLinkClick?: LinkEventHandler\n}\n\nexport function TextFrame({\n  value: { text },\n  effect,\n  index,\n  frameCount,\n  onLinkClick,\n}: TextFrameProps) {\n  const EffectElement = effect ? EFFECTS[effect.type] : MotionContainer\n\n  return (\n    <EffectElement\n      options={effect?.options}\n      index={index}\n      frameCount={frameCount}\n    >\n      <Text\n        onClick={(e) => generateLinkClickHandler(onLinkClick)(e, text.link)}\n      >\n        {text.content}\n      </Text>\n    </EffectElement>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/index.ts",
    "content": "export * from './triple-header'\nexport type { TripleHeaderProps } from './types'\n"
  },
  {
    "path": "packages/triple-header/src/layer/index.ts",
    "content": "export { Layer } from './layer'\n"
  },
  {
    "path": "packages/triple-header/src/layer/layer.tsx",
    "content": "import { Container, MarginPadding } from '@titicaca/tds-ui'\nimport { styled, css } from 'styled-components'\n\nimport { FrameData, TransitionType } from '../types'\nimport { Frame } from '../frame'\n\nimport { TRANSITIONS } from './transitions'\n\nconst LayerContainer = styled(Container).attrs({\n  position: 'absolute',\n})<{\n  $zIndex: number\n}>`\n  width: 100%;\n\n  ${({ $zIndex }) =>\n    $zIndex &&\n    css`\n      z-index: ${$zIndex};\n    `}\n`\n\nexport function Layer({\n  zIndex,\n  position,\n  frames,\n  transition,\n  calculateFrameRatio,\n}: {\n  zIndex: number\n  position: MarginPadding\n  frames: FrameData[]\n  transition?: { type: TransitionType }\n  calculateFrameRatio: (length?: number) => number\n}) {\n  const TransitionElement = transition\n    ? TRANSITIONS[transition.type]\n    : Container\n\n  return (\n    <LayerContainer\n      $zIndex={zIndex}\n      css={{ top: `${position.top}%`, left: `${position.left}%` }}\n    >\n      <TransitionElement>\n        {frames.map((frame, index) => {\n          return (\n            <Frame\n              key={index}\n              frame={frame}\n              index={index}\n              calculateFrameRatio={calculateFrameRatio}\n              frameCount={frames.length}\n            />\n          )\n        })}\n      </TransitionElement>\n    </LayerContainer>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/layer/transitions/fade-in-out.tsx",
    "content": "import { ReactNode } from 'react'\n\nimport { MotionContainer } from '../../motion-container'\n\nconst variants = {\n  fadeInOut: ({ index, length }: { index: number; length: number }) => ({\n    opacity: [0, 1, 1, 0],\n    transition: {\n      repeat: Infinity,\n      duration: 4,\n      times: [0, 0.375, 0.75, 1],\n      delay: 3 * index,\n      repeatDelay: 3 * length - 4,\n    },\n  }),\n}\n\nexport function FadeInOut({ children }: { children: ReactNode[] }) {\n  return (\n    <>\n      {children.map((slide, index) => (\n        <MotionContainer\n          key={index}\n          custom={{ index, length: children.length }}\n          animate=\"fadeInOut\"\n          variants={variants}\n        >\n          {slide}\n        </MotionContainer>\n      ))}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/layer/transitions/index.ts",
    "content": "import { Slide } from './slide'\nimport { FadeInOut } from './fade-in-out'\nimport { Rolling } from './rolling'\nimport { Marquee } from './marquee'\n\nexport const TRANSITIONS = {\n  slide: Slide,\n  fadeInOut: FadeInOut,\n  rolling: Rolling,\n  marquee: Marquee,\n}\n"
  },
  {
    "path": "packages/triple-header/src/layer/transitions/marquee.tsx",
    "content": "import {\n  Fragment,\n  ReactNode,\n  useEffect,\n  useRef,\n  useState,\n  useMemo,\n} from 'react'\nimport { styled } from 'styled-components'\nimport { motion } from 'framer-motion'\n\nconst marqueeVariants = {\n  animate: (offsetX: number) => ({\n    x: [0, -offsetX],\n    transition: {\n      x: {\n        repeat: Infinity,\n        repeatType: 'loop',\n        ease: 'linear',\n        duration: 10,\n      },\n    },\n  }),\n}\n\nconst MarqueeContainer = styled(motion.div)`\n  display: flex;\n  width: 100%;\n  height: 100%;\n`\n\nexport function Marquee({ children }: { children: ReactNode[] }) {\n  const [offsetX, setOffsetX] = useState(0)\n\n  const containerRef = useRef<HTMLDivElement>(null)\n  const frames = useMemo(\n    () =>\n      Array.from({ length: 2 }).map((_, idx) => {\n        return (\n          <Fragment key={idx}>\n            {children.map((child, index) => (\n              <Fragment key={index}>{child}</Fragment>\n            ))}\n          </Fragment>\n        )\n      }),\n    [children],\n  )\n\n  useEffect(() => {\n    if (containerRef.current) {\n      setOffsetX(containerRef.current.clientWidth)\n    }\n  }, [containerRef])\n\n  useEffect(() => {\n    addEventListener('resize', () => {\n      if (containerRef.current) {\n        setOffsetX(containerRef.current.clientWidth)\n      }\n    })\n  }, [])\n\n  return (\n    <MarqueeContainer\n      variants={marqueeVariants}\n      animate=\"animate\"\n      ref={containerRef}\n      custom={offsetX * children.length}\n    >\n      {frames}\n    </MarqueeContainer>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/layer/transitions/rolling.tsx",
    "content": "import {\n  useRef,\n  useState,\n  useEffect,\n  useCallback,\n  ReactNode,\n  Fragment,\n  useMemo,\n} from 'react'\nimport { Container } from '@titicaca/tds-ui'\nimport { styled, css } from 'styled-components'\n\nconst RollingContainer = styled(Container)<{ $isTransition: boolean }>`\n  position: relative;\n  display: flex;\n  width: 100%;\n  height: 100%;\n\n  ${({ $isTransition }) =>\n    $isTransition &&\n    css`\n      transition: ease all 0.3s;\n    `}\n`\n\nexport function Rolling({ children }: { children: ReactNode[] }) {\n  const [visibleFrameIndex, setVisibleFrameIndex] = useState(0)\n  const [hasTransition, setHasTransition] = useState(true)\n  const containerRef = useRef<HTMLDivElement>(null)\n\n  const newFrameNodes = useMemo(() => [...children, children[0]], [children])\n\n  useEffect(() => {\n    const timer = setInterval(() => {\n      setVisibleFrameIndex((prevVisibleFrameIndex) => {\n        return prevVisibleFrameIndex === newFrameNodes.length - 1\n          ? 0\n          : prevVisibleFrameIndex + 1\n      })\n    }, 3000)\n\n    return () => clearInterval(timer)\n  }, [newFrameNodes, visibleFrameIndex])\n\n  useEffect(() => {\n    if (visibleFrameIndex === newFrameNodes.length - 1) {\n      setTimeout(() => {\n        setHasTransition(false)\n        setVisibleFrameIndex(0)\n      }, 300)\n    } else if (visibleFrameIndex < newFrameNodes.length - 1) {\n      setHasTransition(true)\n    }\n  }, [newFrameNodes, visibleFrameIndex])\n\n  const calculateNewX = useCallback(\n    () => -visibleFrameIndex * (containerRef.current?.clientWidth || 0),\n    [visibleFrameIndex],\n  )\n\n  return (\n    <RollingContainer\n      $isTransition={hasTransition}\n      ref={containerRef}\n      style={{ left: calculateNewX() }}\n    >\n      {newFrameNodes.map((slide, index) => {\n        return <Fragment key={index}>{slide}</Fragment>\n      })}\n    </RollingContainer>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/layer/transitions/slide.tsx",
    "content": "import { ReactNode } from 'react'\n\nimport { MotionContainer } from '../../motion-container'\n\nconst variants = {\n  slide: ({ index, length }: { index: number; length: number }) => ({\n    opacity: [0, 1, 1, 0],\n    transition: {\n      repeat: Infinity,\n      duration: 3,\n      times: [0, 0, 1, 1],\n      delay: 3 * index,\n      repeatDelay: 3 * length - 3,\n    },\n  }),\n}\n\nexport function Slide({ children }: { children: ReactNode[] }) {\n  return (\n    <>\n      {children.map((slide, index) => (\n        <MotionContainer\n          key={index}\n          animate=\"slide\"\n          custom={{ index, length: children.length }}\n          variants={variants}\n        >\n          {slide}\n        </MotionContainer>\n      ))}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/lottie/index.ts",
    "content": "export { Lottie } from './lottie'\n"
  },
  {
    "path": "packages/triple-header/src/lottie/lottie.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { styled } from 'styled-components'\nimport { Container } from '@titicaca/tds-ui'\n\nimport { TripleHeaderProps } from '../types'\nimport { getStorage } from '../service'\nimport { MAX_WIDTH } from '../triple-header'\n\nimport { useLottie } from './use-lottie'\n\nconst BackgroundImage = styled.img`\n  width: 100%;\n`\n\nexport function Lottie({ lottie }: { lottie: TripleHeaderProps['lottie'] }) {\n  const [lottieData, setLottieData] = useState<unknown>()\n\n  const { animationRef } = useLottie<HTMLDivElement>({\n    data: lottieData,\n  })\n\n  const hasLottieAnimationId = lottie && lottie.lottieAnimationId\n  const hasLottieAnimationBackgroundImg = lottie && lottie.backgroundImage\n\n  useEffect(() => {\n    async function fetchAndeSetStorage() {\n      if (hasLottieAnimationId) {\n        const response = await getStorage({\n          id: lottie.lottieAnimationId,\n        })\n        setLottieData(JSON.parse(response as string))\n      }\n    }\n\n    hasLottieAnimationId && fetchAndeSetStorage()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [lottie?.lottieAnimationId])\n\n  return (\n    <Container css={{ position: 'relative' }}>\n      {hasLottieAnimationBackgroundImg ? (\n        <Container css={{ maxWidth: MAX_WIDTH }}>\n          <BackgroundImage\n            src={lottie.backgroundImage?.sizes.full.url}\n            alt=\"\"\n          />\n        </Container>\n      ) : null}\n\n      <Container\n        ref={animationRef}\n        css={\n          hasLottieAnimationBackgroundImg\n            ? {\n                position: 'absolute',\n                top: 0,\n                left: 0,\n                right: 0,\n                bottom: 0,\n              }\n            : {}\n        }\n      />\n    </Container>\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/lottie/use-lottie.ts",
    "content": "import Lottie, { SVGRendererConfig } from 'lottie-web'\nimport { useRef, useEffect } from 'react'\n\nexport function useLottie<T extends HTMLElement>({\n  loop = true,\n  autoplay = true,\n  path,\n  data,\n  rendererSettings,\n}: {\n  loop?: boolean\n  autoplay?: boolean\n  path?: string\n  data?: unknown\n  rendererSettings?: SVGRendererConfig\n}) {\n  const animationRef = useRef<T>(null)\n\n  useEffect(() => {\n    if (animationRef.current) {\n      const instance = Lottie.loadAnimation({\n        container: animationRef.current,\n        renderer: 'svg',\n        loop,\n        autoplay,\n        path,\n        animationData: data,\n        rendererSettings,\n      })\n\n      return () => instance.destroy()\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [data])\n\n  return { animationRef }\n}\n"
  },
  {
    "path": "packages/triple-header/src/mocks/framer-type.sample.json",
    "content": "{\n  \"type\": \"FRAMER\",\n  \"framer\": {\n    \"canvas\": {\n      \"width\": 375,\n      \"height\": 528\n    },\n    \"layers\": [\n      {\n        \"frames\": [\n          {\n            \"type\": \"image\",\n            \"value\": {\n              \"image\": {\n                \"cloudinaryId\": \"495ccaab-e2c7-440f-97db-7a1cf027da3d\",\n                \"id\": \"feba9285-4711-4097-bc23-e78799a0103c\",\n                \"type\": \"image\",\n                \"source\": {},\n                \"width\": 1500,\n                \"height\": 2112,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"metadata\": {\n                  \"format\": \"png\"\n                },\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg\"\n                  }\n                }\n              }\n            },\n            \"width\": 375,\n            \"height\": 528\n          }\n        ]\n      },\n      {\n        \"frames\": [\n          {\n            \"type\": \"image\",\n            \"value\": {\n              \"image\": {\n                \"cloudinaryId\": \"666151ca-6269-4917-bff5-20e99f62c2a4\",\n                \"id\": \"685f7457-0cb0-4e29-82ac-b8f746abf3c1\",\n                \"type\": \"image\",\n                \"source\": {},\n                \"width\": 1500,\n                \"height\": 2112,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"metadata\": {\n                  \"format\": \"png\"\n                },\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/666151ca-6269-4917-bff5-20e99f62c2a4.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/666151ca-6269-4917-bff5-20e99f62c2a4.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/666151ca-6269-4917-bff5-20e99f62c2a4.jpeg\"\n                  }\n                }\n              }\n            },\n            \"width\": 375,\n            \"height\": 528\n          },\n          {\n            \"type\": \"image\",\n            \"value\": {\n              \"image\": {\n                \"cloudinaryId\": \"20b58ab3-60a6-4e09-93b8-612e865a105a\",\n                \"id\": \"1357b77b-971a-4a32-8c00-8ecc834ca678\",\n                \"type\": \"image\",\n                \"source\": {},\n                \"width\": 1500,\n                \"height\": 2112,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"metadata\": {\n                  \"format\": \"png\"\n                },\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/20b58ab3-60a6-4e09-93b8-612e865a105a.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/20b58ab3-60a6-4e09-93b8-612e865a105a.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/20b58ab3-60a6-4e09-93b8-612e865a105a.jpeg\"\n                  }\n                }\n              }\n            },\n            \"width\": 375,\n            \"height\": 528\n          },\n          {\n            \"type\": \"image\",\n            \"value\": {\n              \"image\": {\n                \"cloudinaryId\": \"2eb9f5e2-feae-482a-9c8c-a4488388f633\",\n                \"id\": \"21a0cac2-8c23-4395-8c17-6db286193725\",\n                \"type\": \"image\",\n                \"source\": {},\n                \"width\": 1500,\n                \"height\": 2112,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"metadata\": {\n                  \"format\": \"png\"\n                },\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/2eb9f5e2-feae-482a-9c8c-a4488388f633.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/2eb9f5e2-feae-482a-9c8c-a4488388f633.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/2eb9f5e2-feae-482a-9c8c-a4488388f633.jpeg\"\n                  }\n                }\n              }\n            },\n            \"width\": 375,\n            \"height\": 528\n          }\n        ],\n        \"transition\": {\n          \"type\": \"slide\"\n        }\n      },\n      {\n        \"frames\": [\n          {\n            \"type\": \"image\",\n            \"value\": {\n              \"image\": {\n                \"cloudinaryId\": \"cde58c12-47ec-45fe-87db-9a60e0b34f15\",\n                \"id\": \"d712d0e7-b0de-4eca-b0cf-e6b5c8940ae4\",\n                \"type\": \"image\",\n                \"source\": {},\n                \"width\": 1140,\n                \"height\": 216,\n                \"cloudinaryBucket\": \"triple-cms\",\n                \"metadata\": {\n                  \"format\": \"png\"\n                },\n                \"sizes\": {\n                  \"full\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/cde58c12-47ec-45fe-87db-9a60e0b34f15.jpeg\"\n                  },\n                  \"large\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/cde58c12-47ec-45fe-87db-9a60e0b34f15.jpeg\"\n                  },\n                  \"small_square\": {\n                    \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/cde58c12-47ec-45fe-87db-9a60e0b34f15.jpeg\"\n                  }\n                }\n              }\n            },\n            \"width\": 280,\n            \"height\": 54\n          }\n        ],\n        \"positioning\": {\n          \"top\": 419\n        }\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/triple-header/src/mocks/lottie-type.sample.json",
    "content": "{\n  \"type\": \"LOTTIE\",\n  \"lottie\": {\n    \"backgroundImage\": {\n      \"cloudinaryId\": \"495ccaab-e2c7-440f-97db-7a1cf027da3d\",\n      \"id\": \"feba9285-4711-4097-bc23-e78799a0103c\",\n      \"type\": \"image\",\n      \"source\": {},\n      \"width\": 1500,\n      \"height\": 2112,\n      \"cloudinaryBucket\": \"triple-cms\",\n      \"metadata\": {\n        \"format\": \"png\"\n      },\n      \"sizes\": {\n        \"full\": {\n          \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_2048,w_2048/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg\"\n        },\n        \"large\": {\n          \"url\": \"https://media.triple.guide/triple-cms/c_limit,f_auto,h_1024,w_1024/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg\"\n        },\n        \"small_square\": {\n          \"url\": \"https://media.triple.guide/triple-cms/c_fill,f_auto,h_256,w_256/495ccaab-e2c7-440f-97db-7a1cf027da3d.jpeg\"\n        }\n      }\n    },\n    \"lottieAnimationId\": \"\"\n  }\n}\n"
  },
  {
    "path": "packages/triple-header/src/motion-container.ts",
    "content": "import { styled } from 'styled-components'\nimport { motion } from 'framer-motion'\n\nexport const MotionContainer = styled(motion.div)`\n  position: absolute;\n  top: 0;\n  width: 100%;\n  height: 100%;\n`\n"
  },
  {
    "path": "packages/triple-header/src/service.ts",
    "content": "import { authGuardedFetchers, NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\n\nexport const ARTICLE_ANIMATION_STORAGE_TYPE = 'lottie-animation-v1'\n\nexport async function getStorage({\n  type = ARTICLE_ANIMATION_STORAGE_TYPE,\n  id,\n}: {\n  type?: string\n  id?: string\n}) {\n  if (!id) {\n    throw new Error('Id가 없습니다.')\n  }\n\n  const response = await authGuardedFetchers.get<{ data: string }>(\n    `/api/storage/storages/${type}/${id}`,\n  )\n\n  if (response === NEED_LOGIN_IDENTIFIER || !response.ok) {\n    throw new Error('Request failed')\n  }\n\n  const {\n    parsedBody: { data },\n  } = response\n\n  return data\n}\n"
  },
  {
    "path": "packages/triple-header/src/triple-header.stories.tsx",
    "content": "import type { Meta } from '@storybook/react'\nimport { EventTrackingProvider } from '@titicaca/triple-web'\n\nimport FRAMER_TYPE_SAMPLE from './mocks/framer-type.sample.json'\nimport LOTTIE_TYPE_SAMPLE from './mocks/lottie-type.sample.json'\nimport { TripleHeader } from './triple-header'\nimport { TripleHeaderProps } from './types'\n\nexport default {\n  title: 'triple-header / TripleHeader',\n  decorators: [\n    (Story) => (\n      <EventTrackingProvider page={{ path: '/', label: 'test' }} utm={{}}>\n        <Story />\n      </EventTrackingProvider>\n    ),\n  ],\n  component: TripleHeader,\n} as Meta<typeof TripleHeader>\n\nexport function FramerType() {\n  return <TripleHeader>{FRAMER_TYPE_SAMPLE as TripleHeaderProps}</TripleHeader>\n}\n\nexport function LottieType() {\n  return <TripleHeader>{LOTTIE_TYPE_SAMPLE as TripleHeaderProps}</TripleHeader>\n}\n"
  },
  {
    "path": "packages/triple-header/src/triple-header.tsx",
    "content": "import { useState, useCallback, useLayoutEffect } from 'react'\nimport { Container } from '@titicaca/tds-ui'\nimport { styled, css } from 'styled-components'\n\nimport { Layer } from './layer'\nimport { TripleHeaderProps } from './types'\nimport { Lottie } from './lottie'\n\nexport const MAX_WIDTH = 768\n\nconst Canvas = styled(Container).attrs({\n  position: 'relative',\n  centered: true,\n})<{\n  clientWidth?: number\n  width: number\n  height: number\n}>`\n  overflow: hidden;\n  max-width: ${MAX_WIDTH}px;\n\n  ${({ clientWidth, width, height }) =>\n    width &&\n    height &&\n    css`\n      width: 100%;\n      height: calc(${clientWidth || MAX_WIDTH}px * ${height / width});\n      max-height: ${MAX_WIDTH * (height / width)}px;\n    `}\n`\n\nexport function TripleHeader({ children }: { children: TripleHeaderProps }) {\n  const [clientWidth, setClientWidth] = useState<number | undefined>(undefined)\n  const [node, setNode] = useState<HTMLDivElement | null>(null)\n\n  const previewRef = useCallback((node: HTMLDivElement) => {\n    if (node !== null) {\n      setNode(node)\n      setClientWidth(node.children[0].clientWidth)\n    }\n  }, [])\n\n  useLayoutEffect(() => {\n    if (node) {\n      const getClientWidth = () => {\n        setClientWidth(node.children[0].clientWidth)\n      }\n\n      window.addEventListener('resize', getClientWidth)\n\n      return () => {\n        window.removeEventListener('resize', getClientWidth)\n      }\n    }\n  }, [node])\n\n  const { type = 'FRAMER', canvas, layers, lottie } = children\n\n  const calculateFrameRatio = (length?: number) => {\n    return canvas && length ? (length / canvas.width) * 100 : 0\n  }\n\n  const isFramerType = type === 'FRAMER'\n  const hasFramerCanvas = !!canvas\n  const hasFramerLayers = !!layers\n\n  return isFramerType && hasFramerCanvas && hasFramerLayers ? (\n    <Canvas\n      ref={previewRef}\n      clientWidth={clientWidth}\n      width={canvas.width}\n      height={canvas.height}\n    >\n      {layers.map(({ frames, transition, positioning }, index) => {\n        const position = {\n          top: (Number(positioning?.top || 0) / canvas.height) * 100,\n          left: (Number(positioning?.left || 0) / canvas.height) * 100,\n        }\n\n        return (\n          <Layer\n            key={index}\n            zIndex={index + 1}\n            position={position}\n            frames={frames}\n            transition={transition}\n            calculateFrameRatio={calculateFrameRatio}\n          />\n        )\n      })}\n    </Canvas>\n  ) : (\n    <Lottie lottie={lottie} />\n  )\n}\n"
  },
  {
    "path": "packages/triple-header/src/types.ts",
    "content": "import { MarginPadding } from '@titicaca/tds-ui'\nimport { SyntheticEvent } from 'react'\nimport { ImageMeta } from '@titicaca/type-definitions'\n\nimport { ImageFrame } from './frame/image'\nimport { TextFrame } from './frame/text'\n\nexport interface TripleHeaderProps {\n  type: HeaderType\n  canvas?: Canvas\n  layers?: Layer[]\n  lottie?: {\n    backgroundImage?: ImageMeta\n    lottieAnimationId?: string\n  }\n}\n\nexport type HeaderType = 'FRAMER' | 'LOTTIE'\n\ninterface Canvas {\n  width: number\n  height: number\n}\n\nexport interface Layer {\n  frames: FrameData[]\n  transition?: {\n    type: TransitionType\n  }\n  positioning?: MarginPadding\n}\n\nexport type TransitionType = 'slide' | 'rolling' | 'marquee' | 'fadeInOut'\n\nexport type FrameData = ImageFrame | TextFrame\n\nexport interface Link {\n  href: string\n  label?: string\n  target?: 'browser'\n}\n\nexport type LinkEventHandler = (e: SyntheticEvent, link: Link) => void\n"
  },
  {
    "path": "packages/triple-header/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/triple-header/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/triple-header/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/triple-web/README.md",
    "content": "# EventTrackingContext\n\nga, fa, facebookPixel, tiktokPixel 이벤트를 로깅할 수 있는 메서드를 제공하는 context입니다.\n\n## 주의사항\n\npage_view 이벤트의 경우, fa에서 자동으로 page_view 이벤트를 기록하지 않도록 설정해야 합니다. 각 레포에서 firebase의 앱을 초기화할 때, firebase analytics 인스턴스도 초기화해야 page_view 이벤트가 중복으로 로깅되지 않습니다.\n\n```\n// firebase app 초기화 설정\n\nimport { initializeFirebaseApp } from 'firebase/app'\nimport { initializeAnalytics } from 'firebase/analytics'\n\nfunction initializeFirebase() {\n  const firebaseApp = initializeApp(config)\n\n  // page_view 이벤트 중복 로깅을 위해 다음과 같이 analytics를 초기화합니다.\n  initializeAnalytics(firebaseApp, {\n    config: {\n      send_page_view: false,\n    },\n  })\n}\n```\n\n# HashRouterContext\n\nhash를 사용하여 모달을 여닫을 수 있도록 필요한 값과 메서드를 제공하는 context입니다.\nPC web, iOS, Android 환경에 따라 hash를 추가하는 방식을 다르게 설정할 수 있습니다.\n\n## 알아두기\n\n### PC Web, iOS 환경 기본동작\n\naddUriHash, removeUriHash 메서드에 별도의 type을 전달하지 않는다면, 모달을 연 후 브라우저 뒤로가기 혹은 스와이프 제스쳐를 통해 뒤로가기가 발생했을 때 이전 페이지로 돌아갑니다.\n\n### Android 환경 기본동작\n\naddUriHash, removeUriHash 메서드에 별도의 type을 전달하지 않는다면, 모달을 연 후 브라우저 뒤로가기 혹은 물리 back key로 뒤로가기가 발생했을 때 모달만 닫힙니다.\n\n## 사용방법\n\n### useHashRouter 사용\n\n- 열고자 하는 모달에 고유한 hash 지정합니다.\n- 모달의 open 조건을 아래와 같이 지정\n\n```jsx\nfunction ExampleComponent() {\n  const { uriHash, hasUriHash } = useHashRouter()\n  const open = uriHash.includes('some.unique.hash')\n  // 또는\n  const open = hasUriHash('some.unique.hash')\n\n  return <Popup open={open} />\n}\n```\n\n- 모달이 열리는 로직(버튼 클릭 등) 내에 addUriHash를 추가합니다(iOS, Android 환경에 따라 동작이 상이합니다).\n\n```jsx\n<button onClick={() => addUriHash('some.unique.hash')}>팝업 보기</button>\n```\n\n- 이 때, 기기환경에 의존하지 않고 강제로 push 혹은 replace를 사용하길 원한다면 type을 지정합니다.\n\n```jsx\n/** iOS 환경에서도 Android 처럼 동작 */\n<button onClick={() => addUriHash('some.unique.hash', 'push')} />\n\n/** Android 환경에서도 iOS 처럼동작 */\n<button onClick={() => addUriHash('some.unique.hash', 'replace')} />\n```\n\n- 모달을 닫는 부분에 removeUriHash를 추가합니다. 이 때, 모달을 열 때 사용한 addUriHash와 짝이 되는 type을 적용해야 합니다.\n\n```jsx\n/** Popup을 열 때 addUriHash의 type으로 'push'를 전달한 경우 */\n<Popup onClose={() => removeUriHash('pop')}>\n\n/** Popup을 열 때 addUriHash의 type으로 'replace'를 전달한 경우 */\n<Popup onClose={() => removeUriHash('replace')}>\n\n/** Popup을 열 때 addUriHash의 type으로 아무것도 전달하지 않은 경우 */\n<Popup onClose={() => removeUriHash()}>\n```\n\n- addUriHash와 removeUriHash에 전달하는 type 옵션을 동일하게 설정해야합니다.\n\n### 사용 예시\n\n`uriHash`는 `&`로 엮인 해시값을 리턴합니다.\n\nex) `hash.first&hash.second&hash.third`\n\n`hasUriHash`를 통해 특정 값이 해시에 존재하는지 확인합니다.\n\nex) `hasUriHash('hash.second')`\n\n```jsx\nfunction ExampleComponent() {\n  const POPUP_HASH = 'popup.hash'\n  const { hasUriHash, addUriHash, removeUriHash } = useHashRouter()\n\n  return (\n    <div>\n      <button onClick={() => addUriHash(POPUP_HASH)}>팝업 열기</button>\n      <Popup open={hasUriHash(POPUP_HASH)} onClose={() => removeUriHash()} />\n    </div>\n  )\n}\n```\n\n## `useHashRouter()`\n\n### Return values\n\n- `uriHash: string` : 현재 Hash를 반환합니다. `addUriHash`, `removeUriHash`에 의해 trigger 됩니다.\n- `addUriHash: (hash: string, type?: 'push' | 'replace' = isAndroid ? 'push' : 'replace') => void` : Hash를 추가합니다.\n  - `hash`: 추가할 Hash\n  - `type?`\n    - `push` : `window.history.pushState`를 사용하여 Hash를 추가합니다.\n    - `replace` : `window.history.replaceState`를 사용하여 Hash를 추가합니다.\n- `removeUriHash: (type?: 'pop' | 'replace' = isAndroid ? 'pop' : 'replace') => void` : Hash를 제거합니다. (TODO: Hash가 없다면 동작하지 않습니다.)\n  - `type?`\n    - `pop` : `window.history.back`을 사용하여 Hash를 제거합니다.\n    - `replace` : `window.history.replaceState`를 사용하여 Hash를 제거합니다.\n- `hasUriHash: (hash:string) => boolean` : Hash값이 현재 Hash에 있는지 확인합니다.\n  - `hash`: 값의 존재를 확인할 Hash\n"
  },
  {
    "path": "packages/triple-web/package.json",
    "content": "{\n  \"name\": \"@titicaca/triple-web\",\n  \"version\": \"14.2.3\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/triple-web\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/constants\": \"workspace:*\",\n    \"@titicaca/fetcher\": \"workspace:*\",\n    \"@titicaca/i18n\": \"workspace:*\",\n    \"@titicaca/tds-ui\": \"workspace:*\",\n    \"@titicaca/triple-web-to-native-interfaces\": \"1.11.0\",\n    \"@titicaca/view-utilities\": \"workspace:*\",\n    \"@types/ua-parser-js\": \"^0.7.39\",\n    \"deep-equal\": \"^2.2.3\",\n    \"qs\": \"^6.14.0\",\n    \"semver\": \"^7.6.3\",\n    \"ua-parser-js\": \"^1.0.40\",\n    \"universal-cookie\": \"^8.0.1\"\n  },\n  \"devDependencies\": {\n    \"@types/deep-equal\": \"^1.0.4\",\n    \"@types/qs\": \"^6.9.18\",\n    \"@types/semver\": \"^7.5.8\",\n    \"firebase\": \"11.10.0\",\n    \"react\": \"^18.3.1\",\n    \"styled-components\": \"^6.1.15\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/triple-web-to-native-interfaces\": \"1.11.0\",\n    \"firebase\": \"^11.0\",\n    \"react\": \"^18.0\",\n    \"styled-components\": \"^6.0\"\n  }\n}\n"
  },
  {
    "path": "packages/triple-web/src/client-app/context.ts",
    "content": "'use client'\n\nimport { createContext } from 'react'\n\nimport type { ClientAppValue } from './types'\n\nexport const ClientAppContext = createContext<ClientAppValue | undefined>(\n  undefined,\n)\n"
  },
  {
    "path": "packages/triple-web/src/client-app/index.ts",
    "content": "export * from './types'\nexport * from './use-client-app-actions'\nexport * from './use-client-app-callback'\nexport * from './use-client-app'\nexport * from './use-feature-flag'\n"
  },
  {
    "path": "packages/triple-web/src/client-app/types.ts",
    "content": "export const enum ClientAppName {\n  iOS = 'Triple-iOS',\n  Android = 'Triple-Android',\n}\n\nexport type ClientAppValue = {\n  metadata: {\n    name: ClientAppName\n    version: string\n    isMacApp: boolean\n  }\n  device: {\n    autoplay: 'always' | 'wifi_only' | 'never'\n    networkType: 'wifi' | 'cellular' | 'unknown'\n  }\n} | null\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-client-app-actions.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useClientAppActions\" />\n\n# useClientAppActions\n\n클라이언트 앱과 통신하는 인터페이스를 사용합니다.\n\n## Usage\n\n```tsx\nconst clientAppActions = useClientAppActions()\n```\n\n## Returns\n\nWebToNativeInterfaces\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-client-app-actions.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { ClientAppName } from './types'\nimport { useClientAppActions } from './use-client-app-actions'\nimport { ClientAppContext } from './context'\n\ntest('최소 버전 목록에 등록되지 않은 경우 함수를 그대로 반환해야 합니다', () => {\n  const { result } = renderHook(() => useClientAppActions(), {\n    wrapper: ({ children }) => (\n      <ClientAppContext.Provider\n        value={{\n          device: { autoplay: 'always', networkType: 'unknown' },\n          metadata: {\n            name: ClientAppName.iOS,\n            version: '6.5.0',\n            isMacApp: false,\n          },\n        }}\n      >\n        {children}\n      </ClientAppContext.Provider>\n    ),\n  })\n\n  expect(result.current.showToast).toBeDefined()\n})\n\ntest('최소 버전 목록에 일치하지 않는 경우 함수를 반환하지 않아야 합니다', () => {\n  const { result } = renderHook(() => useClientAppActions(), {\n    wrapper: ({ children }) => (\n      <ClientAppContext.Provider\n        value={{\n          device: { autoplay: 'always', networkType: 'unknown' },\n          metadata: {\n            name: ClientAppName.iOS,\n            version: '5.10.0',\n            isMacApp: false,\n          },\n        }}\n      >\n        {children}\n      </ClientAppContext.Provider>\n    ),\n  })\n\n  expect(result.current.subscribeTripUpdateEvent).toBeUndefined()\n})\n\ntest('최소 버전 목록에 일치하는 경우 함수를 반환해야 합니다', () => {\n  const { result } = renderHook(() => useClientAppActions(), {\n    wrapper: ({ children }) => (\n      <ClientAppContext.Provider\n        value={{\n          device: { autoplay: 'always', networkType: 'unknown' },\n          metadata: {\n            name: ClientAppName.iOS,\n            version: '5.12.0',\n            isMacApp: false,\n          },\n        }}\n      >\n        {children}\n      </ClientAppContext.Provider>\n    ),\n  })\n\n  expect(result.current.subscribeTripUpdateEvent).toBeDefined()\n})\n\ntest('페이지가 트리플 클라이언트에 없으면 함수를 반환하지 않아야 합니다', () => {\n  const { result } = renderHook(() => useClientAppActions(), {\n    wrapper: ({ children }) => (\n      <ClientAppContext.Provider value={null}>\n        {children}\n      </ClientAppContext.Provider>\n    ),\n  })\n\n  expect(result.current.showToast).toBeUndefined()\n})\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-client-app-actions.ts",
    "content": "import * as WebToNativeInterfaces from '@titicaca/triple-web-to-native-interfaces'\nimport * as semver from 'semver'\n\nimport { useClientApp } from './use-client-app'\n\nconst KNOWN_INITIAL_VERSIONS: Partial<\n  Record<keyof typeof WebToNativeInterfaces, string>\n> = {\n  subscribeTripUpdateEvent: '5.11.0',\n  unsubscribeTripUpdateEvent: '5.11.0',\n}\n\n/**\n * 클라이언트 앱과 통신하는 인터페이스를 사용합니다.\n */\nexport function useClientAppActions() {\n  const clientApp = useClientApp()\n\n  if (!clientApp) {\n    return {}\n  }\n\n  const { version } = clientApp.metadata\n  const versionSemver = semver.coerce(version)\n\n  const filteredAccessibleWebToNativeInterfaces = Object.keys(\n    WebToNativeInterfaces,\n  ).reduce<Partial<typeof WebToNativeInterfaces>>(\n    (accessibleWebToNativeInterfaces, interfaceName) => {\n      const interfaceNameKey =\n        interfaceName as keyof typeof WebToNativeInterfaces\n      const interfaceValue = WebToNativeInterfaces[interfaceNameKey]\n\n      if (typeof interfaceValue !== 'function') {\n        /* We ignore non-function exports to expose invocable actions only */\n        return accessibleWebToNativeInterfaces\n      }\n\n      if (\n        KNOWN_INITIAL_VERSIONS[interfaceNameKey] &&\n        !semver.gte(\n          versionSemver as semver.SemVer,\n          KNOWN_INITIAL_VERSIONS[interfaceNameKey] as string,\n        )\n      ) {\n        /* Ignore the interface as current client does not support it */\n        return accessibleWebToNativeInterfaces\n      }\n\n      return {\n        ...accessibleWebToNativeInterfaces,\n        [interfaceName]: interfaceValue as (...args: unknown[]) => unknown,\n      }\n    },\n    {},\n  )\n\n  return filteredAccessibleWebToNativeInterfaces\n}\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-client-app-callback.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useClientAppCallback\" />\n\n# useClientApp\n\nUser Agent가 앱 환경일 때만 주어진 콜백을 실행하는 함수를 반환하는 훅.\n앱이 아닐 때는 AppInstallCta 모달을 띄웁니다.\n\n## Usage\n\n```tsx\nconst clientApp = useClientAppCallback(\n  fn,\n  appInstalCtaModalOptions,\n  returnValue,\n)\n```\n\n## Parameters\n\n### fn\n\n`T extends (...args: any[] => any)`\n\n**Required**\n\n### appInstallCtaModalOptions\n\n`AppInstallCtaModalRef`\n\n### returnValue\n\n`V`\n\n## Returns\n\n`T`\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-client-app-callback.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { HashRouterProvider } from '../hash-router/context'\nimport { ModalProvider } from '../modal/context'\nimport { UserAgentContext } from '../user-agent/context'\n\nimport { useClientAppCallback } from './use-client-app-callback'\nimport { ClientAppName } from './types'\nimport { ClientAppContext } from './context'\n\nconst mockFn = jest.fn()\nconst mockShow = jest.fn()\n\njest.mock('../modal/use-app-install-cta-modal', () => ({\n  __esModule: true,\n  ...jest.requireActual('../modal/use-app-install-cta-modal'),\n  useAppInstallCtaModal: jest.fn().mockImplementation(() => ({\n    show: mockShow,\n  })),\n}))\n\nafterEach(() => {\n  jest.clearAllMocks()\n})\n\ntest('일반 브라우저에서 앱 전환 모달 표시 함수를 호출합니다.', () => {\n  const { result } = renderHook(() => useClientAppCallback(mockFn), {\n    wrapper: ({ children }) => (\n      <ClientAppContext.Provider value={null}>\n        <UserAgentContext.Provider\n          value={{\n            ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n            browser: { name: 'Chrome', version: '120.0.0.0', major: '120' },\n            cpu: { architecture: 'arm64' },\n            device: { type: undefined, model: 'Macintosh', vendor: 'Apple' },\n            engine: { name: 'Blink', version: '120.0.0.0' },\n            os: { name: 'macOS', version: '13.4.0' },\n            isMobile: true,\n          }}\n        >\n          <HashRouterProvider>\n            <ModalProvider>{children}</ModalProvider>\n          </HashRouterProvider>\n        </UserAgentContext.Provider>\n      </ClientAppContext.Provider>\n    ),\n  })\n\n  result.current()\n\n  expect(mockShow).toHaveBeenCalledTimes(1)\n})\n\ntest('앱에서 앱 전환 모달 표시 함수를 호출하지 않습니다.', () => {\n  const { result } = renderHook(() => useClientAppCallback(mockFn), {\n    wrapper: ({ children }) => (\n      <ClientAppContext.Provider\n        value={{\n          device: { autoplay: 'always', networkType: 'unknown' },\n          metadata: {\n            name: ClientAppName.iOS,\n            version: '6.5.0',\n            isMacApp: false,\n          },\n        }}\n      >\n        <UserAgentContext.Provider\n          value={{\n            ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n            browser: { name: 'Chrome', version: '120.0.0.0', major: '120' },\n            cpu: { architecture: 'arm64' },\n            device: { type: undefined, model: 'Macintosh', vendor: 'Apple' },\n            engine: { name: 'Blink', version: '120.0.0.0' },\n            os: { name: 'macOS', version: '13.4.0' },\n            isMobile: true,\n          }}\n        >\n          <HashRouterProvider>\n            <ModalProvider>{children}</ModalProvider>\n          </HashRouterProvider>\n        </UserAgentContext.Provider>\n      </ClientAppContext.Provider>\n    ),\n  })\n\n  result.current()\n\n  expect(mockShow).toHaveBeenCalledTimes(0)\n})\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-client-app-callback.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { useCallback } from 'react'\n\nimport { useAppInstallCtaModal } from '../modal/use-app-install-cta-modal'\nimport type { AppInstallCtaModalRef } from '../modal/types'\n\nimport { useClientApp } from './use-client-app'\n\n/**\n * User Agent가 앱 환경일 때만 주어진 콜백을 실행하는 함수를 반환하는 훅\n * 앱이 아닐 때는 AppInstallCta 모달을 띄웁니다.\n *\n * Usage\n *\n * const invokeNativeFn = useClientAppCallback(() => {}, appInstallCtaModalOptions)\n */\nexport function useClientAppCallback<T extends (...args: any[]) => any, V>(\n  fn: T,\n  appInstallCtaModalOptions: AppInstallCtaModalRef = {},\n  returnValue?: V,\n): (...args: Parameters<T>) => ReturnType<T> {\n  const clientApp = useClientApp()\n  const { show } = useAppInstallCtaModal()\n\n  return useCallback(\n    (...args) => {\n      if (clientApp) {\n        return fn(...args)\n      }\n\n      show(appInstallCtaModalOptions)\n\n      return returnValue\n    },\n    [clientApp, show, appInstallCtaModalOptions, returnValue, fn],\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-client-app.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useClientApp\" />\n\n# useClientApp\n\nClientAppContext 값을 가져옵니다.\n\n## Usage\n\n```tsx\nconst clientApp = useClientApp()\n```\n\n## Returns\n\n`ClientAppContext`\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-client-app.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useClientApp } from './use-client-app'\nimport { ClientAppContext } from './context'\nimport { ClientAppName, ClientAppValue } from './types'\n\ntest('Context가 정의된 경우 값을 반환해야 합니다.', () => {\n  const mockValue: ClientAppValue = {\n    device: { autoplay: 'always', networkType: 'cellular' },\n    metadata: { name: ClientAppName.iOS, version: '6.5.0', isMacApp: false },\n  }\n\n  const { result } = renderHook(() => useClientApp(), {\n    wrapper: ({ children }) => (\n      <ClientAppContext.Provider value={mockValue}>\n        {children}\n      </ClientAppContext.Provider>\n    ),\n  })\n\n  expect(result.current).toBe(mockValue)\n})\n\ntest('Context가 정의되지 않은 경우 오류를 발생시켜야 합니다.', () => {\n  expect(() => {\n    renderHook(() => useClientApp())\n  }).toThrow()\n})\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-client-app.ts",
    "content": "import { useContext } from 'react'\n\nimport { ClientAppContext } from './context'\n\n/**\n * ClientAppContext 값을 가져옵니다.\n */\nexport function useClientApp() {\n  const context = useContext(ClientAppContext)\n\n  if (context === undefined) {\n    throw new Error('ClientAppContext가 없습니다.')\n  }\n\n  return context\n}\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-feature-flag.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useFeatureFlag\" />\n\n# useFeatureFlag\n\n특정 버전의 트리플 클라이언트에서만 노출이 필요할 경우 사용합니다.\n\n## Usage\n\n```tsx\nconst featureFlag = useFeatureFlag({\n  operator: 'gte',\n  appVersion: '6.0.0',\n  availableOnPublic: false,\n  appName: 'Triple-iOS',\n})\n```\n\n## Parameters\n\n### operator\n\n버전 비교에 사용할 operator를 결정합니다. 아래 4개 operator가 지원됩니다.\n\n`'lt' | 'lte' | 'gt' | 'gte'`\n\n**Required**\n\n### appVersion\n\n비교의 기준이 될 버전입니다.\n\n`string`\n\n**Required**\n\n### availableOnPublic\n\n트리플 클라이언트 사용 중이 아닐 경우(= 외부 브라우저일 경우) 노출 여부를 결정합니다.\n\n`boolean`\n\n**Required**\n\n### appName\n\n특정 플랫폼의 트리플 클라이언트에서만 노출이 필요할 경우 설정합니다.\n\n`Triple-iOS | Triple-Android`\n\n## Returns\n\n`boolean`\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-feature-flag.test.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport { renderHook } from '@testing-library/react'\n\nimport { useFeatureFlag } from './use-feature-flag'\nimport { ClientAppContext } from './context'\nimport { ClientAppName } from './types'\n\ntest('returns true if app version meets the version operator requirements', () => {\n  const wrapper = ({ children }: PropsWithChildren) => (\n    <ClientAppContext.Provider\n      value={{\n        metadata: {\n          name: ClientAppName.iOS,\n          version: '5.11.0',\n          isMacApp: false,\n        },\n        device: { autoplay: 'always', networkType: 'unknown' },\n      }}\n    >\n      {children}\n    </ClientAppContext.Provider>\n  )\n\n  const { result } = renderHook(\n    () =>\n      useFeatureFlag({\n        operator: 'gte',\n        appVersion: '5.11.0',\n        availableOnPublic: true,\n      }),\n    { wrapper },\n  )\n\n  expect(result.current).toBeTruthy()\n})\n\ntest('returns false if app version does not meet the version operator requirements', () => {\n  const wrapper = ({ children }: PropsWithChildren) => (\n    <ClientAppContext.Provider\n      value={{\n        metadata: {\n          name: ClientAppName.iOS,\n          version: '5.11.0',\n          isMacApp: false,\n        },\n        device: { autoplay: 'always', networkType: 'unknown' },\n      }}\n    >\n      {children}\n    </ClientAppContext.Provider>\n  )\n\n  const { result } = renderHook(\n    () =>\n      useFeatureFlag({\n        operator: 'gte',\n        appVersion: '5.12.0',\n        availableOnPublic: true,\n      }),\n    { wrapper },\n  )\n\n  expect(result.current).toBeFalsy()\n})\n\ntest('returns false if app name does not meet the requirements', () => {\n  const wrapper = ({ children }: PropsWithChildren) => (\n    <ClientAppContext.Provider\n      value={{\n        metadata: {\n          name: ClientAppName.iOS,\n          version: '5.11.0',\n          isMacApp: false,\n        },\n        device: { autoplay: 'always', networkType: 'unknown' },\n      }}\n    >\n      {children}\n    </ClientAppContext.Provider>\n  )\n\n  const { result } = renderHook(\n    () =>\n      useFeatureFlag({\n        operator: 'gte',\n        appName: 'Triple-Android',\n        appVersion: '5.12.0',\n        availableOnPublic: true,\n      }),\n    { wrapper },\n  )\n\n  expect(result.current).toBeFalsy()\n})\n\ntest('returns false if app does not exist and is not available on public', () => {\n  const wrapper = ({ children }: PropsWithChildren) => (\n    <ClientAppContext.Provider value={null}>\n      {children}\n    </ClientAppContext.Provider>\n  )\n\n  const { result } = renderHook(\n    () =>\n      useFeatureFlag({\n        operator: 'gte',\n        appName: 'Triple-Android',\n        appVersion: '5.12.0',\n        availableOnPublic: false,\n      }),\n    { wrapper },\n  )\n\n  expect(result.current).toBeFalsy()\n})\n\ntest('returns true if app does not exist and is available on public', () => {\n  const wrapper = ({ children }: PropsWithChildren) => (\n    <ClientAppContext.Provider value={null}>\n      {children}\n    </ClientAppContext.Provider>\n  )\n\n  const { result } = renderHook(\n    () =>\n      useFeatureFlag({\n        operator: 'gte',\n        appName: 'Triple-Android',\n        appVersion: '5.12.0',\n        availableOnPublic: true,\n      }),\n    { wrapper },\n  )\n\n  expect(result.current).toBeTruthy()\n})\n"
  },
  {
    "path": "packages/triple-web/src/client-app/use-feature-flag.ts",
    "content": "import * as semver from 'semver'\n\nimport { useClientApp } from './use-client-app'\n\ntype Operator = 'lt' | 'lte' | 'gt' | 'gte'\n\nconst VERSION_OPERATIONS: Record<\n  Operator,\n  typeof semver.lt | typeof semver.lte | typeof semver.gt | typeof semver.gte\n> = {\n  lt: semver.lt,\n  lte: semver.lte,\n  gt: semver.gt,\n  gte: semver.gte,\n}\n\nexport interface UseFeatureFlagParams {\n  /**\n   * 버전 비교에 사용할 operator를 결정합니다. 아래 4개 operator가 지원됩니다.\n   *\n   *   - 'gt'\n   *   - 'gte'\n   *   - 'lt'\n   *   - 'lte'\n   */\n  operator: Operator\n  /**\n   * 비교의 기준이 될 버전입니다.\n   */\n  appVersion: string\n  /**\n   * 트리플 클라이언트 사용 중이 아닐 경우(= 외부 브라우저일 경우) 노출 여부를\n   * 결정합니다.\n   */\n  availableOnPublic: boolean\n  /**\n   * 특정 플랫폼의 트리플 클라이언트에서만 노출이 필요할 경우 설정합니다.\n   */\n  appName?: 'Triple-iOS' | 'Triple-Android'\n}\n\n/**\n * 특정 버전의 트리플 클라이언트에서만 노출이 필요할 경우 사용합니다.\n */\nexport function useFeatureFlag({\n  operator,\n  appVersion,\n  availableOnPublic,\n  appName,\n}: UseFeatureFlagParams) {\n  const clientApp = useClientApp()\n\n  if (!clientApp) {\n    return availableOnPublic\n  }\n\n  const tripleClientAppVersion = semver.coerce(clientApp.metadata.version)\n  const operation = VERSION_OPERATIONS[operator]\n  const hasMatchingOs =\n    appName !== undefined ? clientApp.metadata.version === appName : true\n\n  return Boolean(\n    tripleClientAppVersion &&\n      operation(tripleClientAppVersion, appVersion) &&\n      hasMatchingOs,\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/env/context.ts",
    "content": "'use client'\n\nimport { createContext } from 'react'\n\nimport type { EnvValue } from './types'\n\nexport const EnvContext = createContext<EnvValue | undefined>(undefined)\n"
  },
  {
    "path": "packages/triple-web/src/env/index.ts",
    "content": "export * from './types'\nexport * from './use-env'\n"
  },
  {
    "path": "packages/triple-web/src/env/types.ts",
    "content": "export interface EnvValue {\n  /**\n   * 서비스 웹의 basePath\n   */\n  basePath: string\n  /**\n   * 앱을 여는데 필요한 scheme\n   */\n  appUrlScheme: string\n  /**\n   * 서비스 path 앞에 붙여서 URL을 만드는 값\n   */\n  webUrlBase: string\n  /**\n   * facebook에 등록된 App ID. Facebook Open Graph 관련 태그로 사용합니다.\n   */\n  facebookAppId: string\n  /**\n   * 페이지의 기본 제목. title 태그의 기본값이 됩니다.\n   */\n  defaultPageTitle: string\n  /**\n   * 페이지의 기본 설명. meta[name=\"description\"] 태그의 기본 content가 됩니다.\n   */\n  defaultPageDescription: string\n  /**\n   * 구글 맵 API Key\n   */\n  googleMapsApiKey?: string\n  /**\n   * 디퍼드 딥링크의 도메인을 생성할 때 필요한 값입니다.\n   */\n  afOnelinkSubdomain: string\n  /**\n   * AppsFlyer에 등록된 앱 ID입니다.\n   */\n  afOnelinkId: string\n  /**\n   * 미디어 소스 이름을 의미하며, 모든 측정 링크에서 반드시 포함되어야 할 유일하고 중요한 파라미터입니다.\n   */\n  afOnelinkPid: string\n  /**\n   * asset 파일의 웹 URL\n   */\n  webAssetsUrl?: string\n}\n"
  },
  {
    "path": "packages/triple-web/src/env/use-env.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useEnv\" />\n\n# useEnv\n\nEnvContext 값을 가져옵니다.\n\n## Usage\n\n```tsx\nconst env = useEnv()\n```\n\n## Returns\n\n`EnvContext`\n"
  },
  {
    "path": "packages/triple-web/src/env/use-env.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useEnv } from './use-env'\nimport { EnvContext } from './context'\nimport { EnvValue } from './types'\n\ntest('EnvContext가 정의된 경우 값을 반환해야 합니다.', () => {\n  const mockValue: EnvValue = {\n    appUrlScheme: 'triple-test',\n    basePath: '/',\n    afOnelinkId: '',\n    afOnelinkPid: '',\n    afOnelinkSubdomain: '',\n    defaultPageDescription: '',\n    defaultPageTitle: '',\n    facebookAppId: '',\n    webUrlBase: '',\n  }\n\n  const { result } = renderHook(() => useEnv(), {\n    wrapper: ({ children }) => (\n      <EnvContext.Provider value={mockValue}>{children}</EnvContext.Provider>\n    ),\n  })\n\n  expect(result.current).toBe(mockValue)\n})\n\ntest('EnvContext가 정의되지 않은 경우 오류를 발생시켜야 합니다.', () => {\n  expect(() => {\n    renderHook(() => useEnv())\n  }).toThrow('EnvContext가 없습니다.')\n})\n"
  },
  {
    "path": "packages/triple-web/src/env/use-env.ts",
    "content": "import { useContext } from 'react'\n\nimport { EnvContext } from './context'\n\n/**\n * EnvContext 값을 가져옵니다.\n */\nexport function useEnv() {\n  const context = useContext(EnvContext)\n\n  if (context === undefined) {\n    throw new Error('EnvContext가 없습니다.')\n  }\n\n  return context\n}\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/context.ts",
    "content": "'use client'\n\nimport { createContext, useContext } from 'react'\n\nimport type { EventMetadataValue, EventTrackingValue } from './types'\n\nexport const EventMetadataContext = createContext<\n  EventMetadataValue | undefined\n>(undefined)\n\nexport function useEventMetadata() {\n  return useContext(EventMetadataContext)\n}\n\nexport const EventTrackingContext = createContext<\n  EventTrackingValue | undefined\n>(undefined)\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/index.ts",
    "content": "export * from './types'\nexport * from './use-set-firebase-user-id'\nexport * from './use-track-event-with-metadata'\nexport * from './use-track-event'\nexport * from './use-track-screen'\nexport * from './use-utm'\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/libs/firebase-analytics.ts",
    "content": "import { getApp } from 'firebase/app'\nimport { getAnalytics } from 'firebase/analytics'\n\nexport function getFirebaseAnalytics() {\n  try {\n    const app = getApp()\n    const analytics = getAnalytics(app)\n\n    return analytics\n  } catch {}\n}\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/types.ts",
    "content": "export interface EventTrackingUtmValue {\n  source?: string\n  medium?: string\n  campaign?: string\n  term?: string\n  content?: string\n\n  partner?: string\n}\n\nexport interface EventTrackingValue {\n  page: {\n    label: string\n    path: string\n  }\n  utm: EventTrackingUtmValue\n  onError?: (error: Error) => void\n}\n\nexport interface EventMetadataValue {\n  [key: string]: string\n}\n\nexport type { TrackEventParams } from './utils/track-event'\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-set-firebase-user-id.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useSetFirebaseUserId\" />\n\n# useSetFirebaseUserId\n\n## Usage\n\n```tsx\nconst setFirebaseUserId = useSetFirebaseUserId()\n```\n\n## Returns\n\n`(userId: string | null) => void`\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-set-firebase-user-id.ts",
    "content": "import { setUserId } from 'firebase/analytics'\nimport { useCallback } from 'react'\n\nimport { getFirebaseAnalytics } from './libs/firebase-analytics'\n\n/**\n * Firebase user ID 설정.\n */\nexport function useSetFirebaseUserId() {\n  return useCallback((userId: string | null) => {\n    const firebaseAnalytics = getFirebaseAnalytics()\n    if (firebaseAnalytics) {\n      setUserId(firebaseAnalytics, userId || '')\n    }\n  }, [])\n}\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-track-event-with-metadata.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useTrackEventWithMetadata\" />\n\n# useTrackEventWithMetadata\n\n## Usage\n\n```tsx\nconst trackEvent = useTrackEventWithMetadata()\n```\n\n## Returns\n\n`(params: TrackEventParams) => void`\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-track-event-with-metadata.ts",
    "content": "import { useEventMetadata } from './context'\nimport type { EventMetadataValue } from './types'\nimport { useTrackEvent } from './use-track-event'\nimport type { TrackEventParams } from './utils/track-event'\n\n/**\n * `useTrackEvent`를 이벤트 메타데이터와 함께 감싸게 합니다.\n */\nexport function useTrackEventWithMetadata() {\n  const trackEvent = useTrackEvent()\n  const eventMetadata = useEventMetadata()\n\n  return (params: TrackEventParams) => {\n    trackEvent({\n      ga: getGoogleAnalyticsWithMetadata(params.ga, eventMetadata),\n      fa: getFirebaseAnalyticsWithMetadata(params.fa, eventMetadata),\n      metaPixel: getMetaPixelWithMetadata(params.metaPixel, eventMetadata),\n      tiktokPixel: params.tiktokPixel,\n    })\n  }\n}\n\nfunction getFirebaseAnalyticsWithMetadata(\n  fa?: TrackEventParams['fa'],\n  eventMetaContext?: EventMetadataValue,\n) {\n  if (!fa) {\n    return\n  }\n\n  return {\n    ...eventMetaContext,\n    ...fa,\n  }\n}\n\nfunction getGoogleAnalyticsWithMetadata(\n  ga?: TrackEventParams['ga'],\n  eventMetaContext?: EventMetadataValue,\n) {\n  if (!ga) {\n    return\n  }\n\n  if (eventMetaContext) {\n    const arrayOfContext = Object.values(eventMetaContext)\n    const [action, label] = ga\n\n    if (label) {\n      return [action, [...arrayOfContext, label].join('_').substr(0, 150)]\n    }\n    return [action, [...arrayOfContext].join('_').substr(0, 150)]\n  }\n\n  return ga\n}\n\nfunction getMetaPixelWithMetadata(\n  metaPixel?: TrackEventParams['metaPixel'],\n  eventMetaContext?: EventMetadataValue,\n) {\n  if (!metaPixel) {\n    return\n  }\n\n  const { payload } = metaPixel\n  const payloadWithMetadata = { ...eventMetaContext, ...payload }\n  return {\n    ...metaPixel,\n    payload: payloadWithMetadata,\n  }\n}\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-track-event.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useTrackEvent\" />\n\n# trackEvent\n\n## Usage\n\n```tsx\nconst trackEvent = useTrackEvent()\n```\n\n## Returns\n\n`(params: TrackEventParams) => void`\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-track-event.ts",
    "content": "import { useCallback, useContext } from 'react'\n\nimport { EventTrackingContext } from './context'\nimport { type TrackEventParams, trackEvent } from './utils/track-event'\n\n/**\n * 이벤트 트래킹을 사용합니다.\n */\nexport function useTrackEvent() {\n  const context = useContext(EventTrackingContext)\n\n  return useCallback(\n    (params: TrackEventParams) =>\n      context ? trackEvent(params, context) : undefined,\n    [context],\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-track-screen.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useTrackScreen\" />\n\n# useTrackScreen\n\n## Usage\n\n```tsx\nconst env = useTrackScreen()\n```\n\n## Returns\n\n`(path: string, label?: string, additionalMetadata?: { [key: string]: string }) => void`\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-track-screen.ts",
    "content": "import { useCallback, useContext } from 'react'\n\nimport { EventTrackingContext } from './context'\nimport { trackScreen } from './utils/track-screen'\n\n/**\n * 스크린 뷰 이벤트를 트래킹합니다.\n */\nexport function useTrackScreen() {\n  const context = useContext(EventTrackingContext)\n\n  return useCallback(\n    (\n      path: string,\n      label?: string,\n      additionalMetadata?: { [key: string]: string },\n    ) =>\n      context\n        ? trackScreen(path, label, additionalMetadata, context)\n        : undefined,\n    [context],\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-triple-web-device-id.ts",
    "content": "import { useEffect, useState } from 'react'\nimport Cookies from 'universal-cookie'\nimport { X_TRIPLE_WEB_DEVICE_ID } from '@titicaca/constants'\n\nexport function useTripleWebDeviceId() {\n  const [tripleWebDeviceId, setTripleWebDeviceId] = useState<\n    string | undefined\n  >()\n  const [isLoading, setIsLoading] = useState<boolean>(true)\n\n  useEffect(() => {\n    setTripleWebDeviceId(\n      new Cookies(document.cookie).get<string>(X_TRIPLE_WEB_DEVICE_ID),\n    )\n    setIsLoading(false)\n  }, [])\n\n  return { tripleWebDeviceId, isLoading }\n}\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-utm.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useUtm\" />\n\n# useUtm\n\n## Usage\n\n```tsx\nconst { source, medium. campaign, term, content, partner } = useUtm()\n```\n\n## Returns\n\n### source\n\n`string`\n\n### medium\n\n`string`\n\n### campaign\n\n`string`\n\n### term\n\n`string`\n\n### content\n\n`string`\n\n### partner\n\n`string`\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/use-utm.ts",
    "content": "import { useContext } from 'react'\n\nimport { EventTrackingContext } from './context'\n\n/**\n * UTM 값을 가져옵니다.\n */\nexport function useUtm() {\n  const context = useContext(EventTrackingContext)\n\n  if (context) {\n    return context.utm\n  }\n\n  return {}\n}\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/utils/track-event.ts",
    "content": "import {\n  hasAccessibleTripleNativeClients,\n  trackEvent as nativeTrackEvent,\n} from '@titicaca/triple-web-to-native-interfaces'\nimport { logEvent as firebaseLogEvent } from 'firebase/analytics'\n\nimport { getFirebaseAnalytics } from '../libs/firebase-analytics'\nimport type { EventTrackingValue } from '../types'\n\ndeclare const window: {\n  ga?: (\n    method: 'send' | 'set',\n    type: 'pageview' | 'event' | 'page',\n    ...data: (string | undefined)[]\n  ) => void\n  fbq?: (\n    type: 'track' | 'trackCustom',\n    action: string,\n    payload?: { [key: string]: unknown },\n  ) => void\n  ttq?: {\n    track: (type: TiktokPixelEventType, params?: TiktokPixelEventParams) => void\n  }\n} & Window\n\ntype GoogleAnalyticsParams = (string | undefined)[]\n\ninterface FirebaseAnalyticsParams {\n  category?: string\n  event_name?: string\n  [key: string]: unknown\n}\n\ninterface MetaPixelPayload {\n  [key: string]: unknown\n}\n\ninterface MetaPixelStandardEvent {\n  type: 'track'\n  action:\n    | 'AddPaymentInfo'\n    | 'AddToCart'\n    | 'AddToWishlist'\n    | 'CompleteRegistration'\n    | 'Contact'\n    | 'CustomizeProduct'\n    | 'Donate'\n    | 'FindLocation'\n    | 'InitiateCheckout'\n    | 'Lead'\n    | 'Purchase'\n    | 'Schedule'\n    | 'Search'\n    | 'StartTrial'\n    | 'SubmitApplication'\n    | 'Subscribe'\n    | 'ViewContent'\n  payload?: MetaPixelPayload\n}\n\ninterface MetaPixelCustomEvent {\n  type: 'trackCustom'\n  action: string\n  payload?: MetaPixelPayload\n}\n\ntype MetaPixelParams =\n  | MetaPixelStandardEvent\n  | MetaPixelCustomEvent\n  | (Omit<MetaPixelCustomEvent, 'type'> & { type?: never })\n\ntype TiktokPixelEventType =\n  | 'AddPaymentInfo'\n  | 'AddToCart'\n  | 'AddToWishlist'\n  | 'ClickButton'\n  | 'CompletePayment'\n  | 'CompleteRegistration'\n  | 'Contact'\n  | 'Download'\n  | 'InitiateCheckout'\n  | 'PlaceAnOrder'\n  | 'Search'\n  | 'SubmitForm'\n  | 'Subscribe'\n  | 'ViewContent'\n\ninterface TiktokPixelEventParams {\n  content_type?: string\n  contents?: {\n    content_id?: string\n    content_name?: string\n    content_category?: string\n    price?: string\n    quantity?: number\n    brand?: string\n  }[]\n  currency?: string // ISO 4217\n  value?: number // total price of the order\n}\n\ninterface TiktokPixelEvent {\n  type: TiktokPixelEventType\n  params?: TiktokPixelEventParams\n}\n\nexport interface TrackEventParams {\n  ga?: GoogleAnalyticsParams\n  fa?: Partial<FirebaseAnalyticsParams>\n  /**\n   * Meta Pixel 이벤트 파라미터 (구 Facebook Pixel)\n   *\n   * type을 \"track\"으로 설정하면 주어진 action만 사용할 수 있습니다.\n   * 그리고 type을 생략하면 맞춤 이벤트를 사용합니다.\n   */\n  metaPixel?: MetaPixelParams\n  /**\n   * Tiktok Pixel 이벤트 파라미터\n   */\n  tiktokPixel?: TiktokPixelEvent\n}\n\nexport function trackEvent(\n  { ga, fa, metaPixel, tiktokPixel }: TrackEventParams,\n  context: EventTrackingValue | undefined,\n) {\n  const pageLabel = context?.page?.label\n\n  try {\n    if (window.ga && ga) {\n      const [action, label] = ga\n      window.ga('send', 'event', pageLabel, action, label)\n    }\n\n    if (window.fbq && metaPixel) {\n      const { type = 'trackCustom', action, payload } = metaPixel\n      window.fbq(type, action, { pageLabel, ...payload })\n    }\n\n    if (window.ttq && tiktokPixel) {\n      window.ttq.track(tiktokPixel.type, tiktokPixel.params)\n    }\n\n    const firebaseAnalytics = getFirebaseAnalytics()\n\n    if (firebaseAnalytics && fa && !hasAccessibleTripleNativeClients()) {\n      firebaseLogEvent(firebaseAnalytics, 'web_user_interaction', {\n        category: pageLabel,\n        ...fa,\n      })\n    }\n\n    nativeTrackEvent({\n      ga: ga && [pageLabel, ...ga],\n      fa: fa && {\n        category: pageLabel ?? '',\n        event_name: 'user_interaction',\n        ...fa,\n      },\n    })\n  } catch (error) {\n    if (error instanceof Error) {\n      context?.onError?.(error)\n    }\n  }\n}\n"
  },
  {
    "path": "packages/triple-web/src/event-tracking/utils/track-screen.ts",
    "content": "import {\n  hasAccessibleTripleNativeClients,\n  trackScreen as nativeTrackScreen,\n} from '@titicaca/triple-web-to-native-interfaces'\nimport { logEvent as firebaseLogEvent } from 'firebase/analytics'\n\nimport { getFirebaseAnalytics } from '../libs/firebase-analytics'\nimport type { EventTrackingValue } from '../types'\n\ndeclare const window: {\n  ga?: (\n    method: 'send' | 'set',\n    type: 'pageview' | 'event' | 'page',\n    ...data: (string | undefined)[]\n  ) => void\n  fbq?: (\n    type: 'track' | 'trackCustom',\n    action: string,\n    payload?: { [key: string]: unknown },\n  ) => void\n} & Window\n\nexport function trackScreen(\n  path: string,\n  label: string | undefined,\n  additionalMetadata: { [key: string]: string } | undefined,\n  context: EventTrackingValue | undefined,\n) {\n  try {\n    if (window.ga) {\n      window.ga('set', 'page', path)\n      window.ga('send', 'pageview')\n    }\n\n    if (window.fbq && label) {\n      window.fbq('trackCustom', `PageView_${label}`, { path })\n    }\n\n    const firebaseAnalytics = getFirebaseAnalytics()\n\n    if (firebaseAnalytics && !hasAccessibleTripleNativeClients()) {\n      firebaseLogEvent(firebaseAnalytics, 'page_view', {\n        page_path: path,\n        category: label,\n        ...additionalMetadata,\n      })\n    }\n\n    nativeTrackScreen(path, label, additionalMetadata)\n  } catch (error) {\n    if (error instanceof Error) {\n      context?.onError?.(error)\n    }\n  }\n}\n"
  },
  {
    "path": "packages/triple-web/src/hash-router/context.tsx",
    "content": "'use client'\n\nimport {\n  createContext,\n  ReactNode,\n  useCallback,\n  useEffect,\n  useState,\n} from 'react'\n\nimport { useUserAgent } from '../user-agent/use-user-agent'\n\nexport interface HashRouterContextValue {\n  /**\n   * @deprecated 'uriHash는 Multiple hash 값을 지원하지 않습니다. 대신 hasUriHash 메소드를 사용하세요.'\n   */\n  uriHash: string\n  /**\n   *\n   * @param hash 추가할 hash 값입니다.\n   * @param type 기기 환경에 의존하지 않고 강제로 hash를 추가하고 싶을 때 지정합니다. removeUriHash와 동일한 값을 설정해야합니다.\n   */\n  addUriHash: (hash: string, type?: 'push' | 'replace') => void\n  /**\n   * 현재 hash 값에서 마지막 hash를 제거합니다.\n   * @param type 기기 환경에 의존하지 않고 강제로 hash를 제거하고 싶을 때 지정합니다. addUriHash와 동일한 값을 설정해야합니다.\n   */\n  removeUriHash: (type?: 'pop' | 'replace') => void\n  /**\n   *\n   * @param hash 확인할 hash 값입니다.\n   * @returns hash값이 현재 uriHash에 포함되어 있는지 여부를 반환합니다.\n   */\n  hasUriHash: (hash: string) => boolean\n  /**\n   *\n   * @param sourceHash 대체될 hash 값입니다.\n   * @param targetHash 대체될 hash 값입니다.\n   *\n   * 하나의 hash가 제거되는 동시에 다른 hash를 추가해야할 때 유용합니다.\n   */\n  replaceUriHash: (sourceHash: string, targetHash: string) => void\n}\n\nexport const HashRouterContext = createContext<HashRouterContextValue | null>(\n  null,\n)\n\nexport function HashRouterProvider({ children }: { children: ReactNode }) {\n  const { os } = useUserAgent()\n  const isAndroid = os.name === 'Android'\n\n  const [uriHash, setUriHash] = useState<string>('')\n\n  useEffect(() => {\n    setUriHash(window.location.hash.replace('#', ''))\n  }, [])\n\n  useEffect(() => {\n    const onHashChange = () => {\n      setUriHash(window.location.hash.replace('#', ''))\n    }\n\n    window.addEventListener('hashchange', onHashChange)\n    return () => {\n      window.removeEventListener('hashchange', onHashChange)\n    }\n  }, [])\n\n  const addUriHash = useCallback<HashRouterContextValue['addUriHash']>(\n    (hash, type) => {\n      const url = new URL(window.location.href)\n      const currentHash = window.location.hash.replace('#', '')\n\n      if (currentHash) {\n        const hashArray = currentHash.split('&')\n        if (\n          hashArray.includes(hash) &&\n          process.env.NODE_ENV === 'development'\n        ) {\n          // eslint-disable-next-line no-console\n          console.warn(`❗️${hash} already exists in the hash.`)\n        }\n        url.hash = currentHash + '&' + hash\n      } else {\n        url.hash = hash\n      }\n\n      if (type === 'push' || isAndroid) {\n        window.history.pushState(null, '', url)\n      } else {\n        window.history.replaceState(null, '', url)\n      }\n      window.dispatchEvent(new Event('hashchange'))\n    },\n    [isAndroid],\n  )\n\n  const removeUriHash = useCallback<HashRouterContextValue['removeUriHash']>(\n    (type) => {\n      if (!window.location.hash) {\n        return\n      }\n\n      if (type === 'pop' || isAndroid) {\n        return window.history.back()\n      }\n\n      const url = new URL(window.location.href)\n\n      const currentHash = window.location.hash.replace('#', '')\n      if (currentHash.includes('&')) {\n        const hashArray = currentHash.split('&')\n        hashArray.pop()\n        url.hash = hashArray.join('&')\n      } else {\n        url.hash = ''\n      }\n\n      window.history.replaceState(null, '', url)\n      window.dispatchEvent(new Event('hashchange'))\n    },\n    [isAndroid],\n  )\n\n  const replaceUriHash = useCallback(\n    (sourceHash: string, targetHash: string) => {\n      if (!window.location.hash) {\n        return\n      }\n\n      const url = new URL(window.location.href)\n      const currentHash = window.location.hash.replace('#', '')\n\n      if (currentHash.includes(sourceHash)) {\n        url.hash = currentHash.replace(sourceHash, targetHash)\n        window.history.replaceState(null, '', url)\n        window.dispatchEvent(new Event('hashchange'))\n      } else if (process.env.NODE_ENV === 'development') {\n        // eslint-disable-next-line no-console\n        console.warn(`❗️${sourceHash} does not exist in the hash.`)\n      }\n    },\n    [],\n  )\n\n  const hasUriHash = useCallback(\n    (hash: string) => {\n      return uriHash.split('&').includes(hash)\n    },\n    [uriHash],\n  )\n\n  const value = {\n    uriHash,\n    addUriHash,\n    removeUriHash,\n    hasUriHash,\n    replaceUriHash,\n  }\n\n  return (\n    <HashRouterContext.Provider value={value}>\n      {children}\n    </HashRouterContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/hash-router/index.ts",
    "content": "export * from './use-hash-router'\n"
  },
  {
    "path": "packages/triple-web/src/hash-router/use-hash-router.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useHashRouter\" />\n\n# useHashRouter\n\nHashRouterContext 값을 가져옵니다.\n\n## PC Web, iOS 환경 기본동작\n\naddUriHash, removeUriHash 메서드에 별도의 type을 전달하지 않는다면, 모달을 연 후 브라우저 뒤로가기 혹은 스와이프 제스쳐를 통해 뒤로가기가 발생했을 때 이전 페이지로 돌아갑니다.\n\n## Android 환경 기본동작\n\naddUriHash, removeUriHash 메서드에 별도의 type을 전달하지 않는다면, 모달을 연 후 브라우저 뒤로가기 혹은 물리 back key로 뒤로가기가 발생했을 때 모달만 닫힙니다.\n\n## Usage\n\n- 열고자 하는 모달에 고유한 hash 지정합니다.\n- 모달의 open 조건을 아래와 같이 지정\n\n```jsx\nfunction ExampleComponent() {\n  const { uriHash } = useHashRouter()\n  const open = uriHash === 'some.unique.hash'\n\n  return <Popup open={open} />\n}\n```\n\n- 모달이 열리는 로직(버튼 클릭 등) 내에 addUriHash를 추가합니다(iOS, Android 환경에 따라 동작이 상이합니다).\n\n```jsx\n<button onClick={() => addUriHash('some.unique.hash')}>팝업 보기</button>\n```\n\n- 이 때, 기기환경에 의존하지 않고 강제로 push 혹은 replace를 사용하길 원한다면 type을 지정합니다.\n\n```jsx\n/** iOS 환경에서도 Android 처럼 동작 */\n<button onClick={() => addUriHash('some.unique.hash', 'push')} />\n\n/** Android 환경에서도 iOS 처럼동작 */\n<button onClick={() => addUriHash('some.unique.hash', 'replace')} />\n```\n\n- 모달을 닫는 부분에 removeUriHash를 추가합니다. 이 때, 모달을 열 때 사용한 addUriHash와 짝이 되는 type을 적용해야 합니다.\n\n```jsx\n/** Popup을 열 때 addUriHash의 type으로 'push'를 전달한 경우 */\n<Popup onClose={() => removeUriHash('pop')}>\n\n/** Popup을 열 때 addUriHash의 type으로 'replace'를 전달한 경우 */\n<Popup onClose={() => removeUriHash('replace')}>\n\n/** Popup을 열 때 addUriHash의 type으로 아무것도 전달하지 않은 경우 */\n<Popup onClose={() => removeUriHash()}>\n```\n\n- addUriHash와 removeUriHash에 전달하는 type 옵션을 동일하게 설정해야합니다.\n\n```jsx\nfunction ExampleComponent() {\n  const POPUP_HASH = 'popup.hash'\n  const { uriHash, addUriHash, removeUriHash } = useHashRouter()\n\n  return (\n    <div>\n      <button onClick={() => addUriHash(POPUP_HASH)}>팝업 열기</button>\n      <Popup open={uriHash === POPUP_HASH} onClose={() => removeUriHash()} />\n    </div>\n  )\n}\n```\n\n## Returns\n\n### uriHash\n\n현재 Hash를 반환합니다. `addUriHash`, `removeUriHash`에 의해 trigger 됩니다.\n\n`string`\n\n### addUriHash\n\nHash를 추가합니다.\n\n`(hash: string, type?: 'push' | 'replace' = isAndroid ? 'push' : 'replace') => void`\n\n- `hash`: 추가할 Hash\n- `type?`\n  - `push` : `window.history.pushState`를 사용하여 Hash를 추가합니다.\n  - `replace` : `window.history.replaceState`를 사용하여 Hash를 추가합니다.\n\n### removeUriHash\n\nHash를 제거합니다. (TODO: Hash가 없다면 동작하지 않습니다.)\n\n`(type?: 'pop' | 'replace' = isAndroid ? 'pop' : 'replace') => void`\n\n- `type?`\n  - `pop` : `window.history.back`을 사용하여 Hash를 제거합니다.\n  - `replace` : `window.history.replaceState`를 사용하여 Hash를 제거합니다.\n"
  },
  {
    "path": "packages/triple-web/src/hash-router/use-hash-router.ts",
    "content": "import { useContext } from 'react'\n\nimport { HashRouterContext } from './context'\n\n/**\n * HashRouterContext 값을 가져옵니다.\n */\nexport function useHashRouter() {\n  const context = useContext(HashRouterContext)\n\n  if (!context) {\n    throw new Error('HashRouterContext가 존재하지 않습니다.')\n  }\n\n  return context\n}\n"
  },
  {
    "path": "packages/triple-web/src/i18n/context.ts",
    "content": "'use client'\n\nimport { createContext } from 'react'\n\nimport type { I18nValue } from './types'\n\nexport const I18nContext = createContext<I18nValue | undefined>(undefined)\n"
  },
  {
    "path": "packages/triple-web/src/i18n/index.ts",
    "content": "export * from './types'\nexport * from './use-i18n'\nexport * from './use-translation'\n"
  },
  {
    "path": "packages/triple-web/src/i18n/types.ts",
    "content": "export type I18nLocale = 'en' | 'ja' | 'ko' | 'zh-TW'\n\nexport interface I18nValue {\n  defaultLocale: I18nLocale\n  locale?: I18nLocale\n}\n"
  },
  {
    "path": "packages/triple-web/src/i18n/use-i18n.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useI18n\" />\n\n# useI18n\n\nI18nValue 값을 가져옵니다.\n\n## Usage\n\n```tsx\nconst useI18n = useI18n()\n```\n\n## Returns\n\n`I18Context`\n"
  },
  {
    "path": "packages/triple-web/src/i18n/use-i18n.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useI18n } from './use-i18n'\nimport { I18nContext } from './context'\nimport { I18nValue } from './types'\n\ntest('I18nContext가 정의된 경우 값을 반환해야 합니다.', () => {\n  const mockValue: I18nValue = {\n    defaultLocale: 'ko',\n  }\n\n  const { result } = renderHook(() => useI18n(), {\n    wrapper: ({ children }) => (\n      <I18nContext.Provider value={mockValue}>{children}</I18nContext.Provider>\n    ),\n  })\n\n  expect(result.current).toBe(mockValue)\n})\n\ntest('I18nContext가 정의되지 않은 경우 오류를 발생시켜야 합니다.', () => {\n  expect(() => {\n    renderHook(() => useI18n())\n  }).toThrow('I18nContext가 없습니다.')\n})\n"
  },
  {
    "path": "packages/triple-web/src/i18n/use-i18n.ts",
    "content": "import { useContext } from 'react'\n\nimport { I18nContext } from './context'\n\n/**\n * I18nContext 값을 가져옵니다.\n */\nexport function useI18n() {\n  const context = useContext(I18nContext)\n\n  if (!context) {\n    throw new Error('I18nContext가 없습니다.')\n  }\n\n  return context\n}\n"
  },
  {
    "path": "packages/triple-web/src/i18n/use-translation.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useTranslation\" />\n\n# useTranslation\n\n번역 함수를 사용합니다.\n\n## Usage\n\n```tsx\nconst t = useTranslation()\n```\n\n## Returns\n\n`(key: string, values = {}) => string`\n"
  },
  {
    "path": "packages/triple-web/src/i18n/use-translation.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useTranslation } from './use-translation'\nimport { I18nContext } from './context'\n\ntest('주어진 키와 언어에 대한 올바른 번역을 반환해야 합니다', () => {\n  const { result } = renderHook(() => useTranslation(), {\n    wrapper: ({ children }) => (\n      <I18nContext.Provider value={{ defaultLocale: 'en' }}>\n        {children}\n      </I18nContext.Provider>\n    ),\n  })\n  const t = result.current\n\n  expect(t('트리플')).toBe('Triple')\n})\n\ntest('번역이 누락된 경우 키를 반환해야 합니다', () => {\n  const { result } = renderHook(() => useTranslation(), {\n    wrapper: ({ children }) => (\n      <I18nContext.Provider value={{ defaultLocale: 'en' }}>\n        {children}\n      </I18nContext.Provider>\n    ),\n  })\n  const t = result.current\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  expect(t('missing-key' as any)).toBe('missing-key')\n})\n\ntest('값을 올바르게 보간해야 합니다', () => {\n  const { result } = renderHook(() => useTranslation(), {\n    wrapper: ({ children }) => (\n      <I18nContext.Provider value={{ defaultLocale: 'en' }}>\n        {children}\n      </I18nContext.Provider>\n    ),\n  })\n  const t = result.current\n\n  expect(t('{{reviewsCount}}개의 리뷰', { reviewsCount: '100' })).toBe(\n    '100reviews',\n  )\n})\n\ntest('언어가 설정되지 않은 경우 기본 언어를 사용해야 합니다', () => {\n  const { result } = renderHook(() => useTranslation(), {\n    wrapper: ({ children }) => (\n      <I18nContext.Provider value={{ defaultLocale: 'ko' }}>\n        {children}\n      </I18nContext.Provider>\n    ),\n  })\n  const t = result.current\n\n  expect(t('트리플')).toBe('트리플')\n})\n"
  },
  {
    "path": "packages/triple-web/src/i18n/use-translation.ts",
    "content": "import { interpolate, locales } from '@titicaca/i18n'\n\nimport { useI18n } from './use-i18n'\nimport { I18nLocale } from './types'\n\ntype Key = keyof typeof locales.ko\n\nconst translations: Record<I18nLocale, Record<Key, string>> = {\n  en: locales.en,\n  ja: locales.ja,\n  ko: locales.ko,\n  'zh-TW': locales.zhTw,\n}\n\n/**\n * 번역 함수를 사용합니다.\n */\nexport function useTranslation() {\n  const i18n = useI18n()\n\n  const locale = i18n.locale ?? i18n.defaultLocale\n\n  const t = (key: Key, values = {}) => {\n    const translation = translations[locale]?.[key] ?? key\n    return interpolate(translation, values)\n  }\n\n  return t\n}\n"
  },
  {
    "path": "packages/triple-web/src/index.ts",
    "content": "export * from './client-app'\nexport * from './env'\nexport * from './event-tracking'\nexport * from './hash-router'\nexport * from './i18n'\nexport * from './modal'\nexport * from './providers'\nexport * from './session'\nexport * from './user-agent'\n"
  },
  {
    "path": "packages/triple-web/src/modal/app-install-cta-modal-context.ts",
    "content": "'use client'\n\nimport { createContext } from 'react'\n\nimport type { AppInstallCtaModalRef } from './types'\n\nexport interface AppInstallCtaModalContextValue {\n  showOptions?: AppInstallCtaModalRef\n}\n\nexport const AppInstallCtaModalContext = createContext<\n  AppInstallCtaModalContextValue | undefined\n>(undefined)\n"
  },
  {
    "path": "packages/triple-web/src/modal/components/app-install-cta-modal.tsx",
    "content": "import { Modal, Text } from '@titicaca/tds-ui'\nimport { styled } from 'styled-components'\nimport { useEffect } from 'react'\nimport { generateUrl } from '@titicaca/view-utilities'\n\nimport { APP_INSTALL_CTA_MODAL_HASH } from '../constants'\nimport { useModal } from '../context'\nimport { useHashRouter } from '../../hash-router/use-hash-router'\nimport { trackEvent } from '../../event-tracking/utils/track-event'\nimport { useEnv } from '../../env'\nimport { useTranslation } from '../../i18n'\n\nconst IconImage = styled.img`\n  display: block;\n  width: 66px;\n  height: 66px;\n  margin: 0 auto 10px;\n`\n\nexport function AppInstallCtaModal() {\n  const t = useTranslation()\n  const { appInstallCtaModalRef, eventTrackingContextForkRef } = useModal()\n  const { removeUriHash, uriHash } = useHashRouter()\n  const { appUrlScheme } = useEnv()\n\n  const open = uriHash === APP_INSTALL_CTA_MODAL_HASH\n  const eventLabel = appInstallCtaModalRef.current.triggeredEventAction\n\n  const handleCancelOrClose = () => removeUriHash()\n\n  const handleClick = () => {\n    appInstallCtaModalRef.current.onActionClick?.()\n\n    trackEvent(\n      {\n        ga: [\n          '설치유도팝업_선택',\n          ['선택_트리플가기', eventLabel].filter((v) => v).join('_'),\n        ],\n        fa: {\n          action: '설치유도팝업_선택',\n          ...(eventLabel && { referrer_event: eventLabel }),\n        },\n      },\n      eventTrackingContextForkRef.current,\n    )\n\n    const appMain = generateUrl({\n      scheme: appUrlScheme,\n      path: '/main',\n    })\n\n    window.location.href = appInstallCtaModalRef.current.deepLink || appMain\n  }\n\n  useEffect(() => {\n    if (eventLabel) {\n      trackEvent(\n        {\n          ga: ['설치유도팝업_노출', eventLabel],\n          fa: {\n            action: '설치유도팝업_노출',\n            ...(eventLabel && { referrer_event: eventLabel }),\n          },\n        },\n        eventTrackingContextForkRef.current,\n      )\n    }\n  }, [eventLabel, eventTrackingContextForkRef])\n\n  return (\n    <Modal open={open} onClose={handleCancelOrClose}>\n      <Modal.Body>\n        <IconImage src=\"https://assets.triple.guide/images/ico-popup-app@4x.png\" />\n        <Modal.Title>{t('여기는 트리플 앱이 필요해요')}</Modal.Title>\n        <Text center alpha={0.7} size=\"small\">\n          {t(\n            '일정 짜기부터 호텔, 투어・티켓 예약까지! 트리플로 한 번에 여행 준비하세요.',\n          )}\n        </Text>\n      </Modal.Body>\n      <Modal.Actions>\n        <Modal.Action color=\"gray\" onClick={handleCancelOrClose}>\n          {t('취소')}\n        </Modal.Action>\n        <Modal.Action color=\"blue\" onClick={handleClick}>\n          {t('트리플 가기')}\n        </Modal.Action>\n      </Modal.Actions>\n    </Modal>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/modal/components/login-cta-modal.tsx",
    "content": "import { Confirm } from '@titicaca/tds-ui'\nimport { useEffect } from 'react'\nimport { generateUrl } from '@titicaca/view-utilities'\nimport qs from 'qs'\n\nimport { LOGIN_CTA_MODAL_HASH } from '../constants'\nimport { useModal } from '../context'\nimport { useHashRouter } from '../../hash-router/use-hash-router'\nimport { trackEvent } from '../../event-tracking/utils/track-event'\nimport { useTranslation } from '../../i18n'\n\nexport function LoginCtaModal() {\n  const t = useTranslation()\n  const { loginCtaModalRef, eventTrackingContextForkRef } = useModal()\n  const { removeUriHash, uriHash } = useHashRouter()\n  const open = uriHash === LOGIN_CTA_MODAL_HASH\n\n  const handleCancelOrClose = () => {\n    removeUriHash()\n    return true\n  }\n\n  const handleConfirm = () => {\n    const triggeredEventAction =\n      loginCtaModalRef.current.triggeredEventAction ?? ''\n\n    trackEvent(\n      {\n        ga: ['로그인유도팝업_로그인선택'],\n        fa: {\n          action: '로그인유도팝업_로그인선택',\n          ...(triggeredEventAction && { referrer_event: triggeredEventAction }),\n        },\n      },\n      eventTrackingContextForkRef.current,\n    )\n\n    const loginUrl = generateUrl({\n      path: '/login',\n      query: qs.stringify({\n        returnUrl:\n          loginCtaModalRef.current.returnUrl ||\n          document.location.href.split('#')[0],\n      }),\n    })\n\n    removeUriHash()\n\n    window.location.href = loginUrl\n\n    return true\n  }\n\n  useEffect(() => {\n    if (open) {\n      const triggeredEventAction =\n        loginCtaModalRef.current.triggeredEventAction ?? ''\n\n      trackEvent(\n        {\n          ga: ['로그인유도팝업_노출', triggeredEventAction],\n          fa: {\n            action: '로그인유도팝업_노출',\n            ...(triggeredEventAction && {\n              referrer_event: triggeredEventAction,\n            }),\n          },\n        },\n        eventTrackingContextForkRef.current,\n      )\n    }\n  }, [eventTrackingContextForkRef, loginCtaModalRef, open])\n\n  return (\n    <Confirm\n      open={open}\n      title={t('로그인이 필요합니다.')}\n      onClose={handleCancelOrClose}\n      onCancel={handleCancelOrClose}\n      onConfirm={handleConfirm}\n    >\n      {t('로그인하고 트리플을 더 편하게 이용하세요')}\n    </Confirm>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/modal/constants.ts",
    "content": "export const LOGIN_CTA_MODAL_HASH = 'login-cta-modal'\nexport const APP_INSTALL_CTA_MODAL_HASH = 'app-install-cta-modal'\n"
  },
  {
    "path": "packages/triple-web/src/modal/context.tsx",
    "content": "'use client'\n\nimport {\n  type PropsWithChildren,\n  createContext,\n  useContext,\n  useRef,\n} from 'react'\n\nimport type { EventTrackingValue } from '../event-tracking/types'\n\nimport type {\n  LoginCtaModalRef,\n  ModalValue,\n  AppInstallCtaModalRef,\n} from './types'\n\nexport const ModalContext = createContext<ModalValue | undefined>(undefined)\n\nexport function useModal() {\n  const modalContext = useContext(ModalContext)\n\n  if (modalContext === undefined) {\n    throw new Error('ModalContext가 없습니다.')\n  }\n\n  return modalContext\n}\n\nexport function ModalProvider({ children }: PropsWithChildren) {\n  const loginCtaModalRef = useRef<LoginCtaModalRef>({})\n  const appInstallCtaModalRef = useRef<AppInstallCtaModalRef>({})\n  const eventTrackingContextForkRef = useRef<EventTrackingValue>()\n\n  return (\n    <ModalContext.Provider\n      value={{\n        loginCtaModalRef,\n        appInstallCtaModalRef,\n        eventTrackingContextForkRef,\n      }}\n    >\n      {children}\n    </ModalContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/modal/index.ts",
    "content": "export * from './constants'\nexport * from './use-login-cta-modal'\nexport * from './use-app-install-cta-modal'\n"
  },
  {
    "path": "packages/triple-web/src/modal/login-cta-modal-context.ts",
    "content": "'use client'\n\nimport { createContext } from 'react'\n\nimport type { LoginCtaModalRef } from './types'\n\nexport interface LoginCtaModalContextValue {\n  showOptions?: LoginCtaModalRef\n}\n\nexport const LoginCtaModalContext = createContext<\n  LoginCtaModalContextValue | undefined\n>(undefined)\n"
  },
  {
    "path": "packages/triple-web/src/modal/types.ts",
    "content": "import { MutableRefObject } from 'react'\n\nimport type { EventTrackingValue } from '../event-tracking/types'\n\nexport interface LoginCtaModalRef {\n  returnUrl?: string\n  triggeredEventAction?: string\n}\n\nexport interface AppInstallCtaModalRef {\n  deepLink?: string\n  onActionClick?: () => void\n  triggeredEventAction?: string\n}\n\nexport interface ModalValue {\n  loginCtaModalRef: MutableRefObject<LoginCtaModalRef>\n  appInstallCtaModalRef: MutableRefObject<AppInstallCtaModalRef>\n  eventTrackingContextForkRef: MutableRefObject<EventTrackingValue | undefined>\n}\n"
  },
  {
    "path": "packages/triple-web/src/modal/use-app-install-cta-modal.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useAppInstallCtaModal\" />\n\n# useAppInstallCtaModal\n\n앱 설치 유도 modal을 관리합니다.\n\n## Usage\n\n```tsx\nconst { show, close } = useAppInstallCtaModal()\n```\n\n## Returns\n\n### show\n\n`(options?: ShowOptions) => void`\n\n### close\n\n`() => void`\n"
  },
  {
    "path": "packages/triple-web/src/modal/use-app-install-cta-modal.ts",
    "content": "'use client'\n\nimport { useCallback, useContext, useEffect } from 'react'\n\nimport { useHashRouter } from '../hash-router/use-hash-router'\nimport { EventTrackingContext } from '../event-tracking/context'\n\nimport { useModal } from './context'\nimport type { AppInstallCtaModalRef } from './types'\nimport { APP_INSTALL_CTA_MODAL_HASH } from './constants'\nimport { AppInstallCtaModalContext } from './app-install-cta-modal-context'\n\ntype ShowOptions = AppInstallCtaModalRef\n\nexport function useAppInstallCtaModal() {\n  const { appInstallCtaModalRef, eventTrackingContextForkRef } = useModal()\n  const appInstallCtaModalContext = useContext(AppInstallCtaModalContext)\n  const eventTrackingContext = useContext(EventTrackingContext)\n  const { addUriHash, removeUriHash } = useHashRouter()\n\n  const show = useCallback(\n    (options?: ShowOptions) => {\n      addUriHash(APP_INSTALL_CTA_MODAL_HASH)\n\n      const combinedOptions = {\n        ...appInstallCtaModalContext?.showOptions,\n        ...options,\n      }\n\n      if (Object.keys(combinedOptions).length > 0) {\n        appInstallCtaModalRef.current = combinedOptions\n      }\n    },\n    [addUriHash, appInstallCtaModalContext, appInstallCtaModalRef],\n  )\n\n  const close = useCallback(() => {\n    removeUriHash()\n\n    appInstallCtaModalRef.current = {}\n  }, [removeUriHash, appInstallCtaModalRef])\n\n  useEffect(() => {\n    const previous = eventTrackingContextForkRef.current\n    eventTrackingContextForkRef.current = eventTrackingContext\n\n    return () => {\n      eventTrackingContextForkRef.current = previous\n    }\n  }, [eventTrackingContext, eventTrackingContextForkRef])\n\n  return {\n    show,\n    close,\n  }\n}\n"
  },
  {
    "path": "packages/triple-web/src/modal/use-login-cta-modal.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useLoginCtaModal\" />\n\n# useLoginCtaModal\n\n로그인 유도 modal을 관리합니다.\n\n## Usage\n\n```tsx\nconst { show, close } = useLoginCtaModal()\n```\n\n## Returns\n\n### show\n\n`(options?: ShowOptions) => void`\n\n### close\n\n`() => void`\n"
  },
  {
    "path": "packages/triple-web/src/modal/use-login-cta-modal.ts",
    "content": "'use client'\n\nimport { useCallback, useContext, useEffect } from 'react'\n\nimport { useHashRouter } from '../hash-router'\nimport { EventTrackingContext } from '../event-tracking/context'\n\nimport { LOGIN_CTA_MODAL_HASH } from './constants'\nimport { useModal } from './context'\nimport type { LoginCtaModalRef } from './types'\nimport { LoginCtaModalContext } from './login-cta-modal-context'\n\ntype ShowOptions = LoginCtaModalRef\n\n/**\n * 로그인 유도 modal을 관리합니다.\n */\nexport function useLoginCtaModal() {\n  const { loginCtaModalRef, eventTrackingContextForkRef } = useModal()\n  const loginCtaModalContext = useContext(LoginCtaModalContext)\n  const eventTrackingContext = useContext(EventTrackingContext)\n  const { addUriHash, removeUriHash } = useHashRouter()\n\n  const show = useCallback(\n    (options?: ShowOptions) => {\n      addUriHash(LOGIN_CTA_MODAL_HASH)\n\n      const combinedOptions = {\n        ...loginCtaModalContext?.showOptions,\n        ...options,\n      }\n      if (Object.keys(combinedOptions).length > 0) {\n        loginCtaModalRef.current = combinedOptions\n      }\n    },\n    [addUriHash, loginCtaModalContext, loginCtaModalRef],\n  )\n\n  const close = useCallback(() => {\n    removeUriHash()\n\n    loginCtaModalRef.current = {}\n  }, [loginCtaModalRef, removeUriHash])\n\n  useEffect(() => {\n    const previous = eventTrackingContextForkRef.current\n    eventTrackingContextForkRef.current = eventTrackingContext\n\n    return () => {\n      eventTrackingContextForkRef.current = previous\n    }\n  }, [eventTrackingContext, eventTrackingContextForkRef])\n\n  return {\n    show,\n    close,\n  }\n}\n"
  },
  {
    "path": "packages/triple-web/src/providers/app-install-cta-modal-provider.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport {\n  AppInstallCtaModalContext,\n  type AppInstallCtaModalContextValue,\n} from '../modal/app-install-cta-modal-context'\n\nexport type AppInstallCtaModalProviderProps =\n  PropsWithChildren<AppInstallCtaModalContextValue>\n\nexport function AppInstallCtaModalProvider({\n  children,\n  showOptions,\n}: AppInstallCtaModalProviderProps) {\n  return (\n    <AppInstallCtaModalContext.Provider value={{ showOptions }}>\n      {children}\n    </AppInstallCtaModalContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/providers/event-metadata-provider.tsx",
    "content": "'use client'\n\nimport { type PropsWithChildren, useContext } from 'react'\n\nimport { EventMetadataContext } from '../event-tracking/context'\nimport type { EventMetadataValue } from '../event-tracking/types'\n\nexport interface EventMetadataProviderProps extends PropsWithChildren {\n  eventMetadataContext?: EventMetadataValue\n}\n\nexport function EventMetadataProvider({\n  children,\n  eventMetadataContext,\n}: EventMetadataProviderProps) {\n  const parentContext = useContext(EventMetadataContext)\n\n  return (\n    <EventMetadataContext.Provider\n      value={{ ...parentContext, ...eventMetadataContext }}\n    >\n      {children}\n    </EventMetadataContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/providers/event-tracking-provider.tsx",
    "content": "'use client'\n\nimport { type PropsWithChildren, useEffect } from 'react'\nimport { setUserId } from 'firebase/analytics'\n\nimport { EventTrackingContext } from '../event-tracking/context'\nimport type { EventTrackingValue } from '../event-tracking/types'\nimport { getFirebaseAnalytics } from '../event-tracking/libs/firebase-analytics'\nimport { trackScreen } from '../event-tracking/utils/track-screen'\nimport { useSession } from '../session/use-session'\nimport { useTripleWebDeviceId } from '../event-tracking/use-triple-web-device-id'\n\nexport type EventTrackingProviderProps = EventTrackingValue & PropsWithChildren\n\nexport function EventTrackingProvider({\n  children,\n  page,\n  utm,\n  onError,\n}: EventTrackingProviderProps) {\n  const { user } = useSession()\n  const { tripleWebDeviceId, isLoading: isTripleWebDeviceIdLoading } =\n    useTripleWebDeviceId()\n\n  useEffect(() => {\n    const firebaseAnalytics = getFirebaseAnalytics()\n    if (firebaseAnalytics) {\n      setUserId(firebaseAnalytics, user?.uid ?? null)\n    }\n  }, [user?.uid])\n\n  useEffect(() => {\n    if (!isTripleWebDeviceIdLoading) {\n      trackScreen(\n        page.path,\n        page.label,\n        {\n          ...utm,\n          ...(tripleWebDeviceId && { nol_device_id: tripleWebDeviceId }),\n        },\n        { page, utm, onError },\n      )\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [page, tripleWebDeviceId, isTripleWebDeviceIdLoading])\n\n  return (\n    <EventTrackingContext.Provider value={{ page, utm, onError }}>\n      {children}\n    </EventTrackingContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/providers/index.ts",
    "content": "export * from './app-install-cta-modal-provider'\nexport * from './event-metadata-provider'\nexport * from './event-tracking-provider'\nexport * from './login-cta-modal-provider'\nexport * from './triple-web'\n"
  },
  {
    "path": "packages/triple-web/src/providers/login-cta-modal-provider.tsx",
    "content": "import { PropsWithChildren } from 'react'\n\nimport {\n  LoginCtaModalContext,\n  type LoginCtaModalContextValue,\n} from '../modal/login-cta-modal-context'\n\nexport type LoginCtaModalProviderProps =\n  PropsWithChildren<LoginCtaModalContextValue>\n\nexport function LoginCtaModalProvider({\n  children,\n  showOptions,\n}: LoginCtaModalProviderProps) {\n  return (\n    <LoginCtaModalContext.Provider value={{ showOptions }}>\n      {children}\n    </LoginCtaModalContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/providers/triple-web.tsx",
    "content": "'use client'\n\nimport { PropsWithChildren } from 'react'\n\nimport { ClientAppContext } from '../client-app/context'\nimport { EnvContext } from '../env/context'\nimport { I18nContext } from '../i18n/context'\nimport { SessionProvider } from '../session/context'\nimport { UserAgentContext } from '../user-agent/context'\nimport { HashRouterProvider } from '../hash-router/context'\nimport { ModalProvider } from '../modal/context'\nimport type { ClientAppValue } from '../client-app/types'\nimport type { EnvValue } from '../env/types'\nimport type { I18nValue } from '../i18n/types'\nimport type { SessionValue } from '../session/types'\nimport type { UserAgentValue } from '../user-agent/types'\nimport { LoginCtaModal } from '../modal/components/login-cta-modal'\nimport { AppInstallCtaModal } from '../modal/components/app-install-cta-modal'\n\nexport interface TripleWebProps extends PropsWithChildren {\n  clientAppProvider: ClientAppValue\n  envProvider: EnvValue\n  i18nProvider: I18nValue\n  sessionProvider: SessionValue\n  userAgentProvider: UserAgentValue\n}\n\nexport function TripleWeb({\n  children,\n  clientAppProvider,\n  envProvider,\n  i18nProvider,\n  sessionProvider,\n  userAgentProvider,\n}: TripleWebProps) {\n  return (\n    <ClientAppContext.Provider value={clientAppProvider}>\n      <EnvContext.Provider value={envProvider}>\n        <I18nContext.Provider value={i18nProvider}>\n          <SessionProvider initialSession={sessionProvider}>\n            <UserAgentContext.Provider value={userAgentProvider}>\n              <HashRouterProvider>\n                <ModalProvider>\n                  {children}\n                  <LoginCtaModal />\n                  <AppInstallCtaModal />\n                </ModalProvider>\n              </HashRouterProvider>\n            </UserAgentContext.Provider>\n          </SessionProvider>\n        </I18nContext.Provider>\n      </EnvContext.Provider>\n    </ClientAppContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/session/context.tsx",
    "content": "'use client'\n\nimport deepEqual from 'deep-equal'\nimport {\n  type Dispatch,\n  type PropsWithChildren,\n  type SetStateAction,\n  createContext,\n  useEffect,\n  useState,\n} from 'react'\n\nimport type { SessionValue } from './types'\n\nexport const SessionStateContext = createContext<SessionValue | undefined>(\n  undefined,\n)\nexport const SessionUpdaterContext = createContext<\n  Dispatch<SetStateAction<SessionValue>> | undefined\n>(undefined)\n\nexport interface SessionProviderProps extends PropsWithChildren {\n  initialSession: SessionValue\n}\n\nexport function SessionProvider({\n  children,\n  initialSession,\n}: SessionProviderProps) {\n  const [session, setSession] = useState(initialSession)\n\n  useEffect(() => {\n    if (!deepEqual(session, initialSession)) {\n      setSession(initialSession)\n    }\n  }, [initialSession])\n\n  return (\n    <SessionStateContext.Provider value={session}>\n      <SessionUpdaterContext.Provider value={setSession}>\n        {children}\n      </SessionUpdaterContext.Provider>\n    </SessionStateContext.Provider>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/session/index.ts",
    "content": "export * from './types'\nexport * from './use-login'\nexport * from './use-logout'\nexport * from './use-session-availability'\nexport * from './use-session-callback'\nexport * from './use-session'\n"
  },
  {
    "path": "packages/triple-web/src/session/types.tsx",
    "content": "export interface SessionUser {\n  name: string\n  provider: AuthProvider\n  country: string\n  lang: string\n  unregister: boolean | null\n  photo: string\n  mileage: Mileage\n  uid: string\n  email: string\n  nolConnected?: boolean\n  nolConnectedAt?: string\n}\n\nexport type AuthProvider =\n  | 'TRIPLE'\n  | 'NAVER'\n  | 'KAKAO'\n  | 'FACEBOOK'\n  | 'APPLE'\n  | 'INVALID'\n\ninterface Mileage {\n  badges: {\n    icon: {\n      image_url: string\n    }\n  }[]\n  level: number\n  point: number\n}\n\nexport interface SessionValue {\n  user: SessionUser | null\n}\n"
  },
  {
    "path": "packages/triple-web/src/session/use-login.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useLogin\" />\n\n# useLogin\n\n로그인 함수를 사용합니다.\n\n## Usage\n\n```tsx\nconst login = useLogin()\n```\n\n## Returns\n\n`(options?: LoginOptions) => void`\n"
  },
  {
    "path": "packages/triple-web/src/session/use-login.ts",
    "content": "import { useCallback, useContext } from 'react'\nimport qs from 'qs'\nimport { generateUrl } from '@titicaca/view-utilities'\n\nimport { ClientAppContext } from '../client-app/context'\nimport { useEnv } from '../env/use-env'\n\nexport interface LoginOptions {\n  returnUrl?: string\n}\n\n/**\n * 로그인 함수를 사용합니다.\n */\nexport function useLogin() {\n  const clientApp = useContext(ClientAppContext)\n  const env = useEnv()\n\n  return useCallback(\n    (options?: LoginOptions) => {\n      if (clientApp) {\n        handleClientApp(env.appUrlScheme)\n      } else {\n        handleBrowser(options?.returnUrl)\n      }\n    },\n    [clientApp, env.appUrlScheme],\n  )\n}\n\nfunction handleClientApp(appUrlScheme: string) {\n  const loginUrl = generateUrl({ scheme: appUrlScheme, path: '/login' })\n\n  window.location.href = loginUrl\n}\n\nfunction handleBrowser(returnUrl: string | undefined) {\n  const loginUrl = generateUrl({\n    path: '/login',\n    query: qs.stringify({\n      returnUrl:\n        returnUrl ?? window.location.href.replace(window.location.origin, ''),\n    }),\n  })\n\n  window.location.href = loginUrl\n}\n"
  },
  {
    "path": "packages/triple-web/src/session/use-logout.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useLogout\" />\n\n# useLogout\n\n로그아웃 함수를 사용합니다.\n\n## Usage\n\n```tsx\nconst logout = useLogout()\n```\n\n## Returns\n\n`() => void`\n"
  },
  {
    "path": "packages/triple-web/src/session/use-logout.ts",
    "content": "import { useCallback, useContext } from 'react'\nimport { authGuardedFetchers, captureHttpError } from '@titicaca/fetcher'\n\nimport { ClientAppContext } from '../client-app/context'\n\nimport { SessionUpdaterContext } from './context'\nimport { getRedirectUrl } from './utils/redirect'\n\n/**\n * 로그아웃 함수를 사용합니다.\n */\nexport function useLogout() {\n  const clientApp = useContext(ClientAppContext)\n  const setSession = useContext(SessionUpdaterContext)\n\n  if (setSession === undefined) {\n    throw new Error()\n  }\n\n  return useCallback(async () => {\n    setSession({ user: null })\n\n    if (clientApp) {\n      await handleClientApp()\n    } else {\n      await handleBrowser()\n    }\n  }, [clientApp, setSession])\n}\n\nfunction handleClientApp() {\n  return Promise.resolve()\n}\n\nasync function handleBrowser() {\n  const response = await authGuardedFetchers.put<{ redirectUrl: string }>(\n    '/api/users/logout',\n  )\n\n  if (response === 'NEED_LOGIN') {\n    return\n  }\n\n  const isNolConnectedUser =\n    response.ok && response.status === 200 && !!response.parsedBody.redirectUrl\n\n  if (isNolConnectedUser) {\n    const redirectLocation = response.parsedBody.redirectUrl\n\n    if (!redirectLocation) {\n      captureHttpError(response)\n      window.location.reload()\n      return\n    }\n\n    const redirectUrl = getRedirectUrl(redirectLocation)\n\n    window.location.href = redirectUrl\n    return\n  }\n\n  window.location.reload()\n}\n"
  },
  {
    "path": "packages/triple-web/src/session/use-session-availability.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useSessionAvailability\" />\n\n# useSessionAvailability\n\n세션이 유효한지 여부를 판별합니다.\n\n## Usage\n\n```tsx\nconst sessionAvailability = useSessionAvailability()\n```\n\n## Returns\n\n`boolean`\n"
  },
  {
    "path": "packages/triple-web/src/session/use-session-availability.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useSessionAvailability } from './use-session-availability'\nimport { SessionProvider } from './context'\n\ntest('session이 있는 경우 true를 반환해야 합니다.', () => {\n  const { result } = renderHook(() => useSessionAvailability(), {\n    wrapper: ({ children }) => (\n      <SessionProvider\n        initialSession={{\n          user: {\n            country: '',\n            email: '',\n            lang: '',\n            mileage: { badges: [], level: 0, point: 0 },\n            name: '',\n            photo: '',\n            provider: 'APPLE',\n            uid: '',\n            unregister: false,\n          },\n        }}\n      >\n        {children}\n      </SessionProvider>\n    ),\n  })\n\n  expect(result.current).toBe(true)\n})\n\ntest('session이 없는 경우 false를 반환해야 합니다.', () => {\n  const { result } = renderHook(() => useSessionAvailability(), {\n    wrapper: ({ children }) => (\n      <SessionProvider initialSession={{ user: null }}>\n        {children}\n      </SessionProvider>\n    ),\n  })\n\n  expect(result.current).toBe(false)\n})\n"
  },
  {
    "path": "packages/triple-web/src/session/use-session-availability.ts",
    "content": "import { useSession } from './use-session'\n\n/**\n * 세션이 유효한지 여부를 판별합니다.\n */\nexport function useSessionAvailability() {\n  const { user } = useSession()\n  return !!user\n}\n"
  },
  {
    "path": "packages/triple-web/src/session/use-session-callback.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useSessionCallback\" />\n\n# useSessionCallback\n\nsessionId가 있는 환경에서만 주어진 콜백을 실행하는 함수를 반환하는 훅.\nsessionId가 없으면 로그인 유도 모달을 띄웁니다.\n\n## Usage\n\n```tsx\nconst callback = useSessionCallback()\n```\n\n## Parameters\n\n### fn\n\n`T extends (...args: any[]) => any`\n\n## Returns\n\n`T`\n"
  },
  {
    "path": "packages/triple-web/src/session/use-session-callback.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport * as useLoginCtaModalModule from '../modal/use-login-cta-modal'\n\nimport { useSessionCallback } from './use-session-callback'\nimport * as useSessionAvailabilityModule from './use-session-availability'\nimport * as useLoginModule from './use-login'\n\njest.mock('../modal/use-login-cta-modal', () => ({\n  __esModule: true,\n  ...jest.requireActual('../modal/use-login-cta-modal'),\n}))\njest.mock('./use-session-availability', () => ({\n  __esModule: true,\n  ...jest.requireActual('./use-session-availability'),\n}))\njest.mock('./use-login', () => ({\n  __esModule: true,\n  ...jest.requireActual('./use-login'),\n}))\n\nconst mockFn = jest.fn()\nconst mockLogin = jest.fn()\nconst mockShow = jest.fn()\nconst mockClose = jest.fn()\nconst mockUseSessionAvailability = jest.spyOn(\n  useSessionAvailabilityModule,\n  'useSessionAvailability',\n)\nconst mockUseLogin = jest.spyOn(useLoginModule, 'useLogin')\nconst mockUseLoginCtaModal = jest.spyOn(\n  useLoginCtaModalModule,\n  'useLoginCtaModal',\n)\n\nbeforeEach(() => {\n  mockUseLogin.mockReturnValue(mockLogin)\n  mockUseLoginCtaModal.mockReturnValue({ show: mockShow, close: mockClose })\n})\n\nafterEach(() => {\n  jest.clearAllMocks()\n})\n\ntest('when user has not logged in, it returns undefined value', () => {\n  mockUseSessionAvailability.mockReturnValue(false)\n\n  const { result } = renderHook(() => useSessionCallback(mockFn))\n\n  const returnValue = result.current()\n\n  expect(mockShow).toHaveBeenCalled()\n  expect(returnValue).toBeUndefined()\n})\n\ntest('when user has not logged in, returns fallback value if provided', () => {\n  mockUseSessionAvailability.mockReturnValue(false)\n  const fallbackValue = true\n\n  const { result } = renderHook(() =>\n    useSessionCallback(mockFn, {\n      returnValue: fallbackValue,\n    }),\n  )\n\n  const returnValue = result.current()\n\n  expect(mockShow).toHaveBeenCalled()\n  expect(returnValue).toBe(fallbackValue)\n})\n\ntest('when user has logged in, returns the return value of fn', () => {\n  mockUseSessionAvailability.mockReturnValue(true)\n  const expectedReturnValue = 'expected'\n  mockFn.mockReturnValue(expectedReturnValue)\n\n  const { result } = renderHook(() => useSessionCallback(mockFn))\n\n  const returnValue = result.current()\n\n  expect(mockShow).not.toHaveBeenCalled()\n  expect(returnValue).toBe(expectedReturnValue)\n})\n"
  },
  {
    "path": "packages/triple-web/src/session/use-session-callback.ts",
    "content": "import { useCallback } from 'react'\n\nimport { useLoginCtaModal } from '../modal/use-login-cta-modal'\n\nimport { useSessionAvailability } from './use-session-availability'\nimport { useLogin } from './use-login'\n\n/**\n * sessionId가 있는 환경에서만 주어진 콜백을 실행하는 함수를 반환하는 훅\n * sessionId가 없으면 로그인 유도 모달을 띄웁니다.\n * @param fn\n * @param returnValue sessionId가 없을 때 리턴할 값\n * @param returnUrl 로그인 완료 후 복귀할 페이지 주소\n * @param triggeredEventAction 로그인 유도 모달 팝업을 발생시킨 이벤트 액션\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function useSessionCallback<T extends (...args: any[]) => any>(\n  fn: T,\n  ...options:\n    | [boolean | undefined, string]\n    | [boolean]\n    | [\n        {\n          returnUrl?: string\n          returnValue?: boolean\n          skipAppInstallCtaModal?: boolean\n          triggeredEventAction?: string\n        },\n      ]\n    | []\n): (...args: Parameters<T>) => ReturnType<T> | boolean | void {\n  const sessionAvailable = useSessionAvailability()\n  const login = useLogin()\n  const { show } = useLoginCtaModal()\n\n  return useCallback(\n    (...args) => {\n      if (sessionAvailable === false) {\n        if (typeof options[0] === 'object') {\n          const {\n            returnUrl,\n            returnValue,\n            skipAppInstallCtaModal,\n            triggeredEventAction,\n          } = options[0]\n\n          if (skipAppInstallCtaModal) {\n            login({ returnUrl })\n          } else {\n            show({ returnUrl, triggeredEventAction })\n          }\n\n          return returnValue\n        } else {\n          const [returnValue, returnUrl] = options\n\n          show({ returnUrl })\n\n          return returnValue\n        }\n      }\n      return fn(...args)\n    },\n    [fn, login, options, sessionAvailable, show],\n  )\n}\n"
  },
  {
    "path": "packages/triple-web/src/session/use-session.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useSession\" />\n\n# useSession\n\nSessionContext 값을 가져옵니다.\n\n## Usage\n\n```tsx\nconst session = useSession()\n```\n\n## Returns\n\n`SessionContext`\n"
  },
  {
    "path": "packages/triple-web/src/session/use-session.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useSession } from './use-session'\nimport { SessionProvider } from './context'\nimport { SessionValue } from './types'\n\ntest('SessionContext가 정의된 경우 값을 반환해야 합니다.', () => {\n  const mockValue: SessionValue = {\n    user: null,\n  }\n\n  const { result } = renderHook(() => useSession(), {\n    wrapper: ({ children }) => (\n      <SessionProvider initialSession={{ user: null }}>\n        {children}\n      </SessionProvider>\n    ),\n  })\n\n  expect(result.current).toBe(mockValue)\n})\n\ntest('SessionContext가 정의되지 않은 경우 오류를 발생시켜야 합니다.', () => {\n  expect(() => {\n    renderHook(() => useSession())\n  }).toThrow('SessionContext가 없습니다.')\n})\n"
  },
  {
    "path": "packages/triple-web/src/session/use-session.ts",
    "content": "import { useContext } from 'react'\n\nimport { SessionStateContext } from './context'\n\n/**\n * SessionContext 값을 가져옵니다.\n */\nexport function useSession() {\n  const context = useContext(SessionStateContext)\n\n  if (context === undefined) {\n    throw new Error('SessionContext가 없습니다.')\n  }\n\n  return context\n}\n"
  },
  {
    "path": "packages/triple-web/src/session/utils/redirect.spec.ts",
    "content": "import { getRedirectUrl } from './redirect'\n\ndescribe('getRedirectUrl', () => {\n  const mockUrl = 'https://triple.guide'\n\n  Object.defineProperty(window, 'location', {\n    value: new URL(mockUrl),\n  })\n\n  it('should return redirect url with redirect query string', () => {\n    const result = getRedirectUrl('https://example.com')\n    expect(result).toBe(\n      `https://example.com?redirectUrl=${encodeURIComponent(\n        window.location.href,\n      )}`,\n    )\n  })\n\n  it('should return redirect url with redirect query string and original href query string', () => {\n    const result = getRedirectUrl('https://example.com?code=123')\n    expect(result).toBe(\n      `https://example.com?code=123&redirectUrl=${encodeURIComponent(\n        window.location.href,\n      )}`,\n    )\n  })\n})\n"
  },
  {
    "path": "packages/triple-web/src/session/utils/redirect.ts",
    "content": "import { generateUrl } from '@titicaca/view-utilities'\nimport qs from 'qs'\n\nexport function getRedirectUrl(href: string) {\n  const currentUrl = decodeURI(window.location.href)\n\n  const redirectUrl = generateUrl(\n    {\n      query: qs.stringify({\n        redirectUrl: currentUrl,\n      }),\n    },\n    href,\n  )\n\n  return redirectUrl\n}\n"
  },
  {
    "path": "packages/triple-web/src/user-agent/context.tsx",
    "content": "'use client'\n\nimport { createContext } from 'react'\n\nimport type { UserAgentValue } from './types'\n\nexport const UserAgentContext = createContext<UserAgentValue | undefined>(\n  undefined,\n)\n"
  },
  {
    "path": "packages/triple-web/src/user-agent/index.ts",
    "content": "export * from './types'\nexport * from './use-user-agent'\n"
  },
  {
    "path": "packages/triple-web/src/user-agent/types.ts",
    "content": "import type { IResult } from 'ua-parser-js'\n\nexport type UserAgentValue = IResult & { isMobile: boolean }\n"
  },
  {
    "path": "packages/triple-web/src/user-agent/use-user-agent.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web / useUserAgent\" />\n\n# useUserAgent\n\nUserAgentContext 값을 가져옵니다.\n\n## Usage\n\n```tsx\nconst userAgent = useUserAgent()\n```\n\n## Returns\n\n`UserAgentContext`\n"
  },
  {
    "path": "packages/triple-web/src/user-agent/use-user-agent.test.tsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useUserAgent } from './use-user-agent'\nimport { UserAgentContext } from './context'\nimport { UserAgentValue } from './types'\n\ntest('UserAgentContext가 정의된 경우 값을 반환해야 합니다.', () => {\n  const mockValue: UserAgentValue = {\n    browser: { name: 'Chrome', version: '91.0.4472.124', major: undefined },\n    cpu: { architecture: 'amd64' },\n    device: { model: undefined, type: 'desktop', vendor: undefined },\n    engine: { name: 'Blink', version: '91.0.4472.124' },\n    isMobile: false,\n    os: { name: 'Windows', version: '10.0' },\n    ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',\n  }\n\n  const { result } = renderHook(() => useUserAgent(), {\n    wrapper: ({ children }) => (\n      <UserAgentContext.Provider value={mockValue}>\n        {children}\n      </UserAgentContext.Provider>\n    ),\n  })\n\n  expect(result.current).toBe(mockValue)\n})\n\ntest('UserAgentContext가 정의되지 않은 경우 오류를 발생시켜야 합니다.', () => {\n  expect(() => {\n    renderHook(() => useUserAgent())\n  }).toThrow('UserAgentContext가 없습니다.')\n})\n"
  },
  {
    "path": "packages/triple-web/src/user-agent/use-user-agent.ts",
    "content": "import { useContext } from 'react'\n\nimport { UserAgentContext } from './context'\n\n/**\n * UserAgentContext 값을 가져옵니다.\n */\nexport function useUserAgent() {\n  const context = useContext(UserAgentContext)\n\n  if (context === undefined) {\n    throw new Error('UserAgentContext가 없습니다.')\n  }\n\n  return context\n}\n"
  },
  {
    "path": "packages/triple-web/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/triple-web/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/triple-web/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/triple-web-nextjs/package.json",
    "content": "{\n  \"name\": \"@titicaca/triple-web-nextjs\",\n  \"version\": \"14.2.3\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/triple-web-nextjs\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/constants\": \"workspace:*\",\n    \"@titicaca/fetcher\": \"workspace:*\",\n    \"@titicaca/triple-web-utils\": \"workspace:*\",\n    \"server-only\": \"^0.0.1\",\n    \"ua-parser-js\": \"^1.0.40\"\n  },\n  \"devDependencies\": {\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"@types/ua-parser-js\": \"^0.7.39\",\n    \"next\": \"^14.2.24\",\n    \"react\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/triple-web\": \"*\",\n    \"next\": \"^13.4 || ^14.0\",\n    \"react\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/index.ts",
    "content": "export * from './initializers'\nexport * from './providers'\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/initializers/client-app.ts",
    "content": "import 'server-only'\n\nimport { headers } from 'next/headers'\nimport { ClientAppName, type ClientAppValue } from '@titicaca/triple-web'\nimport { clientAppRegex, macAppRegex } from '@titicaca/triple-web-utils'\n\nexport function getClientApp(): ClientAppValue {\n  const headersList = headers()\n\n  const userAgent = headersList.get('user-agent') ?? ''\n  const metadata = clientAppRegex.exec(userAgent)\n\n  if (!metadata) {\n    return null\n  }\n\n  const autoplay =\n    (headersList.get('x-triple-autoplay') as\n      | NonNullable<ClientAppValue>['device']['autoplay']\n      | null) ?? 'always'\n  const networkType =\n    (headersList.get('x-triple-network-type') as\n      | NonNullable<ClientAppValue>['device']['networkType']\n      | null) ?? 'unknown'\n\n  return {\n    metadata: {\n      name:\n        metadata[1] === 'Triple-Android'\n          ? ClientAppName.Android\n          : ClientAppName.iOS,\n      version: metadata[2],\n      isMacApp: macAppRegex.test(userAgent),\n    },\n    device: {\n      autoplay,\n      networkType,\n    },\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/initializers/index.ts",
    "content": "import type { TripleWebProps } from '../providers'\n\nimport { getClientApp } from './client-app'\nimport { getSession } from './session'\nimport { getUserAgent } from './user-agent'\n\nexport type BuildTripleWebPropsResult = Omit<\n  TripleWebProps,\n  'children' | 'envProvider' | 'i18nProvider'\n>\n\nexport async function buildTripleWebProps(): Promise<BuildTripleWebPropsResult> {\n  return {\n    clientAppProvider: getClientApp(),\n    sessionProvider: await getSession(),\n    userAgentProvider: getUserAgent(),\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/initializers/session.ts",
    "content": "import 'server-only'\n\nimport { cookies, headers } from 'next/headers'\nimport {\n  ssrFetcherize,\n  captureHttpError,\n  authFetcherize,\n  post,\n  get,\n} from '@titicaca/fetcher'\nimport type { SessionUser, SessionValue } from '@titicaca/triple-web'\nimport { GET_USER_REQUEST_URL } from '@titicaca/triple-web-utils'\nimport { SESSION_KEY, TP_TK } from '@titicaca/constants'\n\n/**\n * - app: refresh X\n * - browser: refresh O\n * @returns\n */\nexport async function getSession(): Promise<SessionValue> {\n  const hasSession = checkSession()\n\n  if (!hasSession) {\n    return {\n      user: null,\n    }\n  }\n\n  const user = await fetchUser()\n\n  return {\n    user,\n  }\n}\n\nfunction checkSession() {\n  const cookiesList = cookies()\n\n  return cookiesList.has(TP_TK) || cookiesList.has(SESSION_KEY)\n}\n\nasync function fetchUser() {\n  const headersList = headers()\n\n  const ssrFetcherizeOptions = {\n    apiUriBase: process.env.API_URI_BASE || '',\n    cookie: headersList.get('cookie') ?? undefined,\n  }\n\n  const finalFetcher = authFetcherize(\n    ssrFetcherize(get, ssrFetcherizeOptions),\n    {\n      refresh: () =>\n        ssrFetcherize(\n          post,\n          ssrFetcherizeOptions,\n        )('/api/users/web-session/token'),\n    },\n  )\n  const response = await finalFetcher<SessionUser>(GET_USER_REQUEST_URL)\n\n  if (response === 'NEED_LOGIN') {\n    return null\n  }\n\n  captureHttpError(response)\n\n  if (response.ok === false) {\n    return null\n  }\n\n  return response.parsedBody\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/initializers/user-agent.ts",
    "content": "import 'server-only'\n\nimport { headers } from 'next/headers'\nimport UAParser from 'ua-parser-js'\nimport type { UserAgentValue } from '@titicaca/triple-web'\nimport { isMobile } from '@titicaca/triple-web-utils'\n\nexport function getUserAgent(): UserAgentValue {\n  const headersList = headers()\n  const userAgent = headersList.get('user-agent')\n  const parser = new UAParser(userAgent ?? undefined)\n  const result = parser.getResult()\n\n  return {\n    ...result,\n    isMobile: isMobile(result),\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/app-install-cta-modal-provider.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web-nextjs / AppInstallCtaModalProvider\" />\n\n# AppInstallCtaModalProvider\n\n## Usage\n\n```tsx\nexport function Page({ children }) {\n  return (\n    <AppInstallCtaModalProvider\n      showOptions={{\n        deepLink: 'triple:///deeplink',\n        triggeredEventAction: 'action',\n        onActionClick,\n      }}\n    >\n      {children}\n    </AppInstallCtaModalProvider>\n  )\n}\n```\n\n## Props\n\n### children\n\n`ReactNode`\n\n### showOptions\n\n`ShowOptions`\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/app-install-cta-modal-provider.tsx",
    "content": "'use client'\n\nexport {\n  AppInstallCtaModalProvider,\n  type AppInstallCtaModalProviderProps,\n} from '@titicaca/triple-web'\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/event-metadata-provider.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web-nextjs / EventMetadatProvider\" />\n\n# EventMetadatProvider\n\n## Usage\n\n```tsx\nexport function Page({ children }) {\n  return (\n    <EventMetadataProvider\n      eventMetadataContext={{\n        userId: 'userId',\n      }}\n    >\n      {children}\n    </EventMetadataProvider>\n  )\n}\n```\n\n## Props\n\n### children\n\n`ReactNode`\n\n### eventMetadataContext\n\n`EventMetadataValue`\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/event-metadata-provider.tsx",
    "content": "'use client'\n\nexport {\n  EventMetadataProvider,\n  type EventMetadataProviderProps,\n} from '@titicaca/triple-web'\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/event-tracking-provider.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web-nextjs / EventTrackingProvider\" />\n\n# EventTrackingProvider\n\n## Usage\n\n```tsx\nexport function Page({ children }) {\n  return (\n    <EventTrackingProvider\n      page={{ label: '페이지 이름', path: 'page-path' }}\n      onError={logError}\n    >\n      {children}\n    </EventTrackingProvider>\n  )\n}\n```\n\n## Props\n\n### children\n\n`ReactNode`\n\n### page\n\n**Required**\n\n`{ label: string; path: string }`\n\n`EventTrackingUtmValue`\n\n### onError\n\n`(error: Error) => void`\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/event-tracking-provider.tsx",
    "content": "'use client'\n\nimport {\n  EventTrackingProvider as EventTrackingProviderBase,\n  type EventTrackingUtmValue,\n  type EventTrackingProviderProps as EventTrackingProviderBaseProps,\n} from '@titicaca/triple-web'\nimport { useSearchParams } from 'next/navigation'\n\nexport function getEventTrackingUtm(\n  searchParams: URLSearchParams,\n): EventTrackingUtmValue {\n  return {\n    source:\n      searchParams.get('utm_source') ||\n      searchParams.get('utmSource') ||\n      undefined,\n    medium:\n      searchParams.get('utm_medium') ||\n      searchParams.get('utmMedium') ||\n      undefined,\n    campaign:\n      searchParams.get('utm_campaign') ||\n      searchParams.get('utmCampaign') ||\n      undefined,\n    term:\n      searchParams.get('utm_term') || searchParams.get('utmTerm') || undefined,\n    content:\n      searchParams.get('utm_content') ||\n      searchParams.get('utmContent') ||\n      undefined,\n    partner: searchParams.get('prt') || undefined,\n  }\n}\n\nexport type EventTrackingProviderProps = Omit<\n  EventTrackingProviderBaseProps,\n  'utm'\n>\n\nexport function EventTrackingProvider({\n  children,\n  page,\n  onError,\n}: EventTrackingProviderProps) {\n  const searchParams = useSearchParams()\n\n  return (\n    <EventTrackingProviderBase\n      page={page}\n      utm={getEventTrackingUtm(searchParams)}\n      onError={onError}\n    >\n      {children}\n    </EventTrackingProviderBase>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/index.ts",
    "content": "export * from './app-install-cta-modal-provider'\nexport * from './event-metadata-provider'\nexport * from './event-tracking-provider'\nexport * from './login-cta-modal-provider'\nexport * from './triple-web'\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/login-cta-modal-provider.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web-nextjs / LoginCtaModalProvider\" />\n\n# LoginCtaModalProvider\n\n## Usage\n\n```tsx\nexport function Page({ children }) {\n  return (\n    <LoginCtaModalProvider\n      showOptions={{\n        returnUrl: 'https://returnurl.example'\n        triggeredEventAction: 'action'\n      }}\n    >\n      {children}\n    </LoginCtaModalProvider>\n  )\n}\n```\n\n## Props\n\n### children\n\n`ReactNode`\n\n### showOptions\n\n`ShowOptions`\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/login-cta-modal-provider.tsx",
    "content": "'use client'\n\nexport {\n  LoginCtaModalProvider,\n  type LoginCtaModalProviderProps,\n} from '@titicaca/triple-web'\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/triple-web.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web-nextjs / TripleWeb\" />\n\n# TripleWeb\n\n## Usage\n\n```tsx\n// app/layout.tsx\nimport { TripleWeb, buildTripleWebProps } from '@titicaca/triple-web-nextjs'\nimport i18n from '../src/i18n'\n\nexport default async function RootLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <html>\n      <body>\n        <TripleWeb\n          {...(await buildTripleWebProps())}\n          envProvider={{ ... }}\n          i18nProvider={{ ... }\n        >\n          {children}\n        </TripleWeb>\n      </body>\n    </html>\n  )\n}\n```\n\n## Props\n\n### children\n\n`ReactNode`\n\n### clientAppProvider\n\n`ClientAppValue`\n\n**Required**\n\n### envProvider\n\n`EnvValue`\n\n**Required**\n\n### i18nProvider\n\n`I18nValue`\n\n**Required**\n\n### sessionProvider\n\n`UserAgentValue`\n\n**Required**\n\n### userAgentProvider\n\n`UserAgentValue`\n\n**Required**\n"
  },
  {
    "path": "packages/triple-web-nextjs/src/providers/triple-web.tsx",
    "content": "import {\n  TripleWeb as TripleWebBase,\n  type TripleWebProps as TripleWebBaseProps,\n} from '@titicaca/triple-web'\n\nexport type TripleWebProps = TripleWebBaseProps\n\nexport function TripleWeb({\n  children,\n  clientAppProvider,\n  envProvider,\n  i18nProvider,\n  sessionProvider,\n  userAgentProvider,\n}: TripleWebProps) {\n  return (\n    <TripleWebBase\n      clientAppProvider={clientAppProvider}\n      envProvider={envProvider}\n      i18nProvider={i18nProvider}\n      sessionProvider={sessionProvider}\n      userAgentProvider={userAgentProvider}\n    >\n      {children}\n    </TripleWebBase>\n  )\n}\n\nTripleWeb.displayName = 'TripleWebNextjs'\n"
  },
  {
    "path": "packages/triple-web-nextjs/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/package.json",
    "content": "{\n  \"name\": \"@titicaca/triple-web-nextjs-pages\",\n  \"version\": \"14.2.3\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/triple-web-nextjs-pages\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\",\n    \"middleware.d.ts\",\n    \"middleware.js\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/constants\": \"workspace:*\",\n    \"@titicaca/fetcher\": \"workspace:*\",\n    \"@titicaca/triple-web-utils\": \"workspace:*\",\n    \"@titicaca/view-utilities\": \"workspace:*\",\n    \"qs\": \"^6.14.0\",\n    \"ua-parser-js\": \"^1.0.40\",\n    \"universal-cookie\": \"^8.0.1\"\n  },\n  \"devDependencies\": {\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"@types/qs\": \"^6.9.18\",\n    \"@types/ua-parser-js\": \"^0.7.39\",\n    \"next\": \"^14.2.24\",\n    \"react\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/triple-web\": \"*\",\n    \"next\": \"^13.4 || ^14.0\",\n    \"react\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/helpers/client-app.ts",
    "content": "import { ClientAppName, type ClientAppValue } from '@titicaca/triple-web'\nimport { clientAppRegex, macAppRegex } from '@titicaca/triple-web-utils'\n\ntype AutoplayOption = NonNullable<ClientAppValue>['device']['autoplay']\n\nfunction isValidAutoplayOption(value: string): value is AutoplayOption {\n  const validOptions: AutoplayOption[] = ['always', 'wifi_only', 'never']\n  return validOptions.includes(value as AutoplayOption)\n}\n\nfunction getAutoplayValue(value: string): AutoplayOption {\n  if (isValidAutoplayOption(value)) {\n    return value\n  } else {\n    return 'always'\n  }\n}\n\ntype NetworkTypeOption = NonNullable<ClientAppValue>['device']['networkType']\n\nfunction isValidNetworkTypeOption(value: string): value is NetworkTypeOption {\n  const validOptions: NetworkTypeOption[] = ['wifi', 'cellular', 'unknown']\n  return validOptions.includes(value as NetworkTypeOption)\n}\n\nfunction getNetwork(value: string): NetworkTypeOption {\n  if (isValidNetworkTypeOption(value)) {\n    return value\n  } else {\n    return 'unknown'\n  }\n}\n\ninterface Params {\n  userAgent: string | undefined\n  autoplay: string | string[] | undefined\n  networkType: string | string[] | undefined\n}\n\nexport function extractClientApp({\n  userAgent,\n  autoplay,\n  networkType,\n}: Params): ClientAppValue {\n  const metadata = userAgent ? clientAppRegex.exec(userAgent) : null\n\n  if (!metadata) {\n    return null\n  }\n\n  return {\n    metadata: {\n      name:\n        metadata[1] === 'Triple-Android'\n          ? ClientAppName.Android\n          : ClientAppName.iOS,\n      version: metadata[2],\n      isMacApp: userAgent ? macAppRegex.test(userAgent) : false,\n    },\n    device: {\n      autoplay: getAutoplayValue(\n        Array.isArray(autoplay) ? autoplay[0] : (autoplay ?? ''),\n      ),\n      networkType: getNetwork(\n        Array.isArray(networkType) ? networkType[0] : (networkType ?? ''),\n      ),\n    },\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/helpers/session.ts",
    "content": "import type { IncomingMessage } from 'http'\n\nimport Cookies from 'universal-cookie'\nimport { SESSION_KEY, TP_TK } from '@titicaca/constants'\n\nexport function checkSession(req: IncomingMessage) {\n  const cookies = new Cookies(req.headers.cookie)\n\n  return !!cookies.get(TP_TK) || !!cookies.get(SESSION_KEY)\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/helpers/user-agent.ts",
    "content": "import type { UserAgentValue } from '@titicaca/triple-web'\nimport { isMobile } from '@titicaca/triple-web-utils'\nimport { UAParser } from 'ua-parser-js'\n\ninterface Params {\n  userAgent: string | undefined\n}\n\nexport function extractUserAgent({ userAgent }: Params): UserAgentValue {\n  const parser = new UAParser(userAgent ?? undefined)\n  const result = parser.getResult()\n\n  return {\n    ...result,\n    isMobile: isMobile(result),\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/index.ts",
    "content": "export * from './initializers'\nexport * from './providers'\nexport * from './ssr-utils'\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/initializers/client-app.ts",
    "content": "import type { NextPageContext } from 'next'\nimport { type ClientAppValue } from '@titicaca/triple-web'\n\nimport { extractClientApp } from '../helpers/client-app'\n\nexport function getClientApp(ctx: NextPageContext): ClientAppValue {\n  const userAgent = ctx.req\n    ? ctx.req.headers['user-agent']\n    : window.navigator.userAgent\n\n  const autoplay = ctx.req?.headers['x-triple-autoplay']\n  const networkType = ctx.req?.headers['x-triple-network-type']\n\n  return extractClientApp({ autoplay, networkType, userAgent })\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/initializers/index.ts",
    "content": "import { NextPageContext } from 'next'\n\nimport type { TripleWebProps } from '../providers/triple-web'\n\nimport { getClientApp } from './client-app'\nimport { getSession } from './session'\nimport { getUserAgent } from './user-agent'\n\nexport type BuildTripleWebPropsResult = Omit<\n  TripleWebProps,\n  'children' | 'envProvider' | 'i18nProvider'\n>\n\nexport async function buildTripleWebProps(\n  ctx: NextPageContext,\n): Promise<BuildTripleWebPropsResult> {\n  return {\n    clientAppProvider: getClientApp(ctx),\n    sessionProvider: await getSession(ctx),\n    userAgentProvider: getUserAgent(ctx),\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/initializers/session.ts",
    "content": "import type { NextPageContext } from 'next'\nimport type { SessionUser, SessionValue } from '@titicaca/triple-web'\nimport {\n  ssrFetcherize,\n  captureHttpError,\n  authFetcherize,\n  post,\n  get,\n} from '@titicaca/fetcher'\nimport { GET_USER_REQUEST_URL } from '@titicaca/triple-web-utils'\n\nimport { checkSession } from '../helpers/session'\n\n/**\n * @returns\n */\nexport async function getSession(ctx: NextPageContext): Promise<SessionValue> {\n  const user = await fetchUser(ctx)\n\n  return {\n    user,\n  }\n}\n\nasync function fetchUser(ctx: NextPageContext) {\n  if (ctx.req) {\n    // Server-side\n\n    const hasSession = checkSession(ctx.req)\n\n    // 세션이 없으면 fetch를 스킵합니다.\n    if (!hasSession) {\n      return null\n    }\n\n    // fetch 시작\n    const ssrFetcherizeOptions = {\n      apiUriBase: process.env.API_URI_BASE || '',\n      cookie: ctx.req.headers.cookie,\n    }\n\n    const finalFetcher = authFetcherize(\n      ssrFetcherize(get, ssrFetcherizeOptions),\n      {\n        refresh: () =>\n          ssrFetcherize(\n            post,\n            ssrFetcherizeOptions,\n          )('/api/users/web-session/token'),\n      },\n    )\n\n    const response = await finalFetcher<SessionUser>(GET_USER_REQUEST_URL)\n\n    if (response === 'NEED_LOGIN') {\n      return null\n    }\n\n    captureHttpError(response)\n\n    if (response.ok === false) {\n      return null\n    }\n\n    return response.parsedBody\n  } else {\n    // Client-side\n\n    const finalFetcher = authFetcherize(get, {\n      refresh: () => post('/api/users/web-session/token'),\n    })\n\n    const response = await finalFetcher<SessionUser>(GET_USER_REQUEST_URL)\n\n    if (response === 'NEED_LOGIN') {\n      return null\n    }\n\n    captureHttpError(response)\n\n    if (response.ok === false) {\n      return null\n    }\n\n    return response.parsedBody\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/initializers/user-agent.ts",
    "content": "import type { NextPageContext } from 'next'\nimport type { UserAgentValue } from '@titicaca/triple-web'\n\nimport { extractUserAgent } from '../helpers/user-agent'\n\nexport function getUserAgent(ctx: NextPageContext): UserAgentValue {\n  const userAgent = ctx.req\n    ? ctx.req.headers['user-agent']\n    : window.navigator.userAgent\n\n  return extractUserAgent({ userAgent })\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/app-install-cta-modal-provider.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web-nextjs-pages / AppInstallCtaModalProvider\" />\n\n# AppInstallCtaModalProvider\n\n## Usage\n\n```tsx\nexport function Page({ children }) {\n  return (\n    <AppInstallCtaModalProvider\n      showOptions={{\n        deepLink: 'triple:///deeplink',\n        triggeredEventAction: 'action',\n        onActionClick,\n      }}\n    >\n      {children}\n    </AppInstallCtaModalProvider>\n  )\n}\n```\n\n## Props\n\n### children\n\n`ReactNode`\n\n### showOptions\n\n`ShowOptions`\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/app-install-cta-modal-provider.tsx",
    "content": "export {\n  AppInstallCtaModalProvider,\n  type AppInstallCtaModalProviderProps,\n} from '@titicaca/triple-web'\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/event-metadata-provider.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web-nextjs-pages / EventMetadatProvider\" />\n\n# EventMetadatProvider\n\n## Usage\n\n```tsx\nexport function Page({ children }) {\n  return (\n    <EventMetadataProvider\n      eventMetadataContext={{\n        userId: 'userId',\n      }}\n    >\n      {children}\n    </EventMetadataProvider>\n  )\n}\n```\n\n## Props\n\n### children\n\n`ReactNode`\n\n### eventMetadataContext\n\n`EventMetadataValue`\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/event-metadata-provider.tsx",
    "content": "export {\n  EventMetadataProvider,\n  type EventMetadataProviderProps,\n} from '@titicaca/triple-web'\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/event-tracking-provider.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web-nextjs-pages / EventTrackingProvider\" />\n\n# EventTrackingProvider\n\n## Usage\n\n```tsx\nexport function Page({ children }) {\n  return (\n    <EventTrackingProvider\n      page={{ label: '페이지 이름', path: 'page-path' }}\n      onError={logError}\n    >\n      {children}\n    </EventTrackingProvider>\n  )\n}\n```\n\n## Props\n\n### children\n\n`ReactNode`\n\n### page\n\n**Required**\n\n`{ label: string; path: string }`\n\n`EventTrackingUtmValue`\n\n### onError\n\n`(error: Error) => void`\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/event-tracking-provider.tsx",
    "content": "/* eslint-disable camelcase */\nimport {\n  EventTrackingProvider as EventTrackingProviderBase,\n  type EventTrackingUtmValue,\n  type EventTrackingProviderProps as EventTrackingProviderPropsBase,\n} from '@titicaca/triple-web'\nimport { useRouter } from 'next/router'\nimport { strictQuery } from '@titicaca/view-utilities'\n\nfunction getEventTrackingUtm(\n  query: Record<string, string | string[] | undefined>,\n): EventTrackingUtmValue {\n  const {\n    utm_source,\n    utm_medium,\n    utm_campaign,\n    utm_term,\n    utm_content,\n    utmSource,\n    utmMedium,\n    utmCampaign,\n    utmTerm,\n    utmContent,\n    prt,\n  } = strictQuery(query)\n    .string('utm_source')\n    .string('utm_medium')\n    .string('utm_campaign')\n    .string('utm_term')\n    .string('utm_content')\n    .string('utmSource')\n    .string('utmMedium')\n    .string('utmCampaign')\n    .string('utmTerm')\n    .string('utmContent')\n    .string('prt')\n    .use()\n\n  return {\n    source: utm_source || utmSource,\n    medium: utm_medium || utmMedium,\n    campaign: utm_campaign || utmCampaign,\n    term: utm_term || utmTerm,\n    content: utm_content || utmContent,\n    partner: prt,\n  }\n}\n\nexport type EventTrackingProviderProps = Omit<\n  EventTrackingProviderPropsBase,\n  'utm'\n>\n\nexport function EventTrackingProvider({\n  children,\n  page,\n  onError,\n}: EventTrackingProviderProps) {\n  const router = useRouter()\n\n  return (\n    <EventTrackingProviderBase\n      page={page}\n      utm={getEventTrackingUtm(router.query)}\n      onError={onError}\n    >\n      {children}\n    </EventTrackingProviderBase>\n  )\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/index.ts",
    "content": "export * from './app-install-cta-modal-provider'\nexport * from './event-metadata-provider'\nexport * from './event-tracking-provider'\nexport * from './login-cta-modal-provider'\nexport * from './triple-web'\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/login-cta-modal-provider.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web-nextjs-pages / LoginCtaModalProvider\" />\n\n# LoginCtaModalProvider\n\n## Usage\n\n```tsx\nexport function Page({ children }) {\n  return (\n    <LoginCtaModalProvider\n      showOptions={{\n        returnUrl: 'https://returnurl.example'\n        triggeredEventAction: 'action'\n      }}\n    >\n      {children}\n    </LoginCtaModalProvider>\n  )\n}\n```\n\n## Props\n\n### children\n\n`ReactNode`\n\n### showOptions\n\n`ShowOptions`\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/login-cta-modal-provider.tsx",
    "content": "export {\n  LoginCtaModalProvider,\n  type LoginCtaModalProviderProps,\n} from '@titicaca/triple-web'\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/triple-web.mdx",
    "content": "import { Meta, ArgTypes } from '@storybook/blocks'\n\n<Meta title=\"triple-web-nextjs-pages / TripleWeb\" />\n\n# TripleWeb\n\n## Usage\n\n```tsx\n// pages/_app.tsx\nimport { TripleWeb, buildTripleWebProps, BuildTripleWebPropsResult } from '@titicaca/triple-web-nextjs-pages'\n\nexport default function MyApp({\n  Component,\n  pageProps,\n  clientAppProvider,\n  sessionProvider,\n  userAgentProvider,\n}: AppProps & BuildTripleWebPropsResult) {\n  return (\n    <TripleWeb\n      clientAppProvider={clientAppProvider}\n      envProvider={{ ... }}\n      i18nProvider={{ ... }}\n      sessionProvider={sessionProvider}\n      userAgentProvider={userAgentProvider}\n    >\n      <Component {...pageProps} />\n    </TripleWeb>\n  )\n}\n\nMyApp.getInitialProps = async (\n  appContext: AppContext\n): Promise<AppInitialProps & BuildTripleWebPropsResult> => {\n  const { ctx } = appContext\n\n  const appInitialProps = await App.getInitialProps(appContext)\n\n  return {\n    ...appInitialProps,\n    ...(await buildTripleWebProps(ctx)),\n  }\n}\n```\n\n## Props\n\n### children\n\n`ReactNode`\n\n### clientAppProvider\n\n`ClientAppValue`\n\n**Required**\n\n### envProvider\n\n`EnvValue`\n\n**Required**\n\n### i18nProvider\n\n`I18nValue`\n\n**Required**\n\n### sessionProvider\n\n`UserAgentValue`\n\n**Required**\n\n### userAgentProvider\n\n`UserAgentValue`\n\n**Required**\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/providers/triple-web.tsx",
    "content": "import {\n  TripleWeb as TripleWebBase,\n  type TripleWebProps as TripleWebBaseProps,\n} from '@titicaca/triple-web'\n\nexport type TripleWebProps = TripleWebBaseProps\n\nexport function TripleWeb({\n  children,\n  clientAppProvider,\n  envProvider,\n  i18nProvider,\n  sessionProvider,\n  userAgentProvider,\n}: TripleWebProps) {\n  return (\n    <TripleWebBase\n      clientAppProvider={clientAppProvider}\n      envProvider={envProvider}\n      i18nProvider={i18nProvider}\n      sessionProvider={sessionProvider}\n      userAgentProvider={userAgentProvider}\n    >\n      {children}\n    </TripleWebBase>\n  )\n}\n\nTripleWeb.displayName = 'TripleWebNextjsPages'\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/ssr-utils/auth-guard.test.ts",
    "content": "import { get, HttpResponse } from '@titicaca/fetcher'\nimport { generateUrl } from '@titicaca/view-utilities'\n\nimport { authGuard } from './auth-guard'\n\nconst validMemberCookie = 'VALID_MEMBER_COOKIE'\nconst validNonMemberCookie = 'VALID_NON_MEMBER_COOKIE'\nconst invalidCookie = 'INVALID_COOKIE'\n\nconst browserUserAgent =\n  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36'\nconst appUserAgent =\n  'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148;Triple-iOS/5.4.0'\n\njest.mock('@titicaca/fetcher')\nconst mockedGet = (get as jest.MockedFunction<typeof get>).mockImplementation(\n  (href, options) => {\n    if (href !== '/api/users/me') {\n      throw new Error('Mock하지 않은 API입니다.')\n    }\n    const { req: { headers: { cookie } = { cookie: undefined } } = {} } =\n      options || {}\n\n    if (cookie === validMemberCookie) {\n      const user = { uid: 'MOCK_USER_UID' }\n      const response: HttpResponse<{ uid: string }> = {\n        ok: true,\n        url: '/api/users/me',\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        headers: {} as any,\n        status: 200,\n        parsedBody: user,\n      }\n\n      return Promise.resolve(response)\n    } else if (cookie === validNonMemberCookie) {\n      const user = { uid: '_PH_01000000000' }\n      const response: HttpResponse<{\n        uid: string\n      }> = {\n        ok: true,\n        url: '/api/users/me',\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        headers: {} as any,\n        status: 200,\n        parsedBody: user,\n      }\n\n      return Promise.resolve(response)\n    }\n\n    const response: HttpResponse<{ uid: string }> = {\n      ok: false,\n      url: '/api/users/me',\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      headers: {} as any,\n      status: 401,\n      parsedBody: '',\n    }\n\n    return Promise.resolve(response)\n  },\n)\n\nafterEach(() => {\n  mockedGet.mockClear()\n})\n\ndescribe('유효한 쿠키와 함께 요청할 때', () => {\n  let validMemberContext: ReturnType<typeof createContext>\n\n  beforeEach(() => {\n    validMemberContext = createContext({\n      cookie: validMemberCookie,\n    })\n  })\n\n  test('기존 gssp를 호출합니다.', async () => {\n    const oldGssp = jest.fn()\n    const newGssp = authGuard(oldGssp)\n\n    await newGssp(validMemberContext)\n\n    expect(oldGssp).toHaveBeenCalledTimes(1)\n  })\n\n  test('기존 gssp는 customContext.user 파라미터를 사용할 수 있습니다.', async () => {\n    const oldGssp = jest.fn()\n    const newGssp = authGuard(oldGssp)\n\n    await newGssp(validMemberContext)\n\n    expect(oldGssp).toHaveBeenCalledWith(\n      expect.objectContaining({\n        customContext: expect.objectContaining({\n          user: expect.objectContaining({ uid: expect.any(String) }),\n        }),\n      }),\n    )\n  })\n})\n\ntest('allowNonMembers 옵션을 켜면 휴대폰 번호로 가입한 계정의 쿠키와 함께 요청할 때 기존 GSSP를 호출합니다.', async () => {\n  const validNonMemberContext = createContext({\n    cookie: validNonMemberCookie,\n  })\n\n  const oldGssp = jest.fn()\n  const newGssp = authGuard(oldGssp, { allowNonMembers: true })\n\n  await newGssp(validNonMemberContext)\n\n  expect(oldGssp).toHaveBeenCalledTimes(1)\n  expect(oldGssp).toHaveBeenCalledWith(\n    expect.objectContaining({\n      customContext: expect.objectContaining({\n        user: expect.objectContaining({ uid: expect.any(String) }),\n      }),\n    }),\n  )\n})\n\ntest('/api/users/me가 401 이외의 에러로 응답했다면 에러를 던집니다.', async () => {\n  mockedGet.mockResolvedValueOnce({\n    url: '/api/users/me',\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    headers: {} as any,\n    status: 500,\n    ok: false,\n    parsedBody: '',\n  })\n\n  const oldGssp = jest.fn()\n  const newGssp = authGuard(oldGssp)\n  const context = createContext({})\n\n  await expect(newGssp(context)).rejects.toThrow()\n})\n\ntest('resolveReturnUrl 함수 속성으로 로그인 후 돌아갈 URL을 만들 수 있습니다.', async () => {\n  const oldGssp = jest.fn()\n  const newGssp = authGuard(oldGssp, {\n    resolveReturnUrl: ({ query }) => `/foo/${query.foo}`,\n  })\n  const context = createContext({\n    userAgent: '',\n    resolvedUrl: '/air/foo',\n  })\n\n  const result = await newGssp({ ...context, query: { foo: 1 } })\n\n  expect(oldGssp).toHaveBeenCalledTimes(0)\n  expect(result).toEqual({\n    redirect: {\n      destination: `/login?returnUrl=${encodeURIComponent('/foo/1')}`,\n      basePath: false,\n      permanent: false,\n    },\n  })\n})\n\ndescribe('브라우저에서 페이지 접근을 막아야 하면 로그인 페이지로 리디렉션하는 값을 반환합니다.', () => {\n  const resolvedUrl = '/test-url?_triple_no_navbar'\n\n  test('쿠키가 없을 때', async () => {\n    const oldGssp = jest.fn()\n    const newGssp = authGuard(oldGssp)\n    const context = createContext({ userAgent: browserUserAgent, resolvedUrl })\n\n    const result = await newGssp(context)\n\n    expect(oldGssp).toHaveBeenCalledTimes(0)\n    expect(result).toEqual({\n      redirect: {\n        destination: `/login?returnUrl=${encodeURIComponent(resolvedUrl)}`,\n        basePath: false,\n        permanent: false,\n      },\n    })\n  })\n\n  test('쿠키가 유효하지 않을 때', async () => {\n    const oldGssp = jest.fn()\n    const newGssp = authGuard(oldGssp)\n    const context = createContext({\n      userAgent: browserUserAgent,\n      resolvedUrl,\n      cookie: invalidCookie,\n    })\n\n    const result = await newGssp(context)\n\n    expect(oldGssp).toHaveBeenCalledTimes(0)\n    expect(result).toEqual({\n      redirect: {\n        destination: `/login?returnUrl=${encodeURIComponent(resolvedUrl)}`,\n        basePath: false,\n        permanent: false,\n      },\n    })\n  })\n\n  test('휴대전화 로그인한 회원 정보를 반환하고 allowNonMember 옵션이 꺼져있을 때', async () => {\n    const oldGssp = jest.fn()\n    const newGssp = authGuard(oldGssp)\n    const context = createContext({\n      userAgent: browserUserAgent,\n      cookie: validNonMemberCookie,\n      resolvedUrl,\n    })\n\n    const result = await newGssp(context)\n\n    expect(oldGssp).toHaveBeenCalledTimes(0)\n    expect(result).toEqual({\n      redirect: {\n        destination: `/login?returnUrl=${encodeURIComponent(resolvedUrl)}`,\n        basePath: false,\n        permanent: false,\n      },\n    })\n  })\n})\n\ntest('authType을 이용해 로그인 페이지의 Type을 명시할 수 있습니다.', async () => {\n  const resolvedUrl = '/test-url?_triple_no_navbar'\n  const oldGssp = jest.fn()\n  const newGssp = authGuard(oldGssp, { authType: 'bookings' })\n  const context = createContext({\n    userAgent: browserUserAgent,\n    cookie: invalidCookie,\n    resolvedUrl,\n  })\n\n  const result = await newGssp(context)\n\n  expect(oldGssp).toHaveBeenCalledTimes(0)\n  expect(result).toEqual({\n    redirect: {\n      destination: `/login?returnUrl=${encodeURIComponent(\n        resolvedUrl,\n      )}&type=bookings`,\n      basePath: false,\n      permanent: false,\n    },\n  })\n})\n\ndescribe('앱에서', () => {\n  const resolvedUrl = '/test-url?_triple_no_navbar'\n  describe('로그인이 필요하면 앱 내 로그인 페이지를 호출합니다.', () => {\n    test('쿠키가 없을 때', async () => {\n      const oldGssp = jest.fn()\n      const newGssp = authGuard(oldGssp)\n      const context = createContext({ userAgent: appUserAgent, resolvedUrl })\n\n      const result = await newGssp(context)\n\n      expect(oldGssp).toHaveBeenCalledTimes(0)\n      expect(result).toEqual({\n        redirect: {\n          destination: `/login?returnUrl=${encodeURIComponent(\n            generateUrl({}, resolvedUrl),\n          )}`,\n          basePath: false,\n          permanent: false,\n        },\n      })\n    })\n\n    test('쿠키가 유효하지 않을 때', async () => {\n      const oldGssp = jest.fn()\n      const newGssp = authGuard(oldGssp)\n      const context = createContext({\n        userAgent: appUserAgent,\n        resolvedUrl,\n        cookie: invalidCookie,\n      })\n\n      const result = await newGssp(context)\n\n      expect(oldGssp).toHaveBeenCalledTimes(0)\n      expect(result).toEqual({\n        redirect: {\n          destination: `/login?returnUrl=${encodeURIComponent(\n            generateUrl({}, resolvedUrl),\n          )}`,\n          basePath: false,\n          permanent: false,\n        },\n      })\n    })\n  })\n})\n\nfunction createContext({\n  userAgent,\n  cookie,\n  resolvedUrl,\n}: {\n  userAgent?: string\n  cookie?: string\n  resolvedUrl?: string\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n}): any {\n  return {\n    req: {\n      headers: { 'user-agent': userAgent, cookie },\n    },\n    resolvedUrl,\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/ssr-utils/auth-guard.ts",
    "content": "import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'\nimport { authGuardedFetchers, NEED_LOGIN_IDENTIFIER } from '@titicaca/fetcher'\nimport qs from 'qs'\nimport { generateUrl, parseUrl, strictQuery } from '@titicaca/view-utilities'\nimport { checkClientApp } from '@titicaca/triple-web-utils'\nimport { SessionUser } from '@titicaca/triple-web'\n\nimport { getSessionAvailability } from './get-session-availability'\n\ninterface AuthGuardOptions {\n  authType?: string\n  allowNonMembers?: boolean\n  resolveReturnUrl?: (\n    ctx: GetServerSidePropsContext & {\n      customContext?: { [key: string]: unknown }\n    },\n  ) => string\n}\n\nconst NON_MEMBER_REGEX = /^_PH/\n\nexport function authGuard<Props>(\n  gssp: (\n    ctx: GetServerSidePropsContext & {\n      customContext?: { user?: SessionUser }\n    },\n  ) => Promise<GetServerSidePropsResult<Props>>,\n  options?: AuthGuardOptions,\n): (\n  ctx: GetServerSidePropsContext & {\n    customContext?: { [key: string]: unknown }\n  },\n) => Promise<GetServerSidePropsResult<Props>> {\n  return async (ctx) => {\n    const {\n      req,\n      req: {\n        headers: { 'user-agent': userAgentString },\n      },\n      resolvedUrl,\n    } = ctx\n\n    const returnUrl = options?.resolveReturnUrl\n      ? options.resolveReturnUrl(ctx)\n      : `${process.env.NEXT_PUBLIC_BASE_PATH || ''}${resolvedUrl}`\n\n    const response = await authGuardedFetchers.get<SessionUser>(\n      '/api/users/me',\n      {\n        req,\n        retryable: true,\n      },\n    )\n\n    if (response === NEED_LOGIN_IDENTIFIER) {\n      if (\n        // TODO : WEB, APP 구분 제거\n        userAgentString &&\n        checkClientApp(userAgentString) &&\n        getSessionAvailability(ctx)\n      ) {\n        return refreshInAppSession({ resolvedUrl, returnUrl })\n      }\n\n      return redirectToLogin({ returnUrl, authType: options?.authType })\n    } else if (!response.ok) {\n      const { status } = response\n\n      throw new Error(`Fail to fetch User: ${status}`)\n    } else {\n      const { parsedBody: user } = response\n\n      const isNonMember = user.uid.match(NON_MEMBER_REGEX)\n\n      if (!options?.allowNonMembers && isNonMember) {\n        return redirectToLogin({ returnUrl, authType: options?.authType })\n      }\n\n      return gssp({ ...ctx, customContext: { ...ctx.customContext, user } })\n    }\n  }\n}\n\nfunction refreshInAppSession({\n  resolvedUrl,\n  returnUrl,\n}: {\n  resolvedUrl: string\n  returnUrl: string\n}) {\n  const { query } = parseUrl(resolvedUrl)\n  const { refreshed } = strictQuery(\n    query\n      ? (qs.parse(query) as Record<string, string | string[] | undefined>)\n      : {},\n  )\n    .boolean('refreshed')\n    .use()\n\n  if (refreshed) {\n    throw new Error('세션 갱신에 실패했습니다.')\n  }\n\n  const destinationQuery = qs.stringify({\n    returnUrl: generateUrl({ query: 'refreshed=true' }, returnUrl),\n  })\n\n  return {\n    redirect: {\n      destination: `/landing/refresh?${destinationQuery}`,\n      basePath: false,\n      permanent: false,\n    },\n  } as const\n}\n\nfunction redirectToLogin({\n  returnUrl,\n  authType,\n}: {\n  returnUrl: string\n  authType?: string\n}) {\n  const query = qs.stringify({\n    returnUrl,\n    type: authType,\n  })\n\n  return {\n    redirect: {\n      destination: `/login?${query}`,\n      basePath: false,\n      permanent: false,\n    },\n  } as const\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/ssr-utils/get-client-app.ts",
    "content": "import type { GetServerSidePropsContext } from 'next'\nimport type { ClientAppValue } from '@titicaca/triple-web'\n\nimport { extractClientApp } from '../helpers/client-app'\n\nexport function getClientApp(ctx: GetServerSidePropsContext): ClientAppValue {\n  const userAgent = ctx.req.headers['user-agent']\n\n  const autoplay = ctx.req?.headers['x-triple-autoplay']\n  const networkType = ctx.req?.headers['x-triple-network-type']\n\n  return extractClientApp({ autoplay, networkType, userAgent })\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/ssr-utils/get-session-availability.ts",
    "content": "import type { GetServerSidePropsContext } from 'next'\n\nimport { checkSession } from '../helpers/session'\n\nexport function getSessionAvailability(\n  ctx: GetServerSidePropsContext,\n): boolean {\n  return checkSession(ctx.req)\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/ssr-utils/get-user-agent.ts",
    "content": "import type { GetServerSidePropsContext } from 'next'\nimport type { UserAgentValue } from '@titicaca/triple-web'\n\nimport { extractUserAgent } from '../helpers/user-agent'\n\nexport function getUserAgent(ctx: GetServerSidePropsContext): UserAgentValue {\n  const userAgent = ctx.req\n    ? (ctx.req.headers['user-agent'] ?? '')\n    : window.navigator.userAgent\n\n  return extractUserAgent({ userAgent })\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/ssr-utils/index.ts",
    "content": "export * from './auth-guard'\nexport * from './get-client-app'\nexport * from './get-session-availability'\nexport * from './get-user-agent'\nexport * from './put-invalid-session-id-remover'\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/src/ssr-utils/put-invalid-session-id-remover.ts",
    "content": "import { checkClientApp } from '@titicaca/triple-web-utils'\nimport { generateUrl, strictQuery } from '@titicaca/view-utilities'\nimport type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'\nimport qs from 'qs'\n\nexport function putInvalidSessionIdRemover(\n  ctx: GetServerSidePropsContext,\n): () => Extract<GetServerSidePropsResult<unknown>, { redirect: unknown }> {\n  const { req, res, query, resolvedUrl } = ctx\n\n  const isClientApp = checkClientApp(req.headers['user-agent'] ?? '')\n\n  const handleInvalidSessionInApp: ReturnType<\n    typeof putInvalidSessionIdRemover\n  > = () => {\n    const { redirected } = strictQuery(query).boolean('redirected').use()\n\n    if (redirected === true) {\n      throw new Error('세션 새로고침 이후에도 유효하지 않은 인증 정보입니다.')\n    }\n\n    return {\n      redirect: {\n        destination: generateUrl({\n          path: '/landing/refresh',\n          query: qs.stringify({\n            returnUrl: generateUrl(\n              {\n                query: 'redirected=true',\n              },\n              addBasePath(resolvedUrl),\n            ),\n          }),\n        }),\n        basePath: false,\n        permanent: false,\n      },\n    }\n  }\n\n  const handleInvalidSessionInBrowser = () => {\n    res.setHeader(\n      'set-cookie',\n      `x-soto-session=; path=/; expires=${new Date(0).toUTCString()};`,\n    )\n\n    throw new Error('유효하지 않은 인증 정보입니다.')\n  }\n\n  return isClientApp ? handleInvalidSessionInApp : handleInvalidSessionInBrowser\n}\n\nfunction addBasePath(href: string) {\n  const rootOrRootWithQueryRegEx = /^\\/($|\\?)/\n  const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''\n\n  return rootOrRootWithQueryRegEx.test(href)\n    ? `${basePath}${href.slice(1)}`\n    : `${basePath}${href}`\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/triple-web-nextjs-pages/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/triple-web-test-utils/package.json",
    "content": "{\n  \"name\": \"@titicaca/triple-web-test-utils\",\n  \"version\": \"14.2.3\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/triple-web-test-utils\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@titicaca/triple-web\": \"workspace:*\",\n    \"react\": \"^18.3.1\"\n  },\n  \"peerDependencies\": {\n    \"@titicaca/triple-web\": \"*\",\n    \"react\": \"^18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-test-utils/src/create-test-wrapper.tsx",
    "content": "import {\n  ClientAppValue,\n  EnvValue,\n  SessionValue,\n  TripleWeb,\n  UserAgentValue,\n} from '@titicaca/triple-web'\nimport { PropsWithChildren } from 'react'\n\nexport interface TestWrapperProps {\n  clientAppProvider?: ClientAppValue\n  envProvider?: EnvValue\n  sessionProvider?: SessionValue\n  userAgentProvider?: UserAgentValue\n}\n\nexport function createTestWrapper({\n  clientAppProvider = null,\n  envProvider = {\n    appUrlScheme: 'dev-soto',\n    webUrlBase: 'https://triple-dev.titicaca-corp.com',\n    basePath: '/',\n    facebookAppId: '',\n    defaultPageTitle: '',\n    defaultPageDescription: '',\n    afOnelinkId: '',\n    afOnelinkPid: '',\n    afOnelinkSubdomain: '',\n  },\n  sessionProvider = {\n    user: null,\n  },\n  userAgentProvider = {\n    ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',\n    browser: { name: 'Chrome', version: '123.0.6312.106', major: '123' },\n    cpu: { architecture: 'arm64' },\n    device: { type: '', model: 'Macintosh', vendor: 'Apple' },\n    engine: { name: 'Blink', version: '123.0.0.0' },\n    os: { name: 'macOS', version: '14.4.1' },\n    isMobile: false,\n  },\n}: TestWrapperProps = {}) {\n  return function TestWrapper({ children }: PropsWithChildren) {\n    return (\n      <TripleWeb\n        clientAppProvider={clientAppProvider}\n        envProvider={envProvider}\n        i18nProvider={{ defaultLocale: 'ko' }}\n        sessionProvider={sessionProvider}\n        userAgentProvider={userAgentProvider}\n      >\n        {children}\n      </TripleWeb>\n    )\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-test-utils/src/index.ts",
    "content": "export * from './create-test-wrapper'\n"
  },
  {
    "path": "packages/triple-web-test-utils/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/triple-web-test-utils/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/triple-web-test-utils/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/triple-web-utils/package.json",
    "content": "{\n  \"name\": \"@titicaca/triple-web-utils\",\n  \"version\": \"14.2.3\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/triple-web-utils\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:style\": \"stylelint 'src/**/*.{js,ts,tsx}'\",\n    \"lint:style:fix\": \"stylelint 'src/**/*.{js,ts,tsx}' --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@types/ua-parser-js\": \"^0.7.39\",\n    \"ua-parser-js\": \"^1.0.40\"\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-utils/src/check-client-app.test.ts",
    "content": "import { checkClientApp } from './check-client-app'\n\ndescribe('checkClientApp', () => {\n  test('안드로이드 앱 user agent 문자열에 대해 true를 반환해야 합니다', () => {\n    const userAgent =\n      'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1 Triple-Android/7.4.2'\n    expect(checkClientApp(userAgent)).toBe(true)\n  })\n\n  test('iOS 앱 user agent 문자열에 대해 true를 반환해야 합니다', () => {\n    const userAgent =\n      'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1 Triple-iOS/7.4.2'\n    expect(checkClientApp(userAgent)).toBe(true)\n  })\n\n  test('트리플 앱 정보가 없는 user agent 문자열에 대해 false를 반환해야 합니다', () => {\n    const userAgent =\n      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'\n    expect(checkClientApp(userAgent)).toBe(false)\n  })\n\n  test('빈 user agent 문자열에 대해 false를 반환해야 합니다', () => {\n    const userAgent = ''\n    expect(checkClientApp(userAgent)).toBe(false)\n  })\n})\n"
  },
  {
    "path": "packages/triple-web-utils/src/check-client-app.ts",
    "content": "import { clientAppRegex } from './regex'\n\nexport function checkClientApp(userAgent: string) {\n  return clientAppRegex.test(userAgent)\n}\n"
  },
  {
    "path": "packages/triple-web-utils/src/index.ts",
    "content": "export * from './check-client-app'\nexport * from './regex'\nexport * from './user'\nexport * from './user-agent'\n"
  },
  {
    "path": "packages/triple-web-utils/src/regex.ts",
    "content": "export const clientAppRegex = /(Triple-iOS|Triple-Android)\\/([^ ]+)/\n\nexport const macAppRegex = /TripleMacApp/\n"
  },
  {
    "path": "packages/triple-web-utils/src/user-agent.ts",
    "content": "import type { IResult } from 'ua-parser-js'\n\nexport function isMobile(userAgent: IResult) {\n  const { device } = userAgent\n\n  if (device.type === 'mobile' || device.type === 'tablet') {\n    return true\n  } else {\n    return false\n  }\n}\n"
  },
  {
    "path": "packages/triple-web-utils/src/user.ts",
    "content": "export const GET_USER_REQUEST_URL = '/api/users/me'\n"
  },
  {
    "path": "packages/triple-web-utils/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/triple-web-utils/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/triple-web-utils/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/type-definitions/README.md",
    "content": "# `@titicaca/type-definitions`\n\n트리플 프론트엔드의 공통 타입을 모아놓는 패키지입니다.\n"
  },
  {
    "path": "packages/type-definitions/package.json",
    "content": "{\n  \"name\": \"@titicaca/type-definitions\",\n  \"version\": \"14.2.3\",\n  \"description\": \"triple frontend global type definitions\",\n  \"keywords\": [\n    \"triple\",\n    \"frontend\",\n    \"typescript\"\n  ],\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/type-definitions\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/type-definitions/src/geojson.ts",
    "content": "export interface PointGeoJson {\n  type: 'Point'\n  coordinates: [number, number]\n}\n\nexport interface LatLngLiteral {\n  lat: number\n  lng: number\n}\n"
  },
  {
    "path": "packages/type-definitions/src/image.ts",
    "content": "interface ImageUrl {\n  url: string\n}\n\ninterface CamelSmallSquare {\n  smallSquare: ImageUrl\n}\n\ninterface SnakeSmallSquare {\n  small_square: ImageUrl\n}\n\ntype SmallSquare = CamelSmallSquare | SnakeSmallSquare\n\nexport type BaseSizes = 'small' | 'medium' | 'large'\n\nexport type GlobalSizes =\n  | 'mini'\n  | 'tiny'\n  | 'big'\n  | 'huge'\n  | 'massive'\n  | BaseSizes\n\nexport type Ratio =\n  | '4:1'\n  | '5:3'\n  | '11:7'\n  | '4:3'\n  | '1:1'\n  | '10:11'\n  | '5:8'\n  | '9:5'\n\nexport type FrameRatioAndSizes =\n  | Exclude<GlobalSizes, 'tiny' | 'massive'>\n  | 'original'\n  | Ratio\n\nexport interface ImageMeta {\n  id: string\n  type?: string\n  title?: string | null\n  description?: string | null\n  sourceUrl?: string\n  width?: number\n  height?: number\n  cloudinaryId?: string\n  cloudinaryBucket?: string\n  sizes: {\n    full: ImageUrl\n    large: ImageUrl\n  } & SmallSquare\n  video?: {\n    full: { url: string }\n    large: { url: string }\n  } & SmallSquare\n  attachmentId?: string\n  /* TODO: Remove FrameRatioAndSizes from core-elements */\n  frame?: FrameRatioAndSizes\n  link?: {\n    href: string\n    label?: string\n  }\n  /* 어드민에서 설정하는 값으로 영상 재생시 소리를 음소거 해주는 기능 */\n  videoInitiallyMuted?: boolean\n}\n"
  },
  {
    "path": "packages/type-definitions/src/index.ts",
    "content": "export * from './translated-property'\nexport * from './listing-poi'\nexport * from './image'\nexport * from './geojson'\nexport * from './inventory-item'\nexport * from './triple-document'\n"
  },
  {
    "path": "packages/type-definitions/src/inventory-item.ts",
    "content": "export interface InventoryItemMeta {\n  id?: string\n  image?: string\n  desc?: string\n  detailedDesc?: string\n  target?: string\n  text?: string\n}\n"
  },
  {
    "path": "packages/type-definitions/src/listing-poi.ts",
    "content": "import { TranslatedProperty } from './translated-property'\nimport { ImageMeta } from './image'\nimport { PointGeoJson } from './geojson'\n\ninterface ListingPoiSourceBase {\n  id: string\n  areas?: { name: string }[]\n  categories?: { id: string; filter?: boolean; name: string }[]\n  comment?: string\n  grade: number\n  hasTnaProducts?: boolean\n  image?: ImageMeta\n  names: TranslatedProperty\n  pointGeolocation: PointGeoJson\n  reviewsRating?: number\n  reviewsCount?: number\n  scrapsCount?: number\n  vicinity?: string\n}\n\ninterface ListingPoiBase {\n  id: string\n  nameOverride?: string\n  reviewed: boolean\n  scraped: boolean\n  distance?: number\n  categories?: { id: string; name: string }[]\n}\n\nexport interface ListingAttraction extends ListingPoiBase {\n  type: 'attraction'\n  source: ListingPoiSourceBase & {\n    type: 'attraction'\n    regionId: string\n  }\n}\n\nexport interface ListingRestaurant extends ListingPoiBase {\n  type: 'restaurant'\n  source: ListingPoiSourceBase & {\n    type: 'restaurant'\n    regionId: string\n  }\n}\n\nexport interface ListingHotel extends ListingPoiBase {\n  type: 'hotel'\n  source: ListingPoiSourceBase & {\n    type: 'hotel'\n    regionId?: string\n    starRating: number\n    tags: { name: string }[]\n  }\n}\n\nexport type ListingPoi = ListingAttraction | ListingRestaurant | ListingHotel\n"
  },
  {
    "path": "packages/type-definitions/src/translated-property.ts",
    "content": "export interface TranslatedProperty {\n  primary?: string | null\n  ko?: string | null\n  en?: string | null\n  ja?: string | null\n  zh?: string | null\n  local?: string | null\n}\n"
  },
  {
    "path": "packages/type-definitions/src/triple-document.ts",
    "content": "/**\n * guestMode가 undefined가 아닌 경우, 로그인이 필요한 동작(스크랩, 리뷰쓰기)등이 불가능하며, 앱으로 연결되는 루트를 차단합니다.\n * 로그인 없이 triple-document를 사용하는 페이지를 사용자가 탐색할 수 있도록 할 때 사용합니다(예. 외부 이벤트에 POI 상세페이지 제공).\n * - 'seoul-con' : '서울콘' 행사. triple-content-web에서 가이드와 POI 영어 컨텐트를 제공합니다.\n */\nexport type GuestModeType = 'seoul-con'\n"
  },
  {
    "path": "packages/type-definitions/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/type-definitions/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/type-definitions/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "packages/view-utilities/README.md",
    "content": "# view-utilities\n\n뷰 영역에서 사용하는 유틸 함수 모음\n\n## How to use\n\n```bash\nnpm install @titicaca/view-utilities\n```\n\n```js\n//index.ts\n\nimport { debounce, formatNumber } from '@titicaca/view-utilities'\n```\n\n## debounce\n\n[API]\n\n## generateDeepLink\n\n[API]\n\n## deriveCurrentStateAndCount\n\n[API]\n\n## formatNumber\n\n[API]\n\n## generateShareImageUrl\n\n[API]\n\n## parseUrl\n\n[API]\n\n## generateUrl\n\n[API]\n\n## measureDistance\n\n지도 좌표의 직선거리를 `meter` 값으로 반환합니다.\n\n```tsx\nimport { measureDistance } from '@titicaca/view-utilities'\n\nconst distance = measureDistance(\n  {\n    coordinates: [121.525966, 25.094853],\n    type: 'Point',\n  },\n  {\n    coordinates: [121.528408, 25.095141],\n    type: 'Point',\n  },\n)\n\nconsole.log(distance) // -> 248\n```\n\n## strict-query\n\nquery-string으로 주어진 값의 타입을 결정하여 사용할 수 있게 도와주는 인터페이스.\n\n```ts\nimport { strictQuery } from '@titicaca/view-utilities'\n\nconst { regionId, tripId, categoryIds, agesOfChildren, inRegion } = strictQuery(\n  query,\n)\n  .string('regionId') // regionId will be string | undefined\n  .number('tripId') // tripId will be number | undefined\n  .stringArray('categoryIds') // categoryIds will be string[]\n  .numberArray('agesOfChildren') // agesOfChildren will be number[]\n  .boolean('inRegion') // inRegion will be boolean\n  .use()\n```\n\n`strictQuery` 함수를 이용해 속성과 타입을 매핑할 수 있는 인스턴스로 바꿔줍니다.\n그 다음 원하는 타입과 속성 키를 매핑해줍니다. chaining으로 여러 속성을 한 번에 매핑할 수 있습니다.\n그리고 `use` 메서드를 이용해 일반 객체를 반환해주면 원하는 타입으로 사용할 수 있습니다.\n"
  },
  {
    "path": "packages/view-utilities/package.json",
    "content": "{\n  \"name\": \"@titicaca/view-utilities\",\n  \"version\": \"14.2.3\",\n  \"description\": \"Utilities for Triple view libraries and applications\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/titicacadev/triple-frontend/tree/main/packages/view-utilities\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/titicacadev/triple-frontend.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/titicacadev/triple-frontend/issues\"\n  },\n  \"sideEffects\": false,\n  \"main\": \"src/index.ts\",\n  \"module\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"publishConfig\": {\n    \"main\": \"lib/index.js\",\n    \"module\": \"lib/index.mjs\",\n    \"types\": \"lib/index.d.ts\"\n  },\n  \"files\": [\n    \"lib\"\n  ],\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"lint:es\": \"eslint src\",\n    \"lint:es:fix\": \"eslint src --fix\",\n    \"lint:etc\": \"prettier src --check\",\n    \"lint:etc:fix\": \"prettier src --write\"\n  },\n  \"lint-staged\": {\n    \"*\": [\n      \"prettier --check\"\n    ],\n    \"*.{js,ts,tsx}\": [\n      \"eslint\"\n    ]\n  },\n  \"dependencies\": {\n    \"@titicaca/type-definitions\": \"workspace:*\",\n    \"date-fns\": \"^3.6.0\",\n    \"haversine\": \"^1.1.1\",\n    \"humps\": \"^2.0.1\",\n    \"qs\": \"^6.14.0\"\n  },\n  \"devDependencies\": {\n    \"@types/haversine\": \"^1.1.8\",\n    \"@types/humps\": \"^2.0.6\",\n    \"@types/qs\": \"^6.9.18\"\n  }\n}\n"
  },
  {
    "path": "packages/view-utilities/src/debounce.ts",
    "content": "interface Option {\n  leading?: boolean\n  trailing?: boolean\n}\n\nexport function debounce<Params extends unknown[]>(\n  func: (...args: Params) => unknown,\n  timeout: number,\n  option?: Option,\n): (...args: Params) => void {\n  let timer: ReturnType<typeof setTimeout>\n  let leadingCall = option?.leading ?? false\n  const trailing = option?.trailing ?? true\n\n  return (...args: Params) => {\n    if (!timer && leadingCall) {\n      func(...args)\n    }\n\n    clearTimeout(timer)\n    timer = setTimeout(() => {\n      if (trailing || !leadingCall) {\n        func(...args)\n      }\n      if (leadingCall) {\n        leadingCall = false\n      }\n    }, timeout)\n  }\n}\n"
  },
  {
    "path": "packages/view-utilities/src/derive-current-state-and-count.ts",
    "content": "export function deriveCurrentStateAndCount({\n  initialState,\n  initialCount,\n  currentState,\n}: {\n  initialState?: boolean | unknown\n  initialCount?: number\n  currentState?: boolean | unknown\n}) {\n  if (typeof initialState !== 'boolean' || typeof currentState !== 'boolean') {\n    /* At least one of the status are unknown: Reduces to a bitwise OR operation */\n    return {\n      state: !!initialState || !!currentState,\n      count: Number(initialCount || 0),\n    }\n  }\n\n  return {\n    state: currentState,\n    count:\n      initialState === currentState\n        ? initialCount\n        : currentState\n          ? Number(initialCount || 0) + 1\n          : Number(initialCount || 0) - 1,\n  }\n}\n"
  },
  {
    "path": "packages/view-utilities/src/find-folded-position.ts",
    "content": "export function findFoldedPosition(\n  maxLines: number,\n  comment?: string | null,\n  charactersPerLine: number = 25,\n) {\n  const lines = (comment || '').split('\\n')\n\n  let rest = maxLines * charactersPerLine\n  let linesCount = 0\n  let foldedIndex = 0\n  for (const line of lines) {\n    if (linesCount === maxLines) {\n      return foldedIndex\n    }\n    if (line.length > rest) {\n      return foldedIndex + rest\n    }\n\n    foldedIndex = foldedIndex + line.length + 1\n    linesCount = linesCount + 1 + Math.floor(line.length / charactersPerLine)\n    rest -= line.length\n  }\n\n  return null\n}\n"
  },
  {
    "path": "packages/view-utilities/src/format-number.spec.ts",
    "content": "import { formatNumber } from './format-number'\n\ndescribe('formatNumber', () => {\n  it('should format number to string with comma', () => {\n    expect(formatNumber(0)).toBe('0')\n    expect(formatNumber(123)).toBe('123')\n    expect(formatNumber(24000)).toBe('24,000')\n    expect(formatNumber(1234567)).toBe('1,234,567')\n    expect(formatNumber(-1234567)).toBe('-1,234,567')\n  })\n\n  it('should format string number with comma', () => {\n    expect(formatNumber('0')).toBe('0')\n    expect(formatNumber('123')).toBe('123')\n    expect(formatNumber('24000')).toBe('24,000')\n    expect(formatNumber('1234567')).toBe('1,234,567')\n    expect(formatNumber('-1234567')).toBe('-1,234,567')\n  })\n\n  it('should return empty string when the parameter is a falsy value except for number zero', () => {\n    expect(formatNumber('')).toBe('')\n    expect(formatNumber(null)).toBe('')\n    expect(formatNumber(undefined)).toBe('')\n  })\n\n  it('should not add commas in decimals', () => {\n    expect(formatNumber(0.1)).toBe('0.1')\n    expect(formatNumber(0.1345)).toBe('0.1345')\n    expect(formatNumber(123214.324234)).toBe('123,214.324234')\n  })\n})\n"
  },
  {
    "path": "packages/view-utilities/src/format-number.ts",
    "content": "export function formatNumber(\n  number: number | string | null | undefined,\n): string {\n  if (typeof number === 'number' || typeof number === 'string') {\n    const [integer, ...fractions] = number.toString().split('.')\n    return [integer.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','), ...fractions].join(\n      '.',\n    )\n  }\n\n  return ''\n}\n"
  },
  {
    "path": "packages/view-utilities/src/generate-deep-link/README.md",
    "content": "# generate-deep-link\n\n## 사용 방법\n\n```:javascript\nconst generateDeepLink = makeDeepLinkGenerator({\n  oneLinkParams: {\n    subdomain: '',\n    id: '',\n    pid: '',\n  },\n  appScheme: '',\n  webURLBase: '',\n})\n\n// 컴포넌트에서\ngenerateDeepLink({\n  campaign: '하단 배너',\n  pid: fromSearchAd ? 'searched': undefined,\n  ...injectContentSource(source),\n  ...injectUTMContext(utmContext),\n})\n```\n"
  },
  {
    "path": "packages/view-utilities/src/generate-deep-link/index.ts",
    "content": "export * from './make-deep-link-generator'\nexport * from './param-injectors'\n"
  },
  {
    "path": "packages/view-utilities/src/generate-deep-link/make-deep-link-generator.test.ts",
    "content": "import qs from 'qs'\n\nimport { generateUrl } from '../url'\n\nimport { makeDeepLinkGenerator } from './make-deep-link-generator'\n\nconst SUBDOMAIN = 'subdomain'\nconst ONELINK_ID = 'onelinkid'\nconst PID = 'onelinkpid'\nconst APP_PATH = '/hoteles/c2eb4fba-cad1-4c08-94b2-9430039d181e'\nconst APP_SCHEME = 'triple'\nconst WEB_URL_BASE = 'https://triple.guide'\n\ntest('it appends proper onelink attribution queries', () => {\n  const generateDeepLink = makeDeepLinkGenerator({\n    oneLinkParams: {\n      subdomain: SUBDOMAIN,\n      id: ONELINK_ID,\n      pid: PID,\n    },\n    appScheme: APP_SCHEME,\n    webURLBase: WEB_URL_BASE,\n  })\n\n  const deepLink = generateDeepLink({\n    path: APP_PATH,\n    channel: 'naver',\n    campaign: 'winter_sale',\n    keywords: 'triple',\n    ad: 'video',\n    adSet: 'naver_email',\n  })\n\n  const expectedDeepLink = generateUrl({\n    scheme: 'https',\n    host: `${SUBDOMAIN}.onelink.me`,\n    path: `/${ONELINK_ID}`,\n    query: qs.stringify({\n      af_dp: `${APP_SCHEME}://${APP_PATH}`,\n      af_web_dp: WEB_URL_BASE,\n      pid: PID,\n      c: 'winter_sale',\n      af_adset: 'naver_email',\n      af_ad: 'video',\n      af_keywords: 'triple',\n      af_channel: 'naver',\n      is_retargeting: true,\n    }),\n  })\n\n  expect(deepLink).toEqual(expectedDeepLink)\n})\n\ntest('it allows pid override', () => {\n  const overridenPid = 'overriden'\n\n  const generateDeepLink = makeDeepLinkGenerator({\n    oneLinkParams: {\n      subdomain: SUBDOMAIN,\n      id: ONELINK_ID,\n      pid: PID,\n    },\n    appScheme: APP_SCHEME,\n    webURLBase: WEB_URL_BASE,\n  })\n\n  const deepLink = generateDeepLink({\n    path: APP_PATH,\n    pid: overridenPid,\n  })\n\n  const expectedDeepLink = generateUrl({\n    scheme: 'https',\n    host: `${SUBDOMAIN}.onelink.me`,\n    path: `/${ONELINK_ID}`,\n    query: qs.stringify({\n      af_dp: `${APP_SCHEME}://${APP_PATH}`,\n      af_web_dp: WEB_URL_BASE,\n      pid: overridenPid,\n      is_retargeting: true,\n    }),\n  })\n\n  expect(deepLink).toEqual(expectedDeepLink)\n})\n\ntest('it provides reengagement window', () => {\n  const generateDeepLink = makeDeepLinkGenerator({\n    oneLinkParams: {\n      subdomain: SUBDOMAIN,\n      id: ONELINK_ID,\n      pid: PID,\n    },\n    appScheme: APP_SCHEME,\n    webURLBase: WEB_URL_BASE,\n  })\n\n  const deepLink = generateDeepLink({\n    path: APP_PATH,\n    reengagementWindow: '7d',\n  })\n\n  const expectedDeepLink = generateUrl({\n    scheme: 'https',\n    host: `${SUBDOMAIN}.onelink.me`,\n    path: `/${ONELINK_ID}`,\n    query: qs.stringify({\n      af_dp: `${APP_SCHEME}://${APP_PATH}`,\n      af_web_dp: WEB_URL_BASE,\n      pid: PID,\n      af_reengagement_window: '7d',\n      is_retargeting: true,\n    }),\n  })\n\n  expect(deepLink).toEqual(expectedDeepLink)\n})\n\ntest('it allows af_web_dp override', () => {\n  const overridenUrl = 'https://foo.bar'\n\n  const generateDeepLink = makeDeepLinkGenerator({\n    oneLinkParams: {\n      subdomain: SUBDOMAIN,\n      id: ONELINK_ID,\n      pid: PID,\n    },\n    appScheme: APP_SCHEME,\n    webURLBase: WEB_URL_BASE,\n  })\n\n  const deepLink = generateDeepLink({\n    path: APP_PATH,\n    webUrl: overridenUrl,\n  })\n\n  const expectedDeepLink = generateUrl({\n    scheme: 'https',\n    host: `${SUBDOMAIN}.onelink.me`,\n    path: `/${ONELINK_ID}`,\n    query: qs.stringify({\n      af_dp: `${APP_SCHEME}://${APP_PATH}`,\n      af_web_dp: overridenUrl,\n      pid: PID,\n      is_retargeting: true,\n    }),\n  })\n\n  expect(deepLink).toEqual(expectedDeepLink)\n})\n"
  },
  {
    "path": "packages/view-utilities/src/generate-deep-link/make-deep-link-generator.ts",
    "content": "import qs from 'qs'\n\nimport { generateUrl } from '../url'\n\ninterface FactoryParams {\n  oneLinkParams: {\n    subdomain: string\n    id: string\n    pid: string\n  }\n  appScheme: string\n  webURLBase: string\n}\n\ninterface GeneratorParams {\n  path: string\n\n  // one link parameter에 들어가는 값\n  pid?: string\n  campaign?: string\n  /**\n   * @deprecated\n   * channel 사용을 권장합니다.\n   * onelink의 af_adset을 측정하지않고, af_channel을 측정합니다.\n   * 참고 : https://docs.google.com/spreadsheets/d/1W01wso5gWwr-3ODCdgZsrC7fpzq0FX954m4Y3NgfCYg/edit#gid=0\n   */\n  adSet?: string\n  keywords?: string\n  ad?: string\n  channel?: string\n  partner?: string\n  clickLookBack?: string\n  reengagementWindow?: string\n  webUrl?: string\n}\n\nexport type DeepLinkGenerator = (params: GeneratorParams) => string\n\n/**\n * 프로젝트에 고정되어있는 값을 받아 딥링크 제너레이터를 반환합니다.\n *\n * @param oneLinkParams subdomain, id, pid\n * @param appScheme\n * @param webURLBase\n */\nexport function makeDeepLinkGenerator({\n  oneLinkParams: { subdomain, id, pid: defaultPid },\n  appScheme,\n  webURLBase,\n}: FactoryParams): DeepLinkGenerator {\n  return ({\n    path,\n    pid,\n    campaign,\n    adSet,\n    keywords,\n    ad,\n    channel,\n    partner,\n    clickLookBack,\n    reengagementWindow,\n    webUrl,\n  }) => {\n    const appLink = generateUrl({ scheme: appScheme, path })\n\n    const query = qs.stringify({\n      af_dp: appLink,\n      af_web_dp: webUrl || webURLBase || appLink,\n      pid: pid || defaultPid,\n\n      c: campaign,\n      af_adset: adSet,\n      af_ad: ad,\n      af_keywords: keywords,\n      af_channel: channel,\n      af_prt: partner,\n      af_click_lookback: clickLookBack,\n      af_reengagement_window: reengagementWindow,\n      is_retargeting: true,\n    })\n\n    return generateUrl({\n      scheme: 'https',\n      host: `${subdomain}.onelink.me`, // AF_ONELINK_SUBDOMAIN\n      path: `/${id}`,\n      query,\n    })\n  }\n}\n"
  },
  {
    "path": "packages/view-utilities/src/generate-deep-link/param-injectors.test.ts",
    "content": "import {\n  injectContentSource,\n  injectIsSearchAd,\n  injectUTMContext,\n} from './param-injectors'\n\ndescribe('injectContentSource', () => {\n  test('should make object with path made of content source.', () => {\n    expect(\n      injectContentSource({\n        regionId: '1',\n        type: 'article',\n        id: '1',\n      }),\n    ).toEqual({\n      path: '/regions/1/articles/1',\n    })\n  })\n})\n\ndescribe('injectUTMContext', () => {\n  test('should make object with path made of utm context.', () => {\n    expect(\n      injectUTMContext({\n        source: 'naver',\n        campaign: 'winter_sale',\n        term: 'triple',\n        content: 'video',\n      }),\n    ).toEqual({\n      channel: 'naver',\n      campaign: 'winter_sale',\n      keywords: 'triple',\n      ad: 'video',\n    })\n  })\n\n  test('should ignore empty string', () => {\n    expect(\n      injectUTMContext({\n        source: '',\n        campaign: '',\n        term: '',\n        content: '',\n      }),\n    ).toEqual({})\n  })\n\n  test('should ignore undefiend parameter', () => {\n    expect(injectUTMContext(undefined)).toEqual({})\n  })\n})\n\ntest('injectSearchAd', () => {\n  expect(\n    injectIsSearchAd({\n      medium: 'search_ad',\n    }),\n  ).toEqual({\n    pid: 'searchad',\n  })\n})\n"
  },
  {
    "path": "packages/view-utilities/src/generate-deep-link/param-injectors.ts",
    "content": "interface ContentSource {\n  regionId: string\n  type: 'article' | 'restaurant' | 'hotel' | 'attraction'\n  id: string\n}\n\ninterface UTMContext {\n  source: string\n  medium: string\n  campaign: string\n  term?: string\n  content?: string\n\n  // utm 명세가 아닌 custom 속성\n  partner?: string\n}\n\n/**\n * 콘텐츠 데이터를 generator parameter 값으로 변환합니다.\n *\n * @param source regionId, type, id를 가진 콘텐츠 데이터\n */\nexport function injectContentSource({ regionId, type, id }: ContentSource) {\n  return { path: `/regions/${regionId}/${type}s/${id}` }\n}\n\n/**\n * utm 콘텍스트를 generator parameter 값으로 변환합니다.\n *\n * @param utmContext\n */\nexport function injectUTMContext({\n  source,\n  campaign,\n  term,\n  content,\n  partner,\n}: Partial<UTMContext> = {}) {\n  return {\n    ...(campaign ? { campaign } : {}),\n    ...(source ? { channel: source } : {}),\n    ...(content ? { ad: content } : {}),\n    ...(term ? { keywords: term } : {}),\n    ...(partner ? { partner } : {}),\n  }\n}\n\n/**\n * 검색 광고라면 pid 값으로 'searchad'을 사용합니다.\n *\n * @param utmContext\n */\nexport function injectIsSearchAd({ medium }: Partial<UTMContext> = {}) {\n  return {\n    pid: medium === 'search_ad' ? 'searchad' : undefined,\n  }\n}\n"
  },
  {
    "path": "packages/view-utilities/src/generate-share-image-url.ts",
    "content": "const transformations: { [key: string]: string } = {\n  full: 'c_limit,h_2048,w_2048',\n  large: 'c_limit,h_1024,w_1024',\n  small: 'c_fill,h_256,w_256',\n}\n\nexport function generateShareImageUrl({\n  mediaBaseUrl,\n  cloudinaryId,\n  version,\n}: {\n  mediaBaseUrl: string\n  cloudinaryId: string\n  version: string\n}): string {\n  const transformation = transformations[version] || transformations.large\n  return `${mediaBaseUrl}/${transformation}/${cloudinaryId}.jpeg`\n}\n"
  },
  {
    "path": "packages/view-utilities/src/index.ts",
    "content": "export * from './generate-deep-link'\nexport * from './normalize-query-keys'\nexport * from './routelist'\nexport * from './strict-query'\nexport * from './debounce'\nexport * from './derive-current-state-and-count'\nexport * from './find-folded-position'\nexport * from './format-number'\nexport * from './generate-share-image-url'\nexport * from './measure-distance'\nexport * from './timestamp'\nexport * from './url'\n"
  },
  {
    "path": "packages/view-utilities/src/measure-distance.spec.ts",
    "content": "import { measureDistance } from './measure-distance'\n\ndescribe('measureDistance', () => {\n  it('직선거리', () => {\n    const distance = measureDistance(\n      {\n        coordinates: [121.525966, 25.094853],\n        type: 'Point',\n      },\n      {\n        coordinates: [121.528408, 25.095141],\n        type: 'Point',\n      },\n    )\n\n    expect(distance).toBe(248)\n  })\n})\n"
  },
  {
    "path": "packages/view-utilities/src/measure-distance.ts",
    "content": "import haversine from 'haversine'\nimport { PointGeoJson } from '@titicaca/type-definitions'\n\n/**\n * 맵상에서의 직선거리를 구하는 함수\n *\n * @param param0 기준점 (e.g.) { coordinates: [125.12345, 59.12345] }\n * @param param1 도착점 { coordinates: [125.12345, 59.12345] }\n *\n * - https://en.wikipedia.org/wiki/Haversine_formula\n */\nexport function measureDistance(\n  { coordinates: [fromLon, fromLat] }: PointGeoJson,\n  { coordinates: [toLon, toLat] }: PointGeoJson,\n) {\n  return Math.round(\n    haversine(\n      { latitude: fromLat, longitude: fromLon },\n      { latitude: toLat, longitude: toLon },\n      { unit: 'meter' },\n    ),\n  )\n}\n"
  },
  {
    "path": "packages/view-utilities/src/normalize-query-keys/index.test.ts",
    "content": "import { normalizeQueryKeys } from './index'\n\ndescribe('normalizeQueryKeys', () => {\n  test('camelize', () => {\n    expect(\n      normalizeQueryKeys({\n        check_in: '2020-01-01',\n        in_region: 'true',\n      }),\n    ).toEqual({\n      checkIn: '2020-01-01',\n      inRegion: 'true',\n    })\n  })\n\n  test('merge array', () => {\n    expect(\n      normalizeQueryKeys({\n        'agesOfChildren[0]': '5',\n      }),\n    ).toEqual({\n      agesOfChildren: ['5'],\n    })\n\n    expect(\n      normalizeQueryKeys({\n        'agesOfChildren[0]': '5',\n        'agesOfChildren[1]': '6',\n      }),\n    ).toEqual({\n      agesOfChildren: ['5', '6'],\n    })\n\n    expect(\n      normalizeQueryKeys({\n        'foo[0]': '{bar: 2}',\n      }),\n    ).toEqual({\n      foo: ['{bar: 2}'],\n    })\n  })\n\n  test('combinations', () => {\n    expect(\n      normalizeQueryKeys({\n        'ages_of_children[0]': '1',\n        'ages_of_children[1]': '3',\n      }),\n    ).toEqual({\n      agesOfChildren: ['1', '3'],\n    })\n  })\n})\n"
  },
  {
    "path": "packages/view-utilities/src/normalize-query-keys/index.ts",
    "content": "import qs from 'qs'\nimport humps from 'humps'\n\ninterface ParsedUrlQuery {\n  [key: string]: string | string[] | undefined\n}\n\n/**\n * 주어진 URL query의 key 값을 camelize합니다.\n * humps의 camelizeKey에 타입을 지정해주기 위한 래퍼입니다.\n * @param query\n */\nfunction camelizeUrlQuery(query: object | object[]): ParsedUrlQuery {\n  return humps.camelizeKeys(query) as unknown as ParsedUrlQuery\n}\n\n/**\n * next.js는 같은 key 값이 있을 때만 array로 합쳐주는데,\n * qs는 어레이를 `key[0]`으로 stringify하기 때문에 불일치가 발생합니다.\n * 이를 해결합니다.\n *\n * qs.parse의 반환 형식은 ParsedQs입니다.\n * 이는 value로 ParsedQs와 ParsedQs[]를 가질 수 있는 형식입니다.\n * 하지만 아직 nested objects를 query string으로 사용하는 경우는 없기 때문에 무시합니다.\n * @param rawQuery\n */\nfunction mergeIndexedKeys(rawQuery: ParsedUrlQuery): ParsedUrlQuery {\n  return qs.parse(qs.stringify(rawQuery)) as unknown as ParsedUrlQuery\n}\n\n/**\n * query string object의 key값을 사용하기 편하게 가공하는 함수\n * camelize와 array를 변환하는 함수를 통과시킵니다.\n *\n * @param rawQuery 가공되기 전 query string (ex. getServerSideProps의 파라미터)\n */\nexport function normalizeQueryKeys(rawQuery: ParsedUrlQuery): ParsedUrlQuery {\n  return mergeIndexedKeys(camelizeUrlQuery(rawQuery))\n}\n"
  },
  {
    "path": "packages/view-utilities/src/routelist/index.ts",
    "content": "export * from './routelist'\n"
  },
  {
    "path": "packages/view-utilities/src/routelist/routelist.spec.ts",
    "content": "import { checkIfRoutable } from './routelist'\n\ndescribe('checkIfRoutable', () => {\n  const regionId = '5eb828fe-cb69-482c-bf37-e166d6cce259'\n  const hotelId = 'f20c23b6-8eff-47d7-b25a-032be42c1ea6'\n  const communityPostId = '45e178b2-41f1-48f7-94db-0dca71361f27'\n\n  it('should allow navigation to login path', () => {\n    expect(\n      checkIfRoutable({\n        href: '/login',\n      }),\n    ).toBe(true)\n  })\n\n  it('should allow navigation to external url', () => {\n    expect(\n      checkIfRoutable({\n        href: 'https://google.com',\n      }),\n    ).toBe(true)\n  })\n\n  it('should allow navigation to hotel details without regionId', () => {\n    const path = `/hotels/${hotelId}`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(true)\n  })\n\n  it('should allow navigation to hotel details with regionId', () => {\n    const path = `/regions/${regionId}/hotels/${hotelId}`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(true)\n  })\n\n  it('should allow navigation to attraction details with regionId', () => {\n    const path = `/regions/${regionId}/attractions/${hotelId}`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(true)\n  })\n\n  it('should not allow navigation to attraction list', () => {\n    const path = `/regions/${regionId}/attractions`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(false)\n  })\n\n  it('should not allow navigation to tna list', () => {\n    const path = `/tna/regions/${regionId}/products`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(false)\n  })\n\n  it('should allow navigation to tna details', () => {\n    const path = `/tna/regions/${regionId}/products/${hotelId}`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(true)\n  })\n\n  it('should allow navigation to hotel hub', () => {\n    const path = `/hotels`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(true)\n  })\n\n  it('should allow navigation to hotels list', () => {\n    const path = `/hotels/list`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(true)\n  })\n\n  it('should allow navigation to article', () => {\n    const path = `/articles/${hotelId}`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(true)\n  })\n\n  it('should allow navigation to privacy-policy page', () => {\n    const path = `/pages/privacy-policy.html`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(true)\n  })\n\n  it('should allow navigation to community', () => {\n    const path = `/community/posts/${communityPostId}`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(true)\n  })\n\n  it('should allow navigation to game', () => {\n    const path = `/game/my-luggage/${regionId}/missions`\n    expect(\n      checkIfRoutable({\n        href: path,\n      }),\n    ).toBe(true)\n  })\n})\n"
  },
  {
    "path": "packages/view-utilities/src/routelist/routelist.ts",
    "content": "import { parseUrl } from '../url'\n\nconst PUBLIC_ROUTELIST_REGEXES = [\n  /^\\/login$/,\n  /^\\/benefit(\\/.+)?$/,\n  /^\\/regions\\/[^/]+\\/(attractions|restaurants|articles)\\/[^/]+$/,\n  /^\\/regions\\/[^/]+\\/hotels(\\/.*)?$/,\n  /^\\/(attractions|restaurants|hotels|articles)\\/[^/]+$/,\n  /^\\/hotels\\/?$/,\n  /^\\/hotels\\/list(\\/.+)?$/,\n  /^\\/hotels\\/curation(\\/.+)?$/,\n  /^\\/hotels\\/[^/]+\\/rate(\\/.+?)?$/,\n  /^\\/hotels\\/[^/]+\\/breakdown(\\/.+?)?$/,\n  /^(\\/hotels)?\\/regions\\/[^/]+\\/hotel-areas$/,\n  /^\\/air\\/?$/,\n  /^\\/air\\/curation(\\/.+)?$/,\n  /^\\/air\\/price-board(\\/.+)?$/,\n  /^\\/tna(\\?.+)?$/,\n  /^\\/tna\\/curation(\\/.+)?$/,\n  /^\\/tna\\/regions\\/[^/]+\\/products(\\/.+)?$/,\n  /^\\/tna\\/products(\\/.+)?$/,\n  /^\\/tna\\/products\\/[^/]+\\/display$/,\n  /^\\/trips\\/intro(\\/.*)?(\\?.*)?$/,\n  /^\\/trips\\/lounge\\/itineraries\\/[^/]+$/,\n  /^\\/trips\\/promotion\\/customized-schedule(\\/.*)?$/,\n  /^\\/trips\\/plan(\\/.*)?$/,\n  /^\\/reviews\\/list(\\/.*)?$/,\n  /^\\/pages(\\/.*)?$/,\n  /^\\/community(\\/.*)?$/,\n  /^\\/game(\\/.*)?$/,\n]\n\nexport function checkIfRoutable({ href }: { href: string }) {\n  const { host, path } = parseUrl(href)\n\n  if (!host && path) {\n    return PUBLIC_ROUTELIST_REGEXES.some((regex) => path.match(regex))\n  }\n\n  return true\n}\n"
  },
  {
    "path": "packages/view-utilities/src/strict-query/index.test.ts",
    "content": "import { strictQuery } from './index'\n\ndescribe('query utils', () => {\n  it('should determine query string value to string type.', () => {\n    // next.js의 query 형식\n    const query: { [key: string]: string | string[] | undefined } = {\n      regionId: 'MOCK_REGION_ID',\n      multipleQueries: ['a', 'b', 'c'],\n      landingPage: 'service-main',\n    }\n\n    const { regionId, hotelId, landingPage, multipleQueries } = strictQuery(\n      query,\n    )\n      .string('regionId')\n      .string('hotelId')\n      .string<'landingPage', 'service-main' | 'public-list'>('landingPage')\n      .string('multipleQueries')\n      .use()\n    expect(regionId).toBe('MOCK_REGION_ID')\n    expect(hotelId).toBeUndefined()\n    expect(landingPage).toBe('service-main')\n    expect(multipleQueries).toBe('a')\n  })\n\n  it('should determine query string value to number type.', () => {\n    // next.js의 query 형식\n    const query: { [key: string]: string | string[] | undefined } = {\n      tripId: '123456',\n      multipleQueries: ['1', '2', '3'],\n    }\n\n    const { tripId, numberOfAdults, multipleQueries } = strictQuery(query)\n      .number('tripId')\n      .number('numberOfAdults')\n      .number('multipleQueries')\n      .use()\n\n    expect(tripId).toBe(123456)\n    expect(numberOfAdults).toBeUndefined()\n    expect(multipleQueries).toBe(1)\n  })\n\n  it('should determine query string value to string array type.', () => {\n    // next.js의 query 형식\n    const query: { [key: string]: string | string[] | undefined } = {\n      regionIds: 'MOCK_REGION_ID',\n      multipleQueries: ['a', 'b', 'c'],\n    }\n\n    const { regionIds, hotelIds, multipleQueries } = strictQuery(query)\n      .stringArray('regionIds')\n      .stringArray('hotelIds')\n      .stringArray('multipleQueries')\n      .use()\n    expect(regionIds).toEqual(['MOCK_REGION_ID'])\n    expect(hotelIds).toEqual([])\n    expect(multipleQueries).toEqual(['a', 'b', 'c'])\n  })\n\n  it('should determine query string value to number array type.', () => {\n    // next.js의 query 형식\n    const query: { [key: string]: string | string[] | undefined } = {\n      tripIds: '123456',\n      multipleQueries: ['1', '2', '3'],\n    }\n\n    const { tripIds, agesOfChildren, multipleQueries } = strictQuery(query)\n      .numberArray('tripIds')\n      .numberArray('agesOfChildren')\n      .numberArray('multipleQueries')\n      .use()\n\n    expect(tripIds).toEqual([123456])\n    expect(agesOfChildren).toEqual([])\n    expect(multipleQueries).toEqual([1, 2, 3])\n  })\n\n  it('should determine query string value to boolean type', () => {\n    // next.js의 query 형식\n    const query: { [key: string]: string | string[] | undefined } = {\n      inRegion: 'true',\n      hideButton: 'false',\n      duplicateValue1: ['true', 'false'],\n      duplicateValue2: ['false', 'true'],\n      lodging: '',\n      weirdBoolean: 'asdfsaf',\n    }\n\n    const {\n      inRegion,\n      hideButton,\n      duplicateValue1,\n      duplicateValue2,\n      closeWindow,\n      lodging,\n      weirdBoolean,\n    } = strictQuery(query)\n      .boolean('inRegion')\n      .boolean('hideButton')\n      .boolean('duplicateValue1')\n      .boolean('duplicateValue2')\n      .boolean('closeWindow')\n      .boolean('lodging')\n      .boolean('weirdBoolean')\n      .use()\n\n    expect(inRegion).toBe(true)\n    expect(hideButton).toBe(false)\n    expect(duplicateValue1).toBe(true)\n    expect(duplicateValue2).toBe(false)\n    expect(closeWindow).toBe(false)\n    expect(lodging).toBe(true)\n    expect(weirdBoolean).toBe(true)\n  })\n})\n"
  },
  {
    "path": "packages/view-utilities/src/strict-query/index.ts",
    "content": "type RawQuery = string | string[] | undefined\n\nfunction normalizeToString<T extends string = string>(\n  query: RawQuery,\n): T | undefined {\n  const param = Array.isArray(query) ? query[0] : query\n  return param !== undefined ? (param as T) : undefined\n}\n\nfunction normalizeToArray<T extends string = string>(query: RawQuery): T[] {\n  if (Array.isArray(query)) {\n    return query as T[]\n  }\n  if (query !== undefined) {\n    return [query as T]\n  }\n  return []\n}\n\n/**\n * query의 value가 undefined이거나 false일 때 false.\n * 그 외의 value를 가지고 있으면 모두 true\n * @param query\n */\nfunction normalizeToBoolean(query: RawQuery) {\n  return !(\n    query === undefined || (Array.isArray(query) ? query[0] : query) === 'false'\n  )\n}\n\nclass StrictQuery<Resolved = object> {\n  private raw: { [key: string]: RawQuery }\n\n  private resolved: Resolved\n\n  public constructor(raw: { [key: string]: RawQuery }, resolved?: Resolved) {\n    this.raw = raw\n    this.resolved = resolved || ({} as Resolved)\n  }\n\n  public use(): Resolved {\n    return this.resolved\n  }\n\n  public string<Key extends string, Value extends string = string>(\n    key: Key,\n  ): StrictQuery<\n    Resolved & {\n      [key in Key]: Value | undefined\n    }\n  > {\n    const { [key]: value, ...restRaw } = this.raw\n    const normalized = {\n      [key]: normalizeToString<Value>(value),\n    } as { [key in Key]: Value | undefined }\n    return new StrictQuery(restRaw, {\n      ...this.resolved,\n      ...normalized,\n    })\n  }\n\n  public number<Key extends string>(\n    key: Key,\n  ): StrictQuery<\n    Resolved & {\n      [key in Key]: number | undefined\n    }\n  > {\n    const { [key]: value, ...restRaw } = this.raw\n    const stringified = normalizeToString(value)\n    const normalized = {\n      [key]: stringified ? parseFloat(stringified) : undefined,\n    } as { [key in Key]: number | undefined }\n    return new StrictQuery(restRaw, {\n      ...this.resolved,\n      ...normalized,\n    })\n  }\n\n  public stringArray<Key extends string>(\n    key: Key,\n  ): StrictQuery<\n    Resolved & {\n      [key in Key]: string[]\n    }\n  > {\n    const { [key]: value, ...restRaw } = this.raw\n    const normalized = {\n      [key]: normalizeToArray(value),\n    } as { [key in Key]: string[] }\n    return new StrictQuery(restRaw, {\n      ...this.resolved,\n      ...normalized,\n    })\n  }\n\n  public numberArray<Key extends string>(\n    key: Key,\n  ): StrictQuery<\n    Resolved & {\n      [key in Key]: number[]\n    }\n  > {\n    const { [key]: value, ...restRaw } = this.raw\n    const normalized = {\n      [key]: normalizeToArray(value).map(parseFloat),\n    } as { [key in Key]: number[] }\n    return new StrictQuery(restRaw, {\n      ...this.resolved,\n      ...normalized,\n    })\n  }\n\n  public boolean<Key extends string>(\n    key: Key,\n  ): StrictQuery<\n    Resolved & {\n      [key in Key]: boolean\n    }\n  > {\n    const { [key]: value, ...restRaw } = this.raw\n    const normalized = {\n      [key]: normalizeToBoolean(value),\n    } as { [key in Key]: boolean }\n\n    return new StrictQuery(restRaw, {\n      ...this.resolved,\n      ...normalized,\n    })\n  }\n}\n\nexport function strictQuery<T extends { [key: string]: RawQuery }>(query: T) {\n  return new StrictQuery(query)\n}\n"
  },
  {
    "path": "packages/view-utilities/src/timestamp.ts",
    "content": "import {\n  isBefore,\n  subMinutes,\n  formatDistanceToNow,\n  setDefaultOptions,\n  subWeeks,\n  format,\n} from 'date-fns'\nimport { ko } from 'date-fns/locale'\n\nsetDefaultOptions({\n  locale: {\n    ...ko,\n    formatDistance: (token, count, options) => {\n      if (token === 'lessThanXMinutes' && count === 1) {\n        return '방금'\n      }\n      if (token === 'xMinutes') {\n        return `${count}분${options?.addSuffix ? ' 전' : ''}`\n      }\n      if (token === 'aboutXHours') {\n        return `${count}시간${options?.addSuffix ? ' 전' : ''}`\n      } else if (token === 'xDays' && count <= 7) {\n        return `${count}일${options?.addSuffix ? ' 전' : ''}`\n      }\n      return ko.formatDistance(token, count, options)\n    },\n  },\n})\n\nexport function formatTimestamp(date: string | Date) {\n  if (isBefore(subMinutes(new Date(), 1), date)) {\n    return formatDistanceToNow(date)\n  } else if (isBefore(subWeeks(new Date(), 1), date)) {\n    return formatDistanceToNow(date, { addSuffix: true })\n  }\n  return format(date, 'y.M.d')\n}\n"
  },
  {
    "path": "packages/view-utilities/src/url.spec.ts",
    "content": "import qs from 'qs'\n\nimport { generateUrl, getTripleUtmQuery, parseUrl } from './url'\n\ndescribe('parseUrl', () => {\n  it('should parse http url', () => {\n    expect(parseUrl('http://triple.guide')).toEqual({\n      href: 'http://triple.guide',\n      scheme: 'http',\n      host: 'triple.guide',\n      path: '',\n      query: '',\n      hash: '',\n    })\n  })\n\n  it('should parse https url', () => {\n    expect(parseUrl('https://triple.guide')).toEqual({\n      href: 'https://triple.guide',\n      scheme: 'https',\n      host: 'triple.guide',\n      path: '',\n      query: '',\n      hash: '',\n    })\n  })\n\n  it('should parse url with custom scheme', () => {\n    expect(parseUrl('triple://triple.guide')).toEqual({\n      href: 'triple://triple.guide',\n      scheme: 'triple',\n      host: 'triple.guide',\n      path: '',\n      query: '',\n      hash: '',\n    })\n  })\n\n  it('should parse simple url with query', () => {\n    expect(parseUrl('https://triple.guide?q=1&_triple_no_navbar')).toEqual({\n      href: 'https://triple.guide?q=1&_triple_no_navbar',\n      scheme: 'https',\n      host: 'triple.guide',\n      path: '',\n      query: 'q=1&_triple_no_navbar',\n      hash: '',\n    })\n  })\n\n  it('should parse simple url with hash', () => {\n    expect(parseUrl('https://triple.guide#show-me-the-money')).toEqual({\n      href: 'https://triple.guide#show-me-the-money',\n      scheme: 'https',\n      host: 'triple.guide',\n      path: '',\n      query: '',\n      hash: 'show-me-the-money',\n    })\n  })\n\n  it('should parse poi page', () => {\n    expect(\n      parseUrl(\n        'https://triple.guide/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      ),\n    ).toEqual({\n      href: 'https://triple.guide/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      scheme: 'https',\n      host: 'triple.guide',\n      path: '/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      query: '',\n      hash: '',\n    })\n  })\n\n  it('should parse poi page with hash', () => {\n    expect(\n      parseUrl(\n        'https://triple.guide/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3#reviews',\n      ),\n    ).toEqual({\n      href: 'https://triple.guide/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3#reviews',\n      scheme: 'https',\n      host: 'triple.guide',\n      path: '/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      query: '',\n      hash: 'reviews',\n    })\n  })\n\n  it('should parse relative path of poi page', () => {\n    expect(\n      parseUrl(\n        '/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      ),\n    ).toEqual({\n      href: '/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      scheme: '',\n      host: '',\n      path: '/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      query: '',\n      hash: '',\n    })\n  })\n\n  it('should parse app url of poi page', () => {\n    expect(\n      parseUrl(\n        'triple:///regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      ),\n    ).toEqual({\n      href: 'triple:///regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      scheme: 'triple',\n      host: '',\n      path: '/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      query: '',\n      hash: '',\n    })\n  })\n\n  it('should parse app url of poi page with hash', () => {\n    expect(\n      parseUrl(\n        'triple:///regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3#reviews',\n      ),\n    ).toEqual({\n      href: 'triple:///regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3#reviews',\n      scheme: 'triple',\n      host: '',\n      path: '/regions/5b13316d-0bfc-4f90-93a1-69ff5a6d1f48/attractions/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      query: '',\n      hash: 'reviews',\n    })\n  })\n\n  it('should parse article page', () => {\n    expect(\n      parseUrl(\n        'https://triple.guide/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      ),\n    ).toEqual({\n      href: 'https://triple.guide/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      scheme: 'https',\n      host: 'triple.guide',\n      path: '/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      query: '',\n      hash: '',\n    })\n  })\n\n  it('should parse outlink', () => {\n    expect(\n      parseUrl(\n        '/outlink?url=https%3A%2F%2Ftriple.guide%2Farticles%2F68dc3c17-01e9-45d2-aa04-a2891d5c7b69%3F_triple_no_navbar%26_triple_swipe_to_close',\n      ),\n    ).toEqual({\n      href: '/outlink?url=https%3A%2F%2Ftriple.guide%2Farticles%2F68dc3c17-01e9-45d2-aa04-a2891d5c7b69%3F_triple_no_navbar%26_triple_swipe_to_close',\n      scheme: '',\n      host: '',\n      path: '/outlink',\n      query:\n        'url=https%3A%2F%2Ftriple.guide%2Farticles%2F68dc3c17-01e9-45d2-aa04-a2891d5c7b69%3F_triple_no_navbar%26_triple_swipe_to_close',\n      hash: '',\n    })\n  })\n\n  it('should trim passed url before parsing', () => {\n    expect(\n      parseUrl(\n        ' https://triple.guide/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3\\t ',\n      ),\n    ).toEqual({\n      href: 'https://triple.guide/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      scheme: 'https',\n      host: 'triple.guide',\n      path: '/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n      query: '',\n      hash: '',\n    })\n  })\n})\n\ndescribe('generateUrl', () => {\n  it('should generate url with scheme and host only', () => {\n    expect(generateUrl({ scheme: 'https', host: 'triple.guide' })).toBe(\n      'https://triple.guide',\n    )\n  })\n\n  it('should generate url with scheme, host and path only', () => {\n    expect(\n      generateUrl({\n        scheme: 'https',\n        host: 'triple.guide',\n        path: '/announcements',\n      }),\n    ).toBe('https://triple.guide/announcements')\n  })\n\n  it('should generate url with scheme and path only', () => {\n    expect(generateUrl({ scheme: 'triple', path: '/announcements' })).toBe(\n      'triple:///announcements',\n    )\n  })\n\n  it('should generate url with all elements', () => {\n    expect(\n      generateUrl({\n        scheme: 'https',\n        host: 'triple.guide',\n        path: '/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3',\n        query: 'in_region=true',\n        hash: 'reviews',\n      }),\n    ).toBe(\n      'https://triple.guide/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3?in_region=true#reviews',\n    )\n  })\n\n  it('should generate url with base url', () => {\n    expect(\n      generateUrl(\n        { query: 'in_region=true', hash: 'reviews' },\n        'https://triple.guide/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3#nearby',\n      ),\n    ).toBe(\n      'https://triple.guide/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3?in_region=true#reviews',\n    )\n  })\n\n  it('should erase scheme and host', () => {\n    expect(\n      generateUrl(\n        {\n          scheme: undefined,\n          host: undefined,\n        },\n        'https://triple.guide/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3#nearby',\n      ),\n    ).toBe('/articles/e62129b9-ea71-4d3a-bcd8-a2af12566ca3#nearby')\n  })\n\n  it('should merge query in base URL', () => {\n    const [, query] = generateUrl(\n      {\n        query: 'asdf=asdf',\n      },\n      '/path?someQuery=value',\n    ).split('?')\n\n    expect(query).toMatch(/(?=.*asdf=asdf)(?=.*someQuery=value)/)\n  })\n\n  it('should override base URL query with element query', () => {\n    const [, query] = generateUrl(\n      {\n        query: 'asdf=asdf',\n      },\n      '/path?asdf=value',\n    ).split('?')\n\n    expect(query).toMatch(/(?=.*asdf=asdf)/)\n  })\n\n  it('should preserve key only query', () => {\n    expect(generateUrl({}, '/path?_triple_no_navbar')).toBe(\n      '/path?_triple_no_navbar',\n    )\n    expect(generateUrl({ query: '_triple_no_navbar' })).toBe(\n      '?_triple_no_navbar',\n    )\n  })\n\n  it('should format array for comma', () => {\n    const query1 = qs.stringify(\n      {\n        adult: '1',\n        child: '0',\n        infant: '0',\n        searchKeys: [\n          'LJ_3ea1403a-d8ec-42c7-bf0e-f2e7f5e43e19_0',\n          'TW_dfc4cef3-c6fa-4c7b-88ed-cdd7f57ad501_0',\n        ],\n      },\n      { indices: false, skipNulls: true },\n    )\n\n    expect(\n      generateUrl({\n        query: query1,\n      }),\n    ).toBe(`?${query1}`)\n\n    const query2 = qs.stringify(\n      {\n        adult: '1',\n        child: '0',\n        infant: '0',\n        searchKeys: [\n          'LJ_3ea1403a-d8ec-42c7-bf0e-f2e7f5e43e19_0',\n          'TW_dfc4cef3-c6fa-4c7b-88ed-cdd7f57ad501_0',\n        ],\n      },\n      { skipNulls: true },\n    )\n\n    expect(\n      generateUrl(\n        {\n          query: query2,\n        },\n        undefined,\n        { arrayFormat: 'comma' },\n      ),\n    ).toBe(`?${query2}`)\n  })\n\n  it('should preserve array format indices', () => {\n    const query = qs.stringify(\n      { places: ['a', 'b'] },\n      { arrayFormat: 'indices' },\n    )\n    expect(generateUrl({ query })).toBe(`?${query}`)\n  })\n\n  it('should preserve array format brackets', () => {\n    const query = qs.stringify(\n      { places: ['a', 'b'] },\n      { arrayFormat: 'brackets' },\n    )\n    expect(generateUrl({ query })).toBe(`?${query}`)\n  })\n\n  it('should preserve array format repeat', () => {\n    const query = qs.stringify(\n      { places: ['a', 'b'] },\n      { arrayFormat: 'repeat' },\n    )\n    expect(generateUrl({ query })).toBe(`?${query}`)\n  })\n\n  it('should preserve array format comma', () => {\n    const query = qs.stringify({ places: ['a', 'b'] }, { arrayFormat: 'comma' })\n    expect(generateUrl({ query })).toBe(`?${query}`)\n  })\n})\n\ndescribe('getTripleUtmQuery', () => {\n  it('should only get targetQuery', () => {\n    const parsedQuery = {\n      _web_expand: 'true',\n      triple_link_param_item_id: '123-1234',\n      skipInitialCache: 'true',\n      triple_link_param_content_type: 'air',\n      _triple_no_navbar: '',\n      triple_link_param_button_name: 'japan-low-price-air-ticket',\n    }\n    const targetQuery = 'triple_link_param_'\n    const result = {\n      triple_link_param_item_id: '123-1234',\n      triple_link_param_content_type: 'air',\n      triple_link_param_button_name: 'japan-low-price-air-ticket',\n    }\n\n    expect(getTripleUtmQuery({ parsedQuery, targetQuery })).toStrictEqual(\n      result,\n    )\n  })\n})\n"
  },
  {
    "path": "packages/view-utilities/src/url.ts",
    "content": "import { ParsedQs } from 'qs'\n\nexport interface UrlElements {\n  href?: string\n  scheme?: string\n  host?: string\n  path?: string\n  query?: string\n  hash?: string\n}\n\nexport function parseUrl(rawHref?: string): UrlElements {\n  if (!rawHref) {\n    return {}\n  }\n\n  const [href = '', scheme = '', host = '', path = '', query = '', hash = ''] =\n    rawHref\n      .trim()\n      .match(\n        /^(?:([^:/?#]*):\\/\\/)?([^/?#]*)(\\/[^?#]*)?(?:\\?([^#]*))?(?:#(.*))?/,\n      ) || []\n\n  return { href, scheme, host, path, query, hash }\n}\n\ninterface ImplicitBooleanQueryValue {\n  type: 'implicitBoolean'\n}\n\ninterface ArrayFormatQueryValue {\n  type: 'indices' | 'brackets' | 'repeat' | 'comma'\n  value: string[]\n}\n\ntype ParsedQueryValue =\n  | ImplicitBooleanQueryValue\n  | ArrayFormatQueryValue\n  | string\n\ninterface ParsedQuery {\n  [key: string]: ParsedQueryValue\n}\n\nfunction parseQuery(query: string): ParsedQuery {\n  return query\n    .split('&')\n    .map((pair) => pair.split('='))\n    .map((pair): readonly [string] | readonly [string, string] => {\n      const [key, value] = pair.map((str) => decodeURIComponent(str))\n      return value ? [key, value] : [key]\n    })\n    .reduce((result: ParsedQuery, [key, value]) => {\n      if (value === undefined) {\n        return { ...result, [key]: { type: 'implicitBoolean' } }\n      }\n\n      if (key.includes('[')) {\n        // indices or brackets,\n        const [strippedKey, index] = key.split(/\\[|\\]/)\n        const prevValue =\n          (result[strippedKey] as ArrayFormatQueryValue | undefined)?.value ||\n          []\n\n        const type = Number.isInteger(parseInt(index)) ? 'indices' : 'brackets'\n\n        return {\n          ...result,\n          [strippedKey]: {\n            type,\n            value: [...prevValue, value],\n          },\n        }\n      }\n      if (value.includes(',')) {\n        // comma format array\n        return {\n          ...result,\n          [key]: { type: 'comma' as const, value: value.split(',') },\n        }\n      }\n\n      const duplicatedValue = result[key] as Exclude<\n        ParsedQueryValue,\n        ImplicitBooleanQueryValue\n      >\n\n      if (duplicatedValue) {\n        return {\n          ...result,\n          [key]: {\n            type: 'repeat',\n            value:\n              typeof duplicatedValue === 'string'\n                ? [duplicatedValue, value]\n                : [...duplicatedValue.value, value],\n          },\n        }\n      }\n\n      return { ...result, [key]: value }\n    }, {})\n}\n\nfunction stringifyQuery(obj: ParsedQuery): string {\n  return Object.entries(obj)\n    .reduce(\n      (result, [key, value]) => {\n        if (typeof value === 'string') {\n          return [...result, [key, value] as const]\n        }\n        if ('type' in value) {\n          if (value.type === 'implicitBoolean') {\n            return [...result, [key] as const]\n          }\n\n          const { type, value: array } = value\n\n          if (type === 'indices') {\n            return [\n              ...result,\n              ...array.map(\n                (value, index) => [`${key}[${index}]`, value] as const,\n              ),\n            ]\n          }\n          if (type === 'brackets') {\n            return [\n              ...result,\n              ...array.map((value) => [`${key}[]`, value] as const),\n            ]\n          }\n          if (type === 'repeat') {\n            return [...result, ...array.map((value) => [key, value] as const)]\n          }\n          if (type === 'comma') {\n            return [...result, [key, array.join(',')] as const]\n          }\n        }\n        return result\n      },\n      [] as (readonly [string] | readonly [string, string])[],\n    )\n    .map((pair: readonly [string] | readonly [string, string]) =>\n      pair.map((str) => encodeURIComponent(str)),\n    )\n    .map((pair) => pair.join('='))\n    .join('&')\n}\n\nexport function generateUrl(\n  { query: elementQuery, ...restElements }: UrlElements,\n  baseUrl?: string,\n  /**\n   * @deprecated\n   */\n  _?: { arrayFormat?: 'indices' | 'brackets' | 'repeat' | 'comma' },\n) {\n  const { query: baseUrlQuery, ...restBaseUrl }: UrlElements = baseUrl\n    ? parseUrl(baseUrl)\n    : {}\n\n  const { scheme, host, path, hash } = {\n    ...restBaseUrl,\n    ...restElements,\n  }\n\n  const query = stringifyQuery({\n    ...(baseUrlQuery && parseQuery(baseUrlQuery)),\n    ...(elementQuery && parseQuery(elementQuery)),\n  })\n\n  return [\n    scheme && `${scheme}://`,\n    host,\n    path,\n    query && `?${query}`,\n    hash && `#${hash}`,\n  ]\n    .filter((v) => v)\n    .join('')\n}\n\nexport function getTripleUtmQuery({\n  parsedQuery,\n  targetQuery = 'triple_link_param_',\n}: {\n  parsedQuery: ParsedQs\n  targetQuery?: string\n}) {\n  const regex = new RegExp(`^${targetQuery}`, 'i')\n\n  return Object.keys(parsedQuery)\n    .filter((key) => key.match(regex))\n    .reduce(\n      (params, key) => ({\n        ...params,\n        [key]: parsedQuery[key],\n      }),\n      {},\n    )\n}\n"
  },
  {
    "path": "packages/view-utilities/tsconfig.build.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"noEmitOnError\": true,\n    \"outDir\": \"./lib\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"lib\",\n    \"src/**/*.test.*\",\n    \"src/**/*.spec.*\",\n    \"src/**/*.stories.*\"\n  ]\n}\n"
  },
  {
    "path": "packages/view-utilities/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"exclude\": [\"node_modules\", \"lib\"]\n}\n"
  },
  {
    "path": "packages/view-utilities/vite.config.mts",
    "content": "export { default } from '../../vite.config.mjs'\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - 'examples/*'\n  - 'packages/*'\n"
  },
  {
    "path": "renovate.json",
    "content": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"packageRules\": [\n    {\n      \"matchManagers\": [\"npm\"],\n      \"matchDepTypes\": [\"engines\"],\n      \"enabled\": false\n    },\n    {\n      \"matchSourceUrls\": [\"https://github.com/titicacadev/triple-frontend\"],\n      \"enabled\": false\n    },\n    {\n      \"groupName\": \"SWC\",\n      \"matchPackageNames\": [\"@swc/*\"]\n    },\n    {\n      \"matchPackageNames\": [\"firebase\", \"nx-cloud\"],\n      \"enabled\": false\n    },\n    {\n      \"matchDepTypes\": [\"dependencies\"],\n      \"matchUpdateTypes\": [\"minor\", \"patch\", \"pin\"],\n      \"enabled\": false\n    },\n    {\n      \"matchDepTypes\": [\"devDependencies\"],\n      \"enabled\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "scripts/changelog.js",
    "content": "/* eslint-disable no-console */\n/* eslint-disable camelcase */\nconst fs = require('fs')\n\nfunction generateChatGptPrompt(inputData) {\n  return `\nRead the following example and markdown format, and write a markdown using given input data in exactly same format. Note that you must remove emojis from package names, and all items must be grouped by their package names properly.\nMarkdown format:\n\\`\\`\\`\n### <package_name>\n\n- <title> [#<number>](<url>)\n- <title> [#<number>](<url>)\n\\`\\`\\`\nJSON data format:\n\\`\\`\\`\n{\n  \"package_name\": [\n    {\n      \"title\": \"...\",\n      \"number\": 123,\n      \"url\": \"...\"\n    }\n  ]\n}\n\\`\\`\\`\nExample JSON data:\n\\`\\`\\`\n{\n  \"ab-experiments\": [\n    {\n      \"title\": \"[core-elements] story title을 소문자로 변경\",\n      \"number\": 2345,\n      \"url\": \"https://github.com/titicacadev/triple-frontend/pull/2345\"\n    },\n\t\t{\n\t\t\t\"title\": \"css prop과 centered prop이 충돌하는 문제 수정\",\n      \"number\": 2352,\n      \"url\": \"https://github.com/titicacadev/triple-frontend/pull/2352\"\n\t\t},\n    {\n      \"title\": \"[core-elements] Rating 컴포넌트에 최대값,최소값 설정 추가\",\n      \"number\": 2364,\n      \"url\": \"https://github.com/titicacadev/triple-frontend/pull/2364\"\n    }\n  ],\n\t\"anchor\": [\n\t\t{\n\t\t\t\"title\": \"css prop과 centered prop이 충돌하는 문제 수정\",\n      \"number\": 2352,\n      \"url\": \"https://github.com/titicacadev/triple-frontend/pull/2352\"\n\t\t}\n\t],\n  \"common\": [\n    {\n      \"title\": \"cd workflow 에러 수정\",\n      \"number\": 2351,\n      \"url\": \"https://github.com/titicacadev/triple-frontend/pull/2351\"\n    },\n\t\t{\n\t\t\t\"title\": \"css prop과 centered prop이 충돌하는 문제 수정\",\n      \"number\": 2352,\n      \"url\": \"https://github.com/titicacadev/triple-frontend/pull/2352\"\n\t\t},\n    {\n      \"title\": \"[core-elements] Rating 컴포넌트에 최대값,최소값 설정 추가\",\n      \"number\": 2364,\n      \"url\": \"https://github.com/titicacadev/triple-frontend/pull/2364\"\n    }\n  ],\n  \"core-elements\": [\n    {\n      \"title\": \"[core-elements] story title을 소문자로 변경\",\n      \"number\": 2345,\n      \"url\": \"https://github.com/titicacadev/triple-frontend/pull/2345\"\n    },\n    {\n      \"title\": \"[core-elements] ConfirmSelector 디자인 수정\",\n      \"number\": 2359,\n      \"url\": \"https://github.com/titicacadev/triple-frontend/pull/2359\"\n    },\n    {\n      \"title\": \"[core-elements] Rating 컴포넌트에 최대값,최소값 설정 추가\",\n      \"number\": 2364,\n      \"url\": \"https://github.com/titicacadev/triple-frontend/pull/2364\"\n    }\n  ]\n}\n\\`\\`\\`\nExample output markdown:\n\\`\\`\\`\n### ab-experiments\n\n- [core-elements] story title을 소문자로 변경 [#2345](https://github.com/titicacadev/triple-frontend/pull/2345)\n- css prop과 centered prop이 충돌하는 문제 수정 [#2352](https://github.com/titicacadev/triple-frontend/pull/2352)\n- Rating 컴포넌트에 최대값,최소값 설정 추가 [#2364](https://github.com/titicacadev/triple-frontend/pull/2364)\n\n### anchor\n\n- css prop과 centered prop이 충돌하는 문제 수정 [#2352](https://github.com/titicacadev/triple-frontend/pull/2352)\n\n### common\n\n- cd workflow 에러 수정 [#2351](https://github.com/titicacadev/triple-frontend/pull/2351)\n- css prop과 centered prop이 충돌하는 문제 수정 [#2352](https://github.com/titicacadev/triple-frontend/pull/2352)\n- Rating 컴포넌트에 최대값,최소값 설정 추가 [#2364](https://github.com/titicacadev/triple-frontend/pull/2364)\n\n### core-elements\n\n- [core-elements] story title을 소문자로 변경 [#2345](https://github.com/titicacadev/triple-frontend/pull/2345)\n- [core-elements] ConfirmSelector 디자인 수정 [#2359](https://github.com/titicacadev/triple-frontend/pull/2359)\n- Rating 컴포넌트에 최대값,최소값 설정 추가 [#2364](https://github.com/titicacadev/triple-frontend/pull/2364)\n\\`\\`\\`\nInput JSON data:\n\\`\\`\\`\n${inputData}\n\\`\\`\\`\nOutput markdown:\n`\n}\n\nfunction groupPullRequestsByPackage(pullRequests) {\n  const groupedPullRequests = pullRequests.reduce(\n    (result, { packages, title, number, url }) => {\n      packages.forEach((pkg) => {\n        if (!result[pkg]) {\n          result[pkg] = []\n        }\n\n        result[pkg].push({\n          title,\n          number,\n          url,\n        })\n      })\n\n      return result\n    },\n    {},\n  )\n\n  return Object.keys(groupedPullRequests)\n    .sort((keyA, keyB) => keyA.toLowerCase().localeCompare(keyB.toLowerCase()))\n    .reduce((acc, key) => ({ ...acc, [key]: groupedPullRequests[key] }), {})\n}\n\nasync function fetchPrsInMilestone() {\n  const response = await fetch(\n    `https://api.github.com/search/issues?q=milestone:${process.env.CURRENT_VERSION}+type:pr+repo:${process.env.GITHUB_REPOSITORY}&per_page=100`,\n    {\n      headers: {\n        Accept: 'application/vnd.github+json',\n        Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,\n        'X-GitHub-Api-Version': '2022-11-28',\n      },\n    },\n  )\n\n  const data = await response.json()\n  if (data.total_count === undefined) {\n    console.log(data.message)\n    process.exit(1)\n  }\n\n  const pullRequests = data.items\n    .filter(\n      ({ pull_request, labels }) =>\n        !!pull_request.merged_at &&\n        labels.length > 0 &&\n        !labels.some(({ name }) => name === 'release'),\n    )\n    .map(({ title, number, html_url: url, labels }) => ({\n      title,\n      number,\n      url,\n      packages: labels.map(({ name }) => name),\n    }))\n    .sort((a, b) => a.number - b.number)\n\n  return pullRequests\n}\n\nasync function writeChangelog(prsInMilestone) {\n  const response = await fetch('https://api.openai.com/v1/chat/completions', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,\n    },\n    body: JSON.stringify({\n      model: 'gpt-3.5-turbo',\n      messages: [\n        {\n          role: 'user',\n          content: generateChatGptPrompt(JSON.stringify(prsInMilestone)),\n        },\n      ],\n      temperature: 0,\n    }),\n  })\n\n  const res = await response.json()\n  if (res.error) {\n    console.log(res.error.message)\n    process.exit(1)\n  }\n\n  return res.choices[0].message.content\n}\n\nasync function main() {\n  const changelogContent = await writeChangelog(\n    groupPullRequestsByPackage(await fetchPrsInMilestone()),\n  )\n\n  const changelogFile = fs.readFileSync('CHANGELOG.md', 'utf8')\n  const lines = changelogFile.split('\\n')\n  lines.splice(\n    2,\n    0,\n    `## ${process.env.CURRENT_VERSION}\\n`,\n    `${changelogContent}\\n`,\n  )\n  fs.writeFileSync('CHANGELOG.md', lines.join('\\n'), 'utf-8')\n}\n\nmain()\n"
  },
  {
    "path": "stories/introduction.mdx",
    "content": "import { Meta } from '@storybook/blocks'\nimport lernaConfig from '../lerna.json'\n\n<Meta title=\"Introduction\" />\n\n# Introduction\n\n`Triple Frontend`는 트리플 프론트엔드 공용 컴포넌트 및 라이브러리입니다.\n\n## 버전\n\n<span>{lernaConfig.version}</span>\n\n## 기여하기\n\n[CONTRIBUTING.md](https://github.com/titicacadev/triple-frontend/blob/main/CONTRIBUTING.md)를 참고해주세요.\n\n## 라이선스\n\n[MIT License](https://github.com/titicacadev/triple-frontend/blob/main/LICENSE)\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"lib\": [\"DOM\", \"DOM.Iterable\", \"ES2015\"],\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"target\": \"ES2015\",\n\n    \"allowJs\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"jsx\": \"react-jsx\",\n    \"noFallthroughCasesInSwitch\": true,\n    \"resolveJsonModule\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true\n  },\n  \"include\": [\"global.d.ts\", \"**/*.ts\", \"**/*.tsx\"],\n  \"exclude\": [\"**/node_modules\", \"**/lib\"]\n}\n"
  },
  {
    "path": "tsconfig.test.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@titicaca/ab-experiments\": [\"./packages/ab-experiments/src\"],\n      \"@titicaca/constants\": [\"./packages/constants/src\"],\n      \"@titicaca/fetcher\": [\"./packages/fetcher/src\"],\n      \"@titicaca/i18n\": [\"./packages/i18n/src\"],\n      \"@titicaca/intersection-observer\": [\n        \"./packages/intersection-observer/src\"\n      ],\n      \"@titicaca/meta-tags\": [\"./packages/meta-tags/src\"],\n      \"@titicaca/react-hooks\": [\"./packages/react-hooks/src\"],\n      \"@titicaca/router\": [\"./packages/router/src\"],\n      \"@titicaca/scroll-to-element\": [\"./packages/scroll-to-element/src\"],\n      \"@titicaca/standard-action-handler\": [\n        \"./packages/standard-action-handler/src\"\n      ],\n      \"@titicaca/tds-theme\": [\"./packages/tds-theme/src\"],\n      \"@titicaca/tds-ui\": [\"./packages/tds-ui/src\"],\n      \"@titicaca/tds-widget\": [\"./packages/tds-widget/src\"],\n      \"@titicaca/triple-document\": [\"./packages/triple-document/src\"],\n      \"@titicaca/triple-email-document\": [\n        \"./packages/triple-email-document/src\"\n      ],\n      \"@titicaca/triple-fallback-action\": [\n        \"./packages/triple-fallback-action/src\"\n      ],\n      \"@titicaca/triple-header\": [\"./packages/triple-header/src\"],\n      \"@titicaca/triple-web\": [\"./packages/triple-web/src\"],\n      \"@titicaca/triple-web-nextjs-pages\": [\n        \"./packages/triple-web-nextjs-pages/src\"\n      ],\n      \"@titicaca/triple-web-nextjs\": [\"./packages/triple-web-nextjs/src\"],\n      \"@titicaca/triple-web-utils\": [\"./packages/triple-web-utils/src\"],\n      \"@titicaca/type-definitions\": [\"./packages/type-definitions/src\"],\n      \"@titicaca/view-utilities\": [\"./packages/view-utilities/src\"]\n    }\n  },\n  \"include\": [\"global.d.ts\", \"**/*.ts\", \"**/*.tsx\"],\n  \"exclude\": [\"**/node_modules\", \"**/lib\"]\n}\n"
  },
  {
    "path": "vite.config.mts",
    "content": "import { defineConfig } from 'vite'\nimport checker from 'vite-plugin-checker'\nimport dts from 'vite-plugin-dts'\nimport react from '@vitejs/plugin-react-swc'\nimport { nodeExternals } from 'rollup-plugin-node-externals'\nimport preserveDirectives from 'rollup-plugin-preserve-directives'\n\nexport default defineConfig({\n  build: {\n    target: 'es6',\n    outDir: 'lib',\n    lib: {\n      entry: ['src/index.ts'],\n      fileName: (format, entryName) =>\n        format === 'es' ? `${entryName}.mjs` : `${entryName}.js`,\n      formats: ['es', 'cjs'],\n    },\n    rollupOptions: {\n      plugins: [nodeExternals()],\n      output: {\n        interop: 'auto',\n        preserveModules: true,\n        preserveModulesRoot: 'src',\n      },\n    },\n    minify: false,\n  },\n  plugins: [\n    checker({ typescript: { tsconfigPath: './tsconfig.build.json' } }),\n    dts({ tsconfigPath: './tsconfig.build.json' }),\n    react({ plugins: [['@swc/plugin-styled-components', {}]] }),\n    preserveDirectives(),\n  ],\n})\n"
  }
]