Skip to content

۰۳ — جهت‌گیری UI (Premium, Minimal)

مکمل 02-mvp-requirements.md — فقط لایه ظاهری/فرانت.

نام موقت پروژه: TakeEat — عنصر امضای بصری «مُهر» (seal) می‌تواند حتی با تغییر نام برند بماند.


۰. درس از رقبای دبی

تمایز بصری = گیفت‌باکس نه سطل تخفیف. Peekabox با برندهای پریمیوم (Carrefour، Pret، Krispy Kreme) این را ثابت کرده.


۰.۵ — TGTG benchmark (Too Good To Go)

مرجع UX برای لیست و جزئیات باکس. قبل/بعد هر تغییر UI موبایل (390×844) side-by-side با TGTG چک شود.

الگوهای TGTG که adopt کردیم

بخشTGTGTakeEat (فعلی)
لیستکارت compact، چند آیتم روی صفحهhorizontal card 96×96 + 3 خط info
جزئیاتfull-screen page + back/box/[id] full page + sticky back header
footer رزروprice + CTA در یک rowstickyCompact + CTAButton md
hierarchyنام → فاصله/زمان → قیمتهمان — بدون rating در لیست

Target metrics

Metricهدف
کارت visible روی iPhone 390×844≥ 2.5
footer رزرو (step detail)≤ 72px
info layers per list card3–4
detail viewfull page (نه bottom sheet)

چک‌لیست ۱۰ آیتمی (با TGTG)

  1. چند کارت بدون scroll دیده می‌شود؟
  2. scroll لیست روان است؟ (بدون nested scroll)
  3. tap → full page با back واضح؟
  4. footer رزرو جا نمی‌گیرد بیش از حد؟
  5. قیمت یک focal point در کارت؟
  6. urgency («آخرین») فقط یک‌بار؟
  7. shadow minimal — border-first؟
  8. header home یک band (نه دو)؟
  9. checkout step هنوز readable؟
  10. RTL و اعداد fa-IR درست؟

۱. چیزهایی که عمداً رد شد

❌ رد شدهدلیل
کرم + سریف + ترکوتاکلیشه AI design
مشکی + نئونکلیشه دوم
بج «٪ تخفیف» روی عکسحس junk-food
پارالاکس، confetti، جشن روی هر اکشنحس template
چند فونت/چند radiusبی‌نظمی

۲. Design tokens

رنگ

TokenHexCSS varنقش
Clay#EFEAE1--color-clayپس‌زمینه صفحات
Surface#FBF9F5--color-surfaceکارت‌ها
Ink#21241F--color-inkمتن اصلی
Ink Muted#6E6A60--color-ink-mutedزمان، فاصله
Saffron#B5651D--color-saffronقیمت، CTA، مُهر
Firouzeh#1F5C56--color-firouzehتایید vendor، success
Plum#4A2740--color-plumفقط «آخرین باکس»
Border#DCD5C8بوردر ۰.۵px کارت
Device frame (mockup)#2B2B29قاب موبایل در mockup

قانون: حداکثر یک نقطه Saffron per صفحه. Firouzeh/Plum معنادار، نه تزئینی.

الهام: سفال/کاشی ایرانی — گل‌رنگ خاکی + زعفرانی + فیروزه کدر.

تایپوگرافی

نقشفونتاستفاده
Body/UIVazirmatn (variable)همه‌جا
DisplayEstedad (variable)عنوان صفحه، قیمت بزرگ جزئیات — با احتیاط
Pickup codeVazirmatn + font-feature-settings: "tnum"اعداد tabular — نه mono جدا

اعداد: پیشنهاد Western (0123) برای قیمت/زمان/کد — حس fintech مدرن. تصمیم برندی → 11-open-decisions.md.

Spacing / Radius / Shadow

Tokenمقدار
Base unit4px (Tailwind default)
Gap کارت‌ها12–16px
Padding کارت14–16px
Radius کارت18px (یکسان همه‌جا)
Shadowعملاً صفر — عمق با border #DCD5C8
Shadow exceptionmodal / bottom sheet — خیلی نرم

Motion

لحظهرفتارمدت
باز کردن باکس (Container Transform)کارت expand → کل صفحه500ms
بستن مودالمحتوا fade، container shrink به کارت500ms
تایید رزرو (مُهر)دایره چرخان → seal می‌نشیند~600ms
بقیهfade/slide150–200ms
Card pressscale(0.98)150ms
reduced-motionprefers-reduced-motion: reduce

ممنوع: پارالاکس، confetti، bounce زیاد، transition-* CSS روی element های layoutId.


۳.۵ — Container Transform Animation (پیاده‌سازی شده)

این امضای بصری اصلی اپ است — وقتی باکس tap می‌شود، خودِ کارت expand می‌شود و کل صفحه را می‌گیرد و تبدیل به صفحه محصول می‌شود.

معماری

(client)/layout.tsx
  └── LayoutGroup           ← framer-motion: همه layoutId ها در یک group
        ├── {children}      ← صفحه خانه با لیست کارت‌ها
        └── AnimatePresence ← wraps @modal slot — بدون mode="wait"
              └── @modal/(.)box/[id]/page.tsx  ← intercepting route
                    └── BoxDetail isModal={true}

قوانین پیاده‌سازی

  1. فقط یک layoutId روی container بیرونی در هر دو SurpriseBoxCard و BoxDetail
  2. هیچ nested layoutId — عکس، title، price، icon — هیچ‌کدام layoutId نمی‌گیرند
  3. AnimatePresence بدون mode="wait" — source و target باید لحظه‌ای هم‌زمان در DOM باشند
  4. بدون CSS transition-* روی element دارای layoutId (conflict با framer-motion)
  5. Easing: [0.32, 0.72, 0, 1] + duration: 0.5 (iOS-style deceleration)

معماری scroll — حیاتی

layoutId element نباید scroll container باشد:

tsx
{/* Shape shell — layoutId، بدون overflow-y */}
<motion.div layoutId="box-{id}" className="fixed inset-0 z-50 bg-surface">
  {/* Scroll container — از inset-0 ابعاد می‌گیرد، نه از height انیمیت‌شده parent */}
  <div className="absolute inset-0 overflow-y-auto overscroll-contain bg-clay">
    {/* محتوا بعد از expand ظاهر می‌شود */}
    <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
      transition={{ delay: 0.32, duration: 0.22 }}>
      ...
    </motion.div>
  </div>
</motion.div>

اگر overflow-y-auto روی layoutId element باشد → height انیمیت‌شده Motion به scroll calculation نشت می‌کند → فضای سفید پایین صفحه.

عکس در حین انیمیشن

عکس هیچ‌وقت width/height props تغییرپذیر نمی‌گیرد:

tsx
{/* ✅ */}
<div style={{ height: 260 }}>
  <img style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>

{/* ❌ */}
<img width={400} height={176} /> {/* این در کارت یه سایز داره، تو دیتیل سایز دیگه */}

جریان انیمیشن

  1. مشتری کلیک می‌کند
  2. Next.js Intercepting Route صفحه را intercept می‌کند
  3. BoxDetail isModal با همان layoutId در DOM ظاهر می‌شود
  4. Framer Motion: کارت morph → کل صفحه (500ms، iOS easing)
  5. محتوا بعد از 320ms با fade ظاهر می‌شود
  6. URL به /box/[id] تغییر می‌کند — دکمه back گوشی کار می‌کند
  7. هنگام برگشت: محتوا fade out، container shrink به کارت

۳. عنصر امضا — «مُهر» (Seal)

SVG: دایره + نقطه/حلقه داخلی (مهر موم).

محلرفتار
Loadingچرخش آرام
Confirm reservationمهر «می‌نشیند»
Pickup ticketکد داخل مهر — بلیط مهرشده

۴. کامپوننت‌ها

ComponentSpec
SurpriseBoxCardعکس 16:9، radius 18px بالا؛ بدون بج درصد؛ قیمت Saffron + strikethrough؛ تگ «آخرین باکس» فقط if quantity_remaining <= 1
VendorBadgeنقطه Firouzeh 6px کنار نام — فقط approved + سابقه خوب
PickupTicketfullscreen؛ مهر بزرگ وسط؛ کد؛ آدرس؛ بازه؛ دکمه «مسیر را نشان بده»
CTAButtonSaffron fill؛ «رزرو می‌کنم»؛ active scale 0.98
EmptyState«هنوز باکسی این‌دور و بر نیست. فردا همین ساعت دوباره سر بزن»
BottomNav۳ آیتم: خانه، جستجو، پروفایل — active = Saffron

Mockup sample data

VendorفاصلهPickupقیمتارزش
کافه نیلوفر800 متر20:00–21:0065,000180,000
نان و کیک پرنیان1.2 km21:30–22:0085,000220,000

۵. لحن copy (microcopy)

موقعیت
CTAثبت سفارشرزرو می‌کنم
بعد پرداختتبریک! 🎉رزرو شد. مهرتون آماده‌ست
کم‌ماندهعجله کنید!آخرین باکس
لیست خالینتیجه‌ای یافت نشدهنوز باکسی این‌دور و بر نیست…
خطای پرداختخطا!!پرداخت انجام نشد. باکس‌تون هنوز نگه‌داشته شده…

Subtitle home: «باکس‌های نزدیک شما»


۶. RTL / فارسی

  • dir="rtl" روی <html>
  • Tailwind v4.3.x — logical properties: ps-*, pe-*, ms-*, me-*, inset-s-*نه left/right
  • یک فونت برای فارسی + لاتin
  • آیکن: Lucide/Tabler — فقط directional ها rtl:rotate-180

۷. استک فرانت

لایهانتخابنسخه
FrameworkNext.js App Router16.2.x (Jun 2026 stable)
React19
BundlerTurbopackdefault
Node20+
CSSTailwindv4.3.x@theme در globals.css
Componentsshadcn/uitheme سفارشی (نه zinc)
MotionFramer Motionمُهر + card press
IconsLucide یا Tabler
PWAmanifest + SWadd-to-homescreen

create-next-app

bash
npx create-next-app@latest TakeEat-client --typescript --tailwind --eslint --app --src-dir --turbopack

globals.css @theme

css
@import "tailwindcss";
@theme {
  --color-clay: #EFEAE1;
  --color-surface: #FBF9F5;
  --color-ink: #21241F;
  --color-ink-muted: #6E6A60;
  --color-saffron: #B5651D;
  --color-firouzeh: #1F5C56;
  --color-plum: #4A2740;
  --font-body: "Vazirmatn", sans-serif;
  --font-display: "Estedad", sans-serif;
}

next/font

ts
import { Vazirmatn, Estedad } from "next/font/google";
const vazirmatn = Vazirmatn({ subsets: ["arabic"], variable: "--font-body" });
const estedad = Estedad({ subsets: ["arabic"], variable: "--font-display" });

۸. Quality checklist (قبل از demo)

  • [ ] تست روی موبایل واقعی
  • [ ] focus state روی input/button
  • [ ] prefers-reduced-motion برای مُهر
  • [ ] محتوای فارسی واقعی (نه Lorem)
  • [ ] WCAG contrast: Ink/Clay، Saffron/Surface

۹. Mockup

mockups/client-home.html