- accountApi: shopStatus(), buyShopItem(item) 추가; buyShopItem은 DEMO_WRITES에 등록 - router: /settings/shop → ShopView.vue 라우트 추가 - ShopView.vue: 포인트 잔액·상품 카드(계좌 슬롯/AI 통계) 구매 화면, PREMIUM 회원 안내 분기 - AccountInfoView: '기프트콘 교환' → '포인트 상점' 로 이름 변경 + 클릭 시 /settings/shop 이동 - AccountDashboardView: AI 코멘트 402 응답 시 크레딧 소진 안내 + 상점 이동 버튼 노출 - LAUNCH-PROGRESS: 포인트 상점 ✅ 완료 표시, 다음 재개 지점 현행화 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+12
-17
@@ -139,14 +139,12 @@
|
||||
## 📌 남은 백로그 (출시 후 업데이트)
|
||||
1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기
|
||||
2. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증
|
||||
3. **조직 계정 전환 + 기프트콘 교환** — 사업자등록→DUNS→앱이전, B2B 기프트콘 매입
|
||||
4. **포인트 상점 (사용량·슬롯 판매)** — 견적 ~1~1.5일. 무료회원 대상, 유료회원은 무제한.
|
||||
- **AI 텍스트 입력**(`/entries/parse`): 월 5회 무료 + **추가 10회 = 300P**
|
||||
- **OCR 영수증 인식**(`/ocr/receipt-ai`): 월 3회 무료 + **추가 10회 = 500P**
|
||||
- **계좌 슬롯**: 종류별 기본 2개(적용됨) + **500P당 +1, 최대 5개**, 구매분은 **영구**
|
||||
- 설계: 월별 사용량 테이블(`ai_usage`) + 구매 크레딧(`ai_credit`) / 매 호출 전 `무료쿼터→구매크레딧→거부(충전유도)` / 구매는 PointService로 포인트 차감(+크레딧)
|
||||
- 확정 필요: ①유료=무제한 ②구매팩 이월(기본은 매월 리셋) ③'1회'=AI API 호출 1건(비용 기준) ④월 1일 리셋
|
||||
- 참고: AI 호출 비용 상한 통제 효과. 지금은 `/entries/parse`가 무료·미게이트(영수증·재무코멘트는 이미 유료 게이트) → 상점 없이 단순 유료화하려면 WebConfig 1줄로도 가능
|
||||
3. **조직 계정 전환** — 사업자등록→DUNS→앱이전
|
||||
4. ✅ **포인트 상점** (2026-07-10 구현 완료)
|
||||
- **계좌 슬롯**: 500P/개, 최대 3개, 영구. `extra_wallet_slots` (member 테이블)
|
||||
- **AI 통계 분석**: 1000P → 10회 이용권. `ai_stat_credits` (member 테이블)
|
||||
- FREE 회원이 크레딧 없이 AI 코멘트 호출 시 402 → 통계 화면에서 상점 안내
|
||||
- PREMIUM 회원은 AI 코멘트 무제한 (크레딧 불필요)
|
||||
|
||||
### 🤖 AI 기능 — ✅ 구현·배포 완료 (2026-07-05)
|
||||
- 백엔드: `AiEntryParser`(Claude Haiku 4.5, raw HTTP `/v1/messages`), 키는 서버 `/opt/sb-backend/.env`(`ANTHROPIC_API_KEY`), 실패/미설정 시 규칙기반·Google Vision OCR 폴백.
|
||||
@@ -169,12 +167,9 @@
|
||||
|
||||
## 다음 세션 재개 지점
|
||||
0. ✅ **[해결] 화면 가로 밀림/잘림** — 진짜 원인은 **iOS input 자동확대**였음(모달·CSS 아님).
|
||||
viewport에 maximum-scale 없어 16px 미만 input 포커스 시 페이지 확대→우측 잘림. `maximum-scale=1.0, user-scalable=no` 추가로 해결(ec130e8). *교훈: "모든 화면 input 포커스 시 동일" = iOS 줌 의심.*
|
||||
1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`)
|
||||
2. **v4 AAB 비공개 트랙 업로드** — versionCode 4(1.0.3) 빌드 완료, Play Console 업로드 대기
|
||||
- ⚠️ 2026-07-07 추가분(정기결제·계좌개편·상환조회·월상환금 등)은 **웹·서버 배포 완료** — 다음 AAB에 포함해 빌드
|
||||
3. **포인트 상점** — 견적 ~1~1.5일. 착수 전 정책 4가지 확정 필요:
|
||||
- ①유료 회원 = AI 무제한 여부 ②구매 크레딧 이월 vs 매월 리셋 ③1회=API 호출 1건 ④리셋 기준일=월 1일
|
||||
4. **DUNS 발급 대기** → 발급 시 Play Console 개인사업자 계정 전환 마저 진행
|
||||
5. (선택) **음성 전용 🎤 버튼** — `@capacitor-community/speech-recognition`(현재는 iOS 키보드 받아쓰기로 대체 가능)
|
||||
6. **iOS(App Store) 트랙** — 실기기 빌드 확인 완료. 애플 개발자 승인·번들ID·App Store 심사는 Play와 독립 진행
|
||||
1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**
|
||||
2. **v5 AAB 비공개 트랙 업로드** — versionCode 5(1.0.4) 빌드 완료, Play Console 업로드 대기
|
||||
- 포인트 상점 포함 이번 작업분 서버 배포 확인 후 AAB 업로드
|
||||
3. **DUNS 발급 대기** → 발급 시 Play Console 개인사업자 계정 전환
|
||||
4. (선택) **음성 전용 🎤 버튼** — `@capacitor-community/speech-recognition`
|
||||
5. **iOS(App Store) 트랙** — 실기기 빌드 확인 완료. 애플 개발자 승인·번들ID·App Store 심사는 Play와 독립 진행
|
||||
|
||||
@@ -225,6 +225,14 @@ const realApi = {
|
||||
return http.delete('/account/all')
|
||||
},
|
||||
|
||||
// 포인트 상점 — 현황 조회 / 아이템 구매
|
||||
shopStatus() {
|
||||
return http.get('/account/shop/status')
|
||||
},
|
||||
buyShopItem(item) {
|
||||
return http.post('/account/shop/buy', { item })
|
||||
},
|
||||
|
||||
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환) — AI 폴백용
|
||||
// suppressNetError: runOcr 이 자체 formError 로 안내하므로 전역 배너 억제
|
||||
ocrReceipt(blob) {
|
||||
@@ -271,7 +279,7 @@ const DEMO_WRITES = new Set([
|
||||
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
|
||||
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
|
||||
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
|
||||
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'parseReceiptAi', 'aiComment', 'restore', 'deleteAllData',
|
||||
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'parseReceiptAi', 'aiComment', 'restore', 'deleteAllData', 'buyShopItem',
|
||||
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
|
||||
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
|
||||
'refreshPrices', 'refreshAllPrices',
|
||||
|
||||
@@ -213,6 +213,12 @@ const router = createRouter({
|
||||
component: () => import('../views/settings/ProfileEditView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/shop',
|
||||
name: 'settings-shop',
|
||||
component: () => import('../views/settings/ShopView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth() + 1)
|
||||
@@ -32,6 +35,7 @@ const forecastHint = ref('') // 초반(표본 부족)엔 값 대신 안내
|
||||
const FORECAST_MIN_DAYS = 10 // 이 날짜(경과일)부터 예상 지출 노출(초반 표본 부족·튐 방지)
|
||||
const forecastLoading = ref(false)
|
||||
const aiLoading = ref(false)
|
||||
const aiNoCredit = ref(false) // FREE 회원 크레딧 소진 상태
|
||||
|
||||
const periodLabel = computed(() => `${year.value}년 ${String(month.value).padStart(2, '0')}월`)
|
||||
|
||||
@@ -334,6 +338,7 @@ async function load() {
|
||||
// 이번 달 집계만 백엔드로 보내 AI 재무 코멘트를 받는다(거래 원문 미전송, 실패해도 무중단)
|
||||
async function loadAiComment() {
|
||||
aiLoading.value = true
|
||||
aiNoCredit.value = false
|
||||
aiBullets.value = []
|
||||
aiTips.value = []
|
||||
try {
|
||||
@@ -349,7 +354,10 @@ async function loadAiComment() {
|
||||
})
|
||||
aiBullets.value = res?.bullets || []
|
||||
aiTips.value = res?.tips || []
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e.response?.status === 402) {
|
||||
aiNoCredit.value = true
|
||||
}
|
||||
aiBullets.value = []
|
||||
aiTips.value = []
|
||||
} finally {
|
||||
@@ -421,6 +429,13 @@ onMounted(async () => {
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- 무료 회원 AI 크레딧 소진 안내 -->
|
||||
<div v-if="aiNoCredit" class="ai-comment ai-no-credit">
|
||||
<div class="ai-comment-head">✨ AI 코멘트</div>
|
||||
<p class="ai-credit-msg">AI 통계 분석 이용권이 소진되었습니다.<br>포인트 상점에서 추가 구매할 수 있어요.</p>
|
||||
<button type="button" class="ai-shop-btn" @click="router.push('/settings/shop')">포인트 상점 ›</button>
|
||||
</div>
|
||||
|
||||
<!-- AI 재무 코멘트 + 절약 가이드: 이번 달 집계 해석 -->
|
||||
<div v-if="aiLoading || aiBullets.length || aiTips.length" class="ai-comment">
|
||||
<div class="ai-comment-head">✨ 이번 달 AI 코멘트</div>
|
||||
@@ -725,6 +740,26 @@ h2 {
|
||||
color: #b8860b;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.ai-no-credit {
|
||||
border-color: hsla(220, 80%, 60%, 0.4);
|
||||
}
|
||||
.ai-credit-msg {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-muted, #888);
|
||||
line-height: 1.5;
|
||||
margin: 0.4rem 0 0.7rem;
|
||||
}
|
||||
.ai-shop-btn {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -161,9 +161,8 @@ async function restoreSub() {
|
||||
}
|
||||
}
|
||||
|
||||
// 포인트 기프트콘 교환 — 업데이트 예정
|
||||
function exchangeGiftcon() {
|
||||
dialog.alert('포인트 기프트콘 교환은 업데이트 예정입니다. 조금만 기다려 주세요!')
|
||||
function goShop() {
|
||||
router.push('/settings/shop')
|
||||
}
|
||||
|
||||
// 플랜 구독/변경 — Android 만 결제, PC 는 업그레이드(앱 안내) 화면으로
|
||||
@@ -323,9 +322,9 @@ async function withdraw() {
|
||||
<span class="point-more">내역 ›</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="row row-point" @click="exchangeGiftcon">
|
||||
<span class="k">기프트콘 교환</span>
|
||||
<span class="v"><span class="soon">업데이트 예정</span></span>
|
||||
<button type="button" class="row row-point" @click="goShop">
|
||||
<span class="k">포인트 상점</span>
|
||||
<span class="v"><span class="point-more">›</span></span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="shop-wrap">
|
||||
<header class="shop-header">
|
||||
<button type="button" class="back-btn" @click="router.back()">‹</button>
|
||||
<h1 class="shop-title">포인트 상점</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="shop-loading">불러오는 중…</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 포인트 잔액 -->
|
||||
<section class="points-bar">
|
||||
<span class="points-label">보유 포인트</span>
|
||||
<span class="points-value">{{ points.toLocaleString('ko-KR') }} P</span>
|
||||
</section>
|
||||
|
||||
<!-- 유료 회원 안내 -->
|
||||
<div v-if="isPremium" class="premium-notice">
|
||||
유료 멤버십 이용 중 — 모든 기능 무제한 사용 가능합니다.<br>
|
||||
포인트 상점 아이템은 무료 회원 전용입니다.
|
||||
</div>
|
||||
|
||||
<!-- 상품 목록 -->
|
||||
<section v-else class="product-list">
|
||||
<!-- 추가 계좌 슬롯 -->
|
||||
<div class="product-card">
|
||||
<div class="product-icon">🏦</div>
|
||||
<div class="product-info">
|
||||
<div class="product-name">추가 계좌 슬롯</div>
|
||||
<div class="product-desc">
|
||||
무료 회원은 종류별 계좌 2개까지 등록 가능합니다.<br>
|
||||
슬롯 1개 구매 시 모든 종류에 +1개 추가 (최대 3개 구매 가능).
|
||||
</div>
|
||||
<div class="product-status">
|
||||
보유 슬롯: <strong>{{ status.extraWalletSlots }}</strong> / {{ status.maxExtraSlots }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-buy">
|
||||
<div class="product-price">{{ status.walletSlotPrice.toLocaleString('ko-KR') }} P</div>
|
||||
<button
|
||||
type="button"
|
||||
class="buy-btn"
|
||||
:disabled="status.extraWalletSlots >= status.maxExtraSlots || buying === 'WALLET_SLOT'"
|
||||
@click="buy('WALLET_SLOT')"
|
||||
>
|
||||
<template v-if="status.extraWalletSlots >= status.maxExtraSlots">최대 구매</template>
|
||||
<template v-else-if="buying === 'WALLET_SLOT'">구매 중…</template>
|
||||
<template v-else>구매</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 통계 분석 -->
|
||||
<div class="product-card">
|
||||
<div class="product-icon">✨</div>
|
||||
<div class="product-info">
|
||||
<div class="product-name">AI 통계 분석 {{ status.aiStatPackSize }}회</div>
|
||||
<div class="product-desc">
|
||||
통계 화면에서 AI 재무 코멘트를 받을 수 있습니다.<br>
|
||||
{{ status.aiStatPackSize }}회 이용권 구매 (소진 후 재구매 가능).
|
||||
</div>
|
||||
<div class="product-status">
|
||||
남은 이용권: <strong>{{ status.aiStatCredits }}</strong>회
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-buy">
|
||||
<div class="product-price">{{ status.aiStatPrice.toLocaleString('ko-KR') }} P</div>
|
||||
<button
|
||||
type="button"
|
||||
class="buy-btn"
|
||||
:disabled="buying === 'AI_STAT'"
|
||||
@click="buy('AI_STAT')"
|
||||
>
|
||||
<template v-if="buying === 'AI_STAT'">구매 중…</template>
|
||||
<template v-else>구매</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 포인트 적립 안내 -->
|
||||
<section class="earn-guide">
|
||||
<h3 class="earn-title">포인트 적립 방법</h3>
|
||||
<ul class="earn-list">
|
||||
<li>가계부 내역 작성: <strong>10P</strong> (일 5회 한도)</li>
|
||||
<li>게시판 글 작성: <strong>10P</strong> (일 5회 한도)</li>
|
||||
<li>게시글 추천받기: <strong>5P</strong></li>
|
||||
<li>남의 글 추천하기: <strong>1P</strong></li>
|
||||
<li>한 달 개근 보너스: <strong>최대 100P</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { authApi } from '@/api/authApi'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const dialog = useDialog()
|
||||
|
||||
const loading = ref(true)
|
||||
const buying = ref('')
|
||||
const points = ref(0)
|
||||
const status = ref({
|
||||
extraWalletSlots: 0,
|
||||
aiStatCredits: 0,
|
||||
maxExtraSlots: 3,
|
||||
walletSlotPrice: 500,
|
||||
aiStatPrice: 1000,
|
||||
aiStatPackSize: 10,
|
||||
})
|
||||
|
||||
const isPremium = computed(() => auth.user?.plan === 'PREMIUM')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [pt, st] = await Promise.all([authApi.points(), accountApi.shopStatus()])
|
||||
points.value = pt.points ?? 0
|
||||
status.value = st
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function buy(item) {
|
||||
const labels = { WALLET_SLOT: '추가 계좌 슬롯', AI_STAT: `AI 통계 분석 ${status.value.aiStatPackSize}회` }
|
||||
const prices = { WALLET_SLOT: status.value.walletSlotPrice, AI_STAT: status.value.aiStatPrice }
|
||||
|
||||
const ok = await dialog.confirm(
|
||||
`${labels[item]} (${prices[item].toLocaleString('ko-KR')}P)를 구매하시겠습니까?`
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
buying.value = item
|
||||
try {
|
||||
status.value = await accountApi.buyShopItem(item)
|
||||
const pt = await authApi.points()
|
||||
points.value = pt.points ?? 0
|
||||
dialog.alert('구매가 완료되었습니다!')
|
||||
} catch (e) {
|
||||
const msg = e.response?.data?.message
|
||||
if (msg === 'SHOP_NOT_ENOUGH_POINTS') {
|
||||
dialog.alert('포인트가 부족합니다.')
|
||||
} else if (msg === 'SHOP_WALLET_SLOT_MAX') {
|
||||
dialog.alert('추가 계좌 슬롯은 최대 3개까지 구매할 수 있습니다.')
|
||||
} else {
|
||||
dialog.alert('구매 중 오류가 발생했습니다.')
|
||||
}
|
||||
} finally {
|
||||
buying.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shop-wrap {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 0 0 40px;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg, #f7f8fa);
|
||||
}
|
||||
|
||||
.shop-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 16px 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-bottom: 1px solid var(--color-border, #e8eaed);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #222);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.shop-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shop-loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
|
||||
.points-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
background: var(--color-surface, #fff);
|
||||
margin: 12px 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.premium-notice {
|
||||
margin: 12px 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
font-size: 28px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted, #888);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-status {
|
||||
font-size: 13px;
|
||||
color: var(--color-text, #222);
|
||||
}
|
||||
|
||||
.product-buy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.buy-btn:disabled {
|
||||
background: #c0c8d8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.earn-guide {
|
||||
margin: 20px 16px 0;
|
||||
padding: 16px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.earn-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.earn-list {
|
||||
margin: 0;
|
||||
padding: 0 0 0 18px;
|
||||
list-style: disc;
|
||||
font-size: 13px;
|
||||
color: var(--color-muted, #888);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.earn-list strong {
|
||||
color: var(--color-text, #222);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.shop-wrap { background: #111; }
|
||||
.shop-header { background: #1c1c1e; border-color: #2c2c2e; }
|
||||
.points-bar, .product-card, .earn-guide { background: #1c1c1e; box-shadow: none; }
|
||||
.premium-notice { background: #052e16; color: #bbf7d0; border-color: #166534; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user