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); --btn-fill: var(--color-navy);
position: relative; position: relative;
display: inline-flex; display: inline-flex;
min-width: 270px; min-width: 220px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
overflow: hidden; overflow: hidden;
padding: 0;
border: 1px solid var(--color-navy); border: 1px solid var(--color-navy);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--color-white); background: var(--color-white);
color: var(--color-navy); color: var(--color-navy);
font: inherit;
font-size: 0.92rem;
cursor: pointer;
transition: color 0.35s ease; transition: color 0.35s ease;
} }
button.primary-btn:disabled {
opacity: 0.7;
cursor: wait;
}
.primary-btn--dark { .primary-btn--dark {
--btn-fill: #000; --btn-fill: #000;
border-color: #000; border-color: #000;
@@ -30,19 +39,22 @@
} }
.primary-btn__label { .primary-btn__label {
padding: 0.7rem 1rem; padding: 0.55rem 0.9rem;
font-weight: 500; font-weight: 500;
} }
.primary-btn__icon { .primary-btn__icon {
display: grid; display: grid;
place-items: center; place-items: center;
width: 52px; align-self: stretch;
height: 45px; width: 44px;
min-height: 38px;
background: var(--btn-fill); background: var(--btn-fill);
} }
.primary-btn__icon img { .primary-btn__icon img {
width: 15px;
height: 15px;
filter: brightness(0) invert(1); 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 arrowLeft from '../assets/images/icons/arrow-left.svg'
import './PrimaryButton.css' import './PrimaryButton.css'
type Props = { type SharedProps = {
to: string
title?: string title?: string
variant?: 'default' | 'dark' variant?: 'default' | 'dark'
full?: boolean full?: boolean
download?: boolean
iconSrc?: string iconSrc?: string
className?: 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({ export default function PrimaryButton({
to, to,
title = 'دوست دارم بیشتر بدونم', title = 'دوست دارم بیشتر بدونم',
@@ -20,6 +35,9 @@ export default function PrimaryButton({
download = false, download = false,
iconSrc = arrowLeft, iconSrc = arrowLeft,
className = '', className = '',
type = 'button',
disabled = false,
onClick,
}: Props) { }: Props) {
const classes = [ const classes = [
'primary-btn', 'primary-btn',
@@ -30,6 +48,23 @@ export default function PrimaryButton({
.filter(Boolean) .filter(Boolean)
.join(' ') .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('#')) { if (download || to.startsWith('http') || to.startsWith('#')) {
return ( return (
<a <a
@@ -41,20 +76,14 @@ export default function PrimaryButton({
? { target: '_blank', rel: 'noreferrer' } ? { target: '_blank', rel: 'noreferrer' }
: {})} : {})}
> >
<span className="primary-btn__label">{title}</span> {content}
<span className="primary-btn__icon" aria-hidden="true">
<img src={iconSrc} alt="" width={18} height={18} />
</span>
</a> </a>
) )
} }
return ( return (
<Link className={classes} to={to}> <Link className={classes} to={to}>
<span className="primary-btn__label">{title}</span> {content}
<span className="primary-btn__icon" aria-hidden="true">
<img src={iconSrc} alt="" width={18} height={18} />
</span>
</Link> </Link>
) )
} }
+88 -3
View File
@@ -1,3 +1,5 @@
import { toFaDigits } from './faDigits'
const FA_TO_EN: Record<string, string> = { const FA_TO_EN: Record<string, string> = {
'۰': '0', '۰': '0',
'۱': '1', '۱': '1',
@@ -21,6 +23,56 @@ const FA_TO_EN: Record<string, string> = {
'٩': '9', '٩': '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 PhoneKind = 'mobile' | 'landline'
export type DealerPhone = { export type DealerPhone = {
@@ -42,21 +94,54 @@ export function isMobileDigits(digits: string): boolean {
return /^09\d{9}$/.test(digits) 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 seen = new Set<string>()
const list: DealerPhone[] = [] const list: DealerPhone[] = []
const add = (raw: string | null | undefined) => { const add = (raw: string | null | undefined) => {
const trimmed = (raw ?? '').trim() const trimmed = (raw ?? '').trim()
if (!trimmed || trimmed === '\\N') return if (!trimmed || trimmed === '\\N') return
const digits = normalizePhoneDigits(trimmed) let digits = normalizePhoneDigits(trimmed)
if (digits.length < 8) return if (digits.length < 8) return
if (!isMobileDigits(digits)) {
digits = ensureLandlineAreaCode(digits, opts?.city, opts?.province)
}
if (seen.has(digits)) return if (seen.has(digits)) return
seen.add(digits) seen.add(digits)
const kind: PhoneKind = isMobileDigits(digits) ? 'mobile' : 'landline' const kind: PhoneKind = isMobileDigits(digits) ? 'mobile' : 'landline'
list.push({ list.push({
kind, kind,
display: trimmed, display: toFaDigits(digits),
href: `tel:${digits}`, href: `tel:${digits}`,
}) })
} }
+41 -8
View File
@@ -24,22 +24,44 @@
} }
.dealers-search { .dealers-search {
position: relative;
display: block; display: block;
width: 100%; 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 { .dealers-search input {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid var(--color-navy); border: 1px solid #d7dde8;
border-radius: var(--radius, 8px); border-radius: 12px;
background: #fff; background: #fff;
padding: 14px 16px; padding: 14px 16px;
padding-inline-start: 44px;
font: inherit; font: inherit;
font-size: 15px; font-size: 15px;
color: var(--color-navy); color: var(--color-navy);
outline: none; 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 { .dealers-search input::placeholder {
@@ -51,6 +73,10 @@
box-shadow: 0 0 0 3px rgba(37, 99, 176, 0.12); 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 { .visually-hidden {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -172,12 +198,16 @@
padding-top: 10px; padding-top: 10px;
} }
.catalog--flush-cta {
padding-bottom: 0;
}
.dealers-cta { .dealers-cta {
margin-top: 36px; margin-top: 36px;
background: #fff; background: #eef3f9;
border-top: 1px solid #e8e8e8; border-top: 1px solid #dbe4f0;
border-bottom: 1px solid #e8e8e8; border-bottom: 0;
padding: 18px 0; padding: 22px 0;
} }
.dealers-cta__inner { .dealers-cta__inner {
@@ -268,7 +298,10 @@
text-decoration: none; text-decoration: none;
font-family: var(--font-en); font-family: var(--font-en);
font-size: 14px; 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 { .dealer-row__phone--mobile {
+25 -11
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import PrimaryButton from '../components/PrimaryButton' import PrimaryButton from '../components/PrimaryButton'
import SeoHead from '../components/SeoHead' import SeoHead from '../components/SeoHead'
import { import {
@@ -7,7 +6,6 @@ import {
listPublicRepresentations, listPublicRepresentations,
type PublicRepresentation, type PublicRepresentation,
} from '../lib/api' } from '../lib/api'
import { toFaDigits } from '../lib/faDigits'
import { paths } from '../lib/paths' import { paths } from '../lib/paths'
import { dealerPhones, type PhoneKind } from '../lib/phones' import { dealerPhones, type PhoneKind } from '../lib/phones'
import './CatalogPages.css' import './CatalogPages.css'
@@ -80,7 +78,7 @@ export default function DealersPage() {
}, [items, city, query]) }, [items, city, query])
return ( return (
<section className="catalog"> <section className="catalog catalog--flush-cta">
<SeoHead <SeoHead
title="نمایندگی های مادو موتور در سراسر ایران" title="نمایندگی های مادو موتور در سراسر ایران"
description="با لیست کامل نمایندگی‌ های مادو موتور در سراسر ایران آشنا شوید. آدرس، شماره تماس و خدمات ارائه‌ شده توسط هر نمایندگی را به‌ راحتی مشاهده کنید و نزدیک‌ترین شعبه را انتخاب کنید." description="با لیست کامل نمایندگی‌ های مادو موتور در سراسر ایران آشنا شوید. آدرس، شماره تماس و خدمات ارائه‌ شده توسط هر نمایندگی را به‌ راحتی مشاهده کنید و نزدیک‌ترین شعبه را انتخاب کنید."
@@ -92,6 +90,25 @@ export default function DealersPage() {
<p>نمایندگی‌های فعال در سراسر ایران</p> <p>نمایندگی‌های فعال در سراسر ایران</p>
<label className="dealers-search"> <label className="dealers-search">
<span className="visually-hidden">جستجوی نمایندگی</span> <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 <input
type="search" type="search"
value={query} value={query}
@@ -149,7 +166,10 @@ export default function DealersPage() {
<p>{item.address || '—'}</p> <p>{item.address || '—'}</p>
</div> </div>
<div className="dealer-row__contacts"> <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 <a
key={`${phone.kind}-${phone.href}`} key={`${phone.kind}-${phone.href}`}
className={`dealer-row__phone dealer-row__phone--${phone.kind}`} className={`dealer-row__phone dealer-row__phone--${phone.kind}`}
@@ -157,7 +177,7 @@ export default function DealersPage() {
dir="ltr" dir="ltr"
> >
<PhoneIcon kind={phone.kind} /> <PhoneIcon kind={phone.kind} />
<span>{toFaDigits(phone.display)}</span> <span>{phone.display}</span>
</a> </a>
))} ))}
</div> </div>
@@ -174,12 +194,6 @@ export default function DealersPage() {
<PrimaryButton to={paths.representationRequest} title="درخواست نمایندگی" /> <PrimaryButton to={paths.representationRequest} title="درخواست نمایندگی" />
</div> </div>
</div> </div>
<div className="container">
<div className="catalog__foot">
<Link to="/">بازگشت به صفحه اصلی</Link>
</div>
</div>
</section> </section>
) )
} }
+2 -2
View File
@@ -291,8 +291,8 @@ export default function HomePage() {
{branch.ceo ? <li>{branch.ceo}</li> : null} {branch.ceo ? <li>{branch.ceo}</li> : null}
<li>{toFaDigits(branch.address)}</li> <li>{toFaDigits(branch.address)}</li>
{branch.phones.map((phone) => ( {branch.phones.map((phone) => (
<li key={phone} dir="ltr"> <li key={phone} className="en" dir="ltr">
{toFaDigits(phone)} {phone}
</li> </li>
))} ))}
{branch.postalCode ? ( {branch.postalCode ? (
+1 -11
View File
@@ -63,20 +63,10 @@
.rep-request__actions { .rep-request__actions {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-end;
margin-top: 24px; 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 { .rep-request__error {
color: #d32f2f; color: #d32f2f;
margin: 16px 0 0; margin: 16px 0 0;
+6 -10
View File
@@ -1,11 +1,10 @@
import { useMemo, useState, type FormEvent } from 'react' import { useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom' 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 SeoHead from '../components/SeoHead'
import { IRAN_PROVINCES } from '../data/provinces' import { IRAN_PROVINCES } from '../data/provinces'
import { ApiError, submitRepresentationRequest } from '../lib/api' import { ApiError, submitRepresentationRequest } from '../lib/api'
import { paths } from '../lib/paths' import { paths } from '../lib/paths'
import '../components/PrimaryButton.css'
import './RepresentationRequestPage.css' import './RepresentationRequestPage.css'
type FormState = { type FormState = {
@@ -227,14 +226,11 @@ export default function RepresentationRequestPage() {
{error ? <p className="rep-request__error">{error}</p> : null} {error ? <p className="rep-request__error">{error}</p> : null}
<div className="rep-request__actions"> <div className="rep-request__actions">
<button className="primary-btn" type="submit" disabled={saving}> <PrimaryButton
<span className="primary-btn__label"> type="submit"
{saving ? 'در حال ارسال...' : 'ثبت درخواست'} title={saving ? 'در حال ارسال...' : 'ثبت درخواست'}
</span> disabled={saving}
<span className="primary-btn__icon" aria-hidden="true"> />
<img src={arrowLeft} alt="" width={18} height={18} />
</span>
</button>
</div> </div>
</form> </form>
)} )}