1256 lines
32 KiB
Vue
1256 lines
32 KiB
Vue
<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> |