create landing page for boursemes

This commit is contained in:
2026-08-01 10:11:28 +03:30
commit 4cc5ca6b5c
150 changed files with 18714 additions and 0 deletions
@@ -0,0 +1,27 @@
import { inject, provide, type InjectionKey } from 'vue'
import { useConfigStore } from '@/store/configStore'
export interface AboutUsPageContext {
config: ReturnType<typeof useConfigStore>
}
export const ABOUT_US_PAGE_CONTEXT_KEY: InjectionKey<AboutUsPageContext> = Symbol('aboutUsPageContext')
export function useAboutUsPage() {
const config = useConfigStore()
const context: AboutUsPageContext = {
config,
}
provide(ABOUT_US_PAGE_CONTEXT_KEY, context)
return context
}
export function useAboutUsPageContext() {
const context = inject(ABOUT_US_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useAboutUsPageContext must be used within a page that calls useAboutUsPage()')
}
return context
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
<template>
<div class="about-us-page-root">
<DesktopAboutUsView />
</div>
</template>
<script lang="ts" setup>
import { useAboutUsPage } from './composables/useAboutUsPage'
import DesktopAboutUsView from './desktop/DesktopAboutUsView.vue'
useAboutUsPage()
</script>
<style scoped>
.about-us-page-root {
min-height: 100%;
display: flex;
flex-direction: column;
background: #f4f5f7;
}
</style>
<style>
/*
* Mobile shell locks html/body overflow. Scroll the landing inside the page root.
* Desktop must NOT use overflow:auto here — a full-height auto container with
* overscroll-behavior:contain steals wheel events and blocks document scroll.
*/
html:not(.app-layout--desktop) .app-main > .about-us-page-root {
min-height: 0;
height: 100%;
max-height: 100%;
overflow: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
html.dlanding-menu-open .about-us-page-root {
overflow: hidden;
}
</style>
@@ -0,0 +1,206 @@
import { computed, ref, watch } from 'vue'
import { getAllPrices } from '@/api/home'
import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog'
import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price'
export type ChartMetalType = 'katod' | 'maftol' | 'shamsh'
type ChartPoint = [number, number]
/**
* مارک‌آپ ثابت روی قیمت بورس کالا برای نمودار:
* کاتد +۳٪ ، مفتول +۶٪ ، شمش +۱۲٪
*/
export const CHART_METAL_MARKUP_RATIO: Record<ChartMetalType, number> = {
katod: 0.03,
maftol: 0.06,
shamsh: 0.12,
}
/** Module-level chart state — one dataset shared by mobile and desktop PriceChart instances. */
const priceChartSeries = ref<{ name: string; data: ChartPoint[] }[]>([
{ name: 'قیمت', data: [] },
])
const rawChartPoints = ref<ChartPoint[]>([])
const chartSelection = ref('DAILY')
const chartLoading = ref(false)
const selectedChartMetal = ref<ChartMetalType>('katod')
const catalogProducts = getProductsCatalogRef()
const maftolChartRatio = computed(() => CHART_METAL_MARKUP_RATIO.maftol)
const shamshChartRatio = computed(() => CHART_METAL_MARKUP_RATIO.shamsh)
const selectedMetalChartRatio = computed(
() => CHART_METAL_MARKUP_RATIO[selectedChartMetal.value],
)
let chartFetchPromise: Promise<void> | null = null
let chartInitialized = false
let metalOffsetPromise: Promise<void> | null = null
let metalOffsetLoaded = false
function applyChartRatio(basePrice: number, ratio: number) {
if (!ratio) {
return basePrice
}
return Math.round(basePrice + basePrice * ratio)
}
/** بالاترین/پایین‌ترین امروز — همان مارک‌آپ ثابت فلز روی قیمت بورس کالا */
export function resolveMetalDayRangePrices(options: {
price: { up?: { buy?: number; sell?: number }; buy?: number; sell?: number; price_last?: number } | null | undefined
metal: ChartMetalType
metalRatio: number
}) {
const baseHigh = options.price?.up?.buy ?? getTradeBuyPrice(options.price as Parameters<typeof getTradeBuyPrice>[0])
const baseLow = options.price?.up?.sell ?? getTradeSellPrice(options.price as Parameters<typeof getTradeSellPrice>[0])
const ratio = options.metalRatio || CHART_METAL_MARKUP_RATIO[options.metal]
return {
high: applyChartRatio(baseHigh, ratio),
low: applyChartRatio(baseLow, ratio),
}
}
function normalizeChartTimestamp(timestamp: number) {
return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp
}
function resolvePricesMap(response: unknown): Record<string, Array<{ timestamp: number; value: number }>> | null {
if (!response || typeof response !== 'object') {
return null
}
const body = response as Record<string, unknown>
const nested = body.data
if (nested && typeof nested === 'object' && 'prices' in nested) {
return (nested as { prices: Record<string, Array<{ timestamp: number; value: number }>> }).prices
}
if ('prices' in body) {
return body.prices as Record<string, Array<{ timestamp: number; value: number }>>
}
return null
}
function buildChartPoints(items: Array<{ timestamp: number; value: number }>): ChartPoint[] {
return items.map((item) => [
normalizeChartTimestamp(item.timestamp),
item.value - 1,
])
}
function syncChartSeries() {
const ratio = selectedMetalChartRatio.value
priceChartSeries.value[0].data = rawChartPoints.value.map(
([timestamp, value]) => [timestamp, applyChartRatio(value, ratio)] as ChartPoint,
)
}
watch([selectedChartMetal, maftolChartRatio, shamshChartRatio], () => {
syncChartSeries()
})
async function fetchPrices(force = false) {
if (!force && chartInitialized && rawChartPoints.value.length > 0) {
syncChartSeries()
return
}
if (!force && chartFetchPromise) {
return chartFetchPromise
}
chartFetchPromise = (async () => {
try {
chartLoading.value = true
const response = await getAllPrices()
const data = resolvePricesMap(response)
const selectedData = data?.[chartSelection.value] ?? []
rawChartPoints.value = buildChartPoints(selectedData)
syncChartSeries()
chartInitialized = true
} catch (error) {
console.error('Error fetching prices:', error)
} finally {
chartLoading.value = false
chartFetchPromise = null
}
})()
return chartFetchPromise
}
async function loadMetalChartOffsets(force = false) {
if (!force && metalOffsetLoaded && catalogProducts.value.length > 0) {
return
}
if (!force && metalOffsetPromise) {
return metalOffsetPromise
}
metalOffsetPromise = (async () => {
try {
await getProductsCatalog(force)
syncChartSeries()
metalOffsetLoaded = true
} catch (error) {
console.error('Error loading boursecu chart metal ratios:', error)
syncChartSeries()
} finally {
metalOffsetPromise = null
}
})()
return metalOffsetPromise
}
function updateChartData(timeline: string) {
chartSelection.value = timeline
void fetchPrices(true)
}
function setChartMetal(metal: ChartMetalType) {
selectedChartMetal.value = metal
syncChartSeries()
}
export function usePriceChart() {
return {
priceChartSeries,
chartSelection,
chartLoading,
selectedChartMetal,
maftolChartRatio,
shamshChartRatio,
selectedMetalChartRatio,
updateChartData,
fetchPrices,
loadMaftolOffset: loadMetalChartOffsets,
loadMetalChartOffsets,
setChartMetal,
}
}
export function fetchHomeChartPrices(force = false) {
return fetchPrices(force)
}
export function loadHomeMaftolOffset(force = false) {
return loadMetalChartOffsets(force)
}
export function resetHomePriceChart() {
priceChartSeries.value = [{ name: 'قیمت', data: [] }]
rawChartPoints.value = []
chartSelection.value = 'DAILY'
selectedChartMetal.value = 'katod'
chartInitialized = false
chartFetchPromise = null
metalOffsetLoaded = false
metalOffsetPromise = null
}
@@ -0,0 +1,66 @@
<script setup lang="ts">
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import shareAppleIcon from '@/assets/img/share-apple.png'
import addToHomeScreenIcon from '@/assets/img/Add-to-homeScreen.png'
defineProps<{
show: boolean
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
confirm: []
}>()
function close() {
emit('update:show', false)
}
function confirm() {
emit('confirm')
close()
}
</script>
<template>
<BasicModal
:visible="show"
:custom-footer="true"
destroy-on-close
@update:visible="emit('update:show', $event)"
>
<h2 class="text-base font-semibold text-center mb-2 text-gray-800">
وب اپلیکیشن بورس مس را به صفحه اصلی خود اضافه کنید
</h2>
<div class="dauth-pwa-steps">
<div class="dauth-pwa-step">
<div class="dauth-pwa-step__icon">
<img :src="shareAppleIcon" alt="Share" />
</div>
<p class="dauth-pwa-step__text">روی دکمه Share در نوار پایین کلیک کنید.</p>
</div>
<div class="dauth-pwa-step">
<div class="dauth-pwa-step__icon">
<img :src="addToHomeScreenIcon" alt="Add to Home Screen" />
</div>
<p class="dauth-pwa-step__text">
در منوی باز شده در قسمت پایین گزینه Add to Home Screen را انتخاب کنید.
</p>
</div>
<div class="dauth-pwa-step">
<div class="dauth-pwa-step__icon">
<span class="text-blue-600 font-bold text-lg">Add</span>
</div>
<p class="dauth-pwa-step__text">در مرحله آخر در قسمت بالا روی Add کلیک کنید.</p>
</div>
</div>
<BasicButton class="w-full mt-4" type="primary" @click="confirm">
متوجه شدم
</BasicButton>
</BasicModal>
</template>
@@ -0,0 +1,499 @@
/* Premium desktop auth — split-panel SaaS login */
.dauth {
min-height: 100vh;
background: #f6f7f9;
display: flex;
align-items: stretch;
color: #111827;
}
.dauth__layout {
display: grid;
grid-template-columns: minmax(380px, 1fr) minmax(420px, 520px);
width: 100%;
min-height: 100vh;
}
/* Brand panel — uses config primary color (same as buttons) */
.dauth-brand {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 56px 64px;
background: @App-primary-color;
overflow: hidden;
&--boursecu {
.dauth-brand__logo {
max-width: 280px;
max-height: 280px;
width: min(100%, 280px);
object-fit: contain;
}
}
&--soroushgold {
.dauth-brand__logo {
max-width: 220px;
max-height: 220px;
width: min(100%, 220px);
object-fit: contain;
}
}
&__pattern {
position: absolute;
inset: 0;
opacity: 0.04;
background-image: radial-gradient(circle at 1px 1px, #fff 1px, transparent 0);
background-size: 28px 28px;
pointer-events: none;
}
&__content {
position: relative;
z-index: 1;
max-width: 420px;
}
&__logo {
width: auto;
max-width: 220px;
height: auto;
max-height: 72px;
object-fit: contain;
margin-bottom: 32px;
&--round {
width: 72px;
height: 72px;
border-radius: 16px;
object-fit: cover;
}
}
&__title {
font-size: 32px;
font-weight: 600;
letter-spacing: -0.03em;
color: #ffffff;
line-height: 1.25;
margin: 0 0 12px;
}
&__tagline {
font-size: 15px;
font-weight: 400;
color: rgba(255, 255, 255, 0.72);
line-height: 1.7;
margin: 0 0 40px;
}
&__features {
display: flex;
flex-direction: column;
gap: 16px;
list-style: none;
margin: 0;
padding: 0;
}
&__feature {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
}
&__feature-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
flex-shrink: 0;
}
&__footer {
position: absolute;
bottom: 32px;
right: 64px;
left: 64px;
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
}
}
/* Form panel */
.dauth-form-panel {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 56px;
}
.dauth-card {
width: 100%;
max-width: 420px;
background: #ffffff;
border-radius: 16px;
padding: 36px 40px;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.04),
0 8px 24px rgba(15, 23, 42, 0.06);
&__header {
margin-bottom: 28px;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 6px;
}
&__title {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.02em;
color: #111827;
margin: 0;
line-height: 1.3;
}
&__subtitle {
font-size: 13px;
color: #6b7280;
margin: 8px 0 0;
line-height: 1.5;
}
}
/* Method toggle */
.dauth-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
background: #f3f4f6;
border-radius: 10px;
margin-bottom: 24px;
&__btn {
padding: 9px 12px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: #6b7280;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
&--active {
background: #ffffff;
color: #111827;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
&:hover:not(&--active) {
color: #374151;
}
}
}
/* Form fields */
.dauth-form {
display: flex;
flex-direction: column;
width: 100%;
&.ant-form-vertical :deep(.ant-form-item) {
margin-bottom: 18px;
}
&.ant-form-vertical :deep(.ant-form-item-label) {
padding-bottom: 6px;
}
&.ant-form-vertical :deep(.ant-form-item-label > label) {
font-size: 12px;
font-weight: 600;
color: #374151;
height: auto;
}
:deep(.ant-form-item) {
margin-bottom: 18px;
}
:deep(.ant-form-item-label > label) {
font-size: 12px;
font-weight: 600;
color: #374151;
}
:deep(.ant-input),
:deep(.ant-input-affix-wrapper) {
border-radius: 10px;
border-color: #e5e7eb;
padding: 10px 14px;
&:hover,
&:focus,
&-focused {
border-color: @App-primary-color;
}
}
:deep(.ant-input-lg) {
padding: 11px 14px;
}
.dauth-toggle {
flex-shrink: 0;
}
.dauth-actions,
.dauth-switch {
width: 100%;
}
}
/* OTP */
.dauth-otp {
margin-top: 8px;
:deep(.otp-input) {
width: 52px !important;
height: 52px !important;
border-radius: 10px !important;
border: 1px solid #e5e7eb !important;
font-size: 20px !important;
font-weight: 600 !important;
}
}
.dauth-phone-edit {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 16px 0 20px;
font-size: 13px;
&__number {
color: #9ca3af;
direction: ltr;
}
&__btn {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
color: #374151;
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.15s ease;
&:hover {
background: #f3f4f6;
}
}
}
.dauth-resend {
margin-bottom: 20px;
:deep(.ant-btn) {
border-radius: 10px;
height: 42px;
font-size: 13px;
}
}
/* Actions */
.dauth-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
width: 100%;
&__submit {
min-height: 44px;
font-size: 14px;
font-weight: 600;
}
:deep(.ant-btn),
:deep(button),
:deep(.noname-base-button) {
border-radius: 10px;
min-height: 44px;
height: auto;
font-size: 14px;
font-weight: 600;
}
}
.dauth-actions-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.dauth-switch {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 20px;
padding-top: 4px;
font-size: 13px;
color: #6b7280;
flex-shrink: 0;
&__link {
border: none;
background: transparent;
color: @App-primary-color;
font-weight: 600;
font-size: 13px;
cursor: pointer;
font-family: inherit;
padding: 0;
&:hover {
text-decoration: underline;
}
}
}
/* Garoosi extras */
.dauth-garoosi-links {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.dauth-link-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 10px 16px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #ffffff;
font-size: 13px;
font-weight: 600;
color: #374151;
text-decoration: none;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease;
&:hover {
background: #f9fafb;
border-color: #d1d5db;
color: #111827;
}
&--primary {
background: @App-primary-color;
border-color: @App-primary-color;
color: #ffffff;
&:hover {
opacity: 0.92;
color: #ffffff;
}
}
}
/* PWA guide modal */
.dauth-pwa-steps {
display: flex;
flex-direction: column;
gap: 20px;
margin: 20px 0 8px;
}
.dauth-pwa-step {
display: flex;
align-items: center;
gap: 16px;
&__icon {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 10px;
background: #f0f4ff;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__text {
font-size: 13px;
color: #374151;
line-height: 1.6;
margin: 0;
}
}
@media (max-width: 900px) {
.dauth__layout {
grid-template-columns: minmax(300px, 1fr) minmax(340px, 1fr);
}
.dauth-brand {
padding: 40px 36px;
&__footer {
right: 36px;
left: 36px;
}
}
.dauth-form-panel {
padding: 32px 28px;
}
}
.login-form--desktop {
:deep(.ant-form-item) {
margin-bottom: 18px;
}
:deep(.ant-form-item-label > label) {
font-size: 12px;
font-weight: 600;
color: #374151;
}
}