Polish dealers and shared buttons for clearer contacts and CTAs.

This commit is contained in:
2026-09-03 23:55:52 +03:30
parent 95733d99d4
commit 23c4dc9dc7
8 changed files with 219 additions and 60 deletions
+16 -4
View File
@@ -2,17 +2,26 @@
--btn-fill: var(--color-navy);
position: relative;
display: inline-flex;
min-width: 270px;
min-width: 220px;
align-items: center;
justify-content: space-between;
overflow: hidden;
padding: 0;
border: 1px solid var(--color-navy);
border-radius: var(--radius);
background: var(--color-white);
color: var(--color-navy);
font: inherit;
font-size: 0.92rem;
cursor: pointer;
transition: color 0.35s ease;
}
button.primary-btn:disabled {
opacity: 0.7;
cursor: wait;
}
.primary-btn--dark {
--btn-fill: #000;
border-color: #000;
@@ -30,19 +39,22 @@
}
.primary-btn__label {
padding: 0.7rem 1rem;
padding: 0.55rem 0.9rem;
font-weight: 500;
}
.primary-btn__icon {
display: grid;
place-items: center;
width: 52px;
height: 45px;
align-self: stretch;
width: 44px;
min-height: 38px;
background: var(--btn-fill);
}
.primary-btn__icon img {
width: 15px;
height: 15px;
filter: brightness(0) invert(1);
}
+40 -11
View File
@@ -2,16 +2,31 @@ import { Link } from 'react-router-dom'
import arrowLeft from '../assets/images/icons/arrow-left.svg'
import './PrimaryButton.css'
type Props = {
to: string
type SharedProps = {
title?: string
variant?: 'default' | 'dark'
full?: boolean
download?: boolean
iconSrc?: string
className?: string
disabled?: boolean
}
type LinkProps = SharedProps & {
to: string
download?: boolean
type?: never
onClick?: never
}
type ButtonProps = SharedProps & {
to?: undefined
download?: never
type?: 'button' | 'submit' | 'reset'
onClick?: () => void
}
type Props = LinkProps | ButtonProps
export default function PrimaryButton({
to,
title = 'دوست دارم بیشتر بدونم',
@@ -20,6 +35,9 @@ export default function PrimaryButton({
download = false,
iconSrc = arrowLeft,
className = '',
type = 'button',
disabled = false,
onClick,
}: Props) {
const classes = [
'primary-btn',
@@ -30,6 +48,23 @@ export default function PrimaryButton({
.filter(Boolean)
.join(' ')
const content = (
<>
<span className="primary-btn__label">{title}</span>
<span className="primary-btn__icon" aria-hidden="true">
<img src={iconSrc} alt="" width={18} height={18} />
</span>
</>
)
if (!to) {
return (
<button className={classes} type={type} disabled={disabled} onClick={onClick}>
{content}
</button>
)
}
if (download || to.startsWith('http') || to.startsWith('#')) {
return (
<a
@@ -41,20 +76,14 @@ export default function PrimaryButton({
? { target: '_blank', rel: 'noreferrer' }
: {})}
>
<span className="primary-btn__label">{title}</span>
<span className="primary-btn__icon" aria-hidden="true">
<img src={iconSrc} alt="" width={18} height={18} />
</span>
{content}
</a>
)
}
return (
<Link className={classes} to={to}>
<span className="primary-btn__label">{title}</span>
<span className="primary-btn__icon" aria-hidden="true">
<img src={iconSrc} alt="" width={18} height={18} />
</span>
{content}
</Link>
)
}
+88 -3
View File
@@ -1,3 +1,5 @@
import { toFaDigits } from './faDigits'
const FA_TO_EN: Record<string, string> = {
'۰': '0',
'۱': '1',
@@ -21,6 +23,56 @@ const FA_TO_EN: Record<string, string> = {
'٩': '9',
}
/** Common Iranian city / province landline area codes (with leading 0). */
const AREA_CODES: Record<string, string> = {
تهران: '021',
کرج: '026',
البرز: '026',
اصفهان: '031',
مبارکه: '031',
شیراز: '071',
فارس: '071',
مشهد: '051',
نیشابور: '051',
'خراسان رضوی': '051',
تبریز: '041',
'آذربایجان شرقی': '041',
ارومیه: '044',
'آذربایجان غربی': '044',
رشت: '013',
گیلان: '013',
ساری: '011',
چالوس: '011',
مازندران: '011',
اهواز: '061',
شوش: '061',
خوزستان: '061',
کرمان: '034',
یزد: '035',
قم: '025',
قزوین: '028',
کرمانشاه: '083',
بندرعباس: '076',
کیش: '076',
هرمزگان: '076',
بوشهر: '077',
گرگان: '017',
'بندر ترکمن': '017',
گلستان: '017',
اراک: '086',
محلات: '086',
مرکزی: '086',
همدان: '081',
زنجان: '024',
سمنان: '023',
اردبیل: '045',
بجنورد: '058',
ایلام: '084',
یاسوج: '074',
شهرکرد: '038',
بیرجند: '056',
}
export type PhoneKind = 'mobile' | 'landline'
export type DealerPhone = {
@@ -42,21 +94,54 @@ export function isMobileDigits(digits: string): boolean {
return /^09\d{9}$/.test(digits)
}
export function dealerPhones(mobile: string, landline: string): DealerPhone[] {
function areaCodeFor(city?: string, province?: string): string | undefined {
const keys = [city, province].map((v) => v?.trim()).filter(Boolean) as string[]
for (const key of keys) {
if (AREA_CODES[key]) return AREA_CODES[key]
}
return undefined
}
function ensureLandlineAreaCode(digits: string, city?: string, province?: string): string {
if (isMobileDigits(digits)) return digits
if (digits.startsWith('0') && digits.length >= 10) return digits
const area = areaCodeFor(city, province)
if (!area) {
if (digits.length === 10) return `0${digits}`
return digits
}
if (digits.startsWith(area.slice(1))) return `0${digits}`
if (digits.startsWith(area)) return digits
if (digits.length <= 8) return `${area}${digits}`
return digits.startsWith('0') ? digits : `0${digits}`
}
export function dealerPhones(
mobile: string,
landline: string,
opts?: { city?: string; province?: string },
): DealerPhone[] {
const seen = new Set<string>()
const list: DealerPhone[] = []
const add = (raw: string | null | undefined) => {
const trimmed = (raw ?? '').trim()
if (!trimmed || trimmed === '\\N') return
const digits = normalizePhoneDigits(trimmed)
let digits = normalizePhoneDigits(trimmed)
if (digits.length < 8) return
if (!isMobileDigits(digits)) {
digits = ensureLandlineAreaCode(digits, opts?.city, opts?.province)
}
if (seen.has(digits)) return
seen.add(digits)
const kind: PhoneKind = isMobileDigits(digits) ? 'mobile' : 'landline'
list.push({
kind,
display: trimmed,
display: toFaDigits(digits),
href: `tel:${digits}`,
})
}
+41 -8
View File
@@ -24,22 +24,44 @@
}
.dealers-search {
position: relative;
display: block;
width: 100%;
margin-top: 8px;
margin-top: 12px;
}
.dealers-search__icon {
position: absolute;
top: 50%;
inset-inline-start: 14px;
transform: translateY(-50%);
display: grid;
place-items: center;
color: #8b95a8;
pointer-events: none;
}
.dealers-search input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-navy);
border-radius: var(--radius, 8px);
border: 1px solid #d7dde8;
border-radius: 12px;
background: #fff;
padding: 14px 16px;
padding-inline-start: 44px;
font: inherit;
font-size: 15px;
color: var(--color-navy);
outline: none;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.dealers-search input::-webkit-search-decoration,
.dealers-search input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
.dealers-search input::placeholder {
@@ -51,6 +73,10 @@
box-shadow: 0 0 0 3px rgba(37, 99, 176, 0.12);
}
.dealers-search:focus-within .dealers-search__icon {
color: var(--color-blue);
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -172,12 +198,16 @@
padding-top: 10px;
}
.catalog--flush-cta {
padding-bottom: 0;
}
.dealers-cta {
margin-top: 36px;
background: #fff;
border-top: 1px solid #e8e8e8;
border-bottom: 1px solid #e8e8e8;
padding: 18px 0;
background: #eef3f9;
border-top: 1px solid #dbe4f0;
border-bottom: 0;
padding: 22px 0;
}
.dealers-cta__inner {
@@ -268,7 +298,10 @@
text-decoration: none;
font-family: var(--font-en);
font-size: 14px;
line-height: 1;
font-weight: 400;
letter-spacing: 0.02em;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.dealer-row__phone--mobile {
+25 -11
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import PrimaryButton from '../components/PrimaryButton'
import SeoHead from '../components/SeoHead'
import {
@@ -7,7 +6,6 @@ import {
listPublicRepresentations,
type PublicRepresentation,
} from '../lib/api'
import { toFaDigits } from '../lib/faDigits'
import { paths } from '../lib/paths'
import { dealerPhones, type PhoneKind } from '../lib/phones'
import './CatalogPages.css'
@@ -80,7 +78,7 @@ export default function DealersPage() {
}, [items, city, query])
return (
<section className="catalog">
<section className="catalog catalog--flush-cta">
<SeoHead
title="نمایندگی های مادو موتور در سراسر ایران"
description="با لیست کامل نمایندگی‌ های مادو موتور در سراسر ایران آشنا شوید. آدرس، شماره تماس و خدمات ارائه‌ شده توسط هر نمایندگی را به‌ راحتی مشاهده کنید و نزدیک‌ترین شعبه را انتخاب کنید."
@@ -92,6 +90,25 @@ export default function DealersPage() {
<p>نمایندگی‌های فعال در سراسر ایران</p>
<label className="dealers-search">
<span className="visually-hidden">جستجوی نمایندگی</span>
<span className="dealers-search__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<circle
cx="11"
cy="11"
r="6.5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
/>
<path
d="M16.2 16.2 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
</span>
<input
type="search"
value={query}
@@ -149,7 +166,10 @@ export default function DealersPage() {
<p>{item.address || '—'}</p>
</div>
<div className="dealer-row__contacts">
{dealerPhones(item.mobile, item.landline).map((phone) => (
{dealerPhones(item.mobile, item.landline, {
city: item.city,
province: item.province,
}).map((phone) => (
<a
key={`${phone.kind}-${phone.href}`}
className={`dealer-row__phone dealer-row__phone--${phone.kind}`}
@@ -157,7 +177,7 @@ export default function DealersPage() {
dir="ltr"
>
<PhoneIcon kind={phone.kind} />
<span>{toFaDigits(phone.display)}</span>
<span>{phone.display}</span>
</a>
))}
</div>
@@ -174,12 +194,6 @@ export default function DealersPage() {
<PrimaryButton to={paths.representationRequest} title="درخواست نمایندگی" />
</div>
</div>
<div className="container">
<div className="catalog__foot">
<Link to="/">بازگشت به صفحه اصلی</Link>
</div>
</div>
</section>
)
}
+2 -2
View File
@@ -291,8 +291,8 @@ export default function HomePage() {
{branch.ceo ? <li>{branch.ceo}</li> : null}
<li>{toFaDigits(branch.address)}</li>
{branch.phones.map((phone) => (
<li key={phone} dir="ltr">
{toFaDigits(phone)}
<li key={phone} className="en" dir="ltr">
{phone}
</li>
))}
{branch.postalCode ? (
+1 -11
View File
@@ -63,20 +63,10 @@
.rep-request__actions {
display: flex;
justify-content: flex-start;
justify-content: flex-end;
margin-top: 24px;
}
.rep-request__actions .primary-btn {
border: 0;
cursor: pointer;
}
.rep-request__actions .primary-btn:disabled {
opacity: 0.7;
cursor: wait;
}
.rep-request__error {
color: #d32f2f;
margin: 16px 0 0;
+6 -10
View File
@@ -1,11 +1,10 @@
import { useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import arrowLeft from '../assets/images/icons/arrow-left.svg'
import PrimaryButton from '../components/PrimaryButton'
import SeoHead from '../components/SeoHead'
import { IRAN_PROVINCES } from '../data/provinces'
import { ApiError, submitRepresentationRequest } from '../lib/api'
import { paths } from '../lib/paths'
import '../components/PrimaryButton.css'
import './RepresentationRequestPage.css'
type FormState = {
@@ -227,14 +226,11 @@ export default function RepresentationRequestPage() {
{error ? <p className="rep-request__error">{error}</p> : null}
<div className="rep-request__actions">
<button className="primary-btn" type="submit" disabled={saving}>
<span className="primary-btn__label">
{saving ? 'در حال ارسال...' : 'ثبت درخواست'}
</span>
<span className="primary-btn__icon" aria-hidden="true">
<img src={arrowLeft} alt="" width={18} height={18} />
</span>
</button>
<PrimaryButton
type="submit"
title={saving ? 'در حال ارسال...' : 'ثبت درخواست'}
disabled={saving}
/>
</div>
</form>
)}