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:
mahmood
2026-10-27 16:40:54 +03:30
co-authored by Cursor
parent bc881a0a8a
commit c438ab2159
94 changed files with 3385 additions and 1396 deletions
+40 -25
View File
@@ -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 -2
View File
@@ -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):
+24 -30
View File
@@ -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,
}
}