feat(stage-ui): add hologram promo banner (#1514)

---------

Co-authored-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
Lovehsigure_520
2026-04-03 07:22:34 +08:00
committed by GitHub
co-authored by Neko Ayaka
parent b85808c0af
commit f9ebe6522b
19 changed files with 847 additions and 0 deletions
+42
View File
@@ -41,4 +41,46 @@ profile-switcher:
save-as-new: Save as New Profile
new-profile-name: Profile name
manage: Manage Profiles
promo-banner:
items:
signin:
watermark: Moon
fallbackLabel: Moon Gift
title: 7-Day Sign-In
eventName: Sign in to claim stardust, avatar frames, and launch gifts.
date: 03/28 - 04/10
reward: Up to 120 Stardust
cta: Sign In
build:
watermark: Build
fallbackLabel: Build Bonus
title: Supply Box Bonus
eventName: Finish construction tasks to earn bonus vouchers and double materials.
date: 03/30 - 04/12
reward: 5 Supply Vouchers + Upgrade Materials
cta: View Tasks
spring:
watermark: Spring
fallbackLabel: Spring Draw
title: Capsule Carnival
eventName: Join the spring draw to unlock bubbles, charms, and themed furniture.
date: 04/01 - 04/18
reward: Half-Price First Pull + Rare Charm
cta: Draw Now
coupon:
watermark: Talk
fallbackLabel: Coupon
title: Top-Up Coupon
eventName: Finish chat interactions to claim coupons for expansion and voice packs.
date: 04/02 - 04/20
reward: Save 6 on 30 / Save 15 on 68
cta: Claim Coupon
home:
watermark: Home
fallbackLabel: Room Theme
title: Spring Theme Update
eventName: Unlock window, desk, and night-lamp themes to refresh your room.
date: 04/05 - 04/22
reward: Free Theme Draw Ticket on Login
cta: View Theme
waiting: Waiting
+42
View File
@@ -41,4 +41,46 @@ profile-switcher:
save-as-new: Guardar como Nuevo Perfil
new-profile-name: Nombre del perfil
manage: Administrar Perfiles
promo-banner:
items:
signin:
watermark: Moon
fallbackLabel: Regalo lunar
title: Recompensa de 7 días
eventName: Inicia sesión para conseguir polvo estelar, marcos de avatar y regalos especiales.
date: 03/28 - 04/10
reward: Hasta 120 de polvo estelar
cta: Iniciar sesión
build:
watermark: Build
fallbackLabel: Bono de obra
title: Caja de suministro extra
eventName: Completa tareas de construcción para obtener cupones y materiales al doble.
date: 03/30 - 04/12
reward: 5 cupones + materiales de mejora
cta: Ver tareas
spring:
watermark: Spring
fallbackLabel: Sorteo primaveral
title: Carnaval cápsula
eventName: Participa en el sorteo de primavera y desbloquea burbujas, dijes y muebles temáticos.
date: 04/01 - 04/18
reward: Primer intento a mitad + dije raro
cta: Ir al sorteo
coupon:
watermark: Talk
fallbackLabel: Cupón
title: Cupón de recarga
eventName: Completa interacciones para recibir cupones para paquetes de diálogo y voces.
date: 04/02 - 04/20
reward: Ahorra 6 en 30 / 15 en 68
cta: Recibir cupón
home:
watermark: Home
fallbackLabel: Tema del cuarto
title: Decoración de primavera
eventName: Desbloquea temas de ventana, escritorio y lámpara para renovar tu espacio.
date: 04/05 - 04/22
reward: Ticket de tema gratis al iniciar sesión
cta: Ver tema
waiting: Esperando...
+42
View File
@@ -41,4 +41,46 @@ profile-switcher:
save-as-new: Enregistrer comme Nouveau Profil
new-profile-name: Nom du profil
manage: Gérer les Profils
promo-banner:
items:
signin:
watermark: Moon
fallbackLabel: Cadeau lunaire
title: Cadeau de connexion 7 jours
eventName: Connectez-vous pour obtenir poussière d’étoile, cadre davatar et cadeaux spéciaux.
date: 03/28 - 04/10
reward: Jusqu’à 120 poussières d’étoile
cta: Se connecter
build:
watermark: Build
fallbackLabel: Bonus chantier
title: Caisse bonus
eventName: Terminez les missions de construction pour obtenir des coupons et des matériaux doublés.
date: 03/30 - 04/12
reward: 5 coupons + matériaux damélioration
cta: Voir les missions
spring:
watermark: Spring
fallbackLabel: Tirage printanier
title: Carnaval capsule
eventName: Participez au tirage du printemps pour débloquer bulles, charms et meubles thématiques.
date: 04/01 - 04/18
reward: Premier tirage à moitié prix + charm rare
cta: Aller au tirage
coupon:
watermark: Talk
fallbackLabel: Coupon
title: Coupon de recharge
eventName: Terminez des interactions pour obtenir des coupons pour packs de dialogue et de voix.
date: 04/02 - 04/20
reward: -6 sur 30 / -15 sur 68
cta: Récupérer le coupon
home:
watermark: Home
fallbackLabel: Theme de chambre
title: Décor de printemps
eventName: Débloquez des thèmes de fenêtre, bureau et veilleuse pour renouveler votre espace.
date: 04/05 - 04/22
reward: Ticket de thème offert à la connexion
cta: Voir le thème
waiting: En attente
+42
View File
@@ -41,4 +41,46 @@ profile-switcher:
save-as-new: 新規プロファイルとして保存
new-profile-name: Profile name
manage: プロファイルを管理
promo-banner:
items:
signin:
watermark: Moon
fallbackLabel: 月の贈り物
title: 7日間ログイン特典
eventName: ログインで星砂、アイコンフレーム、登場記念ギフトを獲得できます。
date: 03/28 - 04/10
reward: 最大 120 星砂
cta: 今すぐ受け取る
build:
watermark: Build
fallbackLabel: 建造ボーナス
title: 補給箱ボーナス
eventName: 建造任務を完了すると補給券と2倍素材報酬を獲得できます。
date: 03/30 - 04/12
reward: 補給券 x5 + 強化素材
cta: 任務を見る
spring:
watermark: Spring
fallbackLabel: 春の抽選
title: カプセルカーニバル
eventName: 春のガチャに参加して吹き出し、チャーム、テーマ家具を解放しましょう。
date: 04/01 - 04/18
reward: 初回半額 + レアチャーム
cta: 引きに行く
coupon:
watermark: Talk
fallbackLabel: クーポン
title: チャージクーポン
eventName: 会話ミッションを完了すると拡張パックや音声パック用クーポンを受け取れます。
date: 04/02 - 04/20
reward: 30で6割引 / 68で15割引
cta: クーポンを受け取る
home:
watermark: Home
fallbackLabel: ルームテーマ
title: 春のルーム更新
eventName: 窓辺、デスク、ナイトライトのテーマを開放して部屋を模様替えできます。
date: 04/05 - 04/22
reward: ログインでテーマ抽選券
cta: テーマを見る
waiting: 待機中
+42
View File
@@ -41,4 +41,46 @@ profile-switcher:
save-as-new: Save as New Profile
new-profile-name: Profile name
manage: Manage Profiles
promo-banner:
items:
signin:
watermark: Moon
fallbackLabel: 달빛 선물
title: 7일 출석 보상
eventName: 출석만 해도 스타더스트, 아바타 프레임, 기념 보상을 받을 수 있어요.
date: 03/28 - 04/10
reward: 최대 120 스타더스트
cta: 지금 출석하기
build:
watermark: Build
fallbackLabel: 건설 보너스
title: 보급 상자 추가 지급
eventName: 건설 임무를 완료하면 보급 쿠폰과 2배 재료 보상을 획득합니다.
date: 03/30 - 04/12
reward: 보급 쿠폰 x5 + 강화 재료
cta: 임무 보기
spring:
watermark: Spring
fallbackLabel: 봄 추첨
title: 캡슐 카니발
eventName: 봄 뽑기에 참여해 말풍선, 장식, 테마 가구를 잠금 해제하세요.
date: 04/01 - 04/18
reward: 첫 뽑기 반값 + 희귀 장식
cta: 뽑기 하러 가기
coupon:
watermark: Talk
fallbackLabel: 쿠폰
title: 충전 쿠폰
eventName: 대화 미션을 완료하면 확장팩과 음성팩에 사용할 쿠폰을 받을 수 있어요.
date: 04/02 - 04/20
reward: 30 이상 6 할인 / 68 이상 15 할인
cta: 쿠폰 받기
home:
watermark: Home
fallbackLabel: 룸 테마
title: 봄 배경 업데이트
eventName: 창가, 책상, 무드등 테마를 열어 홈 화면을 새롭게 꾸며보세요.
date: 04/05 - 04/22
reward: 로그인 시 테마 추첨권 지급
cta: 테마 보기
waiting: 대기 중
+42
View File
@@ -41,4 +41,46 @@ profile-switcher:
save-as-new: Сохранить как Новый Профиль
new-profile-name: Profile name
manage: Управление профилями
promo-banner:
items:
signin:
watermark: Moon
fallbackLabel: Лунный подарок
title: Подарок за 7 дней входа
eventName: За вход можно получить звёздную пыль, рамку аватара и памятные награды.
date: 03/28 - 04/10
reward: До 120 звёздной пыли
cta: Войти сейчас
build:
watermark: Build
fallbackLabel: Бонус стройки
title: Дополнительный ящик снабжения
eventName: Выполняйте строительные задания и получайте купоны и двойные материалы.
date: 03/30 - 04/12
reward: 5 купонов + материалы усиления
cta: Открыть задания
spring:
watermark: Spring
fallbackLabel: Весенний розыгрыш
title: Весенний карнавал
eventName: Участвуйте в розыгрыше и открывайте пузырьки, подвески и тематическую мебель.
date: 04/01 - 04/18
reward: Первый розыгрыш со скидкой + редкая подвеска
cta: Перейти к розыгрышу
coupon:
watermark: Talk
fallbackLabel: Купон
title: Купон на пополнение
eventName: Завершите диалоговые задания и получите купоны на наборы диалогов и голосов.
date: 04/02 - 04/20
reward: Скидка 6 от 30 / 15 от 68
cta: Получить купон
home:
watermark: Home
fallbackLabel: Тема комнаты
title: Весеннее оформление
eventName: Откройте темы окна, стола и ночника, чтобы обновить домашнее пространство.
date: 04/05 - 04/22
reward: Билет на тему за вход
cta: Открыть тему
waiting: Ожидание
+42
View File
@@ -41,4 +41,46 @@ profile-switcher:
save-as-new: Save as New Profile
new-profile-name: Profile name
manage: Manage Profiles
promo-banner:
items:
signin:
watermark: Moon
fallbackLabel: Qua trang
title: Quà đăng nhập 7 ngày
eventName: Đăng nhập để nhận bụi sao, khung avatar và quà ra mắt đặc biệt.
date: 03/28 - 04/10
reward: Tối đa 120 bụi sao
cta: Điểm danh ngay
build:
watermark: Build
fallbackLabel: Thuong xay dung
title: Thùng tiếp tế thêm quà
eventName: Hoàn thành nhiệm vụ xây dựng để nhận phiếu tiếp tế và nguyên liệu nhân đôi.
date: 03/30 - 04/12
reward: 5 phiếu tiếp tế + nguyên liệu nâng cấp
cta: Xem nhiệm vụ
spring:
watermark: Spring
fallbackLabel: Rut thuong xuan
title: Lễ hội capsule
eventName: Tham gia vòng quay mùa xuân để mở khóa bong bóng chat, charm và nội thất chủ đề.
date: 04/01 - 04/18
reward: Lượt đầu giảm nửa giá + charm hiếm
cta: Đến quay ngay
coupon:
watermark: Talk
fallbackLabel: Phieu giam gia
title: Phiếu ưu đãi nạp
eventName: Hoàn thành tương tác hội thoại để nhận phiếu cho gói mở rộng và gói giọng nói.
date: 04/02 - 04/20
reward: Giảm 6 cho 30 / giảm 15 cho 68
cta: Nhận phiếu
home:
watermark: Home
fallbackLabel: Chu de phong
title: Cập nhật chủ đề xuân
eventName: Mở khóa chủ đề cửa sổ, bàn và đèn ngủ để làm mới không gian trang chủ.
date: 04/05 - 04/22
reward: Tặng vé quay chủ đề khi đăng nhập
cta: Xem chủ đề
waiting: Đang chờ
@@ -41,4 +41,46 @@ profile-switcher:
save-as-new: 存为新角色
new-profile-name: 角色卡名称
manage: 管理角色卡
promo-banner:
items:
signin:
watermark: Moon
fallbackLabel: 月光礼盒
title: 七日签到礼
eventName: 完成签到即可领取星砂、头像框与登场纪念礼
date: 03/28 - 04/10
reward: 累计可得 120 星砂
cta: 立即签到
build:
watermark: Build
fallbackLabel: 建造加成
title: 补给箱加送
eventName: 完成建造任务可额外掉落补给券,素材收益双倍结算
date: 03/30 - 04/12
reward: 补给券 x5 + 强化素材
cta: 查看任务
spring:
watermark: Spring
fallbackLabel: 春日抽奖
title: 扭蛋嘉年华
eventName: 参与春日池抽奖,解锁聊天气泡、挂件与主题家具
date: 04/01 - 04/18
reward: 首抽半价 + 稀有挂件
cta: 前往抽取
coupon:
watermark: Talk
fallbackLabel: 优惠券
title: 充值优惠券
eventName: 完成对话互动即可领取充值券,可用于对话扩展包与语音包
date: 04/02 - 04/20
reward: 满 30 减 6 / 满 68 减 15
cta: 领取优惠券
home:
watermark: Home
fallbackLabel: 房间主题
title: 春季布景更新
eventName: 解锁窗台、桌摆与晚安灯饰三套主题,快速焕新主页空间
date: 04/05 - 04/22
reward: 登录即送布景抽取券
cta: 查看主题
waiting: 等待中
@@ -41,4 +41,46 @@ profile-switcher:
save-as-new: Save as New Profile
new-profile-name: Profile name
manage: Manage Profiles
promo-banner:
items:
signin:
watermark: Moon
fallbackLabel: 月光贈禮
title: 七日簽到禮
eventName: 完成簽到即可領取星砂、頭像框與登場紀念禮
date: 03/28 - 04/10
reward: 累計可得 120 星砂
cta: 立即簽到
build:
watermark: Build
fallbackLabel: 建造加成
title: 補給箱加送
eventName: 完成建造任務可額外掉落補給券,素材收益雙倍結算
date: 03/30 - 04/12
reward: 補給券 x5 + 強化素材
cta: 查看任務
spring:
watermark: Spring
fallbackLabel: 春日抽獎
title: 扭蛋嘉年華
eventName: 參與春日池抽獎,解鎖聊天氣泡、掛件與主題家具
date: 04/01 - 04/18
reward: 首抽半價 + 稀有掛件
cta: 前往抽取
coupon:
watermark: Talk
fallbackLabel: 優惠券
title: 充值優惠券
eventName: 完成對話互動即可領取充值券,可用於對話擴展包與語音包
date: 04/02 - 04/20
reward: 滿 30 減 6 / 滿 68 減 15
cta: 領取優惠券
home:
watermark: Home
fallbackLabel: 房間主題
title: 春季佈景更新
eventName: 解鎖窗台、桌擺與晚安燈飾三套主題,快速煥新主頁空間
date: 04/05 - 04/22
reward: 登錄即送佈景抽取券
cta: 查看主題
waiting: 等待中
+2
View File
@@ -103,6 +103,8 @@
"d3": "catalog:",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
"embla-carousel-autoplay": "9.0.0-rc01",
"embla-carousel-vue": "catalog:",
"es-toolkit": "catalog:",
"gpuu": "^1.0.6",
"hono": "catalog:",
@@ -0,0 +1,276 @@
<script setup lang="ts">
import type { PromoBannerAction, PromoBannerItem, PromoBannerItemKey, PromoBannerVisual } from './promo-banner'
import useEmblaCarousel from 'embla-carousel-vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../../stores/auth'
import { getPromoBannerFallbackLabelKey, promoBannerVisuals } from './promo-banner'
import { usePromoBannerLayout } from './use-promo-banner-layout'
const router = useRouter()
const { locale, t } = useI18n()
const authStore = useAuthStore()
const isVisible = ref(false)
function translateBannerItem(key: PromoBannerItemKey): PromoBannerItem {
const prefix = `stage.promo-banner.items.${key}`
return {
watermark: t(`${prefix}.watermark`),
title: t(`${prefix}.title`),
eventName: t(`${prefix}.eventName`),
date: t(`${prefix}.date`),
reward: t(`${prefix}.reward`),
cta: t(`${prefix}.cta`),
}
}
const items = computed<(PromoBannerItem & PromoBannerVisual)[]>(() =>
promoBannerVisuals.map(item => ({
...item,
...translateBannerItem(item.key),
})),
)
const currentIndex = ref(0)
let autoplayTimer: ReturnType<typeof setInterval> | undefined
const [_emblaRef, emblaApi] = useEmblaCarousel({ loop: true })
function stopAutoplay() {
if (autoplayTimer !== undefined) {
clearInterval(autoplayTimer)
autoplayTimer = undefined
}
}
function startAutoplay() {
stopAutoplay()
autoplayTimer = setInterval(() => {
emblaApi.value?.goToNext()
}, 5000)
}
watch(emblaApi, (api, _, onCleanup) => {
if (!api) {
return
}
const syncIndex = () => {
currentIndex.value = api.selectedSnap()
}
syncIndex()
api.on('select', syncIndex)
const rootNode = api.rootNode()
rootNode.addEventListener('mouseenter', stopAutoplay)
rootNode.addEventListener('mouseleave', startAutoplay)
startAutoplay()
onCleanup(() => {
api.off?.('select', syncIndex)
rootNode.removeEventListener('mouseenter', stopAutoplay)
rootNode.removeEventListener('mouseleave', startAutoplay)
stopAutoplay()
})
}, { immediate: true })
function scrollTo(index: number) {
if (emblaApi.value) {
emblaApi.value.goTo(index)
startAutoplay()
}
}
function handlePromoBannerAction(action: PromoBannerAction) {
close()
if (action.type === 'login') {
authStore.needsLogin = true
return
}
void router.push(action.to)
}
function close() {
isVisible.value = false
stopAutoplay()
}
function open() {
isVisible.value = true
startAutoplay()
}
const activeItem = computed(() => items.value[currentIndex.value] ?? items.value[0])
const {
buttonClass,
descriptionClass,
metaClass,
titleClass,
watermarkClass,
} = usePromoBannerLayout(locale)
onBeforeUnmount(() => {
stopAutoplay()
})
</script>
<template>
<div class="fixed bottom-10 left-6 z-50 <md:hidden">
<button
v-if="!isVisible"
:class="[
'pointer-events-auto max-h-[10lh] min-h-[1lh]',
'bg-neutral-100 dark:bg-neutral-800',
'text-lg text-neutral-500 dark:text-neutral-400',
'flex items-center justify-center rounded-md p-2 outline-none',
'transition-colors transition-transform active:scale-95',
]"
title="Open promo banner"
type="button"
@click="open"
>
<div class="i-solar:gift-bold-duotone" />
</button>
<div v-else class="pointer-events-auto relative flex flex-col items-start">
<div :class="['relative h-60 w-108 overflow-hidden rounded-3xl border border-white/8 bg-neutral-900/86 shadow-2xl backdrop-blur-xl', 'ring-1 ring-black/10']">
<button
:class="[
'absolute right-3 top-3 z-30 h-8 w-8 flex items-center justify-center rounded-full',
'text-white/55 transition-colors hover:bg-white/10 hover:text-white',
]"
type="button"
@click="close"
>
<div class="i-lucide-x h-5 w-5" />
</button>
<div
:class="[
'pointer-events-none absolute inset-0 bg-gradient-to-br',
activeItem.accentClass,
]"
/>
<div class="pointer-events-none absolute inset-y-0 right-28 w-px bg-white/8" />
<div ref="_emblaRef" class="embla h-full w-full">
<div class="embla__container h-full">
<div
v-for="(item, index) in items"
:key="index"
class="embla__slide relative h-full min-w-0"
>
<div class="absolute inset-y-0 left-0 w-78 px-4 py-4">
<div class="absolute left-5 top-4 z-0 select-none">
<span :class="['block text-white/8 leading-none uppercase', watermarkClass]">
{{ item.watermark }}
</span>
</div>
<div class="relative z-10 h-full flex flex-col pr-2">
<div :class="['flex items-center gap-1.5 text-white/72', metaClass]">
<div class="i-solar:calendar-mark-bold-duotone text-[13px] text-primary-200" />
<span>{{ item.date }}</span>
</div>
<div :class="['mt-3 text-white break-words', titleClass]">
{{ item.title }}
</div>
<div :class="['mt-2 text-white/68 break-words', descriptionClass]">
{{ item.eventName }}
</div>
<div class="mt-3 space-y-1.5">
<div :class="['flex items-center gap-1.5 text-white/72', metaClass]">
<div class="i-solar:gift-bold-duotone text-[13px] text-amber-300" />
<span>{{ item.reward }}</span>
</div>
</div>
<button
:class="[
'mt-auto mb-1 w-fit rounded-full px-4 py-2 text-neutral-950 transition-transform active:scale-95',
buttonClass,
'bg-white shadow-[0_8px_24px_rgba(255,255,255,0.16)] hover:translate-y-[-1px]',
]"
type="button"
@click="handlePromoBannerAction(item.action)"
>
{{ item.cta }}
</button>
</div>
</div>
<div class="absolute inset-y-3 right-3 w-23 overflow-hidden border border-white/10 rounded-2xl bg-white/5">
<img
v-if="item.image"
:src="item.image"
:alt="item.title"
class="h-full w-full object-cover"
>
<div
v-else
:class="[
'relative h-full w-full overflow-hidden bg-gradient-to-br',
item.fallbackClass,
]"
>
<div class="absolute left-4 top-4 h-10 w-10 rounded-2xl bg-white/12 blur-sm" />
<div class="absolute right-3 top-3 h-14 w-14 rounded-full bg-amber-300/22 blur-md" />
<div class="absolute inset-x-4 bottom-4 h-18 border border-white/12 rounded-[1.4rem] bg-black/18 backdrop-blur-sm" />
<div :class="[item.fallbackIcon, item.fallbackIconClass, 'absolute left-5 top-5 text-2xl']" />
<div :class="[item.fallbackIcon, item.fallbackIconClass, 'absolute bottom-7 right-4 text-4xl opacity-90']" />
<div class="absolute bottom-5 left-4 text-[10px] text-white/70 font-700 tracking-[0.3em] uppercase">
{{ t(getPromoBannerFallbackLabelKey(item.key)) }}
</div>
</div>
<div class="pointer-events-none absolute inset-0 from-neutral-950/55 via-transparent to-white/8 bg-gradient-to-t" />
<div class="pointer-events-none absolute bottom-0 left-0 right-0 h-18 from-neutral-950/88 to-transparent bg-gradient-to-t" />
</div>
</div>
</div>
</div>
<div class="absolute bottom-3.5 right-4 z-30 flex items-center gap-2">
<span class="text-[11px] text-white/42 font-600">{{ currentIndex + 1 }}/{{ items.length }}</span>
<div class="flex gap-1.5">
<button
v-for="(_, index) in items"
:key="`dot-${index}`"
:class="[
'h-2.5 rounded-full transition-all duration-300',
currentIndex === index ? 'w-5 bg-white' : 'w-2.5 bg-white/30 hover:bg-white/50',
]"
type="button"
@click="scrollTo(index)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.embla {
overflow: hidden;
}
.embla__container {
display: flex;
}
.embla__slide {
flex: 0 0 100%;
min-width: 0;
}
</style>
@@ -0,0 +1 @@
export { default as HoloCoupon } from './holo-coupon.vue'
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
getPromoBannerFallbackLabelKey,
promoBannerVisuals,
} from './promo-banner'
describe('promo banner visuals', () => {
it('defines concrete actions for every promo banner item', () => {
expect(promoBannerVisuals).toMatchObject([
{
key: 'signin',
action: { type: 'login' },
},
{
key: 'build',
action: { type: 'route', to: '/settings/modules/consciousness' },
},
{
key: 'spring',
action: { type: 'route', to: '/settings/flux' },
},
{
key: 'coupon',
action: { type: 'route', to: '/settings/flux' },
},
{
key: 'home',
action: { type: 'route', to: '/settings/scene' },
},
])
})
it('resolves fallback labels through locale keys instead of hard-coded English strings', () => {
expect(getPromoBannerFallbackLabelKey('signin')).toBe('stage.promo-banner.items.signin.fallbackLabel')
expect(getPromoBannerFallbackLabelKey('build')).toBe('stage.promo-banner.items.build.fallbackLabel')
expect(getPromoBannerFallbackLabelKey('spring')).toBe('stage.promo-banner.items.spring.fallbackLabel')
expect(getPromoBannerFallbackLabelKey('coupon')).toBe('stage.promo-banner.items.coupon.fallbackLabel')
expect(getPromoBannerFallbackLabelKey('home')).toBe('stage.promo-banner.items.home.fallbackLabel')
})
})
@@ -0,0 +1,79 @@
import factorioPoster from '../../../assets/factorio-simple.png'
import onboardingPoster from '../../../assets/onboarding.avif'
export interface PromoBannerItem {
watermark: string
title: string
eventName: string
date: string
reward: string
cta: string
}
export type PromoBannerItemKey = 'signin' | 'build' | 'spring' | 'coupon' | 'home'
export type PromoBannerAction
= | { type: 'login' }
| { type: 'route', to: string }
export interface PromoBannerVisual {
key: PromoBannerItemKey
image: string
action: PromoBannerAction
accentClass: string
fallbackIcon: string
fallbackIconClass: string
fallbackClass: string
}
export function getPromoBannerFallbackLabelKey(key: PromoBannerItemKey) {
return `stage.promo-banner.items.${key}.fallbackLabel`
}
export const promoBannerVisuals: PromoBannerVisual[] = [
{
key: 'signin',
image: onboardingPoster,
action: { type: 'login' },
accentClass: 'from-fuchsia-500/30 via-rose-400/18 to-transparent',
fallbackIcon: 'i-solar:stars-line-duotone',
fallbackIconClass: 'text-amber-100',
fallbackClass: 'from-fuchsia-300/25 via-rose-300/14 to-violet-400/20',
},
{
key: 'build',
image: factorioPoster,
action: { type: 'route', to: '/settings/modules/consciousness' },
accentClass: 'from-cyan-500/30 via-sky-400/18 to-transparent',
fallbackIcon: 'i-solar:box-bold-duotone',
fallbackIconClass: 'text-cyan-100',
fallbackClass: 'from-cyan-300/25 via-sky-300/14 to-blue-400/20',
},
{
key: 'spring',
image: '',
action: { type: 'route', to: '/settings/flux' },
accentClass: 'from-amber-400/30 via-orange-300/18 to-transparent',
fallbackIcon: 'i-solar:gift-bold-duotone',
fallbackIconClass: 'text-white/88',
fallbackClass: 'from-amber-300/25 via-rose-300/14 to-fuchsia-400/20',
},
{
key: 'coupon',
image: '',
action: { type: 'route', to: '/settings/flux' },
accentClass: 'from-emerald-400/28 via-teal-300/16 to-transparent',
fallbackIcon: 'i-solar:ticket-sale-bold-duotone',
fallbackIconClass: 'text-emerald-100',
fallbackClass: 'from-emerald-300/24 via-cyan-300/12 to-teal-400/18',
},
{
key: 'home',
image: '',
action: { type: 'route', to: '/settings/scene' },
accentClass: 'from-sky-400/28 via-indigo-300/14 to-transparent',
fallbackIcon: 'i-solar:home-angle-bold-duotone',
fallbackIconClass: 'text-sky-100',
fallbackClass: 'from-sky-300/25 via-indigo-300/14 to-violet-400/18',
},
]
@@ -0,0 +1,50 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
const cjkLocalePrefixes = ['zh', 'ja', 'ko']
export function usePromoBannerLayout(locale: Ref<string>) {
const isCjkLocale = computed(() =>
cjkLocalePrefixes.some(prefix => locale.value.startsWith(prefix)),
)
const titleClass = computed(() =>
isCjkLocale.value
? 'text-[28px] leading-none font-700'
: 'font-sans text-[22px] leading-tight font-600 tracking-normal',
)
const descriptionClass = computed(() =>
isCjkLocale.value
? 'max-w-58 text-[13px] leading-5'
: 'max-w-60 font-sans text-[12px] leading-4.5 font-500',
)
const metaClass = computed(() =>
isCjkLocale.value
? 'text-[11px]'
: 'font-sans text-[11px] font-500',
)
const buttonClass = computed(() =>
isCjkLocale.value
? 'text-xs font-700'
: 'font-sans text-[12px] font-600 tracking-normal',
)
const watermarkClass = computed(() =>
isCjkLocale.value
? 'text-5xl font-600'
: 'font-sans text-[44px] font-500 tracking-[0.08em]',
)
return {
buttonClass,
descriptionClass,
isCjkLocale,
metaClass,
titleClass,
watermarkClass,
}
}
@@ -1,6 +1,7 @@
export * from './about'
export * from './chat'
export * from './dialogs'
export * from './hologram'
export * from './providers'
export * from './settings'
export * from './toasters'