diff --git a/.github/workflows/deploy-staging-web.yml b/.github/workflows/deploy-staging-web.yml index a1b42116..fae4260f 100644 --- a/.github/workflows/deploy-staging-web.yml +++ b/.github/workflows/deploy-staging-web.yml @@ -8,28 +8,32 @@ on: - "web-seller/staging-*" - "web-admin/staging-*" -permissions: {} +permissions: + contents: read + +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} jobs: deploy-vercel: runs-on: ubuntu-latest steps: - # 태그에서 프로젝트명과 환경 추출 (예: web-user/staging-v1.0.0) - name: Extract project name and environment from tag id: extract-project run: | TAG_NAME=${GITHUB_REF#refs/tags/} - PROJECT_NAME=$(echo $TAG_NAME | cut -d'/' -f1) - ENV_NAME=$(echo $TAG_NAME | cut -d'/' -f2 | cut -d'-' -f1) - echo "project=$PROJECT_NAME" >> $GITHUB_OUTPUT - echo "environment=$ENV_NAME" >> $GITHUB_OUTPUT - echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT + PROJECT_NAME=$(echo "$TAG_NAME" | cut -d'/' -f1) + ENV_NAME=$(echo "$TAG_NAME" | cut -d'/' -f2 | cut -d'-' -f1) + echo "project=$PROJECT_NAME" >> "$GITHUB_OUTPUT" + echo "environment=$ENV_NAME" >> "$GITHUB_OUTPUT" + echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "app_dir=apps/$PROJECT_NAME" >> "$GITHUB_OUTPUT" echo "📦 Tag: $TAG_NAME" echo "📁 Project: $PROJECT_NAME" echo "🌍 Environment: $ENV_NAME" - # 프로젝트명과 환경 유효성 검증 - name: Validate project name and environment run: | PROJECT=${{ steps.extract-project.outputs.project }} @@ -37,66 +41,194 @@ jobs: if [[ "$PROJECT" != "web-user" && "$PROJECT" != "web-seller" && "$PROJECT" != "web-admin" ]]; then echo "❌ Invalid project name: $PROJECT" - echo "Valid project names: web-user, web-seller, web-admin" exit 1 fi if [[ "$ENV" != "staging" ]]; then echo "❌ Invalid environment: $ENV" - echo "Valid environment: staging" exit 1 fi echo "✅ Valid project: $PROJECT" echo "✅ Valid environment: $ENV" - # 프로젝트별 Vercel 웹훅 URL 설정 - - name: Set project-specific webhook URL - id: set-webhook + - name: Set Vercel project id + id: vercel-project run: | PROJECT=${{ steps.extract-project.outputs.project }} - case $PROJECT in + case "$PROJECT" in web-user) - echo "webhook_url=${{ secrets.VERCEL_WEBHOOK_URL_WEB_USER_STAGING }}" >> $GITHUB_OUTPUT + PROJECT_ID="${{ secrets.VERCEL_PROJECT_ID_WEB_USER_STAGING }}" ;; web-seller) - echo "webhook_url=${{ secrets.VERCEL_WEBHOOK_URL_WEB_SELLER_STAGING }}" >> $GITHUB_OUTPUT + PROJECT_ID="${{ secrets.VERCEL_PROJECT_ID_WEB_SELLER_STAGING }}" ;; web-admin) - echo "webhook_url=${{ secrets.VERCEL_WEBHOOK_URL_WEB_ADMIN_STAGING }}" >> $GITHUB_OUTPUT + PROJECT_ID="${{ secrets.VERCEL_PROJECT_ID_WEB_ADMIN_STAGING }}" ;; esac - # Vercel 웹훅을 통한 배포 트리거 - - name: Trigger Vercel deployment via webhook + if [ -z "$PROJECT_ID" ]; then + echo "❌ VERCEL_PROJECT_ID is not set for $PROJECT" + echo "Add VERCEL_PROJECT_ID_*_STAGING to GitHub repository secrets" + exit 1 + fi + + echo "id=$PROJECT_ID" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + # packageManager: yarn@4.x — setup-node의 cache: yarn은 Corepack 전 Yarn 1.x를 호출해 실패함 + - name: Enable Corepack + run: corepack enable + + - name: Get Yarn cache directory + id: yarn-cache-dir + run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" + + - name: Cache Yarn dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.yarn-cache-dir.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn install --immutable + + - name: Install Vercel CLI + run: npm install -g vercel@latest + + # 모노레포: apps/에서 vercel CLI를 실행하면 rootDirectory가 중복되어 + # apps/web-user/apps/web-user 경로가 되며 "spawn sh ENOENT"가 발생함 → 저장소 루트에서 실행 + - name: Pull Vercel environment + env: + VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} run: | - PROJECT=${{ steps.extract-project.outputs.project }} - TAG_NAME=${{ steps.extract-project.outputs.tag }} - WEBHOOK_URL=${{ steps.set-webhook.outputs.webhook_url }} + if [ -z "$VERCEL_TOKEN" ] || [ -z "$VERCEL_ORG_ID" ]; then + echo "❌ VERCEL_TOKEN and VERCEL_ORG_ID secrets are required" + exit 1 + fi + + rm -rf apps/*/.vercel .vercel + + vercel pull --yes --environment=production --token="$VERCEL_TOKEN" \ + 2>&1 | tee /tmp/vercel-pull.log + + # 의존성은 위에서 루트 yarn install 완료 — vercel build의 install 단계 스킵 + jq '.installCommand = "true"' .vercel/project.json > .vercel/project.json.tmp + mv .vercel/project.json.tmp .vercel/project.json - echo "🚀 Triggering deployment for $PROJECT (staging) via Vercel webhook..." - echo "📋 Tag: $TAG_NAME" - echo "🔗 Webhook URL: ${WEBHOOK_URL:0:50}..." # URL 일부만 표시 (보안) + - name: Build with Vercel + id: vercel-build + env: + VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} + run: | + set -o pipefail + vercel build --prod --token="$VERCEL_TOKEN" \ + 2>&1 | tee /tmp/vercel-build.log + echo "result=success" >> "$GITHUB_OUTPUT" + + - name: Deploy to Vercel + id: vercel-deploy + env: + VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} + run: | + set -o pipefail + vercel deploy --prebuilt --prod --yes --token="$VERCEL_TOKEN" \ + 2>&1 | tee /tmp/vercel-deploy.log - if [ -z "$WEBHOOK_URL" ]; then - echo "❌ Error: Webhook URL is not set for $PROJECT" - echo "Please set VERCEL_WEBHOOK_URL_${PROJECT^^}_STAGING secret in GitHub repository settings" + DEPLOY_URL=$(grep -Eo 'https://[a-zA-Z0-9./_-]+' /tmp/vercel-deploy.log | tail -n 1) + if [ -z "$DEPLOY_URL" ]; then + echo "❌ Could not parse deployment URL from Vercel CLI output" exit 1 fi - # Vercel 웹훅 호출 - echo "📤 Calling Vercel webhook..." - HTTP_STATUS=$(curl -s -o /tmp/vercel_response.txt -w "%{http_code}" \ + echo "url=$DEPLOY_URL" >> "$GITHUB_OUTPUT" + echo "✅ Deployed: $DEPLOY_URL" + + - name: Notify Discord + if: always() + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL_WEB_FE }} + PROJECT: ${{ steps.extract-project.outputs.project }} + TAG: ${{ steps.extract-project.outputs.tag }} + ENVIRONMENT: ${{ steps.extract-project.outputs.environment }} + DEPLOY_URL: ${{ steps.vercel-deploy.outputs.url }} + JOB_STATUS: ${{ job.status }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "⚠️ DISCORD_WEBHOOK_URL_WEB_FE is not set — skipping notification" + exit 0 + fi + + case "$JOB_STATUS" in + success) STATUS_LABEL="✅ 배포 성공"; COLOR=5763719 ;; + failure) STATUS_LABEL="❌ 배포 실패"; COLOR=15548997 ;; + cancelled) STATUS_LABEL="⚠️ 배포 취소"; COLOR=9807270 ;; + *) STATUS_LABEL="ℹ️ 배포 종료 ($JOB_STATUS)"; COLOR=3447003 ;; + esac + + LOG_SOURCE="/tmp/vercel-build.log" + if [ ! -s "$LOG_SOURCE" ]; then + LOG_SOURCE="/tmp/vercel-pull.log" + fi + if [ ! -s "$LOG_SOURCE" ]; then + LOG_SOURCE="/tmp/vercel-deploy.log" + fi + + LOG_SNIPPET="로그 파일 없음" + if [ -s "$LOG_SOURCE" ]; then + LOG_SNIPPET=$(tail -c 900 "$LOG_SOURCE" | sed 's/```/``\`/g') + fi + + DEPLOY_FIELD="${DEPLOY_URL:-배포 URL 없음 (빌드/배포 단계 실패)}" + if [ -n "$DEPLOY_URL" ]; then + DEPLOY_FIELD="[$DEPLOY_URL]($DEPLOY_URL)" + fi + + PAYLOAD=$(jq -n \ + --arg title "$STATUS_LABEL — $PROJECT (staging)" \ + --argjson color "$COLOR" \ + --arg project "$PROJECT" \ + --arg tag "$TAG" \ + --arg environment "$ENVIRONMENT" \ + --arg deploy "$DEPLOY_FIELD" \ + --arg run_url "$RUN_URL" \ + --arg log "$LOG_SNIPPET" \ + '{ + embeds: [{ + title: $title, + color: $color, + fields: [ + { name: "프로젝트", value: $project, inline: true }, + { name: "환경", value: $environment, inline: true }, + { name: "태그", value: ("`" + $tag + "`"), inline: false }, + { name: "배포 URL", value: $deploy, inline: false }, + { name: "GitHub Actions", value: ("[워크플로우 로그](" + $run_url + ")"), inline: false }, + { name: "Vercel 로그 (마지막 900자)", value: ("```\n" + $log + "\n```"), inline: false } + ], + timestamp: (now | strftime("%Y-%m-%dT%H:%M:%SZ")) + }] + }') + + HTTP_STATUS=$(curl -s -o /tmp/discord_response.txt -w "%{http_code}" \ -X POST \ -H "Content-Type: application/json" \ - "$WEBHOOK_URL") + -d "$PAYLOAD" \ + "$DISCORD_WEBHOOK_URL") if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then - echo "✅ Webhook triggered successfully (HTTP $HTTP_STATUS)" - cat /tmp/vercel_response.txt 2>/dev/null || echo "No response body" + echo "✅ Discord notification sent (HTTP $HTTP_STATUS)" else - echo "❌ Webhook call failed (HTTP $HTTP_STATUS)" - cat /tmp/vercel_response.txt - exit 1 + echo "⚠️ Discord notification failed (HTTP $HTTP_STATUS) — deploy result is unchanged" + cat /tmp/discord_response.txt fi diff --git a/apps/backend/src/apis/consumer/consumer-api.module.ts b/apps/backend/src/apis/consumer/consumer-api.module.ts index 3edc57a6..58a33117 100644 --- a/apps/backend/src/apis/consumer/consumer-api.module.ts +++ b/apps/backend/src/apis/consumer/consumer-api.module.ts @@ -29,6 +29,8 @@ import { NoticeModule } from "@apps/backend/modules/notice/notice.module"; import { ConsumerNoticeController } from "@apps/backend/apis/consumer/controllers/notice.controller"; import { QnaModule } from "@apps/backend/modules/qna/qna.module"; import { ConsumerQnaController } from "@apps/backend/apis/consumer/controllers/qna.controller"; +import { StoreEntryRequestModule } from "@apps/backend/modules/store-entry-request/store-entry-request.module"; +import { ConsumerStoreEntryRequestController } from "@apps/backend/apis/consumer/controllers/store-entry-request.controller"; /** * Consumer(구매자) API 모듈 — 경로 prefix `consumer`, JWT aud `consumer` @@ -51,6 +53,7 @@ import { ConsumerQnaController } from "@apps/backend/apis/consumer/controllers/q TermsModule, NoticeModule, QnaModule, + StoreEntryRequestModule, // ChatModule, ], controllers: [ @@ -69,6 +72,7 @@ import { ConsumerQnaController } from "@apps/backend/apis/consumer/controllers/q ConsumerTermsController, ConsumerNoticeController, ConsumerQnaController, + ConsumerStoreEntryRequestController, // ConsumerChatController, ], }) diff --git a/apps/backend/src/apis/consumer/controllers/store-entry-request.controller.ts b/apps/backend/src/apis/consumer/controllers/store-entry-request.controller.ts new file mode 100644 index 00000000..a69cff6f --- /dev/null +++ b/apps/backend/src/apis/consumer/controllers/store-entry-request.controller.ts @@ -0,0 +1,71 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query, Request } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiExtraModels } from "@nestjs/swagger"; +import { StoreEntryRequestService } from "@apps/backend/modules/store-entry-request/store-entry-request.service"; +import { Auth } from "@apps/backend/modules/auth/decorators/auth.decorator"; +import { SwaggerResponse } from "@apps/backend/common/decorators/swagger-response.decorator"; +import { SwaggerAuthResponses } from "@apps/backend/common/decorators/swagger-auth-responses.decorator"; +import { createMessageObject } from "@apps/backend/common/utils/message.util"; +import { JwtVerifiedPayload } from "@apps/backend/modules/auth/types/auth.types"; +import { AUDIENCE } from "@apps/backend/modules/auth/constants/auth.constants"; +import { + STORE_ENTRY_REQUEST_ERROR_MESSAGES, + STORE_ENTRY_REQUEST_SUCCESS_MESSAGES, +} from "@apps/backend/modules/store-entry-request/constants/store-entry-request.constants"; +import { + CreateStoreEntryRequestDto, + CreateStoreEntryRequestResponseDto, + StoreEntryRequestExistsQueryDto, + StoreEntryRequestExistsResponseDto, +} from "@apps/backend/modules/store-entry-request/dto/store-entry-request.dto"; + +/** + * 입점 요청 컨트롤러 (사용자용) + * 지도에서 미입점 스토어(카카오 장소)에 대한 입점 요청을 처리합니다. + */ +@ApiTags("입점 요청") +@ApiExtraModels(CreateStoreEntryRequestResponseDto, StoreEntryRequestExistsResponseDto) +@Controller(`${AUDIENCE.CONSUMER}/store-entry-requests`) +@Auth({ isPublic: false, audiences: ["consumer"] }) +export class ConsumerStoreEntryRequestController { + constructor(private readonly storeEntryRequestService: StoreEntryRequestService) {} + + /** + * 입점 요청 추가 API + */ + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: "(로그인 필요) 입점 요청 추가", + description: "미입점 스토어(카카오 장소)에 대한 입점 요청을 등록합니다. 동일 장소 중복 요청은 불가합니다.", + }) + @SwaggerResponse(201, { dataDto: CreateStoreEntryRequestResponseDto }) + @SwaggerAuthResponses() + @SwaggerResponse(409, { + dataExample: createMessageObject(STORE_ENTRY_REQUEST_ERROR_MESSAGES.ALREADY_EXISTS), + }) + async create( + @Body() dto: CreateStoreEntryRequestDto, + @Request() req: { user: JwtVerifiedPayload }, + ) { + await this.storeEntryRequestService.createForUser(req.user.sub, dto); + return { message: STORE_ENTRY_REQUEST_SUCCESS_MESSAGES.CREATED }; + } + + /** + * 입점 요청 존재 여부 조회 API (버튼 상태 표시용) + */ + @Get("exists") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "(로그인 필요) 입점 요청 존재 여부 조회", + description: "현재 로그인 사용자가 해당 카카오 장소에 이미 입점 요청했는지 여부를 반환합니다.", + }) + @SwaggerResponse(200, { dataDto: StoreEntryRequestExistsResponseDto }) + @SwaggerAuthResponses() + async exists( + @Query() query: StoreEntryRequestExistsQueryDto, + @Request() req: { user: JwtVerifiedPayload }, + ): Promise { + return await this.storeEntryRequestService.existsForUser(req.user.sub, query.kakaoPlaceId); + } +} diff --git a/apps/backend/src/infra/database/prisma/migrations/20260620120000_add_product_discount_price/migration.sql b/apps/backend/src/infra/database/prisma/migrations/20260620120000_add_product_discount_price/migration.sql new file mode 100644 index 00000000..23eae207 --- /dev/null +++ b/apps/backend/src/infra/database/prisma/migrations/20260620120000_add_product_discount_price/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable: 정가(original_price) 추가, 기존 할인가(discount_price) 컬럼 제거 +ALTER TABLE "products" ADD COLUMN IF NOT EXISTS "original_price" INTEGER; + +UPDATE "products" SET "original_price" = "sale_price" WHERE "original_price" IS NULL; + +ALTER TABLE "products" ALTER COLUMN "original_price" SET NOT NULL; + +ALTER TABLE "products" DROP COLUMN IF EXISTS "discount_price"; diff --git a/apps/backend/src/infra/database/prisma/migrations/20260620130000_rename_list_price_to_original_price/migration.sql b/apps/backend/src/infra/database/prisma/migrations/20260620130000_rename_list_price_to_original_price/migration.sql new file mode 100644 index 00000000..2bdd457f --- /dev/null +++ b/apps/backend/src/infra/database/prisma/migrations/20260620130000_rename_list_price_to_original_price/migration.sql @@ -0,0 +1,13 @@ +-- list_price → original_price (이전 마이그레이션 적용 환경 호환) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'products' AND column_name = 'list_price' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'products' AND column_name = 'original_price' + ) THEN + ALTER TABLE "products" RENAME COLUMN "list_price" TO "original_price"; + END IF; +END $$; diff --git a/apps/backend/src/infra/database/prisma/migrations/20260621120000_remove_product_lettering_required/migration.sql b/apps/backend/src/infra/database/prisma/migrations/20260621120000_remove_product_lettering_required/migration.sql new file mode 100644 index 00000000..828e1f6e --- /dev/null +++ b/apps/backend/src/infra/database/prisma/migrations/20260621120000_remove_product_lettering_required/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "products" DROP COLUMN "lettering_required"; + +-- DropEnum +DROP TYPE "OptionRequired"; diff --git a/apps/backend/src/infra/database/prisma/migrations/20260623002342_add_store_entry_request/migration.sql b/apps/backend/src/infra/database/prisma/migrations/20260623002342_add_store_entry_request/migration.sql new file mode 100644 index 00000000..a2dfcd3f --- /dev/null +++ b/apps/backend/src/infra/database/prisma/migrations/20260623002342_add_store_entry_request/migration.sql @@ -0,0 +1,34 @@ +-- CreateEnum +CREATE TYPE "StoreEntryRequestStatus" AS ENUM ('REQUESTED', 'REVIEWING', 'APPROVED', 'REJECTED', 'COMPLETED'); + +-- CreateTable +CREATE TABLE "store_entry_requests" ( + "id" TEXT NOT NULL, + "consumer_id" TEXT NOT NULL, + "kakao_place_id" TEXT NOT NULL, + "place_name" TEXT NOT NULL, + "address" TEXT, + "road_address" TEXT, + "phone" TEXT, + "category_name" TEXT, + "place_url" TEXT, + "latitude" DOUBLE PRECISION, + "longitude" DOUBLE PRECISION, + "status" "StoreEntryRequestStatus" NOT NULL DEFAULT 'REQUESTED', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "store_entry_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "store_entry_requests_kakao_place_id_idx" ON "store_entry_requests"("kakao_place_id"); + +-- CreateIndex +CREATE INDEX "store_entry_requests_status_idx" ON "store_entry_requests"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "store_entry_requests_consumer_id_kakao_place_id_key" ON "store_entry_requests"("consumer_id", "kakao_place_id"); + +-- AddForeignKey +ALTER TABLE "store_entry_requests" ADD CONSTRAINT "store_entry_requests_consumer_id_fkey" FOREIGN KEY ("consumer_id") REFERENCES "consumers"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/src/infra/database/prisma/schema.prisma b/apps/backend/src/infra/database/prisma/schema.prisma index fb87f773..032a9441 100644 --- a/apps/backend/src/infra/database/prisma/schema.prisma +++ b/apps/backend/src/infra/database/prisma/schema.prisma @@ -130,6 +130,7 @@ model Consumer { fcmTokens ConsumerFcmToken[] notificationPreferences ConsumerNotificationPreference[] termsAgreements ConsumerTermsAgreement[] + storeEntryRequests StoreEntryRequest[] @@index([phone]) @@index([googleId]) @@ -137,6 +138,40 @@ model Consumer { @@map("consumers") } +/// 미입점 스토어(카카오 장소) 입점 요청 — 구매자가 지도에서 "이 가게도 입점했으면" 하고 요청 +/// 동일 장소를 여러 구매자가 요청할 수 있으나, 한 구매자는 같은 장소를 한 번만 요청 가능 +model StoreEntryRequest { + id String @id @default(cuid()) + + consumerId String @map("consumer_id") + + /// 카카오 로컬 장소 ID — 중복 요청 방지·동일 장소 수요 집계 기준 + kakaoPlaceId String @map("kakao_place_id") + + /// 요청 시점의 장소 스냅샷 (추후 어드민 검토·연락·실제 스토어 매칭용) + placeName String @map("place_name") + address String? + roadAddress String? @map("road_address") + phone String? + categoryName String? @map("category_name") + placeUrl String? @map("place_url") + latitude Float? + longitude Float? + + /// 처리 상태 — 어드민 워크플로우 확장 대비 + status StoreEntryRequestStatus @default(REQUESTED) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + consumer Consumer @relation(fields: [consumerId], references: [id], onDelete: Cascade) + + @@unique([consumerId, kakaoPlaceId]) + @@index([kakaoPlaceId]) + @@index([status]) + @@map("store_entry_requests") +} + /// 판매자 앱(web-seller) 전용 계정 model Seller { id String @id @default(cuid()) @@ -202,6 +237,7 @@ model Product { // 비즈니스 필드 - 기본 정보 name String // 상품명 (필수) images String[] @default([]) // 상품 이미지 목록 (첫 번째 요소가 대표 이미지) + originalPrice Int @map("original_price") // 정가 salePrice Int @map("sale_price") // 판매 가격 salesStatus EnableStatus @map("sales_status") // 판매 상태 visibilityStatus EnableStatus @map("visibility_status") // 노출 상태 @@ -214,7 +250,6 @@ model Product { cakeSizeOptions Json? @map("cake_size_options") // 케이크 사이즈 옵션 cakeFlavorOptions Json? @map("cake_flavor_options") // 케이크 맛 옵션 letteringVisible EnableStatus @map("lettering_visible") // 레터링 표시 여부 - letteringRequired OptionRequired @map("lettering_required") // 레터링 필수 여부 letteringMaxLength Int @map("lettering_max_length") // 레터링 최대 길이 imageUploadEnabled EnableStatus @map("image_upload_enabled") // 이미지 업로드 가능 여부 @@ -801,11 +836,6 @@ enum AdminApprovalStatus { REJECTED // 거절 } -enum OptionRequired { - REQUIRED // 필수 - OPTIONAL // 선택 -} - enum EnableStatus { ENABLE // 사용 DISABLE // 미사용 @@ -831,6 +861,15 @@ enum ProductCategoryType { PHOTO // 사진 } +/// 미입점 스토어 입점 요청 처리 상태 +enum StoreEntryRequestStatus { + REQUESTED // 요청됨 + REVIEWING // 검토중 + APPROVED // 승인됨 + REJECTED // 반려됨 + COMPLETED // 입점 완료 +} + /// 판매자 검증·온보딩 enum SellerVerificationStatus { REGISTERED // 가입만 완료 diff --git a/apps/backend/src/infra/database/prisma/seed.ts b/apps/backend/src/infra/database/prisma/seed.ts index 330635aa..27e20473 100644 --- a/apps/backend/src/infra/database/prisma/seed.ts +++ b/apps/backend/src/infra/database/prisma/seed.ts @@ -22,7 +22,7 @@ const SEED_ACCOUNTS = { SELLER: { PHONE: "01012345678", NAME: "김철수", - NICKNAME: "김철수_739", + NICKNAME: "달콤한_마카롱_0739", PROFILE_IMAGE_URL: "https://static-staging.picakes.com/uploads/1779605116236_722257b7.jpeg", GOOGLE_ID: "115107911178776387683", GOOGLE_EMAIL: "olo90632951@gmail.com", @@ -34,7 +34,7 @@ const SEED_ACCOUNTS = { CONSUMER: { PHONE: "01023456789", NAME: "홍길동", - NICKNAME: "홍길동_4821", + NICKNAME: "귀여운_강아지_4821", PROFILE_IMAGE_URL: "https://static-staging.picakes.com/uploads/1779605131078_b337595b.jpeg", GOOGLE_ID: "115107911178776387683", GOOGLE_EMAIL: "olo90632951@gmail.com", @@ -116,6 +116,7 @@ const SEED_PRODUCT_BASE = { "https://static-staging.picakes.com/uploads/1779605200087_f0d30173.jpeg", ], SALE_PRICE: 45000, + ORIGINAL_PRICE: 50000, SIZE_OPTIONS: [ { id: "size_seed_dosirak", @@ -174,7 +175,6 @@ const SEED_PRODUCT_BASE = { ], LETTERING: { VISIBLE: "ENABLE", - REQUIRED: "OPTIONAL", MAX_LENGTH: 20, }, SEARCH_TAGS: ["생일케이크", "초콜릿", "당일배송"], @@ -585,6 +585,7 @@ async function upsertProducts(stores: Awaited>) storeId: targetStore.id, name: SEED_PRODUCT_BASE.NAME, images: SEED_PRODUCT_BASE.IMAGES, + originalPrice: SEED_PRODUCT_BASE.ORIGINAL_PRICE, salePrice: SEED_PRODUCT_BASE.SALE_PRICE, salesStatus: "ENABLE", visibilityStatus: "ENABLE", @@ -592,7 +593,6 @@ async function upsertProducts(stores: Awaited>) cakeSizeOptions: SEED_PRODUCT_BASE.SIZE_OPTIONS, cakeFlavorOptions: SEED_PRODUCT_BASE.FLAVOR_OPTIONS, letteringVisible: SEED_PRODUCT_BASE.LETTERING.VISIBLE as "ENABLE" | "DISABLE", - letteringRequired: SEED_PRODUCT_BASE.LETTERING.REQUIRED as "REQUIRED" | "OPTIONAL", letteringMaxLength: SEED_PRODUCT_BASE.LETTERING.MAX_LENGTH, imageUploadEnabled, productType, diff --git a/apps/backend/src/modules/auth/constants/auth.constants.ts b/apps/backend/src/modules/auth/constants/auth.constants.ts index ef13dfa1..0a349f62 100644 --- a/apps/backend/src/modules/auth/constants/auth.constants.ts +++ b/apps/backend/src/modules/auth/constants/auth.constants.ts @@ -132,7 +132,7 @@ export const SWAGGER_EXAMPLES = { id: "clxxxxconsumer", phone: "010-1234-5678", name: "홍길동", - nickname: "홍길동_4821", + nickname: "귀여운_강아지_4821", profileImageUrl: "https://lh3.googleusercontent.com/a/example", isPhoneVerified: true, isActive: true, @@ -147,7 +147,7 @@ export const SWAGGER_EXAMPLES = { id: "clxxxxseller", phone: "010-9876-5432", name: "김판매", - nickname: "김판매_3159", + nickname: "달콤한_마카롱_3159", profileImageUrl: "https://lh3.googleusercontent.com/a/example", isPhoneVerified: true, isActive: true, diff --git a/apps/backend/src/modules/auth/constants/register-nickname-adjectives.constants.ts b/apps/backend/src/modules/auth/constants/register-nickname-adjectives.constants.ts new file mode 100644 index 00000000..617b2b80 --- /dev/null +++ b/apps/backend/src/modules/auth/constants/register-nickname-adjectives.constants.ts @@ -0,0 +1,253 @@ +/** 회원가입 초기 닉네임 생성용 형용사 풀 */ +export const REGISTER_NICKNAME_ADJECTIVES = [ + // ㄱ + "가벼운", + "가뿐한", + "간지러운", + "감성적인", + "감칠맛 나는", + "강렬한", + "갸름한", + "거대한", + "건강한", + "검은", + "고소한", + "고운", + "곧은", + "골똘한", + "곰살맞은", + "공손한", + "과분한", + "구수한", + "굳센", + "귀여운", + "그윽한", + "근사한", + "글썽이는", + "기가 막힌", + "기쁜", + "기특한", + "긴", + "길쭉한", + "깊은", + // ㄴ + "나긋나긋한", + "나른한", + "나이 어린", + "날렵한", + "날씬한", + "낯선", + "널널한", + "넙적한", + "늠름한", + "늦은", + "능글맞은", + "낭만적인", + // ㄷ + "다정한", + "다채로운", + "단단한", + "달달한", + "달콤한", + "당찬", + "덤덤한", + "둥글둥글한", + "둥근", + "뒤늦은", + "따끈따끈한", + "따뜻한", + "따스한", + "똘똘한", + "뚱뚱한", + // ㅁ + "마법 같은", + "맑은", + "맛있는", + "매끄러운", + "매력적인", + "매운", + "멋진", + "멍한", + "메마른", + "명랑한", + "명확한", + "모난", + "몽환적인", + "묘한", + "무거운", + "무서운", + "무지개 빛의", + "미끄러운", + "미더운", + "미지의", + "믿음직한", + "민첩한", + "밀도 높은", + // ㅂ + "반가운", + "반짝이는", + "발랄한", + "밝은", + "밥맛 나는", + "방긋 웃는", + "배부른", + "벅찬", + "번쩍이는", + "부드러운", + "부유한", + "북적이는", + "붉은", + "복슬복슬한", + "푹신푹신한", + "비밀스러운", + "빛나는", + "빠른", + "빵빵한", + "뾰족한", + // ㅅ + "사랑스러운", + "사르르 녹는", + "산뜻한", + "상냥한", + "상큼한", + "새콤달콤한", + "새하얀", + "서늘한", + "서운한", + "설레는", + "섬세한", + "소박한", + "소중한", + "속삭이는", + "순수한", + "슬기로운", + "슬픈", + "싱그러운", + "싱싱한", + "씩씩한", + "쑥스러운", + // ㅇ + "아름다운", + "아늑한", + "아련한", + "아찔한", + "안타까운", + "알록달록한", + "앙증맞은", + "얄궂은", + "얌전한", + "어여쁜", + "어엿한", + "엉뚱한", + "여유로운", + "영롱한", + "오래된", + "올곧은", + "옹기종기 모인", + "왈가닥의", + "외로운", + "우아한", + "유쾌한", + "은은한", + "의젓한", + "아기자기한", + // ㅈ + "자그마한", + "자랑스러운", + "자상한", + "자애로운", + "작고 귀여운", + "잠자는", + "장난꾸러기 같은", + "저렴한", + "정감 있는", + "정겨운", + "조그마한", + "조용한", + "졸린", + "종이 같은", + "주황빛의", + "즐거운", + "지혜로운", + "진솔한", + "진한", + "짙은", + "짜릿한", + "짤막한", + "쫀득쫀득한", + "쫄깃한", + // ㅊ + "차가운", + "참신한", + "창의적인", + "처연한", + "천사 같은", + "철없는", + "첫", + "초록빛의", + "촉촉한", + "총명한", + "최선의", + "추억 어린", + "축축한", + "칠칠맞은", + "칭찬할 만한", + // ㅋ + "칼칼한", + "캄캄한", + "쾌활한", + "큼직한", + "쿨한", + "키 큰", + // ㅌ + "타오르는", + "탄탄한", + "털털한", + "톡톡 튀는", + "통통한", + "투명한", + "튼튼한", + "특별한", + "특유의", + // ㅍ + "파란", + "팍팍한", + "판타지 같은", + "팥고물 같은", + "평온한", + "포근한", + "푸근한", + "푸른", + "풍성한", + "풋풋한", + "프랑스풍의", + "피곤한", + "핏빛의", + "핑쿠핑쿠한", + // ㅎ + "하얀", + "한가로운", + "한결같은", + "해맑은", + "핼쑥한", + "험난한", + "헛된", + "헐렁한", + "헬시한", + "호기심 많은", + "화려한", + "화창한", + "환한", + "활기찬", + "황홀한", + "회색빛의", + "흐뭇한", + "흐린", + "흑백의", + "흔한", + "흥미로운", + "희망찬", + "희미한", + "흰", + "힘찬", +] as const; diff --git a/apps/backend/src/modules/auth/constants/register-nickname-nouns.constants.ts b/apps/backend/src/modules/auth/constants/register-nickname-nouns.constants.ts new file mode 100644 index 00000000..259a8832 --- /dev/null +++ b/apps/backend/src/modules/auth/constants/register-nickname-nouns.constants.ts @@ -0,0 +1,725 @@ +/** 회원가입 초기 닉네임 생성용 명사 풀 (동물·자연·음식·생활용품 등) */ +export const REGISTER_NICKNAME_NOUNS = [ + // 동물 + "강아지", + "고양이", + "햄스터", + "다람쥐", + "토끼", + "병아리", + "펭귄", + "사막여우", + "코알라", + "판다", + "아기곰", + "아기호랑이", + "아기사자", + "수달", + "라쿤", + "페럿", + "친칠라", + "고슴도치", + "오리", + "백조", + "부엉이", + "올빼미", + "참새", + "제비", + "나비", + "무당벌레", + "꿀벌", + "개구리", + "도롱뇽", + "물고기", + "돌고래", + "바다거북", + "아기물범", + "북극곰", + "알파카", + "양", + "염소", + "송아지", + "망아지", + "아기돼지", + "기니피그", + "프레리독", + "미어캣", + "나무늘보", + "카피바라", + "여우", + "늑대", + "사슴", + "기린", + "코끼리", + "원숭이", + "카멜레온", + "거북이", + "두더지", + "너구리", + "두루미", + "앵무새", + "카나리아", + "문어", + "오징어", + "해파리", + "불가사리", + "가재", + "게", + "새우", + "고래", + "상어", + "금붕어", + "베타", + "구피", + "펠리칸", + "플라밍고", + "코뿔소", + "하마", + "얼룩말", + "캥거루", + "오소리", + "족제비", + "청설모", + "매", + "독수리", + "까치", + "까마귀", + "비둘기", + "거위", + "닭", + "메추라기", + "공작", + "타조", + "바다표범", + "바다사자", + "고래상어", + "가오리", + "낙지", + "전갈", + "거미", + "지네", + "달팽이", + "개미", + "메뚜기", + "귀뚜라미", + "잠자리", + "매미", + "딱정벌레", + "사슴벌레", + "장수풍뎅이", + "반딧불이", + "박쥐", + "수리", + "청개구리", + "물범", + // 식물·자연 + "꽃", + "새싹", + "풀잎", + "나뭇잎", + "도토리", + "솔방울", + "밤송이", + "사과", + "딸기", + "복숭아", + "귤", + "포도", + "체리", + "블루베리", + "수박", + "바나나", + "망고", + "레몬", + "무지개", + "구름", + "별", + "달", + "햇살", + "이슬", + "바람", + "파도", + "눈꽃", + "눈사람", + "언덕", + "숲", + "산호", + "진주", + "장미", + "튤립", + "해바라기", + "민들레", + "벚꽃", + "은행나무", + "소나무", + "단풍", + "이끼", + "선인장", + "난초", + "수국", + "라벤더", + "모래", + "조약돌", + "바위", + "폭포", + "계곡", + "호수", + "강", + "바다", + "섬", + "초원", + "들판", + "들꽃", + "버섯", + "밤하늘", + "은하수", + "오로라", + "번개", + "해", + "안개", + "서리", + "눈", + "비", + "소나기", + "태풍", + "돌", + "모래사장", + "해변", + "절벽", + "동굴", + "샘물", + "연못", + "개울", + "나무", + "대나무", + "버드나무", + "단풍나무", + "벚나무", + "사과나무", + "배나무", + "감나무", + "포도나무", + "올리브", + "허브", + "바질", + "로즈마리", + "민트", + "카모마일", + "국화", + "백합", + "카네이션", + "안개꽃", + "제비꽃", + "나팔꽃", + "동백꽃", + "매화", + "수선화", + "프리지아", + "칼라", + "히아신스", + "아이리스", + "데이지", + "코스모스", + "맨드라미", + "수정", + "크리스탈", + "산", + "봉우리", + "정원", + "화단", + "잔디", + "씨앗", + "열매", + "꽃잎", + "꽃봉오리", + "햇볕", + "그늘", + "달빛", + "별빛", + "은하", + "우주", + "행성", + "혜성", + "유성", + "별자리", + "얼음", + "빙하", + "빙산", + "오솔길", + "숲길", + "다리", + "터널", + // 음식 + "솜사탕", + "마카롱", + "젤리", + "초콜릿", + "쿠키", + "사탕", + "캔디", + "머핀", + "케이크", + "푸딩", + "팝콘", + "도넛", + "아이스크림", + "호빵", + "붕어빵", + "꿀떡", + "바람떡", + "송편", + "식빵", + "우유", + "커피", + "주스", + "코코아", + "차", + "핫초코", + "크로와상", + "와플", + "프레첼", + "브라우니", + "티라미수", + "치즈케이크", + "샐러드", + "파스타", + "피자", + "햄버거", + "샌드위치", + "김밥", + "떡볶이", + "라면", + "카레", + "스테이크", + "스무디", + "버블티", + "아메리카노", + "라떼", + "에스프레소", + "젤라토", + "슈크림", + "크레페", + "팬케이크", + "초밥", + "회", + "김치", + "된장찌개", + "비빔밥", + "떡", + "과자", + "초코파이", + "빵", + "크림빵", + "단팥빵", + "소금빵", + "베이글", + "토스트", + "잼", + "꿀", + "버터", + "치즈", + "요거트", + "그라놀라", + "시리얼", + "오믈렛", + "스콘", + "마들렌", + "타르트", + "파이", + "롤케이크", + "바게트", + "식혜", + "수정과", + "단호박", + "고구마", + "감자", + "옥수수", + "토마토", + "오이", + "당근", + "양파", + "마늘", + "파", + "상추", + "배추", + "시금치", + "브로콜리", + "양배추", + "피망", + "고추", + "두부", + "계란", + "베이컨", + "소시지", + "햄", + "치킨", + "닭갈비", + "불고기", + "갈비", + "삼겹살", + "목살", + "등심", + "안심", + "연어", + "참치", + "고등어", + "갈치", + "굴", + "전복", + "홍합", + "바지락", + "멸치", + "김", + "미역", + "다시마", + "무", + "배", + "감", + "키위", + "파인애플", + "멜론", + "참외", + "자두", + "살구", + "석류", + "블랙베리", + "라즈베리", + "크랜베리", + "무화과", + "대추", + "밤", + "호두", + "아몬드", + "땅콩", + "캐슈넛", + "피스타치오", + "마카다미아", + "피칸", + "잣", + "은행", + "머그잔", + "유리컵", + "접시", + "포크", + "스푼", + // 문구·팬시 + "연필", + "지우개", + "공책", + "일기장", + "스티커", + "다이어리", + "편지", + "책", + "만화책", + "색연필", + "물감", + "팔레트", + "붓", + "엽서", + "메모지", + "달력", + "시계", + "자", + "가위", + "풀", + "테이프", + "노트", + "클립", + "핀", + "마커", + "형광펜", + "볼펜", + "샤프", + "연필깎이", + "파일", + "바인더", + "포스트잇", + "스탬프", + "스탬프패드", + "스케치북", + "크래프트지", + "리본테이프", + "마스킹테이프", + "스티커북", + "펜케이스", + "필통", + "책갈피", + "독서등", + "램프", + "캔들", + "필기장", + "원고지", + "삼각자", + "컴퍼스", + "각도기", + "자석", + "클립보드", + "명함", + "봉투", + "우표", + "잉크", + "잉크패드", + "펜", + "만년필", + "서명펜", + "브러시펜", + "캘리그라피펜", + "스케치펜", + "오일파스텔", + "파스텔", + "크레파스", + "수채화", + "유화", + "아크릴", + "캔버스", + "이젤", + "물통", + "스펀지", + "거치대", + "액자", + "포스터", + "플래너", + "체크리스트", + "인덱스", + "분류함", + "서류함", + "파일함", + "책꽂이", + "책장", + "책상", + "의자", + "스탠드", + "독서대", + "북마크", + "책받침", + "독서조명", + // 가구·소품 + "베개", + "이불", + "쿠션", + "인형", + "테디베어", + "오르골", + "오리인형", + "토끼인형", + "담요", + "커튼", + "카펫", + "화분", + "거울", + "빗", + "가방", + "파우치", + "동전지갑", + "우산", + "리본", + "단추", + "실타래", + "열쇠", + "자물쇠", + "안경", + "반지", + "목걸이", + "캔들홀더", + "선반", + "소파", + "테이블", + "화병", + "머그컵", + "그릇", + "냄비", + "프라이팬", + "주전자", + "티포트", + "컵", + "병", + "바구니", + "상자", + "서랍", + "옷걸이", + "행거", + "알람시계", + "선풍기", + "히터", + "가습기", + "공기청정기", + "러그", + "발매트", + "수납함", + "빨래건조대", + "다리미판", + "세탁바구니", + "욕실매트", + "샤워기", + "수도꼭지", + "비누통", + "칫솔걸이", + "화장대", + "향수", + "로션", + "크림", + "립밤", + "선크림", + "마스크팩", + "손톱깎이", + "면봉", + "화장솜", + "브러시", + "고데기", + "뷰러", + "속눈썹", + "마스카라", + "아이섀도", + "블러셔", + "파우더", + "립스틱", + "매니큐어", + "큐티클", + "손톱", + "발톱", + "발톱깎이", + "디퓨저", + "방향제", + "섬유탈취제", + "세탁세제", + "섬유유연제", + "주방세제", + "수세미", + "행주", + "걸레", + "빗자루", + "쓰레받기", + "휴지통", + "쓰레기봉투", + "건전지", + "전구", + "멀티탭", + "충전기", + "이어폰", + "헤드폰", + "스피커", + "마이크", + "카메라", + "삼각대", + "태블릿", + "스마트폰", + "리모컨", + "손전등", + "매트리스", + "침대", + "옷장", + "서랍장", + "수건", + "비누", + "샴푸", + "치약", + "칫솔", + "면도기", + "헤어드라이어", + "다리미", + "세탁기", + "청소기", + "휴지", + "물티슈", + "노트북", + "나이프", + // 의류·패션 + "모자", + "스카프", + "장갑", + "양말", + "운동화", + "구두", + "슬리퍼", + "부츠", + "샌들", + "원피스", + "셔츠", + "티셔츠", + "후드", + "재킷", + "코트", + "가디건", + "니트", + "바지", + "치마", + "반바지", + "레깅스", + "벨트", + "넥타이", + "브로치", + "귀걸이", + "팔찌", + "선글라스", + "지갑", + "백팩", + "토트백", + "크로스백", + "에코백", + "슬링백", + "머플러", + "비니", + "캡", + "헤어밴드", + "헤어핀", + "헤어클립", + "헤어타이", + "헤어롤", + "우비", + "레인코트", + "패딩", + "조끼", + "베스트", + "블라우스", + "스커트", + "청바지", + "슬랙스", + "트레이닝복", + "운동복", + "수영복", + "잠옷", + "파자마", + "가운", + "실내화", + // 악기 + "피아노", + "기타", + "바이올린", + "첼로", + "플루트", + "클라리넷", + "색소폰", + "트럼펫", + "트롬본", + "하프", + "드럼", + "탬버린", + "실로폰", + "마림바", + "우쿨렐레", + "하모니카", + "아코디언", + "오르간", + "신디사이저", + "베이스", + "일렉기타", + "어쿠스틱기타", + "만돌린", + "밴조", + "칼림바", + "카우벨", + "심벌", + "캐스터네츠", + "트라이앵글", + "리코더", + "오카리나", + "비올라", + "콘트라베이스", + "오보에", + "바순", + "호른", + "튜바", + "피콜로", + "프렌치호른", +] as const; diff --git a/apps/backend/src/modules/auth/services/auth-google-oauth.service.ts b/apps/backend/src/modules/auth/services/auth-google-oauth.service.ts index 8f1a9fcf..0f5e8bc9 100644 --- a/apps/backend/src/modules/auth/services/auth-google-oauth.service.ts +++ b/apps/backend/src/modules/auth/services/auth-google-oauth.service.ts @@ -18,7 +18,7 @@ import { GoogleRegisterRequestDto, } from "@apps/backend/modules/auth/dto/auth-google-oauth.dto"; import { LoggerUtil } from "@apps/backend/common/utils/logger.util"; -import { buildInitialNicknameFromName } from "@apps/backend/modules/auth/utils/google-register-nickname.util"; +import { buildInitialNickname } from "@apps/backend/modules/auth/utils/register-nickname.util"; import { SentryUtil } from "@apps/backend/common/utils/sentry.util"; import { TermsService } from "@apps/backend/modules/terms/terms.service"; @@ -352,7 +352,7 @@ export class AuthGoogleOauthService { googleEmail, phone: normalizedPhone, name: trimmedName, - nickname: buildInitialNicknameFromName(trimmedName), + nickname: buildInitialNickname(), isPhoneVerified: true, lastLoginAt: now, }, @@ -438,7 +438,7 @@ export class AuthGoogleOauthService { googleEmail, phone: normalizedPhone, name: trimmedName, - nickname: buildInitialNicknameFromName(trimmedName), + nickname: buildInitialNickname(), isPhoneVerified: true, lastLoginAt: now, sellerVerificationStatus: "REGISTERED", diff --git a/apps/backend/src/modules/auth/services/auth-kakao-oauth.service.ts b/apps/backend/src/modules/auth/services/auth-kakao-oauth.service.ts index 51398a34..ddc26c22 100644 --- a/apps/backend/src/modules/auth/services/auth-kakao-oauth.service.ts +++ b/apps/backend/src/modules/auth/services/auth-kakao-oauth.service.ts @@ -18,7 +18,7 @@ import { KakaoRegisterRequestDto, } from "@apps/backend/modules/auth/dto/auth-kakao-oauth.dto"; import { LoggerUtil } from "@apps/backend/common/utils/logger.util"; -import { buildInitialNicknameFromName } from "@apps/backend/modules/auth/utils/google-register-nickname.util"; +import { buildInitialNickname } from "@apps/backend/modules/auth/utils/register-nickname.util"; import { SentryUtil } from "@apps/backend/common/utils/sentry.util"; import { TermsService } from "@apps/backend/modules/terms/terms.service"; @@ -306,7 +306,7 @@ export class AuthKakaoOauthService { kakaoEmail, phone: normalizedPhone, name: trimmedName, - nickname: buildInitialNicknameFromName(trimmedName), + nickname: buildInitialNickname(), isPhoneVerified: true, lastLoginAt: now, }, @@ -382,7 +382,7 @@ export class AuthKakaoOauthService { kakaoEmail, phone: normalizedPhone, name: trimmedName, - nickname: buildInitialNicknameFromName(trimmedName), + nickname: buildInitialNickname(), isPhoneVerified: true, lastLoginAt: now, sellerVerificationStatus: "REGISTERED", diff --git a/apps/backend/src/modules/auth/utils/google-register-nickname.util.ts b/apps/backend/src/modules/auth/utils/google-register-nickname.util.ts deleted file mode 100644 index 34dd7752..00000000 --- a/apps/backend/src/modules/auth/utils/google-register-nickname.util.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { randomInt } from "node:crypto"; - -/** - * 구글 회원가입 시 초기 닉네임: `{실명}_{3 또는 4자리 난수}` - * 전화번호 전체를 붙이면 10·11자리 등 길이 편차가 커서, 짧은 난수 접미사로 구분합니다. - */ -export function buildInitialNicknameFromName(displayName: string): string { - const base = displayName.trim(); - const useFourDigits = randomInt(2) === 1; - const suffix = useFourDigits ? String(randomInt(1000, 10000)) : String(randomInt(100, 1000)); - return `${base}_${suffix}`; -} diff --git a/apps/backend/src/modules/auth/utils/register-nickname.util.ts b/apps/backend/src/modules/auth/utils/register-nickname.util.ts new file mode 100644 index 00000000..00c0b169 --- /dev/null +++ b/apps/backend/src/modules/auth/utils/register-nickname.util.ts @@ -0,0 +1,14 @@ +import { randomInt } from "node:crypto"; + +import { REGISTER_NICKNAME_ADJECTIVES } from "@apps/backend/modules/auth/constants/register-nickname-adjectives.constants"; +import { REGISTER_NICKNAME_NOUNS } from "@apps/backend/modules/auth/constants/register-nickname-nouns.constants"; + +/** + * OAuth 회원가입 시 초기 닉네임: `{형용사}_{명사}_{4자리 난수}` + */ +export function buildInitialNickname(): string { + const adjective = REGISTER_NICKNAME_ADJECTIVES[randomInt(REGISTER_NICKNAME_ADJECTIVES.length)]; + const noun = REGISTER_NICKNAME_NOUNS[randomInt(REGISTER_NICKNAME_NOUNS.length)]; + const suffix = String(randomInt(1000, 10000)); + return `${adjective}_${noun}_${suffix}`; +} diff --git a/apps/backend/src/modules/product/constants/product.constants.ts b/apps/backend/src/modules/product/constants/product.constants.ts index 6781f217..0d94358d 100644 --- a/apps/backend/src/modules/product/constants/product.constants.ts +++ b/apps/backend/src/modules/product/constants/product.constants.ts @@ -11,6 +11,8 @@ export const PRODUCT_ERROR_MESSAGES = { PRODUCT_INACTIVE: "판매중지된 상품입니다.", PRODUCT_OUT_OF_STOCK: "품절된 상품입니다.", PRODUCT_NOT_AVAILABLE: "구매할 수 없는 상품입니다.", + PRODUCT_PRICE_INVALID: "정가와 판매가는 0보다 큰 숫자여야 합니다.", + SALE_PRICE_EXCEEDS_ORIGINAL_PRICE: "판매가는 정가보다 클 수 없습니다.", REVIEW_NOT_FOUND: "후기를 찾을 수 없습니다.", /** 거리순 정렬 시 클라이언트 기준 위도·경도 미전달 */ DISTANCE_SORT_REQUIRES_COORDINATES: @@ -42,13 +44,8 @@ export enum SortBy { } /** - * 옵션 필수/선택 enum + * 사용/미사용 enum */ -export enum OptionRequired { - REQUIRED = "REQUIRED", // 필수 - OPTIONAL = "OPTIONAL", // 선택 -} - export enum EnableStatus { ENABLE = "ENABLE", // 사용 DISABLE = "DISABLE", // 미사용 @@ -93,6 +90,7 @@ export const SWAGGER_EXAMPLES = { id: "prod_123456789", // 상품 정보 name: "초콜릿 케이크", + originalPrice: 50000, salePrice: 45000, likeCount: 25, createdAt: new Date("2024-01-01T00:00:00.000Z"), @@ -138,7 +136,6 @@ export const SWAGGER_EXAMPLES = { }, ], letteringVisible: EnableStatus.ENABLE, - letteringRequired: OptionRequired.OPTIONAL, letteringMaxLength: 20, imageUploadEnabled: EnableStatus.ENABLE, productType: "CUSTOM_CAKE", diff --git a/apps/backend/src/modules/product/dto/product-create.dto.ts b/apps/backend/src/modules/product/dto/product-create.dto.ts index 284df41c..4b37a5f4 100644 --- a/apps/backend/src/modules/product/dto/product-create.dto.ts +++ b/apps/backend/src/modules/product/dto/product-create.dto.ts @@ -11,7 +11,6 @@ import { } from "class-validator"; import { Type } from "class-transformer"; import { - OptionRequired, EnableStatus, ProductCategoryType, CakeSizeDisplayName, @@ -145,6 +144,15 @@ export class CreateProductRequestDto { @IsString({ each: true }) images: string[]; + @ApiProperty({ + description: "정가", + example: PRODUCT_SWAGGER_EXAMPLES.PRODUCT_DATA.originalPrice, + }) + @IsNotEmpty() + @IsNumber() + @Min(0) + originalPrice: number; + @ApiProperty({ description: "판매가", example: PRODUCT_SWAGGER_EXAMPLES.PRODUCT_DATA.salePrice, @@ -203,15 +211,6 @@ export class CreateProductRequestDto { @IsEnum(EnableStatus) letteringVisible: EnableStatus; - @ApiPropertyOptional({ - description: "레터링 문구 사용 (필수/선택)", - enum: OptionRequired, - example: OptionRequired.REQUIRED, - }) - @IsNotEmpty() - @IsEnum(OptionRequired) - letteringRequired: OptionRequired; - @ApiPropertyOptional({ description: "레터링 최대 글자 수", example: PRODUCT_SWAGGER_EXAMPLES.PRODUCT_DATA.letteringMaxLength, diff --git a/apps/backend/src/modules/product/dto/product-detail.dto.ts b/apps/backend/src/modules/product/dto/product-detail.dto.ts index f40f8f5e..7e9888e3 100644 --- a/apps/backend/src/modules/product/dto/product-detail.dto.ts +++ b/apps/backend/src/modules/product/dto/product-detail.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { - OptionRequired, EnableStatus, ProductType, ProductCategoryType, @@ -66,6 +65,12 @@ export class ProductResponseDto extends PickupAddressDto { }) images: string[]; + @ApiProperty({ + description: "정가", + example: SWAGGER_EXAMPLES.PRODUCT_DATA.originalPrice, + }) + originalPrice: number; + @ApiProperty({ description: "판매가", example: SWAGGER_EXAMPLES.PRODUCT_DATA.salePrice, @@ -107,13 +112,6 @@ export class ProductResponseDto extends PickupAddressDto { }) letteringVisible: EnableStatus; - @ApiProperty({ - description: "레터링 문구 사용 (필수/선택)", - enum: OptionRequired, - example: OptionRequired.OPTIONAL, - }) - letteringRequired: OptionRequired; - @ApiProperty({ description: "레터링 최대 글자 수", example: SWAGGER_EXAMPLES.PRODUCT_DATA.letteringMaxLength, diff --git a/apps/backend/src/modules/product/dto/product-update.dto.ts b/apps/backend/src/modules/product/dto/product-update.dto.ts index a49b28c6..fb19d449 100644 --- a/apps/backend/src/modules/product/dto/product-update.dto.ts +++ b/apps/backend/src/modules/product/dto/product-update.dto.ts @@ -11,7 +11,6 @@ import { } from "class-validator"; import { Type } from "class-transformer"; import { - OptionRequired, EnableStatus, ProductCategoryType, } from "@apps/backend/modules/product/constants/product.constants"; @@ -69,6 +68,15 @@ export class UpdateProductRequestDto { @IsString({ each: true }) images?: string[]; + @ApiPropertyOptional({ + description: "정가", + example: PRODUCT_SWAGGER_EXAMPLES.PRODUCT_DATA.originalPrice, + }) + @IsOptional() + @IsNumber() + @Min(0) + originalPrice?: number; + @ApiPropertyOptional({ description: "판매가", example: PRODUCT_SWAGGER_EXAMPLES.PRODUCT_DATA.salePrice, @@ -127,15 +135,6 @@ export class UpdateProductRequestDto { @IsEnum(EnableStatus) letteringVisible?: EnableStatus; - @ApiPropertyOptional({ - description: "레터링 문구 사용 (필수/선택)", - enum: OptionRequired, - example: OptionRequired.REQUIRED, - }) - @IsOptional() - @IsEnum(OptionRequired) - letteringRequired?: OptionRequired; - @ApiPropertyOptional({ description: "레터링 최대 글자 수", example: PRODUCT_SWAGGER_EXAMPLES.PRODUCT_DATA.letteringMaxLength, diff --git a/apps/backend/src/modules/product/services/product-create.service.ts b/apps/backend/src/modules/product/services/product-create.service.ts index 8ae5cc69..413f1a0f 100644 --- a/apps/backend/src/modules/product/services/product-create.service.ts +++ b/apps/backend/src/modules/product/services/product-create.service.ts @@ -8,6 +8,7 @@ import { import { JwtVerifiedPayload } from "@apps/backend/modules/auth/types/auth.types"; import { Prisma } from "@apps/backend/infra/database/prisma/generated/client"; import { ProductOwnershipUtil } from "@apps/backend/modules/product/utils/product-ownership.util"; +import { validateProductPrices } from "@apps/backend/modules/product/utils/product-price.util"; import { LoggerUtil } from "@apps/backend/common/utils/logger.util"; @Injectable() @@ -37,6 +38,8 @@ export class ProductCreateService { user.sub, ); + validateProductPrices(createProductDto.originalPrice, createProductDto.salePrice); + try { return await this.prisma.$transaction( async (tx) => { @@ -80,13 +83,13 @@ export class ProductCreateService { }, name: createProductDto.name, images: createProductDto.images || [], + originalPrice: createProductDto.originalPrice, salePrice: createProductDto.salePrice, salesStatus: createProductDto.salesStatus, visibilityStatus: createProductDto.visibilityStatus, cakeSizeOptions: cakeSizeOptionsWithId as unknown as Prisma.InputJsonValue, cakeFlavorOptions: cakeFlavorOptionsWithId as unknown as Prisma.InputJsonValue, letteringVisible: createProductDto.letteringVisible, - letteringRequired: createProductDto.letteringRequired, letteringMaxLength: createProductDto.letteringMaxLength, imageUploadEnabled: createProductDto.imageUploadEnabled, productType, diff --git a/apps/backend/src/modules/product/services/product-update.service.ts b/apps/backend/src/modules/product/services/product-update.service.ts index 22f306c9..5af81c87 100644 --- a/apps/backend/src/modules/product/services/product-update.service.ts +++ b/apps/backend/src/modules/product/services/product-update.service.ts @@ -8,6 +8,7 @@ import { import { JwtVerifiedPayload } from "@apps/backend/modules/auth/types/auth.types"; import { Prisma } from "@apps/backend/infra/database/prisma/generated/client"; import { ProductOwnershipUtil } from "@apps/backend/modules/product/utils/product-ownership.util"; +import { validateProductPrices } from "@apps/backend/modules/product/utils/product-price.util"; import { LoggerUtil } from "@apps/backend/common/utils/logger.util"; @Injectable() @@ -81,11 +82,18 @@ export class ProductUpdateService { const product = await this.prisma.product.findUnique({ where: { id }, select: { + originalPrice: true, + salePrice: true, cakeSizeOptions: true, cakeFlavorOptions: true, }, }); + const nextOriginalPrice = updateProductDto.originalPrice ?? product!.originalPrice; + const nextSalePrice = updateProductDto.salePrice ?? product!.salePrice; + + validateProductPrices(nextOriginalPrice, nextSalePrice); + const updateData: Prisma.ProductUpdateInput = {}; if (updateProductDto.name !== undefined) { @@ -94,6 +102,9 @@ export class ProductUpdateService { if (updateProductDto.images !== undefined) { updateData.images = updateProductDto.images; } + if (updateProductDto.originalPrice !== undefined) { + updateData.originalPrice = updateProductDto.originalPrice; + } if (updateProductDto.salePrice !== undefined) { updateData.salePrice = updateProductDto.salePrice; } @@ -131,9 +142,6 @@ export class ProductUpdateService { if (updateProductDto.letteringVisible !== undefined) { updateData.letteringVisible = updateProductDto.letteringVisible; } - if (updateProductDto.letteringRequired !== undefined) { - updateData.letteringRequired = updateProductDto.letteringRequired; - } if (updateProductDto.letteringMaxLength !== undefined) { updateData.letteringMaxLength = updateProductDto.letteringMaxLength; } diff --git a/apps/backend/src/modules/product/utils/product-price.util.ts b/apps/backend/src/modules/product/utils/product-price.util.ts new file mode 100644 index 00000000..117e6d95 --- /dev/null +++ b/apps/backend/src/modules/product/utils/product-price.util.ts @@ -0,0 +1,21 @@ +import { BadRequestException } from "@nestjs/common"; +import { PRODUCT_ERROR_MESSAGES } from "@apps/backend/modules/product/constants/product.constants"; + +export function validateProductPrices(originalPrice: number, salePrice: number): void { + if (originalPrice <= 0 || salePrice <= 0) { + throw new BadRequestException(PRODUCT_ERROR_MESSAGES.PRODUCT_PRICE_INVALID); + } + + if (salePrice > originalPrice) { + throw new BadRequestException(PRODUCT_ERROR_MESSAGES.SALE_PRICE_EXCEEDS_ORIGINAL_PRICE); + } +} + +/** 할인율(0~100). salePrice >= originalPrice 이면 null */ +export function getProductDiscountRate(originalPrice: number, salePrice: number): number | null { + if (salePrice >= originalPrice) { + return null; + } + + return Math.round((1 - salePrice / originalPrice) * 100); +} diff --git a/apps/backend/src/modules/store-entry-request/constants/store-entry-request.constants.ts b/apps/backend/src/modules/store-entry-request/constants/store-entry-request.constants.ts new file mode 100644 index 00000000..c14752e6 --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/constants/store-entry-request.constants.ts @@ -0,0 +1,32 @@ +/** + * 입점 요청 관련 에러 메시지 + */ +export const STORE_ENTRY_REQUEST_ERROR_MESSAGES = { + ALREADY_EXISTS: "이미 입점 요청한 가게입니다.", +} as const; + +/** + * 입점 요청 관련 성공 메시지 + */ +export const STORE_ENTRY_REQUEST_SUCCESS_MESSAGES = { + CREATED: "입점 요청이 접수되었습니다.", +} as const; + +/** + * 입점 요청 조회 시 공통으로 노출하는 필드 + */ +export const STORE_ENTRY_REQUEST_SELECT = { + id: true, + kakaoPlaceId: true, + placeName: true, + address: true, + roadAddress: true, + phone: true, + categoryName: true, + placeUrl: true, + latitude: true, + longitude: true, + status: true, + createdAt: true, + updatedAt: true, +} as const; diff --git a/apps/backend/src/modules/store-entry-request/dto/store-entry-request.dto.ts b/apps/backend/src/modules/store-entry-request/dto/store-entry-request.dto.ts new file mode 100644 index 00000000..f3fc7b30 --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/dto/store-entry-request.dto.ts @@ -0,0 +1,136 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsLatitude, IsLongitude, IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; +import { StoreEntryRequestStatus } from "@apps/backend/infra/database/prisma/generated/client"; +import { STORE_ENTRY_REQUEST_SUCCESS_MESSAGES } from "@apps/backend/modules/store-entry-request/constants/store-entry-request.constants"; + +/** + * 입점 요청 생성 요청 DTO + * 카카오 로컬(키워드 검색) 장소 정보를 스냅샷으로 저장한다. + */ +export class CreateStoreEntryRequestDto { + @ApiProperty({ description: "카카오 로컬 장소 ID", example: "26338954" }) + @IsString() + @IsNotEmpty() + @MaxLength(64) + kakaoPlaceId: string; + + @ApiProperty({ description: "장소명", example: "산세바스티안 미입점" }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + placeName: string; + + @ApiPropertyOptional({ description: "지번 주소", example: "서울 강남구 역삼동 123-45" }) + @IsOptional() + @IsString() + @MaxLength(500) + address?: string; + + @ApiPropertyOptional({ description: "도로명 주소", example: "서울 강남구 테헤란로 123" }) + @IsOptional() + @IsString() + @MaxLength(500) + roadAddress?: string; + + @ApiPropertyOptional({ description: "전화번호", example: "02-123-4567" }) + @IsOptional() + @IsString() + @MaxLength(50) + phone?: string; + + @ApiPropertyOptional({ description: "카카오 카테고리명", example: "음식점 > 카페 > 제과,베이커리" }) + @IsOptional() + @IsString() + @MaxLength(255) + categoryName?: string; + + @ApiPropertyOptional({ description: "카카오 장소 상세 URL", example: "http://place.map.kakao.com/26338954" }) + @IsOptional() + @IsString() + @MaxLength(2048) + placeUrl?: string; + + @ApiPropertyOptional({ description: "위도", example: 37.5012 }) + @IsOptional() + @IsLatitude() + latitude?: number; + + @ApiPropertyOptional({ description: "경도", example: 127.0396 }) + @IsOptional() + @IsLongitude() + longitude?: number; +} + +/** + * 입점 요청 존재 여부 조회 쿼리 DTO + */ +export class StoreEntryRequestExistsQueryDto { + @ApiProperty({ description: "카카오 로컬 장소 ID", example: "26338954" }) + @IsString() + @IsNotEmpty() + @MaxLength(64) + kakaoPlaceId: string; +} + +/** + * 입점 요청 생성 응답 DTO + */ +export class CreateStoreEntryRequestResponseDto { + @ApiProperty({ + description: "응답 메시지", + example: STORE_ENTRY_REQUEST_SUCCESS_MESSAGES.CREATED, + }) + message: string; +} + +/** + * 입점 요청 존재 여부 응답 DTO (현재 로그인 사용자 기준) + */ +export class StoreEntryRequestExistsResponseDto { + @ApiProperty({ description: "현재 사용자의 입점 요청 존재 여부", example: true }) + requested: boolean; +} + +/** + * 입점 요청 단건 응답 DTO + */ +export class StoreEntryRequestResponseDto { + @ApiProperty({ example: "clxxxxxxxxxxxxxxxx" }) + id: string; + + @ApiProperty({ example: "26338954" }) + kakaoPlaceId: string; + + @ApiProperty({ example: "산세바스티안 미입점" }) + placeName: string; + + @ApiPropertyOptional({ example: "서울 강남구 역삼동 123-45" }) + address: string | null; + + @ApiPropertyOptional({ example: "서울 강남구 테헤란로 123" }) + roadAddress: string | null; + + @ApiPropertyOptional({ example: "02-123-4567" }) + phone: string | null; + + @ApiPropertyOptional({ example: "음식점 > 카페 > 제과,베이커리" }) + categoryName: string | null; + + @ApiPropertyOptional({ example: "http://place.map.kakao.com/26338954" }) + placeUrl: string | null; + + @ApiPropertyOptional({ example: 37.5012 }) + latitude: number | null; + + @ApiPropertyOptional({ example: 127.0396 }) + longitude: number | null; + + @ApiProperty({ enum: StoreEntryRequestStatus, example: StoreEntryRequestStatus.REQUESTED }) + status: StoreEntryRequestStatus; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} diff --git a/apps/backend/src/modules/store-entry-request/services/store-entry-request-read.service.ts b/apps/backend/src/modules/store-entry-request/services/store-entry-request-read.service.ts new file mode 100644 index 00000000..bc1d6ef8 --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/services/store-entry-request-read.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "@apps/backend/infra/database/prisma.service"; +import { StoreEntryRequestExistsResponseDto } from "@apps/backend/modules/store-entry-request/dto/store-entry-request.dto"; + +/** + * 입점 요청 조회 서비스 + */ +@Injectable() +export class StoreEntryRequestReadService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 현재 사용자가 해당 장소에 이미 입점 요청했는지 여부 (버튼 상태 표시용) + * @param consumerId 구매자 ID + * @param kakaoPlaceId 카카오 로컬 장소 ID + */ + async existsForUser( + consumerId: string, + kakaoPlaceId: string, + ): Promise { + const existing = await this.prisma.storeEntryRequest.findUnique({ + where: { consumerId_kakaoPlaceId: { consumerId, kakaoPlaceId } }, + select: { id: true }, + }); + + return { requested: existing !== null }; + } +} diff --git a/apps/backend/src/modules/store-entry-request/services/store-entry-request-write.service.ts b/apps/backend/src/modules/store-entry-request/services/store-entry-request-write.service.ts new file mode 100644 index 00000000..2f04d674 --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/services/store-entry-request-write.service.ts @@ -0,0 +1,50 @@ +import { Injectable, ConflictException } from "@nestjs/common"; +import { PrismaService } from "@apps/backend/infra/database/prisma.service"; +import { LoggerUtil } from "@apps/backend/common/utils/logger.util"; +import { CreateStoreEntryRequestDto } from "@apps/backend/modules/store-entry-request/dto/store-entry-request.dto"; +import { STORE_ENTRY_REQUEST_ERROR_MESSAGES } from "@apps/backend/modules/store-entry-request/constants/store-entry-request.constants"; + +/** + * 입점 요청 생성 서비스 + * 미입점(카카오 장소) 스토어에 대한 입점 요청을 저장합니다. + */ +@Injectable() +export class StoreEntryRequestWriteService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 입점 요청 추가 (사용자용) + * 동일 사용자가 같은 장소를 중복 요청하는 것은 DB unique 제약으로 방지합니다. + * @param consumerId 구매자 ID + * @param dto 카카오 장소 정보 + */ + async createForUser(consumerId: string, dto: CreateStoreEntryRequestDto) { + try { + await this.prisma.storeEntryRequest.create({ + data: { + consumerId, + kakaoPlaceId: dto.kakaoPlaceId, + placeName: dto.placeName, + address: dto.address, + roadAddress: dto.roadAddress, + phone: dto.phone, + categoryName: dto.categoryName, + placeUrl: dto.placeUrl, + latitude: dto.latitude, + longitude: dto.longitude, + }, + }); + } catch (error: any) { + if (error?.code === "P2002") { + LoggerUtil.log( + `입점 요청 실패: 이미 요청 존재 - consumerId: ${consumerId}, kakaoPlaceId: ${dto.kakaoPlaceId}`, + ); + throw new ConflictException(STORE_ENTRY_REQUEST_ERROR_MESSAGES.ALREADY_EXISTS); + } + LoggerUtil.log( + `입점 요청 실패: 알 수 없는 에러 - consumerId: ${consumerId}, kakaoPlaceId: ${dto.kakaoPlaceId}, error: ${error?.message || String(error)}`, + ); + throw error; + } + } +} diff --git a/apps/backend/src/modules/store-entry-request/store-entry-request.module.ts b/apps/backend/src/modules/store-entry-request/store-entry-request.module.ts new file mode 100644 index 00000000..3fc3976f --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/store-entry-request.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { DatabaseModule } from "@apps/backend/infra/database/database.module"; +import { StoreEntryRequestService } from "@apps/backend/modules/store-entry-request/store-entry-request.service"; +import { StoreEntryRequestReadService } from "@apps/backend/modules/store-entry-request/services/store-entry-request-read.service"; +import { StoreEntryRequestWriteService } from "@apps/backend/modules/store-entry-request/services/store-entry-request-write.service"; + +/** + * 입점 요청 모듈 + * 미입점(카카오 장소) 스토어 입점 요청 기능을 제공합니다. + */ +@Module({ + imports: [DatabaseModule], + providers: [ + StoreEntryRequestService, + StoreEntryRequestReadService, + StoreEntryRequestWriteService, + ], + exports: [StoreEntryRequestService], +}) +export class StoreEntryRequestModule {} diff --git a/apps/backend/src/modules/store-entry-request/store-entry-request.service.ts b/apps/backend/src/modules/store-entry-request/store-entry-request.service.ts new file mode 100644 index 00000000..6e92de95 --- /dev/null +++ b/apps/backend/src/modules/store-entry-request/store-entry-request.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@nestjs/common"; +import { StoreEntryRequestReadService } from "@apps/backend/modules/store-entry-request/services/store-entry-request-read.service"; +import { StoreEntryRequestWriteService } from "@apps/backend/modules/store-entry-request/services/store-entry-request-write.service"; +import { + CreateStoreEntryRequestDto, + StoreEntryRequestExistsResponseDto, +} from "@apps/backend/modules/store-entry-request/dto/store-entry-request.dto"; + +/** + * 입점 요청 서비스 + * 미입점(카카오 장소) 스토어 입점 요청 관련 기능을 통합 제공합니다. + */ +@Injectable() +export class StoreEntryRequestService { + constructor( + private readonly readService: StoreEntryRequestReadService, + private readonly writeService: StoreEntryRequestWriteService, + ) {} + + /** + * 입점 요청 추가 (사용자용) + */ + createForUser(consumerId: string, dto: CreateStoreEntryRequestDto) { + return this.writeService.createForUser(consumerId, dto); + } + + /** + * 현재 사용자의 입점 요청 존재 여부 (사용자용) + */ + existsForUser( + consumerId: string, + kakaoPlaceId: string, + ): Promise { + return this.readService.existsForUser(consumerId, kakaoPlaceId); + } +} diff --git a/apps/backend/src/modules/store/dto/store-detail.dto.ts b/apps/backend/src/modules/store/dto/store-detail.dto.ts index fcdb5ae7..6c05b309 100644 --- a/apps/backend/src/modules/store/dto/store-detail.dto.ts +++ b/apps/backend/src/modules/store/dto/store-detail.dto.ts @@ -9,6 +9,24 @@ import { SWAGGER_EXAMPLES as UPLOAD_SWAGGER_EXAMPLES } from "@apps/backend/modul import { StoreAddressDto } from "@apps/backend/modules/store/dto/store-common.dto"; import { StoreBusinessCalendarDto } from "@apps/backend/modules/store/dto/store-business-calendar.dto"; import { RefundCancellationPolicyDto } from "@apps/backend/modules/store/dto/store-refund-cancellation-policy.dto"; +import { SWAGGER_EXAMPLES as PRODUCT_SWAGGER_EXAMPLES } from "@apps/backend/modules/product/constants/product.constants"; + +/** + * 스토어 상품 대표이미지 (상품당 1장) + */ +export class StoreProductRepresentativeImageDto { + @ApiProperty({ + description: "상품 ID", + example: PRODUCT_SWAGGER_EXAMPLES.PRODUCT_DATA.id, + }) + productId: string; + + @ApiProperty({ + description: "상품 대표이미지 URL", + example: UPLOAD_SWAGGER_EXAMPLES.FILE_URL, + }) + imageUrl: string; +} /** * 스토어 응답 DTO @@ -150,14 +168,22 @@ export class StoreResponseDto extends StoreAddressDto { totalReviewCount: number; @ApiProperty({ - description: "해당 스토어의 모든 상품 대표이미지 URL 배열 (상품당 1장)", - type: [String], + description: "해당 스토어의 모든 상품 대표이미지 (상품당 1장)", + type: [StoreProductRepresentativeImageDto], example: [ - "https://s3.ap-northeast-1.amazonaws.com/picake-uploads/uploads/product1.png", - "https://s3.ap-northeast-1.amazonaws.com/picake-uploads/uploads/product2.png", + { + productId: "prod_123456789", + imageUrl: + "https://s3.ap-northeast-1.amazonaws.com/picake-uploads/uploads/product1.png", + }, + { + productId: "prod_987654321", + imageUrl: + "https://s3.ap-northeast-1.amazonaws.com/picake-uploads/uploads/product2.png", + }, ], }) - productRepresentativeImageUrls: string[]; + productRepresentativeImages: StoreProductRepresentativeImageDto[]; @ApiPropertyOptional({ description: "해당 스토어 상품 중 최소 금액 (노출·판매중인 상품만, 없으면 null)", diff --git a/apps/backend/src/modules/store/utils/store-mapper.util.ts b/apps/backend/src/modules/store/utils/store-mapper.util.ts index df3cb47d..208003bb 100644 --- a/apps/backend/src/modules/store/utils/store-mapper.util.ts +++ b/apps/backend/src/modules/store/utils/store-mapper.util.ts @@ -138,9 +138,10 @@ export class StoreMapperUtil { averageRating = Math.round((totalRating / totalReviewCount) * 10) / 10; // 소수점 첫째자리까지 } - const productRepresentativeImageUrls = productIds - .map((id) => productRepresentativeImage.get(id)) - .filter((url): url is string => !!url); + const productRepresentativeImages = productIds.flatMap((id) => { + const imageUrl = productRepresentativeImage.get(id); + return imageUrl ? [{ productId: id, imageUrl }] : []; + }); const saleablePrices = storeSaleablePrices.get(store.id) ?? []; const minProductPrice = saleablePrices.length > 0 ? Math.min(...saleablePrices) : null; @@ -187,7 +188,7 @@ export class StoreMapperUtil { likeCount: store.likeCount, averageRating, totalReviewCount, - productRepresentativeImageUrls, + productRepresentativeImages, minProductPrice, businessCalendar, refundCancellationPolicy, diff --git a/apps/web-seller/src/App.tsx b/apps/web-seller/src/App.tsx index 1853a1cd..c8775bb7 100644 --- a/apps/web-seller/src/App.tsx +++ b/apps/web-seller/src/App.tsx @@ -11,6 +11,7 @@ import { ErrorBoundaryProvider } from "./common/components/providers/ErrorBounda import { QueryProvider } from "./common/components/providers/QueryProvider"; import { LoadingFallback } from "./common/components/fallbacks/LoadingFallback"; import { Alert } from "./common/components/alerts/Alert"; +import { ConfirmAlert } from "./common/components/alerts/ConfirmAlert"; import BuildInfoLogger from "./common/components/debug/BuildInfoLogger"; const App: React.FC = () => { @@ -20,6 +21,7 @@ const App: React.FC = () => { + }> {/* 인증 관련 경로 (AdminLayout 밖) */} diff --git a/apps/web-seller/src/common/components/alerts/ConfirmAlert.tsx b/apps/web-seller/src/common/components/alerts/ConfirmAlert.tsx new file mode 100644 index 00000000..01849568 --- /dev/null +++ b/apps/web-seller/src/common/components/alerts/ConfirmAlert.tsx @@ -0,0 +1,76 @@ +import { useEffect } from "react"; +import { BaseButton as Button } from "@/apps/web-seller/common/components/buttons/BaseButton"; +import { useConfirmStore } from "@/apps/web-seller/common/store/confirm.store"; + +export function ConfirmAlert() { + const { confirm, hideConfirm } = useConfirmStore(); + + const handleCancel = () => { + confirm.onCancel?.(); + hideConfirm(); + }; + + const handleConfirm = () => { + confirm.onConfirm?.(); + hideConfirm(); + }; + + useEffect(() => { + if (!confirm.isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") handleCancel(); + }; + + document.addEventListener("keydown", handleKeyDown); + document.body.style.overflow = "hidden"; + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = ""; + }; + }, [confirm.isOpen, confirm.onCancel]); + + if (!confirm.isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ {confirm.message} +

+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web-seller/src/common/components/inputs/NumberInput.tsx b/apps/web-seller/src/common/components/inputs/NumberInput.tsx index 93a5b5f4..330b2555 100644 --- a/apps/web-seller/src/common/components/inputs/NumberInput.tsx +++ b/apps/web-seller/src/common/components/inputs/NumberInput.tsx @@ -16,15 +16,25 @@ export interface NumberInputProps extends Omit< } const NumberInput = React.forwardRef( - ({ value, onChange, className, integer = true, ...props }, ref) => { + ({ value, onChange, className, integer = true, inputMode, ...props }, ref) => { const handleChange = (e: React.ChangeEvent) => { - const raw = e.target.valueAsNumber; - if (Number.isNaN(raw) || e.target.value.trim() === "") { + const rawValue = e.target.value; + if (rawValue.trim() === "") { onChange(undefined); // 비어 있는 값은 undefined로 전달, 상위에서 변환 return; } - const next = integer ? Math.floor(raw) : raw; - onChange(next); // 숫자 값은 그대로 전달 + + // type="text"에서도 숫자 검증이 동작하도록 숫자 이외 문자를 제거한 뒤 파싱한다. + const sanitized = integer + ? rawValue.replace(/[^\d-]/g, "") + : rawValue.replace(/[^\d.-]/g, ""); + const parsed = integer ? parseInt(sanitized, 10) : parseFloat(sanitized); + + if (Number.isNaN(parsed)) { + onChange(undefined); + return; + } + onChange(parsed); // 숫자 값은 그대로 전달 }; const displayValue = value === undefined ? "" : String(value); @@ -32,7 +42,8 @@ const NumberInput = React.forwardRef( return ( { - const m = location.pathname.match(/^\/stores\/([^/]+)/); - return m ? m[1] : null; - }, [location.pathname]); - - if (!storeId || !notif) { - return null; - } - - const unread = notif.unreadCount; - - return ( - - ); -} +import { SellerNotificationScope } from "@/apps/web-seller/features/notification/components/providers/SellerNotificationProvider"; +import { AdminHeaderNotificationDropdown } from "@/apps/web-seller/features/notification/components/AdminHeaderNotificationDropdown"; const drawerWidth = 300; @@ -137,7 +95,7 @@ export const AdminLayout: React.FC = ({ children }) => { {isAuthenticated && (
- +
+ + ); +} + +/** 헤더 알림 아이콘 — 클릭 시 아이콘 아래 드롭다운으로 최근 알림 미리보기 */ +export function AdminHeaderNotificationDropdown() { + const navigate = useNavigate(); + const location = useLocation(); + const notif = useSellerNotifications(); + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + const storeId = useMemo(() => { + const m = location.pathname.match(/^\/stores\/([^/]+)/); + return m ? m[1] : null; + }, [location.pathname]); + + const previewItems = useMemo( + () => (notif?.items ?? []).slice(0, PREVIEW_LIMIT), + [notif?.items], + ); + + useEffect(() => { + if (!isOpen) return; + + const handlePointerDown = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen]); + + useEffect(() => { + setIsOpen(false); + }, [location.pathname]); + + if (!storeId || !notif) { + return null; + } + + const unread = notif.unreadCount; + const hasUnread = notif.items.some((n) => !n.read); + + const onRowClick = (orderId: string, id: string) => { + notif.markRead(id); + setIsOpen(false); + navigate(ROUTES.STORE_DETAIL_ORDERS_DETAIL(storeId, orderId)); + }; + + const onViewAll = () => { + setIsOpen(false); + navigate(ROUTES.STORE_DETAIL_NOTIFICATIONS_LIST(storeId)); + }; + + return ( +
+ + + {isOpen ? ( +
+
+
+

알림

+ {unread > 0 ? ( + + {unread > 99 ? "99+" : unread} + + ) : null} +
+ +
+ +
+ {notif.isListLoading && previewItems.length === 0 ? ( +
+ + 알림을 불러오는 중… +
+ ) : previewItems.length === 0 ? ( +
+ +

알림이 없습니다.

+
+ ) : ( +
    + {previewItems.map((item) => ( + onRowClick(item.orderId, item.id)} + /> + ))} +
+ )} +
+ +
+ +
+
+ ) : null} +
+ ); +} diff --git a/apps/web-seller/src/features/order/components/OrderStatusGuideHelpButton.tsx b/apps/web-seller/src/features/order/components/OrderStatusGuideHelpButton.tsx new file mode 100644 index 00000000..5ca72d2b --- /dev/null +++ b/apps/web-seller/src/features/order/components/OrderStatusGuideHelpButton.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { CircleHelp } from "lucide-react"; +import { cn } from "@/apps/web-seller/common/utils/classname.util"; + +interface OrderStatusGuideHelpButtonProps { + onClick: () => void; + className?: string; + ariaLabel?: string; + title?: string; +} + +export const OrderStatusGuideHelpButton: React.FC = ({ + onClick, + className, + ariaLabel = "주문 상태 안내 보기", + title = "주문 상태 안내", +}) => { + return ( + + ); +}; diff --git a/apps/web-seller/src/features/order/components/OrderStatusGuideModal.tsx b/apps/web-seller/src/features/order/components/OrderStatusGuideModal.tsx new file mode 100644 index 00000000..62897da6 --- /dev/null +++ b/apps/web-seller/src/features/order/components/OrderStatusGuideModal.tsx @@ -0,0 +1,87 @@ +import React, { useEffect } from "react"; +import { BaseButton as Button } from "@/apps/web-seller/common/components/buttons/BaseButton"; +import { X } from "lucide-react"; +import type { OrderStatusGuideItem } from "@/apps/web-seller/features/order/utils/order-status-seller-guide.util"; + +interface OrderStatusGuideModalProps { + open: boolean; + onClose: () => void; + /** 스크린 리더용 (화면에는 표시하지 않음) */ + ariaLabel?: string; + items?: readonly OrderStatusGuideItem[]; + numberedLines?: readonly string[]; +} + +export const OrderStatusGuideModal: React.FC = ({ + open, + onClose, + ariaLabel = "주문 상태 안내", + items, + numberedLines, +}) => { + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ +
+ {items && items.length > 0 && ( +
    + {items.map((item) => ( +
  • +

    {item.label}

    +

    {item.description}

    +
  • + ))} +
+ )} + {numberedLines && numberedLines.length > 0 && ( +
    + {numberedLines.map((line, i) => ( +
  • + + {i + 1} + + {line} +
  • + ))} +
+ )} +
+
+
+ ); +}; diff --git a/apps/web-seller/src/features/order/constants/orderQueryKeys.constant.ts b/apps/web-seller/src/features/order/constants/orderQueryKeys.constant.ts index 4e3078d6..a1d4e2e1 100644 --- a/apps/web-seller/src/features/order/constants/orderQueryKeys.constant.ts +++ b/apps/web-seller/src/features/order/constants/orderQueryKeys.constant.ts @@ -13,4 +13,7 @@ export const orderQueryKeys = { /** 스토어 캘린더용: 스토어 + 선택한 픽업일(YYYY-MM-DD)별 주문 */ calendarByStore: (storeId: string, pickupDayKey: string | null) => ["order", "calendar-by-store", storeId, pickupDayKey] as const, + /** 스토어 캘린더용: 스토어 + 픽업 월(YYYY-MM-DD ~ YYYY-MM-DD)별 주문 */ + calendarMonthByStore: (storeId: string, pickupStartKey: string, pickupEndKey: string) => + ["order", "calendar-month-by-store", storeId, pickupStartKey, pickupEndKey] as const, } as const; diff --git a/apps/web-seller/src/features/order/hooks/queries/useOrderQuery.ts b/apps/web-seller/src/features/order/hooks/queries/useOrderQuery.ts index c2b955e5..e0f76662 100644 --- a/apps/web-seller/src/features/order/hooks/queries/useOrderQuery.ts +++ b/apps/web-seller/src/features/order/hooks/queries/useOrderQuery.ts @@ -108,3 +108,28 @@ export function useCalendarDayOrders(storeId: string, pickupDayKey: string | nul return query; } + +/** 스토어 캘린더: 픽업 월(YYYY-MM-DD ~ YYYY-MM-DD) 주문 목록 */ +export function useCalendarMonthOrders( + storeId: string, + pickupStartKey: string, + pickupEndKey: string, +) { + const query = useQuery({ + queryKey: orderQueryKeys.calendarMonthByStore(storeId, pickupStartKey, pickupEndKey), + queryFn: () => + orderApi.getOrders({ + page: 1, + limit: 200, + sortBy: OrderSortBy.LATEST, + storeId, + pickupStartDate: pickupStartKey, + pickupEndDate: pickupEndKey, + }), + enabled: !!storeId && !!pickupStartKey && !!pickupEndKey, + }); + + useQueryErrorAlert(query); + + return query; +} diff --git a/apps/web-seller/src/features/order/utils/order-status-seller-guide.util.ts b/apps/web-seller/src/features/order/utils/order-status-seller-guide.util.ts index ed4a3d0d..615c2a18 100644 --- a/apps/web-seller/src/features/order/utils/order-status-seller-guide.util.ts +++ b/apps/web-seller/src/features/order/utils/order-status-seller-guide.util.ts @@ -3,6 +3,12 @@ * 입금 마감(픽업까지 남은 시간에 따라 최대 12h·6h·1h)·픽업 시각 도달 자동 전환 등은 백엔드 `order-automation`·`order-datetime.util` 규칙과 맞춥니다. */ import { OrderStatus } from "@/apps/web-seller/features/order/types/order.dto"; +import { getOrderStatusLabel } from "@/apps/web-seller/features/order/utils/order-status-ui.util"; + +export type OrderStatusGuideItem = { + label: string; + description: string; +}; /** 상태별 안내 문장 (상세 힌트·흐름 목록 공통) */ export const ORDER_STATUS_SELLER_FLOW_LINE_BY_STATUS: Record = { @@ -60,3 +66,48 @@ export function getOrderStatusSellerHintBody(status: OrderStatus): string { if (idx === -1) return full.trim(); return full.slice(idx + 2).trim(); } + +/** 주문 목록 등에서 노출하는 간단 상태 안내 */ +export const ORDER_STATUS_LIST_GUIDE_ITEMS: readonly OrderStatusGuideItem[] = [ + { + label: getOrderStatusLabel(OrderStatus.RESERVATION_REQUESTED), + description: + "고객이 주문 예약 신청을 완료한 상태예요. 판매자의 확인 전 단계입니다.", + }, + { + label: getOrderStatusLabel(OrderStatus.PAYMENT_PENDING), + description: "주문은 접수되었지만, 고객의 입금이 확인되지 않은 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.PAYMENT_COMPLETED), + description: "고객의 입금이 정상적으로 확인된 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.CONFIRMED), + description: "판매자가 주문 내용을 확인하고 제작/예약을 확정한 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.PICKUP_PENDING), + description: "케이크 제작이 완료되었거나 픽업 준비 중인 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.PICKUP_COMPLETED), + description: "고객이 케이크를 정상적으로 수령한 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.CANCEL_REFUND_PENDING), + description: "고객의 취소 요청이 접수되어 환불 진행을 기다리는 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.CANCEL_REFUND_COMPLETED), + description: "주문 취소 및 환불이 완료된 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.CANCEL_COMPLETED), + description: "주문이 최종 취소 처리된 상태예요.", + }, + { + label: getOrderStatusLabel(OrderStatus.NO_SHOW), + description: "픽업 예정 시간까지 고객이 방문하지 않은 상태예요.", + }, +]; diff --git a/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx b/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx index 15a41c0b..240ef4ee 100644 --- a/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx +++ b/apps/web-seller/src/features/product/components/forms/ProductCreationForm.tsx @@ -9,15 +9,15 @@ import { } from "@/apps/web-seller/common/components/tabs/Tabs"; import { EnableStatus, - OptionRequired, CakeSizeOptionDto, CakeFlavorOptionDto, ProductCategoryType, + CakeSizeDisplayName, } from "@/apps/web-seller/features/product/types/product.dto"; import type { ProductForm } from "@/apps/web-seller/features/product/types/product.ui"; import { ProductCreationBasicInfoSection } from "@/apps/web-seller/features/product/components/sections/ProductCreationBasicInfoSection"; import { ProductCreationCakeOptionsSection } from "@/apps/web-seller/features/product/components/sections/ProductCreationCakeOptionsSection"; -import { ProductCreationLetteringPolicySection } from "@/apps/web-seller/features/product/components/sections/ProductCreationLetteringPolicySection"; +import { ProductCreationOptionsSection } from "@/apps/web-seller/features/product/components/sections/ProductCreationOptionsSection"; import { ProductCreationDetailDescriptionSection } from "@/apps/web-seller/features/product/components/sections/ProductCreationDetailDescriptionSection"; import { ProductCreationProductNoticeSection } from "@/apps/web-seller/features/product/components/sections/ProductCreationProductNoticeSection"; import { validateProductForm } from "@/apps/web-seller/features/product/utils/validateProductForm"; @@ -34,14 +34,28 @@ interface Props { export const defaultForm: ProductForm = { images: [], name: "", + originalPrice: 0, salePrice: 0, salesStatus: EnableStatus.ENABLE, visibilityStatus: EnableStatus.ENABLE, - cakeSizeOptions: [], - cakeFlavorOptions: [], + cakeSizeOptions: [ + { + visible: EnableStatus.ENABLE, + displayName: CakeSizeDisplayName.DOSIRAK, + lengthCm: 0, + price: 0, + description: "1~2인용", + }, + ], + cakeFlavorOptions: [ + { + visible: EnableStatus.ENABLE, + displayName: "초콜릿", + price: 0, + }, + ], letteringVisible: EnableStatus.ENABLE, - letteringRequired: OptionRequired.OPTIONAL, - letteringMaxLength: 0, + letteringMaxLength: 1, imageUploadEnabled: EnableStatus.ENABLE, productCategoryTypes: [], searchTags: [], @@ -99,6 +113,13 @@ export const ProductCreationForm: React.FC = ({ onChange?.(next); }; + const handleOriginalPriceChange = (value: number) => { + if (disabled) return; + const next = { ...form, originalPrice: value }; + setForm(next); + onChange?.(next); + }; + const handleSalePriceChange = (value: number) => { if (disabled) return; const next = { ...form, salePrice: value }; @@ -151,13 +172,13 @@ export const ProductCreationForm: React.FC = ({ }; const handleLetteringVisibleChange = (value: EnableStatus) => { - const next = { ...form, letteringVisible: value }; - setForm(next); - onChange?.(next); - }; - - const handleLetteringRequiredChange = (value: OptionRequired) => { - const next = { ...form, letteringRequired: value }; + const next = { + ...form, + letteringVisible: value, + ...(value === EnableStatus.ENABLE && form.letteringMaxLength < 1 + ? { letteringMaxLength: 1 } + : {}), + }; setForm(next); onChange?.(next); }; @@ -255,6 +276,7 @@ export const ProductCreationForm: React.FC = ({ onMainImageChange={handleMainImageChange} onAdditionalImagesChange={handleAdditionalImagesChange} onChange={handleChange} + onOriginalPriceChange={handleOriginalPriceChange} onSalePriceChange={handleSalePriceChange} disabled={disabled} /> @@ -267,12 +289,11 @@ export const ProductCreationForm: React.FC = ({ onCakeFlavorOptionsChange={handleCakeFlavorOptionsChange} /> - {/* 레터링 정책 섹션 */} -
- {product.salePrice.toLocaleString()}원 + {isProductOnSale(product.originalPrice, product.salePrice) ? ( +
+ + {product.originalPrice.toLocaleString()}원 + + {product.salePrice.toLocaleString()}원 +
+ ) : ( + {product.salePrice.toLocaleString()}원 + )}
diff --git a/apps/web-seller/src/features/product/components/sections/ProductCreationBasicInfoSection.tsx b/apps/web-seller/src/features/product/components/sections/ProductCreationBasicInfoSection.tsx index 5b23f6e3..8f977f45 100644 --- a/apps/web-seller/src/features/product/components/sections/ProductCreationBasicInfoSection.tsx +++ b/apps/web-seller/src/features/product/components/sections/ProductCreationBasicInfoSection.tsx @@ -29,6 +29,7 @@ export interface ProductCreationBasicInfoSectionProps { onChange: ( key: keyof ProductForm, ) => (e: React.ChangeEvent) => void; + onOriginalPriceChange: (value: number) => void; onSalePriceChange: (value: number) => void; disabled?: boolean; } @@ -44,6 +45,7 @@ export const ProductCreationBasicInfoSection: React.FC { @@ -92,6 +94,22 @@ export const ProductCreationBasicInfoSection: React.FC +
+ + onOriginalPriceChange(v ?? 0)} + placeholder="" + min={0} + className={errors.originalPrice ? "border-destructive" : ""} + disabled={disabled} + /> + {errors.originalPrice && ( +

{errors.originalPrice}

+ )} +