feat(brand): rebrand to Hedena Gold and bring main user-app chrome
Rename the brand, ship Hedena logos, persist login OTP text in the SMS list, and port home/nav/auth/theme polish from main while keeping expert customers intact. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,9 @@
|
||||
// and «بهروزرسانی» drops the service worker and every Cache Storage entry,
|
||||
// then reloads — so the next load is the new release, not a precached copy.
|
||||
// localStorage is left alone: sign-in, cart and settings survive the update.
|
||||
//
|
||||
// `available` / `updating` are module-scoped so InstallPromptModal can wait
|
||||
// while the update gate is open (same truth, not a second check loop).
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
declare const __APP_BUILD__: number
|
||||
@@ -23,10 +26,31 @@ const CHECK_EVERY_MS = 5 * 60 * 1000
|
||||
export const runningBuild: number = typeof __APP_BUILD__ === 'number' ? __APP_BUILD__ : 0
|
||||
export const runningVersion: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : ''
|
||||
|
||||
export function useAppUpdate() {
|
||||
const available = ref<VersionInfo | null>(null)
|
||||
const updating = ref(false)
|
||||
/** Shared so PWA install prompt can defer while the update gate is open. */
|
||||
export const updateGateAvailable = ref<VersionInfo | null>(null)
|
||||
const available = updateGateAvailable
|
||||
const updating = ref(false)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let listeners = 0
|
||||
|
||||
async function check() {
|
||||
if (import.meta.env.DEV || !runningBuild || available.value) return
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const info = (await res.json()) as VersionInfo
|
||||
if (typeof info.build === 'number' && info.build > runningBuild) available.value = info
|
||||
} catch {
|
||||
// Offline or a deploy in progress: try again on the next tick.
|
||||
}
|
||||
}
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') check()
|
||||
}
|
||||
|
||||
export function useAppUpdate() {
|
||||
/** What changed between the running build and the server's, newest first. */
|
||||
const changes = computed(() => {
|
||||
const info = available.value
|
||||
@@ -34,18 +58,6 @@ export function useAppUpdate() {
|
||||
return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build)
|
||||
})
|
||||
|
||||
async function check() {
|
||||
if (import.meta.env.DEV || !runningBuild || available.value) return
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const info = (await res.json()) as VersionInfo
|
||||
if (typeof info.build === 'number' && info.build > runningBuild) available.value = info
|
||||
} catch {
|
||||
// Offline or a deploy in progress: try again on the next tick.
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdate() {
|
||||
updating.value = true
|
||||
try {
|
||||
@@ -65,19 +77,22 @@ export function useAppUpdate() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') check()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
check()
|
||||
timer = setInterval(check, CHECK_EVERY_MS)
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
listeners += 1
|
||||
if (listeners === 1) {
|
||||
check()
|
||||
timer = setInterval(check, CHECK_EVERY_MS)
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
listeners -= 1
|
||||
if (listeners <= 0) {
|
||||
listeners = 0
|
||||
if (timer) clearInterval(timer)
|
||||
timer = undefined
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
})
|
||||
|
||||
return { available, changes, updating, applyUpdate, check }
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// Server-side catalogue paging (STUB-03 step 4 / docs/CATALOG-PAGINATION-PLAN.md).
|
||||
// Replaces "download everything, slice in the browser" for list screens.
|
||||
import { computed, ref } from 'vue'
|
||||
import { api, type ProductQuery, type ProductSummary } from '../api/client'
|
||||
|
||||
export const CATALOG_PAGE_SIZE = 20
|
||||
|
||||
export function usePagedProducts() {
|
||||
const rows = ref<ProductSummary[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const hasMore = computed(() => rows.value.length < total.value)
|
||||
|
||||
/**
|
||||
* @param opts.preserveRows — pull-to-refresh: keep the current list on screen
|
||||
* until the new page arrives (avoids an empty flash / stuck full-page spinner).
|
||||
*/
|
||||
async function resetAndLoad(query: ProductQuery, opts: { preserveRows?: boolean } = {}) {
|
||||
const showFullSpinner = !opts.preserveRows || rows.value.length === 0
|
||||
if (showFullSpinner) loading.value = true
|
||||
page.value = 1
|
||||
try {
|
||||
const res = await api.listProductsPaged({
|
||||
...query,
|
||||
page: 1,
|
||||
limit: query.limit ?? CATALOG_PAGE_SIZE,
|
||||
})
|
||||
rows.value = res.rows
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore(query: ProductQuery) {
|
||||
if (loading.value || loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
const next = page.value + 1
|
||||
try {
|
||||
const res = await api.listProductsPaged({
|
||||
...query,
|
||||
page: next,
|
||||
limit: query.limit ?? CATALOG_PAGE_SIZE,
|
||||
})
|
||||
page.value = next
|
||||
// Append; drop accidental duplicates if the catalogue shifted between pages.
|
||||
const seen = new Set(rows.value.map((p) => p.id))
|
||||
rows.value = [...rows.value, ...res.rows.filter((p) => !seen.has(p.id))]
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
page.value = 0
|
||||
}
|
||||
|
||||
return { rows, total, page, loading, loadingMore, hasMore, resetAndLoad, loadMore, clear }
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
// same output shape legacy's Mixins/product/index.ts `useProduct()` produced.
|
||||
// The legacy card components it was originally shaped for (ProductHorizontal/
|
||||
// ProductVertical/ProductVerticalFull) are gone, replaced by the redesigned
|
||||
// components/product/ProductCard.vue, components/home/ShowcaseCard.vue and
|
||||
// components/product/CartItem.vue — all of which consume this same contract.
|
||||
// components/product/ProductCard.vue and components/product/CartItem.vue —
|
||||
// both of which consume this same contract.
|
||||
//
|
||||
// Honest gaps vs. legacy (no backend equivalent exists yet — apps/api was not
|
||||
// touched to add these):
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// Newest / list screens: true server paging (STUB-03 step 4).
|
||||
// Legacy paginated against POST /get-products; the early Nest port returned the
|
||||
// whole catalogue and treated `@fetch` as a no-op. This restores real pages.
|
||||
import { reactive } from 'vue'
|
||||
// Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated
|
||||
// against POST /get-products (page/limit/count). The new API's GET
|
||||
// /catalog/products returns the full active catalog in one flat response with
|
||||
// no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger)
|
||||
// is a no-op here, and `totalPages` is always 0. A caller that wants new
|
||||
// products at the end of the list passes `force` to refresh the cached
|
||||
// catalogue instead of fabricating fake pages.
|
||||
import { reactive, watch } from 'vue'
|
||||
import type { ProductSummary } from '../api/client'
|
||||
import { usePagedProducts, CATALOG_PAGE_SIZE } from './usePagedProducts'
|
||||
import { useCatalogStore } from '../stores/catalogStore'
|
||||
|
||||
export interface ProductsData {
|
||||
totalPages: number
|
||||
@@ -12,38 +16,28 @@ export interface ProductsData {
|
||||
}
|
||||
|
||||
export function useProducts() {
|
||||
const paged = usePagedProducts()
|
||||
const catalog = useCatalogStore()
|
||||
const productsData: ProductsData = reactive({
|
||||
totalPages: 0,
|
||||
productList: [] as ProductSummary[],
|
||||
productList: catalog.products,
|
||||
flag: false,
|
||||
})
|
||||
|
||||
function sync() {
|
||||
productsData.productList = paged.rows.value
|
||||
productsData.totalPages = Math.ceil(paged.total.value / CATALOG_PAGE_SIZE) || 0
|
||||
productsData.flag = paged.total.value < 1 && !paged.loading.value
|
||||
}
|
||||
// A refresh from another screen (pull-to-refresh) lands here too.
|
||||
watch(
|
||||
() => catalog.products,
|
||||
(list) => {
|
||||
productsData.productList = list
|
||||
productsData.flag = list.length < 1
|
||||
},
|
||||
)
|
||||
|
||||
const getProducts = async (loading: { value: boolean }, isPull = false, force = false) => {
|
||||
void force
|
||||
if (isPull) {
|
||||
if (!paged.hasMore.value || paged.loadingMore.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await paged.loadMore({ sort: 'newest' })
|
||||
sync()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
if (isPull) return // no further pages exist server-side yet
|
||||
loading.value = force || !catalog.loaded
|
||||
try {
|
||||
await paged.resetAndLoad({ sort: 'newest' })
|
||||
sync()
|
||||
productsData.productList = await catalog.load(force)
|
||||
productsData.flag = productsData.productList.length < 1
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
@@ -51,5 +45,5 @@ export function useProducts() {
|
||||
}
|
||||
}
|
||||
|
||||
return { productsData, getProducts, hasMore: paged.hasMore }
|
||||
return { productsData, getProducts }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Brand-first PWA install offer (see docs/superpowers/specs/2026-10-15-pwa-install-prompt-design.md).
|
||||
// Shared module state so App.vue and InstallPromptModal share one listener / one clock.
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
import { updateGateAvailable } from './useAppUpdate'
|
||||
|
||||
const STORAGE_KEY = 'pwaInstall'
|
||||
const REOFFER_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const INITIAL_DELAY_MS = 4000
|
||||
|
||||
export type PwaGuidePlatform = 'ios' | 'android'
|
||||
export type PwaInstallMode = 'offer' | 'guide'
|
||||
|
||||
interface Stored {
|
||||
dismissedAt: number | null
|
||||
installed: boolean
|
||||
}
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||
}
|
||||
|
||||
const visible = ref(false)
|
||||
const mode = ref<PwaInstallMode>('offer')
|
||||
const canNativeInstall = ref(false)
|
||||
const platform = ref<PwaGuidePlatform>(detectPlatform())
|
||||
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = null
|
||||
let delayTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let delayElapsed = false
|
||||
let bootstrapped = false
|
||||
let storageOk = true
|
||||
/** When true, van-dialog `@closed` must not write dismissedAt (route/gate hide). */
|
||||
let skipPersistOnClose = false
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const mq = window.matchMedia?.('(display-mode: standalone)')?.matches
|
||||
const iosStandalone = (navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
return Boolean(mq || iosStandalone)
|
||||
}
|
||||
|
||||
function detectPlatform(): PwaGuidePlatform {
|
||||
if (typeof navigator === 'undefined') return 'android'
|
||||
const ua = navigator.userAgent || ''
|
||||
const iOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||
return iOS ? 'ios' : 'android'
|
||||
}
|
||||
|
||||
function load(): Stored {
|
||||
const fallback: Stored = { dismissedAt: null, installed: false }
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return fallback
|
||||
const parsed = JSON.parse(raw) as Partial<Stored>
|
||||
return {
|
||||
dismissedAt: typeof parsed.dismissedAt === 'number' ? parsed.dismissedAt : null,
|
||||
installed: parsed.installed === true,
|
||||
}
|
||||
} catch {
|
||||
storageOk = false
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function persist(value: Stored) {
|
||||
if (!storageOk) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
storageOk = false
|
||||
}
|
||||
}
|
||||
|
||||
const stored = ref<Stored>(load())
|
||||
|
||||
function syncInstalledFromEnvironment() {
|
||||
if (!isStandalone()) return
|
||||
if (stored.value.installed) return
|
||||
stored.value = { ...stored.value, installed: true }
|
||||
persist(stored.value)
|
||||
}
|
||||
|
||||
function eligibilityCore(): boolean {
|
||||
if (!storageOk) return false
|
||||
syncInstalledFromEnvironment()
|
||||
if (stored.value.installed || isStandalone()) return false
|
||||
const dismissedAt = stored.value.dismissedAt
|
||||
if (dismissedAt != null && Date.now() - dismissedAt < REOFFER_MS) return false
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return false
|
||||
return true
|
||||
}
|
||||
|
||||
const shouldShowPrompt = computed(() => eligibilityCore())
|
||||
|
||||
function closeModal(opts?: { silent?: boolean }) {
|
||||
skipPersistOnClose = opts?.silent === true
|
||||
visible.value = false
|
||||
mode.value = 'offer'
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
stored.value = { ...stored.value, dismissedAt: Date.now() }
|
||||
persist(stored.value)
|
||||
closeModal({ silent: true })
|
||||
}
|
||||
|
||||
function markInstalled() {
|
||||
stored.value = { dismissedAt: stored.value.dismissedAt, installed: true }
|
||||
persist(stored.value)
|
||||
deferredPrompt = null
|
||||
canNativeInstall.value = false
|
||||
closeModal({ silent: true })
|
||||
}
|
||||
|
||||
/** Overlay / ESC close from van-dialog — start the 7-day clock unless silent. */
|
||||
function onDialogClosed() {
|
||||
if (skipPersistOnClose) {
|
||||
skipPersistOnClose = false
|
||||
mode.value = 'offer'
|
||||
return
|
||||
}
|
||||
if (!stored.value.installed) {
|
||||
stored.value = { ...stored.value, dismissedAt: Date.now() }
|
||||
persist(stored.value)
|
||||
}
|
||||
mode.value = 'offer'
|
||||
}
|
||||
|
||||
function openGuide() {
|
||||
mode.value = 'guide'
|
||||
}
|
||||
|
||||
async function promptInstall(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
|
||||
if (!deferredPrompt) return 'unavailable'
|
||||
const event = deferredPrompt
|
||||
deferredPrompt = null
|
||||
canNativeInstall.value = false
|
||||
try {
|
||||
await event.prompt()
|
||||
const { outcome } = await event.userChoice
|
||||
if (outcome === 'accepted') {
|
||||
markInstalled()
|
||||
return 'accepted'
|
||||
}
|
||||
return 'dismissed'
|
||||
} catch {
|
||||
return 'dismissed'
|
||||
}
|
||||
}
|
||||
|
||||
async function onPrimaryCta() {
|
||||
if (canNativeInstall.value && deferredPrompt) {
|
||||
await promptInstall()
|
||||
return
|
||||
}
|
||||
openGuide()
|
||||
}
|
||||
|
||||
function tryOpen(isFullPage: boolean, updateGateOpen: boolean) {
|
||||
if (!delayElapsed) return
|
||||
if (isFullPage || updateGateOpen) {
|
||||
if (visible.value) closeModal({ silent: true })
|
||||
return
|
||||
}
|
||||
if (!eligibilityCore()) return
|
||||
if (visible.value) return
|
||||
mode.value = 'offer'
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function onBeforeInstallPrompt(e: Event) {
|
||||
e.preventDefault()
|
||||
deferredPrompt = e as BeforeInstallPromptEvent
|
||||
canNativeInstall.value = true
|
||||
}
|
||||
|
||||
function onAppInstalled() {
|
||||
markInstalled()
|
||||
}
|
||||
|
||||
function bootstrapAutoPrompt(router: Router, updateAvailable: Ref<unknown>) {
|
||||
if (bootstrapped || typeof window === 'undefined') return
|
||||
bootstrapped = true
|
||||
platform.value = detectPlatform()
|
||||
syncInstalledFromEnvironment()
|
||||
|
||||
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt)
|
||||
window.addEventListener('appinstalled', onAppInstalled)
|
||||
|
||||
const isFullPage = () => router.currentRoute.value.meta?.layout === 'FullPage'
|
||||
const updateGateOpen = () => Boolean(updateAvailable.value)
|
||||
|
||||
delayTimer = setTimeout(() => {
|
||||
delayElapsed = true
|
||||
tryOpen(isFullPage(), updateGateOpen())
|
||||
}, INITIAL_DELAY_MS)
|
||||
|
||||
watch(
|
||||
() => [router.currentRoute.value.fullPath, router.currentRoute.value.meta?.layout, updateAvailable.value] as const,
|
||||
() => tryOpen(isFullPage(), updateGateOpen()),
|
||||
)
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') tryOpen(isFullPage(), updateGateOpen())
|
||||
})
|
||||
}
|
||||
|
||||
export function usePwaInstall() {
|
||||
function start(router: Router) {
|
||||
bootstrapAutoPrompt(router, updateGateAvailable)
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
mode,
|
||||
platform,
|
||||
canNativeInstall,
|
||||
shouldShowPrompt,
|
||||
start,
|
||||
dismiss,
|
||||
markInstalled,
|
||||
openGuide,
|
||||
promptInstall,
|
||||
onPrimaryCta,
|
||||
closeModal,
|
||||
onDialogClosed,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user