feat: [가격시뮬레이터] 개발비-구독료 연동 조절 기능 추가

- 토글 스위치로 연동 ON/OFF
- 연동 ON: 개발비 슬라이더 조정 시 구독료가 비율 유지하며 자동 연동
- 각 상품의 원래 개발비(할인가):구독료 비율 기반 계산
- 최저 구독료 이하로는 연동되지 않도록 제한
- 슬라이더 아래 연동 구독료 실시간 표시
- 우측 요약 패널에 원래/연동 구독료 비교 표시
- 상품 목록에서도 연동 구독료 변화 표시
This commit is contained in:
김보곤
2026-03-14 15:16:21 +09:00
parent 83f6663492
commit 82fc2a45d6

View File

@@ -736,6 +736,37 @@ class="w-4 h-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500">
</div>
</div>
{{-- 개발비-구독료 연동 --}}
<div class="bg-white rounded-xl shadow-sm p-4 mb-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg" :class="linkedPricing ? 'bg-blue-100' : 'bg-gray-100'">
<svg class="w-5 h-5" :class="linkedPricing ? 'text-blue-600' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<div>
<span class="text-sm font-semibold text-gray-900">개발비-구독료 연동</span>
<p class="text-xs text-gray-500">개발비를 조정하면 구독료가 비율에 맞춰 자동 연동됩니다</p>
</div>
</div>
<button type="button" x-on:click="toggleLinkedPricing()"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
:class="linkedPricing ? 'bg-blue-600' : 'bg-gray-200'">
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="linkedPricing ? 'translate-x-5' : 'translate-x-0'"></span>
</button>
</div>
<div x-show="linkedPricing" x-collapse class="mt-3 p-3 bg-blue-50 rounded-lg text-xs text-blue-700">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 mt-0.5 shrink-0 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span> 상품의 <strong>개발비(할인가) : 구독료</strong> 비율을 유지합니다. 슬라이더로 개발비를 낮추면 구독료도 같은 비율로 낮아집니다. (최저 구독료 이하로는 내려가지 않습니다)</span>
</div>
</div>
</div>
{{-- 선택된 카테고리의 상품 목록 --}}
@foreach($categories as $category)
<div x-show="selectedCategoryId === {{ $category->id }}" class="bg-white rounded-xl shadow-sm mb-4 overflow-hidden">
@@ -777,7 +808,17 @@ class="w-4 h-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span>개발비: <strong class="text-gray-700">{{ number_format($product->development_fee) }}</strong></span>
<span>개발비: <strong class="text-indigo-600">{{ number_format($product->registration_fee) }}</strong></span>
<span>구독료: <strong class="text-gray-700">{{ number_format($product->subscription_fee) }}/</strong></span>
<span>구독료:
<template x-if="linkedPricing && isSelected({{ $product->id }}) && getAdjustedSubFee({{ $product->id }}) !== {{ $product->subscription_fee }}">
<span>
<span class="line-through text-gray-400">{{ number_format($product->subscription_fee) }}</span>
<strong class="text-blue-600 ml-1" x-text="Number(getAdjustedSubFee({{ $product->id }})).toLocaleString() + '원/월'"></strong>
</span>
</template>
<template x-if="!(linkedPricing && isSelected({{ $product->id }}) && getAdjustedSubFee({{ $product->id }}) !== {{ $product->subscription_fee }})">
<strong class="text-gray-700">{{ number_format($product->subscription_fee) }}/</strong>
</template>
</span>
</div>
{{-- 선택 가격 조정 슬라이더 --}}
@@ -816,6 +857,18 @@ class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-e
최저 {{ number_format($product->min_development_fee) }} 이하 불가
</div>
@endif
{{-- 연동 구독료 표시 --}}
<template x-if="linkedPricing">
<div class="mt-2 pt-2 border-t border-blue-200 bg-blue-50 rounded px-2 py-1.5 flex items-center justify-between">
<span class="text-xs text-blue-600 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
연동 구독료
</span>
<span class="text-xs font-bold text-blue-700" x-text="formatCurrency(getAdjustedSubFee({{ $product->id }})) + '/월'"></span>
</div>
</template>
</div>
</template>
</div>
@@ -1026,9 +1079,14 @@ class="w-full py-2 text-xs text-gray-500 bg-gray-50 hover:bg-gray-100 rounded-lg
</div>
<div class="border-t border-gray-100 pt-3 space-y-2">
{{-- 원래 구독료 (연동 시에만 표시) --}}
<div class="flex justify-between items-center text-sm" x-show="linkedPricing && totalOriginalSubscriptionFee() !== totalSubscriptionFee()">
<span class="text-gray-400"> 구독료 (원래)</span>
<span class="text-gray-400 line-through" x-text="formatCurrency(totalOriginalSubscriptionFee()) + '/월'"></span>
</div>
{{-- 구독료 --}}
<div class="flex justify-between items-center text-sm">
<span class="text-gray-500"> 구독료 (정가)</span>
<span class="text-gray-500" x-text="linkedPricing && totalOriginalSubscriptionFee() !== totalSubscriptionFee() ? '월 구독료 (연동)' : '월 구독료 (정가)'"></span>
<span x-text="formatCurrency(totalSubscriptionFee()) + '/월'"
:class="promoSubscriptionPercent > 0 ? 'text-gray-400 line-through' : 'font-semibold text-gray-900'"></span>
</div>
@@ -1223,6 +1281,9 @@ function buildRequiredSelected(catId) {
selectedCategoryId: firstCategoryId,
selected: buildRequiredSelected(firstCategoryId),
// 개발비-구독료 연동
linkedPricing: false,
// 프로모션/할인
promoRegistrationType: 'percent', // 'percent' | 'amount'
promoRegistrationAmount: 0,
@@ -1277,10 +1338,28 @@ function buildRequiredSelected(catId) {
if (!this.selected[id]) return;
const p = productMap[id];
const minFee = p ? p.minDevFee : 0;
this.selected = {
...this.selected,
[id]: { ...this.selected[id], adjustedFee: Math.max(minFee, value) }
};
const clampedFee = Math.max(minFee, value);
const update = { ...this.selected[id], adjustedFee: clampedFee };
// 연동 모드: 개발비 비율에 맞춰 구독료 자동 조정
if (this.linkedPricing && p) {
const baseRegFee = Number(p.registration_fee) || 1;
const baseSub = Number(p.subscription_fee);
const ratio = baseSub / baseRegFee;
const linkedSub = Math.round(clampedFee * ratio / 10000) * 10000; // 만원 단위 반올림
const minSub = p.minSubFee || 0;
update.adjustedSubFee = Math.max(minSub, linkedSub);
}
this.selected = { ...this.selected, [id]: update };
},
getAdjustedSubFee(id) {
if (!this.selected[id]) return 0;
if (this.selected[id].adjustedSubFee !== undefined) {
return this.selected[id].adjustedSubFee;
}
return Number(productMap[id]?.subscription_fee || 0);
},
getDiscountRate(id, devFee) {
@@ -1304,7 +1383,8 @@ function buildRequiredSelected(catId) {
categoryName: p.categoryName,
developmentFee: Number(p.development_fee),
adjustedFee: this.selected[id].adjustedFee,
subscriptionFee: Number(p.subscription_fee),
subscriptionFee: this.getAdjustedSubFee(id),
originalSubFee: Number(p.subscription_fee),
};
});
},
@@ -1322,6 +1402,12 @@ function buildRequiredSelected(catId) {
},
totalSubscriptionFee() {
return Object.keys(this.selected).reduce((sum, id) => {
return sum + this.getAdjustedSubFee(id);
}, 0);
},
totalOriginalSubscriptionFee() {
return Object.keys(this.selected).reduce((sum, id) => {
return sum + Number(productMap[id].subscription_fee);
}, 0);
@@ -1486,9 +1572,35 @@ function buildRequiredSelected(catId) {
return Number(value).toLocaleString('ko-KR') + '원';
},
toggleLinkedPricing() {
this.linkedPricing = !this.linkedPricing;
if (this.linkedPricing) {
// 연동 ON: 현재 조정된 개발비 기준으로 구독료 재계산
Object.keys(this.selected).forEach(id => {
const p = productMap[id];
if (!p) return;
const baseRegFee = Number(p.registration_fee) || 1;
const baseSub = Number(p.subscription_fee);
const ratio = baseSub / baseRegFee;
const linkedSub = Math.round(this.selected[id].adjustedFee * ratio / 10000) * 10000;
const minSub = p.minSubFee || 0;
this.selected[id] = { ...this.selected[id], adjustedSubFee: Math.max(minSub, linkedSub) };
});
this.selected = { ...this.selected };
} else {
// 연동 OFF: 구독료를 원래값으로 복원
Object.keys(this.selected).forEach(id => {
delete this.selected[id].adjustedSubFee;
});
this.selected = { ...this.selected };
}
this.$nextTick(() => this.clampPromoValues());
},
resetAll() {
this.signupType = 'individual';
this.hasReferrer = false;
this.linkedPricing = false;
this.selectedCategoryId = firstCategoryId;
this.selected = buildRequiredSelected(firstCategoryId);
this.resetPromo();