Compare commits

...

84 Commits

Author SHA1 Message Date
ec5e4f6c78 admin: Slice 3g catalog attributes UI
- hooks: flat categories, attributes + attribute-values list/CRUD; row types.
- /admin/catalog: attributes master (create/edit modal with category
  multi-select, delete) → per-attribute values panel (inline add/edit/delete).
- sidebar "ویژگی‌ها" nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 16:01:01 +03:30
ef7804df0e admin: Slice 3f homepage/merchandising UI
- hooks: banners/categories/seller-tiles list + shared update/delete
  mutations; row types.
- /admin/homepage: tabbed بنرها/دسته‌ها/فروشگاه‌ها with image thumbnails,
  active toggle, seller-tile reorder, delete.
- sidebar "صفحه اصلی" nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 15:55:22 +03:30
b4faa9d180 admin: Slice 3e notifications UI
- hooks: notification types (CRUD), sent list, overview, sms blacklist
  (list + delete); NotificationType/Notification/SmsBlacklist row types.
- /admin/notifications: tabbed — قالب‌ها (template CRUD modal), ارسال‌ها
  (overview KPIs + sent list), لیست سیاه (unblock).
- sidebar "اعلان‌ها" nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 15:11:36 +03:30
97948767a0 admin: Slice 3d moderation UI (comments + chat)
- hooks: useAdminComments/ChatMessages, useAdminCommentModerate/Delete,
  AdminCommentRow/AdminChatMessageRow types.
- /admin/moderation: tabbed page — comments (hide/unhide/delete, filter by
  visibility & top/reply) and read-only chat browse.
- sidebar "مدیریت محتوا" nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 15:05:07 +03:30
b3189db773 admin: Slice 3c AI-health dashboard UI
- useAiHealth hook + AiHealthOverview type (60s refetch).
- /admin/ai-health: per-pipeline sections (try-on, metadata, instagram,
  apgs) with status pills, throughput KPIs, and recent-failure lists.
- sidebar "سلامت AI" nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 14:57:42 +03:30
c1118f70d6 admin: Slice 3b discounts & collections UI
- hooks: useAdminDiscounts/Create/Update/Delete, useAdminCollections/Update/
  Delete, AdminDiscountRow/AdminCollectionRow types, adminDelete helper.
- /admin/discounts: table + create/edit modal form + delete + validity badge.
- /admin/collections: table + is_public toggle + delete (moderation).
- sidebar "تخفیف‌ها" + "مجموعه‌ها" nav links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 14:48:50 +03:30
7ad3fccd6e admin: Slice 3a audit-log viewer
- useAuditLog hook + AuditLogRow type.
- new /admin/audit route: action/target filters, search, and a compact
  before→after diff of the touched fields per entry.
- sidebar "گزارش فعالیت" nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 13:33:59 +03:30
40052b7002 admin ledger: Slice 2c journal UI (derived balances + journal history)
- use-admin-hooks: LedgerOverview.journal, LedgerEntryRow,
  LedgerSellerDetail journal fields, useLedgerJournal.
- new "دفتر روزنامه" sub-tab + admin.ledger.journal route.
- ledger overview: journal-derived KPI row + recorded-vs-journal drift badge.
- seller detail: journal-balance stat + journal history table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 13:24:06 +03:30
24d4c8a302 feat(admin): payout approve/reject actions on the ledger (slice 2b)
Approve (complete) / reject buttons on pending payouts, hitting the new admin
action endpoint (which debits the balance + journals on approve). Toasts + list
and overview invalidation on success.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:44:59 +03:30
cedeef4746 feat(admin): ledger UI — overview/reconciliation, payouts, transactions, per-seller (slice 2a)
Ledger section with sub-tabs: overview (money held/owed, payouts by status, and
a reconciliation warning card), payouts + transactions lists, per-seller balances
list, and a per-seller detail showing recorded vs derived balance + discrepancy,
bank accounts and payout history. Enabled the Ledger nav item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:28:11 +03:30
935c337df0 feat(admin): monitoring boards UI — orders/sellers/users/products (slice 1)
- Reusable admin primitives: AdminTable, AdminPagination, AdminToolbar,
  AdminSearch/Select, StatusBadge + fa number/date/toman + useDebouncedValue.
- Four board routes with search, filters, paging:
  orders (read-only), sellers (approve/reject/verify), users (ban/unban),
  products (active toggle). Wired into the admin sidebar nav + hooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:17:22 +03:30
f8bf002b39 fix(chat): gate store-team grouping behind viewer_is_store
The team-side logic (teamUserIds → right-aligned + coworker username label) was
applied for every viewer, so a CUSTOMER saw the store's staff messages on their
own side and with the staff's username/phone shown. Gate all team grouping on
viewerIsStore (from the thread): customers now see store/staff messages on the
left with no staff identity; the store inbox keeps the team-grouped view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:05:56 +03:30
0360780411 feat(admin): admin-panel foundation shell + overview (slice 0)
- /admin opted out of shopper chrome in MyLayout; /admin added to server
  protectedRoutes; useRequireAdmin() gates to superusers (UserProfile.isSuperuser).
- Admin shell (admin.tsx) with sidebar nav (Overview live; other sections stubbed)
  and a mobile top bar; Overview dashboard (KPI cards + 14-day orders chart) via
  use-admin-hooks.ts hitting /api/adminpanel/v1/overview/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:00:49 +03:30
31bc9f3c7b fix(push): include underlying error text in subscribe failure toast
Sellers running from an installed PWA still hit failures inside subscribe
(iOS 16.4+ PWA has its own quirks even when standalone), and a generic
"لطفاً چند لحظه بعد دوباره تلاش کنید" doesn't tell whoever is helping
them what actually broke. The catch handler now surfaces the underlying
Error.message (e.g. "timeout: pushManager.subscribe", "NotAllowedError")
in the toast description so field debugging works over screenshots.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 16:22:50 +00:00
c4e0f2f89f fix(push): timeout every await + iOS-not-PWA early exit so subscribe never wedges
The subscribe/disable flow awaited several browser APIs that on certain
devices — most reliably iOS Safari in a non-installed PWA — return a
Promise that never resolves or rejects (requestPermission, serviceWorker
.ready, pushManager.subscribe). When any of them hung, the try/finally
never ran, so `busy` stayed true forever: the dashboard nudge button
stuck at "..." and the settings toggle refused to flip.

Every awaited step is now wrapped in withTimeout(15s). Failures surface
as toasts instead of silent hangs (subscribe success, permission denied,
generic error). iOS Safari without the site installed to home screen —
where Web Push physically cannot work — short-circuits with an
add-to-home-screen instruction before we ever try, so we don't burn a
timeout waiting for a doomed subscribe().

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 15:33:04 +00:00
225161249e feat(chat+push): image attachments, day-labeled inbox with reply state, sub self-heal
- MessageInput: paperclip button (team-only, controlled by teamUserIds
  from ThreadDetail) uploads through the media service and hands the URL
  to the WebSocket; Enter now inserts a newline — sending is button-only
  since accidental Enter-sends were noisy for staff replies.
- MessageBubble: renders image messages inline (tap to open), shows the
  coworker's username at the top of team-side bubbles so staff can tell
  who typed what.
- MessageList / ThreadChatPage: any message from a store team member
  (owner or active staff) renders on the "our" side of the bubble even
  for a viewer who isn't the sender, using team_user_ids from
  ThreadDetail. Delete option stays gated to the actual sender.
- ThreadsPage: inbox now shows smart-relative timestamps
  (today HH:MM / دیروز HH:MM / N روز پیش / Persian date), plus a
  reply-state icon per row (red bell = customer waiting, blue double-check
  = you replied and they've seen it, nothing = you replied not yet seen).
  Image-only last messages preview as "📷 تصویر".
- Push notifications: on mount we re-POST the current PushSubscription so
  a browser-rotated sub or a backend row deleted after a 410 auto-heals.
- useWebSocket: SendMessagePayload and NewMessageEvent carry image_url so
  the server relay can round-trip images.
- API types: MessageList.imageUrl, LastMessage.imageUrl,
  ThreadList.lastMessageFromTeam/otherPartySeenLast, ThreadDetail.teamUserIds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 13:22:44 +00:00
56fcce35c8 feat(product-detail): ProductDescription section + strict discount guard + category types
- app/components/product/ProductDescription.tsx: new below-fold section
  with a "توضیحات محصول" heading, whitespace-pre-line so multi-line seller
  copy (Instagram scrapes, victorwear WooCommerce imports) renders with
  its bullet-list line breaks preserved. Renders on both mobile and
  desktop; nothing shown when the product has no description.
- app/components/product/ProductInfo.tsx: removed the collapsed
  single-<p> mobile description; the new section replaces it.
- app/components/product/ProductDetailView.tsx: pricingOf now hides the
  discount when discount_price >= price (was only hiding when equal),
  so a stale row where the discount price accidentally matches or
  exceeds the base can never render as a fake promo. Mounts
  ProductDescription just above the mobile Contact button and reviews.
- app/components/product/index.ts: export ProductDescription.
- src/api/types/models/index.ts: ProductCategory now carries readonly
  parentId / parentName so the two-step category picker can build the
  parent→sub tree from a flat category list without an extra fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-18 13:19:27 +00:00
83b93fe523 feat(seller-editor): 9x9 color grid, mandatory size, two-step category, robust image + discount save
- app/components/store/ColorSelector.tsx: renders as a 9-column grid sorted
  by info.sortOrder. Compact aspect-square swatches; Persian name lives in
  title/aria-label so 81 swatches breathe on mobile. Checkmark ink flips by
  swatch luminance so it stays visible on both سفید and مشکی.
- app/components/store/SizeSelector.tsx + ProductVariantDrawer.tsx: dropped
  the "بدون مشخص کردن سایز" toggle now that "FreeSize" exists in the
  palette. Every product must pick a size (or FreeSize) on save.
- app/routes/store.add.manual.$storeId.tsx:
    - two-step category picker (parent → sub) driven by category.parentId,
      with edit-mode pre-fill deriving the parent from the sub's parentId.
    - getFinalImageUrls now uploads in parallel with concurrency 3, retries
      each image once, persists successful uploads to state as they land so
      a resubmit only retries the still-failing ones. If every upload fails
      we refuse to submit rather than sending an empty gallery.
    - only ship discountPrice when it's a REAL discount (finalNum > 0 and
      < priceNum) — otherwise pass null so the backend clears any stale
      value. Closes the "phantom discount" hole where PricingForm's
      "no discount" radio still shipped the previous typed number.
    - isStep1Valid requires every variant to have a real (non-"unselected")
      size, matching the new mandatory-size rule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-18 13:19:13 +00:00
6a300b8760 fix(store): don't bounce owners home, stop cross-user myStores cache
- app/hooks/useStoreOwnership.ts: the old redirect logic sent owners to /
  the moment useMyStores briefly resolved to []. That empty array came from
  useMyStores's dead `if (!token) return []` inside the queryFn, so any
  transient auth blip left the store page thinking the user has no stores.
  Now compares usernames case-insensitively, only redirects while the query
  is settled (!isLoading && !isFetching && !isError), and never bounces to
  home on empty — worst case the seller sees an empty dashboard rather than
  a silent kick-out.
- app/requestHandler/use-seller-hooks.ts: removed the unreachable
  `if (!token) return []` in useMyStores and added the token to the
  queryKey so a login switch stops serving the previous user's cached list.
- app/routes/store.$storeId._index.tsx: mount StorePushPromptCard at the
  top of the store dashboard.
- app/routes/store.$storeId.tsx: sidebar "پیام‌ها" badge reads
  badges.sellerChat[storeId] (per-store) instead of the buyer-side
  badges.chat, matching the badge-serializer split.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-18 13:18:23 +00:00
0b18438a97 fix(notif+auth): camelCase badge fields, seller push prompt, only 401 signs out
- app/components/MyLayout.tsx / app/components/home/HomePageTopBar.tsx /
  app/routes/profile._index.tsx: the BadgeCounts type was declared with
  snake_case fields (order_updates, seller_orders) while the API's global
  camelCase renderer sends orderUpdates / sellerOrders on the wire — every
  read went to undefined, so the "buyer activity" dot on the profile icon
  and the order-badge on the store dashboard were silently stuck at 0.
  Fixed by renaming the interface to match the wire and adding sellerChat
  (per-store roll-up) so the store dashboard sidebar reads
  badges.sellerChat[storeId] instead of the buyer-side chat count.
- app/hooks/useBadgeCounts.ts: interface + EMPTY constant updated to
  camelCase + new sellerChat map.
- app/utils/api-error-handler.ts: 403 no longer force-redirects to /logout.
  It used to force-log-out on ANY 403/401, so an approved-but-unbadged
  owner (Ehsan / Noelabel) whose store dashboard fired the Instagram-tab
  query got a 403 → forced logout → the store page vanished mid-load
  and the seller landed on the home screen convinced they'd been kicked
  off their own store. Only 401 signs the session out now.
- app/components/store/StorePushPromptCard.tsx: new dashboard-level card
  that nudges the store owner/staff to enable browser push. Unlike the
  global buyer banner it's not dismissible-forever; "بعداً یادآوری کن"
  hides for 24h and it comes back until the user actually subscribes.
  Handles the permission="denied" path with an in-browser-settings hint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-18 13:18:08 +00:00
7a385ba18e fix(pwa): top safe-area inset on products page + owner product carousel
Two more iOS-notch overlaps:
- Seller محصولات page: top container now carries pt-[env(safe-area-inset-top)]
  on mobile (lg:pt-0) so the heading drops below the notch.
- Owner single-product view: the carousel's ⋮/back buttons sat at top-3 (under
  the notch); offset them by calc(env(safe-area-inset-top,0px)+12px), matching
  the buyer back button. Only the owner buttons (product detail, top of screen)
  use this — grid tiles don't pass isOwner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:16:28 +03:30
2cf1224107 change(size-chart): centimeter only (drop cm/inch toggle)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:09:01 +03:30
7867db425e feat(chat): share the product into the chat + fix empty-thread preview
- Product-page chat buttons pass the product id when starting a thread, so it's
  shared into the conversation as a card the seller can see (and tap through).
- MessageBubble renders the product card (image, title, price → product page);
  card-only messages no longer render an empty bubble.
- Inbox preview shows 🛍 <product> instead of "بدون پیام" for product-card
  threads. ChatProductCard type + product on message/last-message types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:09:01 +03:30
d694040bf7 fix(pwa): apply top safe-area inset to page headers (iOS notch)
MyLayout only pads the bottom inset globally; each top bar must add its own
top inset. Several inline page headers omitted it, so their content slid
under the notch in the iOS standalone PWA (home was fine — its bar has it).

Wrap the back-button headers the same way ProfilePagesHeader already does
(outer sticky element carries pt-[env(safe-area-inset-top)]; inner relative
bar holds the content) so the absolute back arrow stays out of the notch.
Fixed: /profile hub, /discounts, /collections, /collection/:id, /sellers,
seller store index, and financial transactions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:21:23 +03:30
d4316381d5 feat(seller): size chart editor + buyer size guide (راهنمای سایز)
Sellers fill a size grid (rows = sizes, columns = measurements) in the
product create/edit wizard; buyers see it via a راهنمای سایز dialog.

- SizeChartSection editor: rows seeded from the product's sizes, columns
  seeded from category-aware defaults, category-specific quick-add chips
  (tops→دور سینه/سرشانه/آستین, pants→دور کمر/دور باسن/فاق, shoes→سایز اروپایی,
  bags→طول/عرض/ارتفاع, …), custom columns, cm/inch toggle, scrollable grid
- required-if-the-product-has-sizes gate on publish (with per-size hint);
  optional otherwise so accessories/one-size and imports aren't blocked
- buyer SizeGuideDialog + راهنمای سایز link in the size selector header
- SizeChart types on ProductDetail / FullProductCreate / FullProductUpdate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:21:23 +03:30
05cbbd8d5e chore(store): update product title placeholder example
"کفش آدیداس" (Adidas shoes) name-checked a third-party brand — swap for
"تیشرت بیسیک" (basic t-shirt), a neutral generic example.
2026-07-09 20:12:30 +00:00
72e4baab10 fix(seller): make inline price editor obviously tappable on touch
The pencil hint was hover-only, so on phones the price read as plain text.
Style the display as an editable field (subtle border + grey fill) with an
always-visible pencil, and drop the negative margins that fought the border.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:51:36 +03:30
5c8e19001c feat(seller): inline quick-edit for product & variant prices
Sellers can tap a price on the products page to edit it inline (Enter to
save / Escape to cancel), mirroring the existing stock quick-edit.

- new reusable InlinePriceEditor (optimistic display, Persian/Latin digit
  input, thousands grouping, loading + toast feedback, a11y labels)
- base price editable on each product row (mobile + desktop column)
- per-variant price editable beside stock in the expanded panel; empty
  clears the override and inherits the base price ("پیش‌فرض: <base>")
- useUpdateProductBasePrice + useUpdateVariantPrice hooks; extend generated
  API client + types for the two new endpoints

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:41:04 +03:30
16389fdd85 fix(desktop): lay out dashboard sub-pages + form pages for wide screens
- HeaderWithSupport is now lg:hidden (all its callers are desktop-full-width
  store/profile pages), removing the stray mobile back+support bar on desktop.
- Constrain + title the previously mobile-only pages so their content doesn't
  stretch edge-to-edge on desktop: store edit/create form (StoreForm), add
  product, shipping method, financial sub-pages (cash-funds/reports/
  transactions), order detail, instagram auth; and profile add/edit address,
  set detail, and payment result. Forms use a narrow centered column, lists/
  details a wider one. Transactions keeps its filter button (header restyled,
  not hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:06:13 +03:30
a67e81ad55 fix(desktop): constrain profile sub-pages so they don't stretch on desktop
These pages are desktop-enabled (they get the DesktopHeader via the /profile
allowlist) but had no desktop layout, so their content stretched edge-to-edge on
wide screens. Add a centered max-width container + a desktop title to the main
profile sub-pages (bookmarks, following, orders, settings, wallet, addresses,
sets). Grids use a wider max-width; single-column lists a narrower one.
ProfilePagesHeader is already lg:hidden, so the mobile header still disappears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:53:28 +03:30
0a482c70fa fix(discounts): enable desktop chrome for /discounts
/discounts was missing from MyLayout's isDesktopReady allowlist, so on desktop it
stayed in the narrow mobile column with no desktop header/footer. Add it so it
gets the full-width desktop canvas; the page's lg:max-w container + Mondrian
4-column masonry then render like /sellers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:41:09 +03:30
b371c50d23 fix(pwa): add apple-mobile-web-app-capable so iOS applies safe-area insets
On iOS, apple-mobile-web-app-status-bar-style: black-translucent has no effect
unless apple-mobile-web-app-capable=yes is also set. Without it the installed
PWA never went edge-to-edge, so env(safe-area-inset-top) resolved to 0 and the
header padding did nothing — content ran under the notch. Android was unaffected
(it honors mobile-web-app-capable, which was already present).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:35:12 +03:30
8b51c3252a feat(search): show sellers and collections in search results
Render a فروشگاه‌ها row (store cards with logo via SellerLogo, linking to the
store) and a کالکشن‌ها row (reusing HorizontalCollectionsList) above the product
grid on the search page. Sellers/collections read off the first page (they don't
paginate); products paginate as before. Types extended (SellerSearchResult.logo,
SearchResponse.collections). Degrades gracefully on older backends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:44:52 +03:30
fea80647fa feat(discounts): full تخفیف‌ها page with infinite scroll
Add a complete /discounts page (MondrianProductList grid, infinite scroll via
useGetHomeDiscounts) and link the home page "تخفیف‌ها" section to it with a
«مشاهده همه» link, mirroring the برترین فروشگاه‌ها → /sellers pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:00:44 +03:30
877b01f483 feat(desktop-header): surface "ورود به فروشگاه من" CTA for sellers and staff
Mobile profile page already has this action, but the desktop chrome had
no way for a seller or staff member to jump into a store they manage —
they'd have to type the URL. Add a compact "فروشگاه من" pill in the
header action row, gated on useMyStores() so it only renders for users
who actually manage a store. Selection mirrors the mobile flow: owned
first, then the first store they staff.
2026-07-07 10:57:14 +00:00
0d0da636fd fix(seller-team): call /api/seller/v1/ (not /api/seller/) for team endpoints
my-stores, members list/invite/remove all mount under /api/seller/v1/ in the
Django backend, so my-stores and every team-management fetch was 404'ing. That
made useMyStores throw silently, useStoreOwnership fall back to role=null, and
the setting page's `!ownerOnly || role === "owner"` filter drop every
owner-only tile (edit-store, team, financial-dashboard, shipping-method,
instagram-sync). Owners could log into their own store and see none of the
owner-gated menu items.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 12:48:36 +00:00
ba8e839959 Merge pull request 'feat(seller): multi-user seller dashboard (owner + staff)' (#2) from feat/seller-team into main 2026-07-01 14:04:19 +00:00
bb44ebf0e5 fix(seller-team): guard owner-only routes + robust useMyStores
Review fixes:
- useMyStores throws on a failed response instead of resolving to [] — a
  transient 401/blip no longer looks like "no stores" and bounces an owner off
  their own dashboard (query stays in error/retry, keeps prior data).
- Add useRequireOwner() guard and apply it to the owner-only routes
  (financial-dashboard + its cash-funds/reports/transactions sub-routes and
  shipping-method) so staff who hit those URLs directly are redirected to the
  dashboard. (Financial data was already backend-protected; this is the matching
  client-side gate.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:04:06 +03:30
a642fc0fae feat(seller-team): multi-user dashboard — store switcher, role gating, team UI
- useMyStores + team hooks (invite by phone / list / remove) in use-seller-hooks.
- useStoreOwnership rewritten to allow staff (not just the owner) via my-stores;
  exposes `role` so callers can gate owner-only UI, and no longer redirects
  members away from a store they help manage.
- Store settings: owner-only items (financial, shipping, edit store, instagram
  sync, team) hidden from staff; new "مدیریت اعضای تیم" entry (owner-only).
- New /store/:storeId/team page: invite by phone + members list with pending/
  active status + remove (owner-only, redirects staff). MyLayout keeps store mode
  on the team route.
- StoreForm edit is owner-only (redirects staff). Profile "enter my store" now
  routes owners to their store and staff to the store they manage.
- Chat already split earlier: seller inbox vs personal buyer inbox.

Depends on the seller-team backend; ships on this branch, not main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:43:53 +03:30
3502168a8b feat(seller-team): split buyer inbox vs store inbox in chat UI
Seller dashboard threads (type="store") now fetch the store's inbox via
useGetStoreThreads(storeId) -> /threads/list/?store_id=; the profile threads page
(type="user") keeps the personal buyer inbox. Depends on the seller-team backend
(store-aware threads + store_id-by-username), so it ships on this branch, not main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:00:23 +03:30
e60a03ac65 feat(seller-tiles): remove seller name overlay on desktop grid
The homepage seller tiles now show just the shop image on all breakpoints.
The desktop-only bottom gradient + name label was making some brand images
harder to read; the shop click target is unchanged and username is still
available on hover-tap navigation to /seller/<username>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-01 09:15:42 +00:00
660e01c3c3 feat(seller): customizable profile banner + seller page polish
- Banner editor in store edit: color swatches + custom color picker, or
  image/GIF upload with a 1600x500 dimension hint. Renders the chosen banner
  (image cover / solid color) on the public seller page, falling back to the
  default gradient.
- Sellers list: drop the desktop name overlay (match mobile, image-only tiles).
- Seller page mobile header: chat moved top-left under the banner, search+filter
  beside the follow button, followers/product counts removed (rating kept),
  compacted so products fill more of the screen, circular logo, thinner action row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:00:23 +03:30
bb10907463 feat(product): record product views for recommendations
Fire a fire-and-forget view event when an authenticated, non-owner user
opens a product page (deduped per product), POSTing to the new
/api/engagement/v1/views/ endpoint. Silent on failure so tracking never
disrupts browsing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:57:04 +03:30
0b874dbd57 feat(address-map): click-to-select + "use my current location"
- Click-to-select instead of geocoding on every move: pan the map freely
  (drag), then tap to drop the pin — reverse geocodes that point ONCE.
  Saves Map.ir reverse tokens. Shows a "tap to select" hint until a point
  is chosen; a draggable-style red marker marks the selection (and the
  existing point in edit mode, without re-geocoding).
- "موقعیت فعلی من" button: uses the device geolocation to fly to and
  select the current position (with an error toast if denied/unsupported).
- removed the old center-pin + moveend auto-geocode and the unused
  user-location-found event/pulse effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:43:55 +03:30
0f617b45c9 feat(profile): show which area a notification is for (chat / orders)
The bottom-nav profile badge sums unread chat + order updates, but the
profile page gave no breakdown. Wire useBadgeCounts into the mobile
account hub: the پیام‌ها (messages) menu item shows the chat unread count,
and the سفارش‌ها quick action shows the order-updates count. Adds a badge
slot to the menu item content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:18:43 +03:30
68786787b4 fix(address-map): RTL label rendering + use address_compact
- register MapLibre's RTL text plugin (@mapbox/mapbox-gl-rtl-text) so
  Persian/Arabic map labels render correctly instead of reversed/broken.
  Guarded via getRTLTextPluginStatus so it's set only once.
- reverse geocoding now prefers Map.ir's address_compact (shorter, cleaner
  address) over the full address.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 02:38:34 +03:30
3c7ee5b24c feat(address-map): MapLibre GL + Map.ir vector map & Persian reverse geocoding
Replace the broken-looking Leaflet/OSM address picker with a MapLibre GL
map using Map.ir vector tiles, plus Map.ir reverse geocoding for fast
coordinates -> Persian address.

- InteractiveMap: drop react-leaflet/leaflet; init a MapLibre map (Map.ir
  style via transformRequest x-api-key), a NavigationControl, and a fixed
  center pin — the map moves under the pin and the centre is reverse-
  geocoded on moveend (debounced). Wrapped dir=ltr so the map renders
  correctly inside the RTL page (fixes the scattered-tiles bug). Selected
  address shown in an overlay chip; city select / geolocation fly the map.
- reverse geocoding: Nominatim -> Map.ir (https://map.ir/reverse), which
  returns proper Persian addresses (Nominatim was weak for Iran and its
  TOS forbids production use).
- default map centre fixed to Tehran (matched the "تهران" selector).
- key via VITE_MAPIR_KEY (documented in .env.example; real key in env).
- deps: add maplibre-gl, remove leaflet/react-leaflet/@types/leaflet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 01:20:37 +03:30
668649d540 feat(profile): mobile account hub redesign
Rework the mobile profile to match the design: an avatar + name + phone
header with an edit button, a wallet gradient card (live balance from
useServiceGetWallet + افزایش موجودی → /profile/wallet), a 4-up quick-action
grid (orders / bookmarks / following[badge] / addresses), a «حساب من»
section label, the theme toggle, and a trimmed menu list (items surfaced
above are dropped from the list). Desktop profile (sidebar + welcome
panel) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:41:36 +03:30
254fed4a99 feat(cart): mockup-style item rows + separate-shipping note
- CartItem: compact row — image + title + رنگ/سایز attribute chips
  (circular color dot) + a bottom row with the stepper and the effective
  price plus struck original. Drops the verbose label:value rows and the
  product-code line. Shared with the desktop detail column (verified).
- cart list: add a green «ارسال جداگانه» badge per seller-cart, matching
  the mockup's per-seller grouping.

Keeps the existing list -> detail checkout flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:16:42 +03:30
e6574b1e9e feat(seller): mobile brand header redesign (cover + overlapping logo)
Bring the seller page mobile header in line with the design / desktop:
replace the plain top bar + centered logo with a cover banner, a floating
circular back button (safe-area aware), an overlapping rounded logo, the
store name + verified badge + @handle, a clean borderless stats row
(محصول / دنبال‌کننده / امتیاز), the description, and follow + message-icon
actions. Search bar and product masonry unchanged. Desktop untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:44:20 +03:30
252d3f8f50 feat(search/explore): home-style product tiles with seller brand chip
Bring the home product tile to search and explore: seller brand chip
(SellerChip) plus price/discount overlay, instead of a bare image.

- search: map discountPercent/discountPrice/basePrice + sellerName from
  ProductSearchResult (sellerName is already returned, so the chip shows
  now with the initial-letter fallback).
- explore: map sellerName/sellerLogo/sellerUsername (and add them to the
  Product type); these populate once the backend ProductSerializer change
  ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:09:58 +03:30
11656c2996 feat(home): seller brand chip (logo + name) on product tiles
Show the seller's brand on the home product cards, like the design:
a small logo circle + name overlaid above the title.

- new SellerChip component: renders nothing without a name; falls back to
  the seller's initial when no logo is present (e.g. before the backend
  ships seller_logo). seller_name is already returned today, so the name
  shows immediately.
- thread sellerName/sellerLogo/sellerUsername through ProductHomepage type,
  the home discounts + for-you mappings, and the MondrianProductList /
  HorizontalProductList card interfaces + overlays.

Only renders where seller data is mapped (home), so seller-page and
store-dashboard tiles are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:38:44 +03:30
692d385d09 feat(product): mobile redesign — brand below image, floating back, circular colors
Per design feedback, restructure the mobile product page (desktop layout
unchanged except color swatches):
- remove the top seller bar; the seller/brand (logo + name + ارسال پیام)
  now sits in a row below the gallery.
- add a floating circular back button over the image (safe-area aware).
- move the rating out of the action row into a prominent body row, shown
  only when the average rating is > 0.
- color swatches are now circles with a selection ring (shared with
  desktop for consistency) instead of squares.
- make the افزودن به سبد خرید button full-width/taller with a compact
  price beside it.
- the action row keeps save / share / add-to-collection / try-on.
- the red discount icon is kept as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:06:54 +03:30
b45e247dda Revert "feat(home): clean discount pill on product cards"
Restore the original red discount-icon badge on product cards per
preference; revert the ٪N pill restyle.

This reverts commit eefad53.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:23:42 +03:30
eefad5387c feat(home): clean discount pill on product cards
Restyle the discount badge to the redesign's pill: ٪ + rounded Persian
digits on a translucent white rounded-full chip, no icon. Drops the raw
decimal percent (e.g. "12.7" -> "٪۱۳") and the discount-red icon import.
Applied in both the discounts rail and the for-you masonry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:08:32 +03:30
f8289898bc feat(home): search-first mobile top bar (redesign)
Replace the mobile home scroll-tab bar with the redesign's search-first
chrome: logo + ویترون wordmark, notification (bell) and cart (bag) icon
buttons with unread dot / count badges, and a prominent tap-to-search
field that opens /explore. Mobile only (stays inside lg:hidden), so the
desktop DesktopHeader is unaffected. Keeps the existing pinar font and
color tokens; preserves the safe-area-inset top padding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:58:46 +03:30
bac93568f5 feat(upload): convert HEIC/HEIF to JPEG on the client before preview/upload
Most browsers (everything but Safari/WebKit) can't decode HEIC in <img> or
canvas, so picking an iPhone HEIC photo broke the preview and the crop editor,
and could reach the backend in a format it couldn't process. Convert HEIC/HEIF
to JPEG right after selection via heic2any (dynamically imported, browser-only).

- new util app/utils/convert-heic.ts: isHeic() detection (MIME or extension
  when iOS reports no type) + convertHeicToJpeg()/convertHeicFiles(); never
  throws — falls back to the original file on failure.
- wired into the product image picker (store.add.manual) and the store
  logo picker (StoreForm).
- adds heic2any dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:43:30 +03:30
641abf711c fix(upload): preserve resolution when cropping product images
The crop canvas was sized to cropArea.width/height, which are the editor's
on-screen (CSS) pixels — a few hundred px. Phone photos were therefore
downscaled to that size before upload, so cropped product images came out
blurry (most visibly on iPhone, whose source photos are large).

Size the canvas to the crop region in the source image's native pixels
(adjustedCropWidth/Height, already computed) and draw 1:1. Bumped JPEG
quality 0.9 -> 0.92. The backend still caps the final image to 1600px webp,
so output size stays bounded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:27:28 +03:30
940cc3e859 fix(store): always revalidate public storefront product list
The seller store page (useGetSellerProducts) inherited the global 30s
staleTime with refetchOnWindowFocus disabled, so after a seller edited a
price the store tiles could keep showing the pre-edit price while the
(SSR) single-product page already showed the new one — a list-vs-detail
mismatch. The backend serves both from live DB and is consistent.

Set staleTime: 0 + refetchOnMount/refetchOnWindowFocus on the storefront
query so it revalidates on every visit and tab refocus, keeping the list
prices current.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:06:34 +00:00
272362cfdf fix(product): detail page price now matches list tiles + fresher feeds
Single-product page showed basePrice with no discount while list tiles
showed the cheapest variant's discounted price. Cause: the detail view
defaulted selectedColor/selectedSize to the first in-stock color and the
first in-stock size independently — a combo that often isn't a real
variant, so no variant matched and it fell back to basePrice / no sale.

- ProductDetailView: derive a `defaultVariant` (cheapest in-stock variant
  by final price, == the tile's min/discount price) and use it for the
  initial selection, the initial displayed price, and the fallback when no
  exact color/size combo is matched. A shared `pricingOf` helper applies
  the same discount rule everywhere. Detail price now matches the tile.
- use-home-hooks: drop staleTime 5m -> 60s on the For You / discounts /
  top-sellers feeds so price/discount edits show up promptly in lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:55:11 +00:00
a405f6ffc4 feat(desktop): add search autocomplete to desktop header
The desktop header search only navigated on submit — the autocomplete
suggestions dropdown existed on mobile (explore SearchSection) but was
never wired into the desktop chrome. Reuse the same useSearchAutocomplete
hook (debounced) and render a suggestions dropdown under the search box:
product titles navigate to /search, sellers navigate to /seller/<id>.
Closes on outside-click / Escape / selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:23:12 +00:00
501cf28b9a fix(auth): 6-digit OTP in LoginRequiredDialog
The login-required popup still rendered a 4-slot OTP (maxLength=4, gated on
length===4) while the main login page already used 6. Users who triggered
login via the dialog saw the old 4-digit code. Match login.tsx: 6 slots,
length===6, same slot sizing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:53:01 +03:30
b4d5f8d8db fix(ios): respect safe-area insets so chrome clears the notch/home bar
Content was drawing under the iOS status bar and home indicator because
the app uses viewport-fit=cover + black-translucent but the fixed/sticky
chrome didn't reserve the safe areas.

- bottom nav (MyLayout): move the 50px row into an inner box and let the
  bar extend by env(safe-area-inset-bottom) so icons sit above the home
  indicator (previously the fixed height clipped the inset padding).
- top headers gain env(safe-area-inset-top): HomePageTopBar, ExploreTopBar,
  SearchTopBar, CartHeader, ChatHeader, and wrapper-padded HeaderWithSupport
  / ProfilePagesHeader (so their absolutely-positioned buttons shift too).
- chat input pads bottom by the inset; chat scroll area height accounts for
  the taller header/input.
- _index.tsx no longer overrides the viewport meta (it was dropping
  viewport-fit=cover on the home page); the global one in root.tsx wins.

env(safe-area-inset-*) is 0 on non-notch devices, so desktop/Android render
identically — only iOS gains the padding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:42:51 +03:30
66b14cfa74 fix(notifications): read camelCase publicKey from VAPID endpoint
The backend view returns {"public_key": ...} but the global
CamelCaseJSONRenderer rewrites it to {"publicKey": ...} on the wire,
so destructuring `public_key` always resolved to undefined and the
subscribe path bailed silently. Browsers never registered with the
backend, no PushSubscription rows were ever created, and no push
notifications fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 12:06:35 +00:00
fef53fd003 feat(notifications): web push opt-in + unread nav badges
Surfaces three events without a full notification center:
- service worker (public/sw.js) gains push + notificationclick handlers
  (RTL, focuses an existing tab or opens the target URL).
- usePushNotifications hook: permission + VAPID subscribe/unsubscribe
  against the backend push endpoints.
- opt-in UI both ways: a one-time dismissible post-login banner
  (PushPermissionBanner, rendered in MyLayout) and a permanent toggle
  (PushSettingToggle) on buyer profile settings and seller store settings.
- useBadgeCounts polls /api/notif/v1/badges/; small red badges on the
  bottom-nav profile icon (chat + order updates), the profile sidebar
  (پیام‌ها / سفارش‌ها) and the seller dashboard sidebar. Visiting orders
  clears its badge.

Degrades gracefully: if the backend endpoints are absent the counts fall
back to zero and nothing throws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:02:56 +03:30
257871d5f1 feat(seller-dashboard): desktop-friendly layouts for list and hub pages
Redesign mobile-style seller dashboard pages for desktop (lg:) while
leaving the mobile view unchanged:

- products: table-style header row (product/price/variant/actions) with a
  bordered card wrapper and an "add product" button; accordion rows align
  to the columns, inline price hidden on desktop.
- orders: pill-style status tabs + search on one row, orders shown as a
  two-column card grid instead of stretched full-width rows.
- financial-dashboard: three-column card grid with icon chips.
- setting: status/theme as side-by-side cards, menu items as a
  three-column card grid (logout styled in red).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:00:18 +03:30
4af143bccf Desktop: seller dashboard shell + gate carousel arrows to product gallery
- new store.$storeId.tsx layout: sticky admin sidebar (store/products/orders/
  financial/shipping/threads/settings) beside content at lg; mobile unchanged
- MyLayout: store/* gets a full-width desktop canvas (own sidebar), no shopper
  header/footer, mobile store-nav hidden at lg
- store home: hide the redundant mobile store header at lg
- ProductImageCarousel: prev/next arrows now opt-in (showArrows) so they only
  appear on the product gallery, not every masonry card
2026-06-17 12:28:59 +03:30
207d46eefd Profile hard-guard: redirect /profile to /login when logged out
The account sub-pages were already protected via validateTokens; add a loader
on the profile index so the hub itself bounces to /login when unauthenticated.
2026-06-16 15:00:53 +03:30
24449fe5e9 Desktop: sticky-footer layout (remove gray gap above + empty area below footer)
- MyLayout: make the desktop shell a flex column with the content area
  flex-1, so the footer sits flush and pins to the bottom on short pages
- DesktopFooter: drop the mt-24 that exposed the wrapper bg as a gray band
- also fixes the light-gray patches in dark mode (wrapper bg no longer shows)
2026-06-16 14:46:39 +03:30
030a883327 Desktop header: login/profile state + theme toggle; hide about/faq header
- header shows "ورود / ثبت نام" when logged out (no profile/cart/bookmark icons,
  so the profile tab isn't reachable) and the avatar + cart + bookmarks when
  logged in
- add the light/dark SunMoonToggle to the desktop header
- about/faq: hide their mobile HeaderWithSupport at lg (content already
  width-constrained)
2026-06-16 13:59:34 +03:30
4a7bf51572 Desktop: inline the fixed-bottom buttons on account pages
profile/location + profile/wallet "add" buttons were fixed to the viewport
bottom; make them static/inline at lg so they sit at the end of the content.
2026-06-16 12:58:51 +03:30
3156fb4204 Desktop: account area (shared sidebar) + brands list
- new profile.tsx layout: sticky desktop sidebar (user card + menu) wrapping all
  /profile/* pages; mobile renders page content only (unchanged)
- ProfilePagesHeader hidden at lg (one change covers all account sub-pages +
  cart); profile index shows a welcome on desktop (menu lives in the sidebar)
- sellers: 5-col brand grid with name overlay, contained, mobile header hidden
- MyLayout: desktop gate now covers /profile/* and /sellers
2026-06-16 12:46:22 +03:30
96034fe61a Desktop: cart checkout two-column + profile account grid
- cart checkout (/cart/$cartId): steps on the left, sticky order summary on the
  right at lg (1320px container); CartSummary becomes an inline card on desktop
  (fixed bottom bar on mobile)
- profile (/profile): contained account layout; menu becomes a 2-col card grid
  on desktop with a heading
- MyLayout: desktop gate now includes /cart/* and /profile
2026-06-16 11:19:55 +03:30
7845ab5e75 Desktop: explore page (category grid + contained layout)
- explore: hide mobile search bar at lg (global header has search); contain
  content to 1320px; category grid 2-col mobile -> 4-col desktop with taller tiles
- MyLayout: desktop gate now includes /explore
2026-06-16 11:11:46 +03:30
5c64bbd66a Desktop product page: match design (gallery overlays + clean info column)
Gallery (desktop):
- portrait 3/4 box, object-contain so the photo isn't cropped
- prev/next arrows on the carousel
- save (heart) overlay top-left; AI try-on button "آنلاین پرو کن" at image bottom

Info column (desktop): hide the mobile action icon row (incl. share); clean
layout — title, star rating + review count, prominent price with old/strike,
color + size, buy row (chat + add-to-cart + qty stepper), assurance cards.
Mobile keeps its existing components (ProductActions / ProductInfo / BottomBar /
ContactButton are lg:hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:31:58 +03:30
fa65db8804 Desktop fixes: product seller + actions, collection cover, masonry skeleton
- product: show seller (logo + name) in the desktop info column (was missing
  after hiding the mobile header); action icons (save/share/collection/AI) are
  now circular bordered buttons on desktop instead of a bare icon row
- collection detail: cover constrained to a 360px portrait column with
  object-cover (fixes over-long image + side margins); tighter spacing
- masonry: only render the loading skeleton while actively fetching (removes
  the persistent grey block before the footer); sentinel moved above it

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:59:24 +03:30
60d6a2a6e4 Desktop: product detail two-column layout
- ProductDetailView: gallery | info two-column grid on desktop with breadcrumb;
  gallery sticky; reviews + similar products full-width below. Mobile stack
  unchanged (only lg: utilities + a moved BottomBar, which is position:fixed so
  its DOM location doesn't affect mobile)
- BottomBar: static inline buy card on desktop (fixed bottom bar on mobile)
- ProductInfo: larger title + tightened top spacing at lg
- MyLayout: desktop gate now includes /product/*

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:35:06 +03:30
f57fe4e9d6 Desktop polish: seller header contrast, collection image crop, infinite scroll
- seller: only the logo overlaps the cover now; name/handle/stats sit below the
  cover on white (fixes low-contrast name on the gradient)
- collections (home rail, /collections grid, /collection cover): portrait 3/4
  images so full-body figures aren't cropped; detail cover uses object-contain
- MondrianProductList: add IntersectionObserver sentinel for reliable
  infinite scroll on desktop (window-scroll math missed under the footer)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:19:40 +03:30
cf55e16a9a Desktop: seller brand header redesign + collections list/detail
- seller: desktop brand header matching the design — cover, overlapping rounded
  logo, name + verified badge + @handle, horizontal stats, Follow/Message
  actions, description; mobile blocks hidden at lg; feed = responsive masonry
- /collections: 4-column card grid on desktop (was a stretched single column)
- /collection/<id>: two-column hero (cover + info) on desktop, contained width,
  mobile header hidden at lg; products use the responsive masonry
- MyLayout: desktop gate now includes /collections and /collection/*

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:00:49 +03:30
d0571f8253 Login: 6-digit OTP + desktop layout
- OTP input now 6 digits (matches backend 6-digit codes): maxLength 6, six
  slots with responsive widths, verify enabled at length === 6
- login page: centered desktop card on a neutral canvas with brand mark
- MyLayout: auth pages (/login, /logout) get full-width desktop canvas and no
  mobile bottom nav at lg (no shopping header/footer)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:36:42 +03:30
232f7d1b3c Desktop responsive: collections grid + seller page
- HorizontalCollectionsList: 4-column grid at lg (overrides mobile fixed-width
  cards), hide scroll arrows on desktop
- seller page: contained desktop layout, hide mobile back-header at lg, show
  store name; feed uses the responsive masonry
- MyLayout: desktop chrome gate now includes /seller/*

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:28:25 +03:30
9650073b04 Desktop responsive: search page, cart list, masonry grid
- MondrianProductList: add desktop 4-column masonry (mobile 2-col unchanged);
  improves home "for you", search results and seller feed at lg+
- search: hide mobile SearchTopBar at lg, add results heading + centered
  1320px container
- cart list: responsive 2-column grid of cart cards + desktop heading
- MyLayout: desktop chrome gate now covers / , /search/* and /cart

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:06:34 +03:30
e1c5899002 Add responsive desktop layout for home page
Single responsive tree (Tailwind lg:) — mobile experience unchanged; desktop
chrome is gated to the home route for now.

- DesktopHeader / DesktopFooter components (visible only at lg+), wired to
  real routes with live cart badge and working search
- MyLayout: render desktop header/footer on home, expand container and hide
  mobile bottom nav at lg
- Home: hide mobile top bar at lg, 1320px centered container, desktop hero
  grid (carousel + 2 promos), larger section headers
- SellerTilesGrid: 2-col mobile -> 5-col desktop with name overlay

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:07:47 +03:30
d75c6065e7 Merge pull request 'Security fixes: session hardening, open redirect, SSR path injection' (#1) from security-fixes into main
Reviewed-on: #1
2026-06-14 15:58:49 +00:00
134 changed files with 11761 additions and 1664 deletions

View File

@ -21,3 +21,6 @@ VITE_BUILD_TARGET=development
# Support Configuration
VITE_SUPPORT_PHONE=+989223740993
# Map.ir key for the address map (MapLibre vector tiles + reverse geocoding)
VITE_MAPIR_KEY=your_mapir_api_key

View File

@ -39,23 +39,27 @@ const HeaderWithSupport = memo(function HeaderWithSupport({
return (
<>
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={onBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold ">{title}</p>
{!hideSupportButton && (
<div
className="absolute top-4 h-8 w-8 left-5 rounded-full bg-WHITE shadow-xl flex items-center justify-center cursor-pointer"
onClick={handleSupportClick}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
>
<Headset size={20} />
</div>
)}
{/* Mobile only — on desktop the dashboard sidebar / desktop header stands
in for this back+support bar. */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={onBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold ">{title}</p>
{!hideSupportButton && (
<div
className="absolute top-4 h-8 w-8 left-5 rounded-full bg-WHITE shadow-xl flex items-center justify-center cursor-pointer"
onClick={handleSupportClick}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
>
<Headset size={20} />
</div>
)}
</div>
</div>
<CallDialog

View File

@ -116,19 +116,22 @@ const HorizontalCollectionsList = ({
{/* Scroll Container */}
<div
ref={scrollContainerRef}
className="w-full overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4"
className="w-full overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4 lg:overflow-visible lg:px-0"
>
<div className="flex" style={{ width: "max-content" }}>
<div
className="flex lg:grid lg:grid-cols-4 lg:gap-4 lg:!w-full"
style={{ width: "max-content" }}
>
{collections.map((collection, index) => (
<Link
key={collection.id || index}
to={`/collection/${collection.id}`}
className="flex flex-col pr-2 md:pr-4 snap-start group"
className="flex flex-col pr-2 md:pr-4 snap-start group lg:!w-auto lg:!max-w-none lg:pr-0"
style={{ width: "calc(85vw)", maxWidth: "300px" }}
>
<div className="flex flex-col gap-3 p-3 bg-WHITE2 rounded-2xl border border-inner-border hover:border-gray-300 transition-all duration-300 hover:shadow-lg h-full">
{/* Collection Image */}
<div className="relative overflow-hidden rounded-xl w-full h-64 bg-gray-100">
<div className="relative overflow-hidden rounded-xl w-full h-64 lg:h-auto lg:aspect-[3/4] bg-gray-100">
{collection.coverImageUrl ? (
<img
src={collection.coverImageUrl}
@ -174,7 +177,7 @@ const HorizontalCollectionsList = ({
{showViewAllButton && (
<Link
to="/collections"
className="flex flex-col pr-2 md:pr-4 snap-start group"
className="flex flex-col pr-2 md:pr-4 snap-start group lg:!w-auto lg:!max-w-none lg:pr-0"
style={{ width: "calc(85vw)", maxWidth: "300px" }}
>
<div className="flex flex-col items-center justify-center gap-3 p-3 bg-gradient-to-br from-black to-gray-800 rounded-2xl border border-inner-border hover:border-gray-600 transition-all duration-300 hover:shadow-lg h-full min-h-[304px]">
@ -203,9 +206,9 @@ const HorizontalCollectionsList = ({
)}
</div>
</div>
{/* Navigation Buttons Row */}
{/* Navigation Buttons Row (mobile only) */}
{collections.length > 1 && (
<div className="flex items-center justify-center gap-3 px-4">
<div className="flex items-center justify-center gap-3 px-4 lg:hidden">
<motion.button
whileHover={!showRightButton ? {} : { scale: 1.1 }}
whileTap={!showRightButton ? {} : { scale: 0.95 }}

View File

@ -6,6 +6,7 @@ import { Skeleton } from "./ui/skeleton";
import { ErrorState } from "./UiProvider";
import discountRed from "~/assets/icons/product/discount-red.svg";
import placeholderImage from "~/assets/icons/product.png";
import SellerChip from "./SellerChip";
interface HorizontalProduct {
id?: string;
@ -14,6 +15,9 @@ interface HorizontalProduct {
discountPercent?: number;
discountPrice?: number;
basePrice?: number;
sellerName?: string;
sellerLogo?: string | null;
sellerUsername?: string;
}
const HorizontalProductList = ({
@ -138,6 +142,10 @@ const HorizontalProductList = ({
)}
{/* Overlay info */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-2 pt-6 pointer-events-none">
<SellerChip
name={product.sellerName}
logo={product.sellerLogo}
/>
<div className="flex w-full justify-between gap-1">
{/* Title */}
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">

View File

@ -1,15 +1,7 @@
import React, { useState, useEffect, useRef } from "react";
import {
MapContainer,
TileLayer,
Marker,
Popup,
useMapEvents,
useMap,
} from "react-leaflet";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
import { Search, X, ChevronLeft } from "lucide-react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import "maplibre-gl/dist/maplibre-gl.css";
import type { Map as MaplibreMap, Marker as MaplibreMarker } from "maplibre-gl";
import { Search, X, ChevronLeft, MapPin, Crosshair } from "lucide-react";
import AppInput from "./AppInput";
import AppTextArea from "./AppTextArea";
import {
@ -32,6 +24,25 @@ import {
type IranianCity,
} from "../data/iranian-cities";
// Map.ir config (vector tiles + reverse geocoding). The key is a client-side
// map key (sent with every tile request), provided via env.
const MAPIR_KEY = import.meta.env.VITE_MAPIR_KEY || "";
const MAPIR_STYLE = "https://map.ir/vector/styles/main/mapir-style.json";
// Reverse geocode (lat/lng -> Persian address) via Map.ir.
async function reverseGeocode(lat: number, lon: number): Promise<string> {
try {
const res = await fetch(`https://map.ir/reverse?lat=${lat}&lon=${lon}`, {
headers: { "x-api-key": MAPIR_KEY },
});
const data = await res.json();
return data?.address_compact || data?.address || "آدرس نامشخص";
} catch (error) {
console.error("Error fetching address:", error);
return "";
}
}
// Form data interface
interface FormData {
title: string;
@ -71,30 +82,6 @@ function convertPersianToEnglish(str: string): string {
return result;
}
// Component to change map view when city is selected
function CityChangeView({ center }: { center: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(center, 13);
}, [center, map]);
return null;
}
// Component to handle map clicks
function MapClickHandler({
onMapClick,
}: {
onMapClick: (latlng: [number, number]) => void;
}) {
useMapEvents({
click: (e) => {
const { lat, lng } = e.latlng;
onMapClick([lat, lng]);
},
});
return null;
}
export default function InteractiveMap({
onLocationSelected,
initialAddress,
@ -112,7 +99,7 @@ export default function InteractiveMap({
isEditing ? "" : "تهران"
);
const [currentCenter, setCurrentCenter] = useState<[number, number]>(
iranianCities[0].position
(iranianCities.find((c) => c.name === "تهران") || iranianCities[0]).position
);
const [mounted, setMounted] = useState<boolean>(false);
@ -166,34 +153,13 @@ export default function InteractiveMap({
isDefault: formData.is_default,
});
// Fix Leaflet's default icon path issues when the component mounts
useEffect(() => {
// Fix Leaflet icon issues
delete (L.Icon.Default.prototype as { _getIconUrl?: string })._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: "https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png",
iconRetinaUrl:
"https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png",
shadowUrl:
"https://unpkg.com/leaflet@1.7.1/dist/images/marker-shadow.png",
});
// Cleanup function to remove map instance when component unmounts
return () => {
// This code ensures previous maps are cleaned up
// eslint-disable-next-line react-hooks/exhaustive-deps
const mapContainer = document.getElementById(mapIdRef.current);
if (mapContainer) {
// Clean up previous map on this element
// In Leaflet version 1+, using map._leaflet no longer works
// and instead removeAttribute should be used
mapContainer.innerHTML = "";
mapContainer.removeAttribute("data-leaflet-internal-id");
mapContainer.removeAttribute("_leaflet_id");
console.log("Map container cleaned up successfully");
}
};
}, []);
// MapLibre refs
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<MaplibreMap | null>(null);
const markerRef = useRef<MaplibreMarker | null>(null);
// select(lat,lng): drop/move the pin and reverse-geocode that point once.
const selectRef = useRef<((lat: number, lng: number) => void) | null>(null);
const [isLocating, setIsLocating] = useState<boolean>(false);
// Initialize with the existing address data when in editing mode
useEffect(() => {
@ -245,76 +211,123 @@ export default function InteractiveMap({
setFilteredCities(cities);
}, [citySearchTerm, selectedProvince]);
// Function to get address from coordinates
const getAddressFromCoordinates = async (latlng: [number, number]) => {
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng[0]}&lon=${latlng[1]}&zoom=18&addressdetails=1&accept-language=fa&countrycodes=ir`,
{
headers: {
"Accept-Language": "fa",
},
}
);
const data = await response.json();
return data.display_name || "آدرس نامشخص";
} catch (error) {
console.error("Error fetching address:", error);
return "";
}
};
// Extract postal code from address data
const getPostalCodeFromAddress = async (latlng: [number, number]) => {
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng[0]}&lon=${latlng[1]}&zoom=18&addressdetails=1&accept-language=fa&countrycodes=ir`,
{
headers: {
"Accept-Language": "fa",
},
}
);
const data = await response.json();
if (data && data.address && data.address.postcode) {
return data.address.postcode;
}
return "";
} catch (error) {
console.error("Error fetching postal code:", error);
return "";
}
};
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const handleMapClick = async (latlng: [number, number]) => {
const address = await getAddressFromCoordinates(latlng);
const postalCode = await getPostalCodeFromAddress(latlng);
// Reverse-geocode a point and push it into the form. Used by the map's
// center pin (on move) — replaces the old click handler.
const applyLocation = useCallback(
async (latlng: [number, number]) => {
const address = await reverseGeocode(latlng[0], latlng[1]);
setSelectedPosition(latlng);
setSelectedAddress(address);
setFormData((prev) => ({
...prev,
main_address: address,
latitude: latlng[0],
longitude: latlng[1],
}));
if (onLocationSelected) {
onLocationSelected({ position: latlng, address });
}
},
[onLocationSelected]
);
setSelectedPosition(latlng);
setSelectedAddress(address);
// Keep a stable ref so the map's event listeners always call the latest.
const applyLocationRef = useRef(applyLocation);
useEffect(() => {
applyLocationRef.current = applyLocation;
}, [applyLocation]);
// Don't update postal_code in edit mode, preserve the original value
setFormData({
...formData,
main_address: address,
// Don't set postal_code here in either mode, preserve the existing value in edit mode
latitude: latlng[0],
longitude: latlng[1],
});
// Initialize the MapLibre map (client-only, once) with the Map.ir style.
useEffect(() => {
if (!mounted || formOnly || !mapContainerRef.current || mapRef.current) {
return;
}
let cancelled = false;
(async () => {
const maplibregl = (await import("maplibre-gl")).default;
if (cancelled || !mapContainerRef.current) return;
// Persian/Arabic labels need the RTL text plugin or they render
// reversed/broken. Safe to attempt once; throws if already set.
try {
if (
maplibregl.getRTLTextPluginStatus?.() === "unavailable"
) {
maplibregl.setRTLTextPlugin(
"https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.3.0/dist/mapbox-gl-rtl-text.js",
true // lazy: load when RTL text is first encountered
);
}
} catch {
/* plugin already registered */
}
const start = selectedPosition || currentCenter; // [lat, lng]
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: MAPIR_STYLE,
center: [start[1], start[0]], // MapLibre uses [lng, lat]
zoom: 14,
attributionControl: false,
transformRequest: (url: string) =>
url.startsWith("https://map.ir")
? { url, headers: { "x-api-key": MAPIR_KEY } }
: { url },
});
mapRef.current = map;
map.addControl(
new maplibregl.NavigationControl({ showCompass: false }),
"top-left"
);
// If using as a location picker in another component, call the callback
if (onLocationSelected) {
onLocationSelected({
position: latlng,
address,
postalCode: isEditing ? formData.postal_code : postalCode,
// Drop/move the pin at a point and reverse-geocode it once (saves
// tokens — we only geocode on an explicit tap, not on every move).
const select = (lat: number, lng: number) => {
if (markerRef.current) {
markerRef.current.setLngLat([lng, lat]);
} else {
markerRef.current = new maplibregl.Marker({ color: "#ff3b30" })
.setLngLat([lng, lat])
.addTo(map);
}
applyLocationRef.current([lat, lng]);
};
selectRef.current = select;
// Pan with drag; a tap selects that point.
map.on("click", (e) => select(e.lngLat.lat, e.lngLat.lng));
// Edit mode: show the existing pin without re-geocoding.
if (selectedPosition) {
markerRef.current = new maplibregl.Marker({ color: "#ff3b30" })
.setLngLat([selectedPosition[1], selectedPosition[0]])
.addTo(map);
}
})();
return () => {
cancelled = true;
if (markerRef.current) {
markerRef.current.remove();
markerRef.current = null;
}
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mounted, formOnly]);
// Recenter the map when the chosen city changes.
useEffect(() => {
if (mapRef.current) {
mapRef.current.flyTo({
center: [currentCenter[1], currentCenter[0]],
zoom: 14,
});
}
};
}, [currentCenter]);
const handleCitySelect = (cityName: string) => {
setSelectedCity(cityName);
@ -327,6 +340,35 @@ export default function InteractiveMap({
setIsCityDrawerOpen(false);
};
// Use the device's current location: fly there and select it.
const handleUseMyLocation = () => {
if (typeof navigator === "undefined" || !navigator.geolocation) {
toast({
title: "موقعیت مکانی پشتیبانی نمی‌شود",
variant: "destructive",
});
return;
}
setIsLocating(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude } = pos.coords;
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 16 });
selectRef.current?.(latitude, longitude);
setIsLocating(false);
},
() => {
setIsLocating(false);
toast({
title: "دسترسی به موقعیت مکانی امکان‌پذیر نشد",
description: "لطفاً دسترسی موقعیت مکانی را در مرورگر فعال کنید.",
variant: "destructive",
});
},
{ enableHighAccuracy: true, timeout: 10000 }
);
};
const handleOpenDrawer = () => {
setIsDrawerOpen(true);
};
@ -478,45 +520,6 @@ export default function InteractiveMap({
}
}, [isEditing, initialAddress]);
// Add keyframes for the pulse animation
useEffect(() => {
const styleTag = document.createElement("style");
styleTag.innerHTML = `
@keyframes pulse {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
}
`;
document.head.appendChild(styleTag);
// Listen for the custom location event
const handleLocationFound = (event: Event) => {
const customEvent = event as CustomEvent<{ lat: number; lng: number }>;
const { lat, lng } = customEvent.detail;
handleMapClick([lat, lng]);
};
window.addEventListener(
"user-location-found",
handleLocationFound as EventListener
);
return () => {
document.head.removeChild(styleTag);
window.removeEventListener(
"user-location-found",
handleLocationFound as EventListener
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// If formOnly mode, render only the form
if (formOnly) {
return (
@ -832,42 +835,44 @@ export default function InteractiveMap({
</DrawerContent>
</Drawer>
{/* Map Container */}
<div className="dd z-0 h-[500px] w-full relative">
<div id={mapIdRef.current} className="h-full w-full">
{mounted && (
<MapContainer
key={mapIdRef.current}
center={currentCenter}
zoom={13}
scrollWheelZoom={true}
style={{ height: "100%", width: "100%" }}
>
<CityChangeView center={currentCenter} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Map Container MapLibre + Map.ir vector tiles. dir=ltr keeps the
map's internal positioning correct inside the RTL page. */}
<div className="dd z-0 h-[500px] w-full relative" dir="ltr">
<div
ref={mapContainerRef}
id={mapIdRef.current}
className="h-full w-full"
/>
<MapClickHandler onMapClick={handleMapClick} />
{/* <LocationButton /> */}
{/* Use my current location */}
<button
type="button"
onClick={handleUseMyLocation}
disabled={isLocating}
dir="rtl"
className="absolute top-3 right-3 z-[5] flex items-center gap-1.5 bg-WHITE/95 backdrop-blur rounded-full shadow-md px-3.5 h-10 text-R12 font-bold text-BLACK disabled:opacity-60"
>
<Crosshair size={18} className="text-VITROWN_BLUE" />
{isLocating ? "در حال یافتن..." : "موقعیت فعلی من"}
</button>
{selectedPosition && (
<Marker position={selectedPosition}>
<Popup>
<div dir="rtl" className="text-right">
<p className="font-bold mb-1">آدرس:</p>
<p className="mb-2">{selectedAddress}</p>
<p className="mb-1">
<span className="font-bold">مختصات: </span>
{selectedPosition[0].toFixed(6)},{" "}
{selectedPosition[1].toFixed(6)}
</p>
</div>
</Popup>
</Marker>
)}
</MapContainer>
{/* Hint / selected-address preview */}
<div
dir="rtl"
className="absolute left-3 right-3 bottom-3 z-[5] bg-WHITE/95 backdrop-blur rounded-xl shadow-md px-3.5 py-2.5"
>
{selectedAddress ? (
<>
<p className="text-R10 text-GRAY mb-0.5">موقعیت انتخابشده</p>
<p className="text-R12 font-bold line-clamp-2">
{selectedAddress}
</p>
</>
) : (
<p className="text-R12 font-medium text-BLACK2 flex items-center gap-1.5">
<MapPin size={16} className="text-RED shrink-0" />
برای انتخاب موقعیت، روی نقشه بزنید
</p>
)}
</div>
</div>

View File

@ -166,7 +166,7 @@ export function LoginRequiredDialog({
</div>
<div className="w-full flex justify-center">
<InputOTP
maxLength={4}
maxLength={6}
onComplete={(finalValue) => {
const convertedValue = convertPersianToEnglish(finalValue);
setOtpValue(convertedValue);
@ -177,17 +177,19 @@ export function LoginRequiredDialog({
setOtpValue(convertedValue);
}}
>
<InputOTPGroup dir="ltr" className="gap-4">
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPGroup dir="ltr" className="gap-1.5 sm:gap-3">
<InputOTPSlot index={0} className="!w-9 sm:!w-12" />
<InputOTPSlot index={1} className="!w-9 sm:!w-12" />
<InputOTPSlot index={2} className="!w-9 sm:!w-12" />
<InputOTPSlot index={3} className="!w-9 sm:!w-12" />
<InputOTPSlot index={4} className="!w-9 sm:!w-12" />
<InputOTPSlot index={5} className="!w-9 sm:!w-12" />
</InputOTPGroup>
</InputOTP>
</div>
<div className="gap-3 w-full flex flex-col items-center">
<Button
disabled={verifyOtpMutation.isPending || !(otpValue.length === 4)}
disabled={verifyOtpMutation.isPending || !(otpValue.length === 6)}
className="w-full"
size="xl"
variant="dark"

View File

@ -11,6 +11,7 @@ import placeholderImage from "~/assets/icons/product.png";
import placeholderImageDark from "~/assets/icons/product-dark.png";
import { useRootData } from "~/hooks/use-root-data";
import discountRed from "~/assets/icons/product/discount-red.svg";
import SellerChip from "./SellerChip";
interface MondrianList {
id?: string;
@ -21,6 +22,9 @@ interface MondrianList {
discountPercent?: number;
discountPrice?: number;
basePrice?: number;
sellerName?: string;
sellerLogo?: string | null;
sellerUsername?: string;
}
// Utility function to detect media type
@ -87,6 +91,24 @@ const MondrianProductList = ({
return () => window.removeEventListener("scroll", handleScroll);
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
// Sentinel-based trigger — more reliable than scroll math on desktop, where
// the footer can keep the window-scroll threshold from being reached.
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = loadMoreRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "600px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
// Memoize expensive filtering operations
const { oddProducts, evenProducts } = useMemo(() => {
const odd = products.filter((_, index) => index % 2 === 0);
@ -96,8 +118,17 @@ const MondrianProductList = ({
const oddHeight = useMemo(() => [220, 260, 320, 200, 280], []);
const evenHeight = useMemo(() => [280, 200, 260, 220, 320], []);
// Desktop masonry: distribute products across 4 columns (round-robin).
const desktopColumns = useMemo(() => {
const cols: MondrianList[][] = [[], [], [], []];
products.forEach((p, i) => cols[i % 4].push(p));
return cols;
}, [products]);
const desktopHeights = useMemo(() => [300, 360, 260, 340, 280, 320], []);
return (
<div className="px-4 flex flex-col w-full">
<div className="px-4 lg:px-0 flex flex-col w-full">
{!isCommonList ? (
<>
{isLoading ? (
@ -113,7 +144,8 @@ const MondrianProductList = ({
/>
) : (
<>
<div className="flex gap-1 w-full">
{/* Mobile / tablet: original two-column masonry (unchanged) */}
<div className="flex gap-1 w-full lg:hidden">
{/* Column 1 (Odd products - index 0, 2, ...) */}
<div className="flex flex-col gap-1 w-[calc(50%)]">
{oddProducts.map((product, index: number) => {
@ -146,7 +178,28 @@ const MondrianProductList = ({
})}
</div>
</div>
{hasNextPage && <MondrianSkeleton isMini={true} />}
{/* Desktop: 4-column masonry */}
<div className="hidden lg:flex gap-4 w-full">
{desktopColumns.map((col, ci) => (
<div key={ci} className="flex flex-col gap-4 flex-1 min-w-0">
{col.map((product, index) => (
<ProductCard
key={product.id}
product={product}
height={
desktopHeights[(ci + index) % desktopHeights.length]
}
storeId={storeId}
storePage={storePage}
/>
))}
</div>
))}
</div>
<div ref={loadMoreRef} className="h-px w-full" />
{isFetchingNextPage && <MondrianSkeleton isMini={true} />}
</>
)}
</>
@ -351,6 +404,7 @@ const ProductCard = ({
)}
{/* Overlay info - Airbnb style */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-2 pt-6 pointer-events-none">
<SellerChip name={product.sellerName} logo={product.sellerLogo} />
<div className="flex w-full justify-between gap-1">
{/* Title */}
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">

View File

@ -8,10 +8,14 @@ import homeFilled from "app/assets/icons/navbar/home-filled.svg";
import profileFilled from "app/assets/icons/navbar/profile-filled.svg";
import searchFilled from "app/assets/icons/navbar/search-filled.svg";
import { Link, useLocation, useParams } from "@remix-run/react";
import DesktopHeader from "~/components/desktop/DesktopHeader";
import DesktopFooter from "~/components/desktop/DesktopFooter";
import { detectPage } from "~/utils/helpers";
import { useRootData } from "~/hooks/use-root-data";
import { useCartCount } from "~/requestHandler/use-cart-hooks";
import { useSellerOrderCount } from "~/requestHandler/use-order-hooks";
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
import { PushPermissionBanner } from "~/components/notifications/PushPermissionBanner";
import settingIconFilled from "~/assets/icons/navbar/setting-filled.svg";
import settingIconOutline from "~/assets/icons/navbar/setting-outline.svg";
import documentFilled from "~/assets/icons/navbar/document-filled.svg";
@ -25,25 +29,71 @@ import productsOutline from "~/assets/icons/navbar/products-outline.svg";
*/
const MyLayout = React.memo(({ children }: { children: React.ReactNode }) => {
const location = useLocation();
// Desktop chrome is rolled out per-page. Only routes matched here get the
// full-width desktop layout; everything else keeps the current (mobile)
// experience at all widths so nothing regresses while we build the rest.
const p = location.pathname;
const isDesktopReady =
p === "/" ||
p === "/explore" ||
p.startsWith("/search/") ||
p.startsWith("/product/") ||
p.startsWith("/seller/") ||
p === "/collections" ||
p.startsWith("/collection/") ||
p === "/cart" ||
p.startsWith("/cart/") ||
p === "/sellers" ||
p === "/discounts" ||
p.startsWith("/profile");
// Auth pages get a full-width desktop canvas (so they can center their own
// card) but no shopping header/footer.
const isAuth = p === "/login" || p === "/logout";
// Seller dashboard: full-width canvas + its own sidebar (store.$storeId.tsx),
// no shopper header/footer.
const isStore = p.startsWith("/store/");
// Admin panel: full-screen own layout (admin.tsx sidebar), no shopper chrome
// and no shopper bottom nav.
const isAdmin = p === "/admin" || p.startsWith("/admin/");
const expandOnDesktop = isDesktopReady || isAuth || isStore || isAdmin;
const isThread = !!location.pathname.split("threads")[1];
return (
<div className="min-h-screen bg-gray-100 lg:bg-gray-200">
<div className="max-w-md mx-auto bg-WHITE min-h-screen shadow-lg lg:shadow-xl">
<div className="pb-[calc(50px+env(safe-area-inset-bottom))]">
<div className="min-h-screen bg-gray-100 lg:bg-gray-200 lg:flex lg:flex-col">
{isDesktopReady && <DesktopHeader />}
<div
className={`max-w-md mx-auto bg-WHITE min-h-screen shadow-lg lg:shadow-xl ${
expandOnDesktop
? "lg:max-w-none lg:mx-0 lg:min-h-0 lg:flex-1 lg:shadow-none"
: ""
}`}
>
<div
className={`pb-[calc(50px_+_env(safe-area-inset-bottom))] ${
expandOnDesktop ? "lg:pb-0" : ""
}`}
>
{children}
{location.pathname.split("threads")[1] ? (
{isThread || isAdmin ? (
<></>
) : (
<MobileBottomNavigation />
<MobileBottomNavigation hideOnDesktop={expandOnDesktop} />
)}
<PushPermissionBanner />
</div>
</div>
{isDesktopReady && <DesktopFooter />}
</div>
);
});
MyLayout.displayName = "Layout";
const MobileBottomNavigation = () => {
const MobileBottomNavigation = ({
hideOnDesktop = false,
}: {
hideOnDesktop?: boolean;
}) => {
const { theme } = useRootData();
const location = useLocation();
const currentPage = useMemo(
@ -54,6 +104,11 @@ const MobileBottomNavigation = () => {
const { user } = useRootData();
const [isStoreMode, setIsStoreMode] = useState(false);
const sellerOrderCount = useSellerOrderCount(!!isStoreMode);
const { data: badgeCounts } = useBadgeCounts();
// Buyer-facing account activity (unread chats + order status changes) is
// surfaced on the profile icon, since both live under /profile.
const accountUnread =
(badgeCounts?.chat || 0) + (badgeCounts?.orderUpdates || 0);
const [storeId, setStoreId] = useState<string | null>(null);
// Initialize state from sessionStorage on mount - only once
@ -88,6 +143,7 @@ const MobileBottomNavigation = () => {
path.match(/^\/store\/[^/]+\/products$/) || // Products list: /store/{id}/products
path.match(/^\/store\/[^/]+\/product\/[^/]+$/) || // Product detail: /store/{id}/products/{id}
path.match(/^\/store\/[^/]+\/edit$/) || // Edit store: /store/{id}/edit
path.match(/^\/store\/[^/]+\/team$/) || // Team management: /store/{id}/team
path.match(/^\/store\/add\/manual\/[^/]+$/) || // Add product: /store/add/manual/{id}
path.match(/^\/store\/create$/) || // Create store: /store/create
path.match(/^\/store\/authenticate\/instagram\/[^/]+$/) || // Instagram auth: /store/authenticate/instagram/{id}
@ -260,6 +316,8 @@ const MobileBottomNavigation = () => {
: theme === "dark"
? profileFilled
: profileOutline,
badge:
accountUnread > 0 && user?.access_token ? accountUnread : null,
},
{
link: "/cart",
@ -301,6 +359,7 @@ const MobileBottomNavigation = () => {
currentPage,
cartCount,
sellerOrderCount,
accountUnread,
user?.access_token,
isStoreMode,
storeId,
@ -309,31 +368,37 @@ const MobileBottomNavigation = () => {
return (
<>
<div className="h-[50px] z-20 bg-WHITE flex gap-[48px] px-[20px] fixed bottom-0 border-t w-full max-w-md mx-auto items-center justify-center pb-[env(safe-area-inset-bottom)] lg:left-1/2 lg:transform lg:-translate-x-1/2">
{navItems.map((item) => (
<Link
key={item.link}
to={item.link}
prefetch="intent"
className="relative"
>
{typeof item.icon === "string" ? (
<img
src={item.icon}
className="h-6 w-6"
alt={item.link}
loading="eager"
/>
) : (
item.icon
)}
{item.badge && (
<span className="absolute -top-1 -right-1 text-R12 ">
{item.badge}
</span>
)}
</Link>
))}
<div
className={`z-20 bg-WHITE fixed bottom-0 border-t w-full max-w-md mx-auto pb-[env(safe-area-inset-bottom)] lg:left-1/2 lg:transform lg:-translate-x-1/2 ${
hideOnDesktop ? "lg:hidden" : ""
}`}
>
<div className="h-[50px] flex gap-[48px] px-[20px] items-center justify-center">
{navItems.map((item) => (
<Link
key={item.link}
to={item.link}
prefetch="intent"
className="relative"
>
{typeof item.icon === "string" ? (
<img
src={item.icon}
className="h-6 w-6"
alt={item.link}
loading="eager"
/>
) : (
item.icon
)}
{item.badge ? (
<span className="absolute -top-1.5 -right-1.5 min-w-[16px] h-4 px-1 rounded-full bg-RED text-white text-[10px] font-bold flex items-center justify-center leading-none">
{item.badge > 99 ? "99+" : item.badge}
</span>
) : null}
</Link>
))}
</div>
</div>
</>
);

View File

@ -20,17 +20,19 @@ const ProfilePagesHeader = memo(function ProfilePagesHeader({
}, [navigate]);
return (
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
{!hideBackButton && (
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
)}
<p className="text-B16 font-bold">{title}</p>
{rightContent && (
<div className="absolute top-4 left-4">{rightContent}</div>
)}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] bg-WHITE w-full items-center justify-center border-b border-inner-border">
{!hideBackButton && (
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
)}
<p className="text-B16 font-bold">{title}</p>
{rightContent && (
<div className="absolute top-4 left-4">{rightContent}</div>
)}
</div>
</div>
);
});

View File

@ -42,7 +42,7 @@ const SearchTopBar: React.FC<SearchTopBarProps> = ({
};
return (
<div className="flex flex-col gap-4 py-4 px-4 w-full sticky top-0 z-30 bg-WHITE">
<div className="flex flex-col gap-4 pb-4 pt-[calc(1rem_+_env(safe-area-inset-top))] px-4 w-full sticky top-0 z-30 bg-WHITE">
<div className="flex items-center justify-center gap-2 rounded-[10px] w-full relative">
<AppInput
inputIcon={SearchIcon}

View File

@ -0,0 +1,32 @@
interface SellerChipProps {
name?: string;
logo?: string | null;
}
/**
* Small brand chip (logo + name) shown over a product card's image overlay.
* Renders nothing when there's no seller name. Falls back to the seller's
* initial when no logo is available (e.g. before the backend exposes it).
*/
export default function SellerChip({ name, logo }: SellerChipProps) {
if (!name) return null;
return (
<div className="flex items-center gap-1.5 mb-1 min-w-0">
<span className="w-[15px] h-[15px] rounded-full overflow-hidden border border-white/40 bg-white/20 shrink-0 grid place-items-center text-[8px] font-bold text-white">
{logo ? (
<img
src={logo}
alt=""
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
) : (
name.trim().charAt(0)
)}
</span>
<span className="text-white/85 text-[10.5px] truncate">{name}</span>
</div>
);
}

View File

@ -44,14 +44,16 @@ const SellerTilesGrid = ({
}
return (
<div className="w-full flex flex-col gap-4 px-4">
{/* Grid Container - 2x2 layout */}
<div className="grid grid-cols-2 gap-2">
{sellers.slice(0, 4).map((seller, index) => (
<div className="w-full flex flex-col gap-4 px-4 lg:px-0">
{/* 2x2 on mobile, single row of 5 on desktop */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-2 lg:gap-4">
{sellers.slice(0, 5).map((seller, index) => (
<Link
key={seller.id || index}
to={`/seller/${seller.sellerUsername}`}
className="relative aspect-square overflow-hidden rounded-lg group"
className={`relative aspect-square overflow-hidden rounded-lg group ${
index === 4 ? "hidden lg:block" : ""
}`}
>
{/* Background Image */}
{seller.imageUrl ? (
@ -65,6 +67,7 @@ const SellerTilesGrid = ({
<Store size={48} className="text-gray-400" />
</div>
)}
</Link>
))}
</div>

View File

@ -0,0 +1,248 @@
import { useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, Search, Loader2 } from "lucide-react";
import { cn } from "~/lib/utils";
/* ------------------------------ helpers ------------------------------ */
export const faNum = (n?: number | string | null) =>
n === null || n === undefined || n === ""
? "—"
: Number(n).toLocaleString("fa-IR");
export const faToman = (n?: number | null) =>
n === null || n === undefined ? "—" : `${Math.round(n).toLocaleString("fa-IR")} تومان`;
export const faDate = (iso?: string | null) => {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("fa-IR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return "—";
}
};
/** Debounce a rapidly-changing value (e.g. a search box). */
export function useDebouncedValue<T>(value: T, delay = 350): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
/* ---------------------------- status badge --------------------------- */
type Tone = "green" | "red" | "yellow" | "blue" | "gray";
const TONE: Record<Tone, string> = {
green: "bg-GREEN/10 text-GREEN",
red: "bg-RED/10 text-RED",
yellow: "bg-yellow-500/10 text-yellow-600",
blue: "bg-VITROWN_BLUE/10 text-VITROWN_BLUE",
gray: "bg-WHITE3 text-BLACK2",
};
export function StatusBadge({ label, tone }: { label: string; tone: Tone }) {
return (
<span
className={cn(
"inline-block px-2 py-0.5 rounded-full text-[11px] font-semibold whitespace-nowrap",
TONE[tone]
)}
>
{label}
</span>
);
}
/* ------------------------------- table ------------------------------- */
export interface Column<T> {
header: string;
render: (row: T) => React.ReactNode;
className?: string;
}
export function AdminTable<T>({
columns,
rows,
isLoading,
getRowKey,
emptyText = "موردی یافت نشد",
}: {
columns: Column<T>[];
rows: T[];
isLoading?: boolean;
getRowKey: (row: T) => string;
emptyText?: string;
}) {
return (
<div className="rounded-2xl border border-WHITE3 bg-WHITE overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right border-collapse">
<thead>
<tr className="bg-WHITE2 text-GRAY text-[12px]">
{columns.map((c, i) => (
<th
key={i}
className={cn("px-3 py-3 font-bold whitespace-nowrap", c.className)}
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={columns.length} className="py-12 text-center">
<Loader2 className="inline h-6 w-6 animate-spin text-GRAY" />
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="py-12 text-center text-GRAY text-[13px]"
>
{emptyText}
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={getRowKey(row)}
className="border-t border-WHITE3 hover:bg-WHITE2/60 transition-colors text-[13px]"
>
{columns.map((c, i) => (
<td
key={i}
className={cn("px-3 py-3 align-middle", c.className)}
>
{c.render(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
/* ---------------------------- pagination ----------------------------- */
export function AdminPagination({
page,
pageSize,
count,
onPageChange,
}: {
page: number;
pageSize: number;
count: number;
onPageChange: (page: number) => void;
}) {
const totalPages = Math.max(1, Math.ceil(count / pageSize));
const from = count === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(count, page * pageSize);
const btn =
"h-8 w-8 grid place-items-center rounded-lg border border-WHITE3 text-BLACK2 disabled:opacity-40 hover:bg-WHITE2 transition-colors cursor-pointer disabled:cursor-default";
return (
<div className="flex items-center justify-between mt-3 text-[13px] text-GRAY">
<span>
{faNum(from)}{faNum(to)} از {faNum(count)}
</span>
<div className="flex items-center gap-1.5">
<button
className={btn}
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="صفحه قبل"
>
<ChevronRight size={16} />
</button>
<span className="tabular-nums px-1">
{faNum(page)} / {faNum(totalPages)}
</span>
<button
className={btn}
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="صفحه بعد"
>
<ChevronLeft size={16} />
</button>
</div>
</div>
);
}
/* --------------------------- toolbar bits ---------------------------- */
export function AdminSearch({
value,
onChange,
placeholder = "جستجو…",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
<div className="relative flex-1 min-w-[180px] max-w-[320px]">
<Search
size={16}
className="absolute right-3 top-1/2 -translate-y-1/2 text-GRAY"
/>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-WHITE3 bg-WHITE pr-9 pl-3 py-2 text-[13px] outline-none focus:border-VITROWN_BLUE"
/>
</div>
);
}
export function AdminSelect({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13px] outline-none focus:border-VITROWN_BLUE cursor-pointer"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
export function AdminToolbar({ children }: { children: React.ReactNode }) {
return <div className="flex flex-wrap items-center gap-2 mb-3">{children}</div>;
}
export function AdminPageTitle({ children }: { children: React.ReactNode }) {
return (
<h1 className="text-[22px] lg:text-[26px] font-extrabold mb-4">{children}</h1>
);
}

View File

@ -0,0 +1,45 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "~/lib/utils";
interface KpiCardProps {
label: string;
value: string | number;
sub?: string;
icon?: LucideIcon;
accent?: "default" | "blue" | "green" | "red";
}
const ACCENT: Record<string, string> = {
default: "text-BLACK",
blue: "text-VITROWN_BLUE",
green: "text-GREEN",
red: "text-RED",
};
export function KpiCard({
label,
value,
sub,
icon: Icon,
accent = "default",
}: KpiCardProps) {
return (
<div className="rounded-2xl border border-WHITE3 bg-WHITE p-4">
<div className="flex items-center justify-between">
<span className="text-GRAY text-[13px]">{label}</span>
{Icon && <Icon size={18} className="text-GRAY shrink-0" />}
</div>
<p
className={cn(
"mt-2 text-[24px] font-extrabold tabular-nums leading-tight",
ACCENT[accent]
)}
>
{value}
</p>
{sub && <p className="text-[12px] text-GRAY mt-1">{sub}</p>}
</div>
);
}
export default KpiCard;

View File

@ -0,0 +1,61 @@
/**
* Dependency-free 14-day orders bar chart for the admin overview.
* (A richer charting lib can replace this in a later slice.)
*/
interface Point {
date: string;
orders: number;
revenue: number;
}
function faNum(n: number): string {
return Math.round(n).toLocaleString("fa-IR");
}
function dayLabel(iso: string): string {
// day-of-month in Persian digits
try {
const d = new Date(iso);
return d.toLocaleDateString("fa-IR", { day: "numeric" });
} catch {
return "";
}
}
export function OrdersBarChart({ data }: { data: Point[] }) {
const max = Math.max(1, ...data.map((d) => d.orders));
return (
<div className="rounded-2xl border border-WHITE3 bg-WHITE p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-[15px]">سفارشهای ۱۴ روز اخیر</h3>
<span className="text-[12px] text-GRAY">
مجموع: {faNum(data.reduce((s, d) => s + d.orders, 0))} سفارش
</span>
</div>
<div className="flex items-end gap-1.5 h-[160px]" dir="ltr">
{data.map((d) => (
<div
key={d.date}
className="flex-1 h-full flex flex-col items-center justify-end gap-1 group"
title={`${dayLabel(d.date)}${faNum(d.orders)} سفارش، ${faNum(
d.revenue
)} تومان`}
>
<div className="w-full flex-1 flex items-end">
<div
className="w-full rounded-t-md bg-VITROWN_BLUE/80 group-hover:bg-VITROWN_BLUE transition-colors min-h-[2px]"
style={{ height: `${(d.orders / max) * 100}%` }}
/>
</div>
<span className="text-[10px] text-GRAY tabular-nums">
{dayLabel(d.date)}
</span>
</div>
))}
</div>
</div>
);
}
export default OrdersBarChart;

View File

@ -14,7 +14,7 @@ export const CartHeader = ({ cart, step, setStep }: CartHeaderProps) => {
const navigate = useNavigate();
return (
<div
className={`flex flex-col gap-3 pt-4 sticky top-0 z-30 bg-WHITE w-full items-center justify-center ${step !== 0 ? "pb-4" : ""}`}
className={`flex flex-col gap-3 pt-[calc(1rem_+_env(safe-area-inset-top))] sticky top-0 z-30 bg-WHITE w-full items-center justify-center ${step !== 0 ? "pb-4" : ""}`}
>
<p className={"text-B16 font-bold px-4"}>
{step === 0
@ -29,7 +29,7 @@ export const CartHeader = ({ cart, step, setStep }: CartHeaderProps) => {
if (step > 0) setStep((p) => p - 1);
else safeGoBack(navigate);
}}
className="absolute top-4 right-4"
className="absolute top-[calc(1rem_+_env(safe-area-inset-top))] right-4"
/>
{cart && cart.items && cart.items.length > 0 && (
<>

View File

@ -1,4 +1,4 @@
import { Check, Trash } from "lucide-react";
import { Trash } from "lucide-react";
import { memo, useMemo } from "react";
import { QuantityControl } from "./QuantityControl";
import { formatPrice } from "../../utils/helpers";
@ -34,145 +34,113 @@ export const CartItem = memo(function CartItem({
);
}, [item.productVariant?.discountPrice, item.productVariant?.price]);
// Memoize video check
const isVideoMedia = useMemo(
() => isVideo(item.productVariant?.productImage || ""),
[item.productVariant?.productImage]
);
const color =
item.productVariant?.color?.value !== "UNSELECTED"
? item.productVariant?.color?.info?.detail || ""
: "";
const size =
item.productVariant?.size?.value !== "UNSELECTED"
? item.productVariant?.size?.info?.detail || ""
: "";
const effectivePrice =
item.productVariant?.discountPrice || item.productVariant?.price || "";
const inStock =
!!item?.productVariant?.stock && item.productVariant.stock > 0;
return (
<div
className={`flex p-4 gap-4 w-full flex-col ${hasBorder && "border-b"}`}
>
<div className="flex gap-2 w-full items-center">
{isVideoMedia ? (
<video
src={item.productVariant?.productImage}
className="rounded-[5px] w-[116px] h-[116px] object-cover"
muted
loop
autoPlay
playsInline
/>
) : (
<img
src={item.productVariant?.productImage}
alt={item.productVariant?.productTitle || ""}
className="rounded-[5px] w-[116px] h-[116px] object-cover"
loading="lazy"
/>
<div className={`flex gap-3 p-4 w-full ${hasBorder && "border-b"}`}>
{isVideoMedia ? (
<video
src={item.productVariant?.productImage}
className="rounded-[11px] w-[88px] h-[110px] object-cover shrink-0"
muted
loop
autoPlay
playsInline
/>
) : (
<img
src={item.productVariant?.productImage}
alt={item.productVariant?.productTitle || ""}
className="rounded-[11px] w-[88px] h-[110px] object-cover shrink-0"
loading="lazy"
/>
)}
<div className="flex flex-col flex-1 min-w-0">
<h4 className="text-B14 font-bold line-clamp-2">
{item.productVariant?.productTitle || ""}
</h4>
{/* Attribute chips */}
{(color || size) && (
<div className="flex gap-1.5 flex-wrap mt-2">
{color && (
<span className="inline-flex items-center gap-1.5 text-R12 text-BLACK2 bg-WHITE3 rounded-[7px] px-2 py-1">
<span
className="w-3.5 h-3.5 rounded-full border border-WHITE3"
style={{ backgroundColor: color }}
/>
رنگ
</span>
)}
{size && (
<span className="text-R12 text-BLACK2 bg-WHITE3 rounded-[7px] px-2 py-1">
سایز {size}
</span>
)}
</div>
)}
<div className="flex flex-col gap-2">
<p className="text-B14 font-bold">
{item.productVariant?.productTitle || ""}
</p>
<p className="text-R12 text-GRAY line-clamp-1">
کد کالا: {item.productVariant?.id || ""}
</p>
</div>
</div>
<ColorSelector
color={
item.productVariant?.color?.value !== "UNSELECTED"
? item.productVariant?.color?.info?.detail || ""
: ""
}
/>
<SizeSelector
size={
item.productVariant?.size?.value !== "UNSELECTED"
? item.productVariant?.size?.info?.detail || ""
: ""
}
/>
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">قیمت واحد :</p>
<p className="text-B14 font-bold">
{formatPrice(item.productVariant?.price || "")} تومان
</p>
</div>
{discountPercent ? (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<p className="text-B14 font-bold">مقدار تخفیف :</p>
<div className="bg-BLACK rounded-full text-WHITE text-R14 px-2 py-1">
{discountPercent}%
{/* Bottom row: stepper + price */}
{inStock ? (
<div className="flex items-center gap-3 mt-auto pt-3">
<QuantityControl
isPending={isLoading}
quantity={item.quantity}
maxQuantity={item.productVariant?.stock || 0}
onIncrease={() =>
onQuantityChange(item.id || "", item.quantity + 1)
}
onDecrease={() =>
onQuantityChange(item.id || "", item.quantity - 1)
}
onRemove={() => onRemoveItem(item.id || "")}
/>
<div className="ms-auto text-left">
<b className="text-B14 font-bold">
{formatPrice(effectivePrice)}
<span className="text-R10 font-semibold text-BLACK2"> ت</span>
</b>
{discountPercent ? (
<del className="block text-R10 text-GRAY">
{formatPrice(item.productVariant?.price || "")}
</del>
) : null}
</div>
</div>
<p className="text-B14 font-bold">
{formatPrice(
(
Number(item.productVariant?.price) -
Number(item.productVariant?.discountPrice)
).toString()
)}{" "}
تومان
</p>
</div>
) : null}
{item?.productVariant?.stock && item?.productVariant?.stock > 0 ? (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2"></div>
<QuantityControl
isPending={isLoading}
quantity={item.quantity}
maxQuantity={item.productVariant?.stock || 0}
onIncrease={() =>
onQuantityChange(item.id || "", item.quantity + 1)
}
onDecrease={() =>
onQuantityChange(item.id || "", item.quantity - 1)
}
onRemove={() => onRemoveItem(item.id || "")}
/>
</div>
) : (
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">محصول در حال حاضر موجود نیست</p>
<Button
variant="dark"
className="flex items-center gap-2 bg-red-500"
size="sm"
onClick={() => onRemoveItem(item.id || "")}
>
<Trash size={16} />
</Button>
</div>
)}
) : (
<div className="flex w-full items-center justify-between mt-auto pt-3">
<p className="text-R12 font-bold text-RED">
در حال حاضر موجود نیست
</p>
<Button
variant="dark"
className="flex items-center gap-2 bg-red-500"
size="sm"
onClick={() => onRemoveItem(item.id || "")}
>
<Trash size={16} />
</Button>
</div>
)}
</div>
</div>
);
});
// Color Selector Component
const ColorSelector = ({ color }: { color: string }) => (
<>
{color && (
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">رنگ :</p>
<div
className="w-10 h-10 rounded-[7px] flex items-center justify-center border border-BLACK"
style={{
backgroundColor: color,
}}
>
<Check size={24} className="text-WHITE" />
</div>
</div>
)}
</>
);
// Size Selector Component
const SizeSelector = ({ size }: { size: string }) => (
<>
{size && (
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">سایز :</p>
<p className="text-BLACK text-R12">{size}</p>
</div>
)}
</>
);

View File

@ -15,7 +15,7 @@ export const CartSummary = ({
step,
isLoading,
}: CartSummaryProps) => (
<div className="max-w-md mx-auto fixed flex flex-row gap-2 w-full bottom-[50px] left-0 right-0 border-t py-1 px-2 z-50 bg-WHITE">
<div className="max-w-md mx-auto fixed flex flex-row gap-2 w-full bottom-[50px] left-0 right-0 border-t py-1 px-2 z-50 bg-WHITE lg:static lg:max-w-none lg:mx-0 lg:bottom-auto lg:flex-col-reverse lg:gap-4 lg:border lg:border-WHITE3 lg:rounded-xl lg:p-5 lg:z-auto">
<Button
variant="dark"
size="lg"

View File

@ -0,0 +1,100 @@
import { Link } from "@remix-run/react";
import { Instagram, Send } from "lucide-react";
import logo from "~/assets/logo/SVG-07.svg";
type FooterLink = { label: string; to: string };
type FooterCol = { heading: string; links: FooterLink[] };
const COLUMNS: FooterCol[] = [
{
heading: "خرید",
links: [
{ label: "تازه‌ها", to: "/explore" },
{ label: "تخفیف‌ها", to: "/" },
{ label: "کالکشن‌ها", to: "/collections" },
{ label: "برندها", to: "/sellers" },
],
},
{
heading: "ویترون",
links: [
{ label: "درباره ما", to: "/profile/about" },
{ label: "سوالات متداول", to: "/profile/faq" },
],
},
{
heading: "پشتیبانی",
links: [
{ label: "راهنمای خرید", to: "/profile/faq" },
{ label: "پیگیری سفارش", to: "/profile/orders" },
],
},
];
/**
* Desktop-only footer. Hidden below the `lg` breakpoint.
*/
const DesktopFooter = () => {
return (
<footer className="hidden lg:block border-t border-WHITE3 bg-WHITE2">
<div className="max-w-[1320px] mx-auto px-8 pt-14 pb-8 grid grid-cols-[1.6fr_1fr_1fr_1fr] gap-10">
<div>
<div className="flex items-center gap-2.5">
<img src={logo} alt="ویترون" className="w-6 h-8 object-contain" />
<b className="text-[22px] font-extrabold text-VITROWN_BLUE">ویترون</b>
</div>
<p className="text-GRAY text-[13.5px] max-w-[280px] my-3 leading-8">
ویترین آنلاین برندهای پوشاک ایران. از بهترین فروشگاهها کشف کن، دنبال
کن و خرید کن.
</p>
<Link
to="/store/create"
className="inline-flex items-center justify-center h-[38px] px-3.5 rounded-[10px] bg-BLACK text-white text-[13.5px] font-bold hover:bg-black transition-colors"
>
فروشگاه خود را بسازید
</Link>
</div>
{COLUMNS.map((col) => (
<div key={col.heading}>
<h4 className="text-[14px] font-bold mb-3.5">{col.heading}</h4>
<ul className="flex flex-col gap-2.5">
{col.links.map((l) => (
<li key={l.label + l.to}>
<Link
to={l.to}
className="text-BLACK2 text-[13.5px] hover:text-BLACK transition-colors"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
<div className="max-w-[1320px] mx-auto px-8 py-5 pb-10 flex justify-between items-center border-t border-WHITE3 text-GRAY text-[12.5px]">
<span>© ۱۴۰۴ ویترون تمامی حقوق محفوظ است.</span>
<div className="flex gap-2.5">
<a
href="#"
aria-label="اینستاگرام"
className="w-[38px] h-[38px] rounded-full bg-WHITE border border-GRAY3 grid place-items-center text-BLACK2 hover:text-BLACK hover:border-BLACK transition-colors"
>
<Instagram size={18} />
</a>
<a
href="#"
aria-label="تلگرام"
className="w-[38px] h-[38px] rounded-full bg-WHITE border border-GRAY3 grid place-items-center text-BLACK2 hover:text-BLACK hover:border-BLACK transition-colors"
>
<Send size={18} />
</a>
</div>
</div>
</footer>
);
};
export default DesktopFooter;

View File

@ -0,0 +1,249 @@
import { Link, useLocation, useNavigate } from "@remix-run/react";
import { useState, useRef, useEffect, type FormEvent } from "react";
import { Search, Heart, ShoppingBag, User, Store } from "lucide-react";
import { useRootData } from "~/hooks/use-root-data";
import { useCartCount } from "~/requestHandler/use-cart-hooks";
import { useMyStores } from "~/requestHandler/use-seller-hooks";
import { useSearchAutocomplete } from "~/requestHandler/use-search-hooks";
import { useDebounce } from "~/hooks/useDebounce";
import { SunMoonToggle } from "~/components/SunMoonToggle";
import { AutocompleteSuggestionTypeEnum } from "../../../src/api/types";
import logo from "~/assets/logo/SVG-07.svg";
const NAV = [
{ label: "خانه", to: "/" },
{ label: "کاوش", to: "/explore" },
{ label: "برندها", to: "/sellers" },
{ label: "کالکشن‌ها", to: "/collections" },
];
/**
* Desktop-only top chrome (sticky header with brand, nav, search and actions).
* Hidden below the `lg` breakpoint; the mobile experience is unchanged.
*/
const DesktopHeader = () => {
const location = useLocation();
const navigate = useNavigate();
const { user, theme } = useRootData();
const cartCount = useCartCount();
const { data: myStores } = useMyStores();
// Mirror the mobile "ورود به فروشگاه من" flow: prefer a store the user owns,
// fall back to the first store they staff. If they don't manage any store,
// the CTA is hidden.
const targetStore =
myStores?.find((s) => s.role === "owner") ?? myStores?.[0];
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const isLoggedIn = !!user?.access_token;
const debouncedQ = useDebounce(q.trim(), 400);
const { data: autocompleteData, isLoading: isSuggesting } =
useSearchAutocomplete(debouncedQ);
const suggestions = autocompleteData?.suggestions ?? [];
const showSuggestions = open && q.trim().length > 0;
// Close the suggestions dropdown when clicking outside the search box.
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
const isActive = (to: string) =>
to === "/" ? location.pathname === "/" : location.pathname.startsWith(to);
const goToSearch = (term: string) => {
const t = term.trim();
if (!t) return;
setOpen(false);
navigate(`/search/${encodeURIComponent(t)}`);
};
const onSearch = (e: FormEvent) => {
e.preventDefault();
goToSearch(q);
};
const onSelectSuggestion = (sug: {
type: AutocompleteSuggestionTypeEnum;
text?: string;
name?: string;
id?: string;
}) => {
if (sug.type === AutocompleteSuggestionTypeEnum.Seller && sug.id) {
setOpen(false);
navigate(`/seller/${sug.id}`);
return;
}
goToSearch(sug.text || sug.name || "");
};
return (
<header className="hidden lg:block sticky top-0 z-40 bg-WHITE/85 backdrop-blur-xl border-b border-WHITE3">
<div className="max-w-[1320px] mx-auto h-[72px] px-8 flex items-center gap-7">
<Link to="/" className="flex items-center gap-2.5 shrink-0">
<img src={logo} alt="ویترون" className="w-7 h-9 object-contain" />
<b className="text-[23px] font-extrabold tracking-tight text-VITROWN_BLUE">
ویترون
</b>
</Link>
<nav className="flex items-center gap-1">
{NAV.map((n) => (
<Link
key={n.to}
to={n.to}
prefetch="intent"
className={`px-3.5 py-2 rounded-[10px] text-[14.5px] transition-colors ${
isActive(n.to)
? "text-BLACK font-bold"
: "text-BLACK2 font-semibold hover:bg-WHITE3 hover:text-BLACK"
}`}
>
{n.label}
</Link>
))}
</nav>
<div ref={searchRef} className="flex-1 max-w-[460px] mx-auto relative">
<form
onSubmit={onSearch}
className="flex items-center gap-2.5 bg-WHITE3 border border-transparent rounded-xl px-3.5 h-11 transition-colors focus-within:bg-WHITE focus-within:border-GRAY3"
>
<Search size={20} className="text-GRAY shrink-0" />
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
}}
name="q"
autoComplete="off"
placeholder="جستجوی محصول، برند یا فروشگاه…"
aria-label="جستجو"
className="w-full bg-transparent outline-none text-[14.5px] text-BLACK placeholder:text-GRAY"
/>
</form>
{showSuggestions ? (
<div
dir="rtl"
className="absolute top-full inset-x-0 mt-2 max-h-[360px] overflow-y-auto rounded-xl bg-WHITE border border-WHITE3 shadow-xl py-1.5 z-50"
>
{isSuggesting && suggestions.length === 0 ? (
<p className="px-4 py-3 text-[13px] text-GRAY">در حال جستجو...</p>
) : suggestions.length > 0 ? (
suggestions.map((sug, index) => {
const isSeller =
sug.type === AutocompleteSuggestionTypeEnum.Seller;
const label = isSeller ? sug.name : sug.text;
if (!label) return null;
return (
<button
key={`${sug.type}-${sug.id ?? sug.text ?? index}`}
type="button"
onClick={() => onSelectSuggestion(sug)}
className="w-full flex items-center gap-2.5 px-4 h-11 text-right hover:bg-WHITE2 transition-colors"
>
{isSeller ? (
<Store size={17} className="text-GRAY shrink-0" />
) : (
<Search size={17} className="text-GRAY shrink-0" />
)}
<span className="text-[14px] text-BLACK truncate">
{label}
</span>
{isSeller ? (
<span className="mr-auto text-[11px] text-GRAY shrink-0">
فروشگاه
</span>
) : null}
</button>
);
})
) : (
<button
type="button"
onClick={() => goToSearch(q)}
className="w-full flex items-center gap-2.5 px-4 h-11 text-right hover:bg-WHITE2 transition-colors"
>
<Search size={17} className="text-GRAY shrink-0" />
<span className="text-[14px] text-BLACK truncate">{q}</span>
</button>
)}
</div>
) : null}
</div>
<div className="flex items-center gap-2.5 shrink-0">
<SunMoonToggle theme={theme} />
{isLoggedIn ? (
<>
{targetStore ? (
<Link
to={`/store/${targetStore.username}`}
aria-label="ورود به فروشگاه من"
prefetch="intent"
className="inline-flex items-center gap-2 h-[42px] px-3.5 rounded-xl bg-WHITE3 text-BLACK text-[13.5px] font-bold hover:bg-WHITE2 transition-colors whitespace-nowrap"
>
<Store size={18} />
<span>فروشگاه من</span>
</Link>
) : null}
<Link
to="/profile/bookmarks"
aria-label="نشان‌شده‌ها"
prefetch="intent"
className="w-[42px] h-[42px] rounded-xl grid place-items-center text-BLACK hover:bg-WHITE3 transition-colors"
>
<Heart size={21} />
</Link>
<Link
to="/cart"
aria-label="سبد خرید"
prefetch="intent"
className="relative w-[42px] h-[42px] rounded-xl grid place-items-center text-BLACK hover:bg-WHITE3 transition-colors"
>
{cartCount > 0 ? (
<span className="absolute top-1 left-1 min-w-[17px] h-[17px] bg-RED text-white rounded-full text-[10.5px] font-bold grid place-items-center px-1 border-2 border-WHITE">
{cartCount}
</span>
) : null}
<ShoppingBag size={21} />
</Link>
<Link
to="/profile"
aria-label="پروفایل"
prefetch="intent"
className="w-[42px] h-[42px] rounded-xl grid place-items-center text-BLACK hover:bg-WHITE3 transition-colors"
>
<span className="w-9 h-9 rounded-full bg-WHITE3 grid place-items-center text-GRAY overflow-hidden">
<User size={20} />
</span>
</Link>
</>
) : (
<Link
to="/login"
prefetch="intent"
className="inline-flex items-center h-10 px-4 rounded-xl bg-VITROWN_BLUE text-white text-[14px] font-bold hover:bg-[#012f6b] transition-colors whitespace-nowrap"
>
ورود / ثبت نام
</Link>
)}
</div>
</div>
</header>
);
};
export default DesktopHeader;

View File

@ -41,7 +41,7 @@ const ExploreTopBar = memo(function ExploreTopBar({
}, [setSearchValue]);
return (
<div className="flex flex-col gap-4 py-4 px-4 w-full sticky top-0 z-30 bg-WHITE">
<div className="flex flex-col gap-4 pb-4 pt-[calc(1rem_+_env(safe-area-inset-top))] px-4 w-full sticky top-0 z-30 bg-WHITE">
<div className="flex items-center justify-center gap-2 rounded-[10px] w-full relative">
<AppInput
inputIcon={Search}

View File

@ -47,7 +47,7 @@ const MainSection = () => {
);
return (
<div className="flex flex-col gap-6 w-full pb-20">
<div className="flex flex-col gap-6 w-full pb-20 lg:max-w-[1320px] lg:mx-auto lg:px-8 lg:pt-6">
<TabContainer
activeTab={activeTab}
setActiveTab={setActiveTab}
@ -64,8 +64,8 @@ const MainSection = () => {
</p>
</div>
) : (
<div className="px-4 flex flex-col gap-6 w-full">
<div className="grid grid-cols-2 gap-4 w-full">
<div className="px-4 lg:px-0 flex flex-col gap-6 w-full">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 w-full">
{categories?.map((category, index) => {
return (
<Link
@ -75,7 +75,7 @@ const MainSection = () => {
>
<img
src={category.imageUrl}
className="w-full h-[120px] object-cover rounded-[15px]"
className="w-full h-[120px] lg:h-[180px] object-cover rounded-[15px]"
alt={category.categoryName}
/>
<p className="text-B16 text-center line-clamp-1 w-full font-bold text-white absolute bottom-2 left-2/4 -translate-x-1/2">
@ -104,6 +104,9 @@ const MainSection = () => {
basePrice: product.basePrice
? parseInt(product.basePrice)
: undefined,
sellerName: product.sellerName,
sellerLogo: product.sellerLogo,
sellerUsername: product.sellerUsername,
};
})}
fetchNextPage={fetchNextPage}

View File

@ -1,103 +1,74 @@
import { useState, useEffect, useMemo, memo, useCallback } from "react";
import { memo, useCallback } from "react";
import { Link } from "@remix-run/react";
import { Bell, Search, ShoppingBag } from "lucide-react";
import logo from "../../assets/logo/SVG-07.svg";
import { useCartCount } from "~/requestHandler/use-cart-hooks";
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
import { useRootData } from "~/hooks/use-root-data";
const HomePageTopBar = memo(() => {
const [activeTab, setActiveTab] = useState(0);
const tabs = useMemo(() => ["تخفیف‌ها", "برترین فروشگاه‌ها", "برای شما"], []);
// Scroll to section when tab is clicked
const scrollToSection = useCallback((sectionId: string) => {
const section = document.getElementById(sectionId);
if (section) {
section.scrollIntoView({ behavior: "smooth" });
}
}, []);
// Update active tab based on scroll position with throttling
useEffect(() => {
let ticking = false;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
const section1 = document.getElementById("section1");
const section2 = document.getElementById("section2");
const section3 = document.getElementById("section3");
if (section1 && section2 && section3) {
const scrollPosition = window.scrollY;
if (scrollPosition >= section3.offsetTop - 100) {
setActiveTab(2);
} else if (scrollPosition >= section2.offsetTop - 100) {
setActiveTab(1);
} else {
setActiveTab(0);
}
}
ticking = false;
});
ticking = true;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const { user } = useRootData();
const isLoggedIn = !!user?.access_token;
const cartCount = useCartCount();
const { data: badgeCounts } = useBadgeCounts();
const accountUnread =
(badgeCounts?.chat || 0) + (badgeCounts?.orderUpdates || 0);
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const handleTabClick = useCallback(
(index: number) => {
setActiveTab(index);
scrollToSection(`section${index + 1}`);
},
[scrollToSection]
);
return (
<div className="flex gap-2 flex-row h-[48px] w-full bg-WHITE items-center px-4 sticky top-0 z-30">
<div className="flex relative items-center gap-2 justify-center w-full">
<div
className="absolute right-0"
onClick={() => scrollToTop()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
scrollToTop();
}
}}
<div className="bg-WHITE sticky top-0 z-30 pt-[env(safe-area-inset-top)]">
<div className="px-4 pt-2.5 pb-3 flex flex-col gap-3">
{/* Brand row */}
<div className="flex items-center gap-2.5">
<button
onClick={scrollToTop}
className="flex items-center gap-2"
aria-label="ویترون"
>
<img src={logo} className="w-7 h-8 object-contain" alt="" />
<b className="text-[21px] font-extrabold text-BLACK tracking-tight">
ویترون
</b>
</button>
<span className="flex-1" />
<Link
to="/profile"
prefetch="intent"
aria-label="اعلان‌ها"
className="relative w-10 h-10 rounded-xl bg-WHITE2 grid place-items-center text-BLACK"
>
{isLoggedIn && accountUnread > 0 ? (
<span className="absolute top-2 left-2.5 w-2 h-2 rounded-full bg-RED border-[1.5px] border-WHITE2" />
) : null}
<Bell size={21} />
</Link>
<Link
to="/cart"
prefetch="intent"
aria-label="سبد خرید"
className="relative w-10 h-10 rounded-xl bg-WHITE2 grid place-items-center text-BLACK"
>
{isLoggedIn && cartCount > 0 ? (
<span className="absolute top-1 left-1 min-w-[16px] h-4 px-1 rounded-full bg-RED text-white text-[10px] font-bold grid place-items-center border-[1.5px] border-WHITE2">
{cartCount > 99 ? "99+" : cartCount}
</span>
) : null}
<ShoppingBag size={21} />
</Link>
</div>
{/* Search-first */}
<Link
to="/explore"
prefetch="intent"
className="flex items-center gap-2.5 h-[46px] px-3.5 bg-WHITE3 rounded-2xl text-GRAY"
>
<img src={logo} className="w-8 h-8 object-contain" alt="logo" />
</div>
<div className="flex gap-4 justify-center">
{tabs.map((tab, index) => (
<div
key={index}
onClick={() => handleTabClick(index)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleTabClick(index);
}
}}
role="button"
tabIndex={index}
className="flex items-center"
>
<p
className={`text-B12 border-b-[2px] transition-all duration-300 ease-in-out py-1 ${
index === activeTab
? "text-BLACK border-BLACK font-bold"
: "text-GRAY border-transparent"
}`}
>
{tab}
</p>
</div>
))}
</div>
<Search size={19} className="shrink-0" />
<span className="text-R14">جستجوی محصول، برند یا فروشگاه</span>
</Link>
</div>
</div>
);

View File

@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { Bell, X } from "lucide-react";
import { useRootData } from "~/hooks/use-root-data";
import { usePushNotifications } from "~/hooks/usePushNotifications";
const DISMISS_KEY = "push_banner_dismissed";
/**
* One-time, dismissible bar prompting logged-in users to enable web push.
* Shown only when: logged in, push is supported, permission not yet decided,
* and the user hasn't dismissed or already subscribed. A permanent toggle in
* settings lets them change their mind later.
*/
export function PushPermissionBanner() {
const { user } = useRootData();
const { supported, permission, isSubscribed, subscribe, busy } =
usePushNotifications();
const [visible, setVisible] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const dismissed = localStorage.getItem(DISMISS_KEY) === "1";
setVisible(
!!user?.access_token &&
supported &&
permission === "default" &&
!isSubscribed &&
!dismissed
);
}, [user?.access_token, supported, permission, isSubscribed]);
if (!visible) return null;
const dismiss = () => {
localStorage.setItem(DISMISS_KEY, "1");
setVisible(false);
};
const enable = async () => {
const ok = await subscribe();
// Whether granted or denied, don't nag again from the banner.
localStorage.setItem(DISMISS_KEY, "1");
setVisible(false);
return ok;
};
return (
<div className="fixed bottom-[calc(58px+env(safe-area-inset-bottom))] lg:bottom-4 left-1/2 -translate-x-1/2 z-40 w-[92%] max-w-md lg:max-w-sm">
<div className="flex items-center gap-3 rounded-2xl bg-BLACK text-white shadow-lg px-4 py-3">
<Bell size={20} className="shrink-0" />
<p className="text-[13px] leading-5 flex-1">
اعلانها را فعال کنید تا از پیامها و وضعیت سفارشها باخبر شوید.
</p>
<button
onClick={enable}
disabled={busy}
className="shrink-0 rounded-xl bg-VITROWN_BLUE px-3 py-1.5 text-[13px] font-bold disabled:opacity-60"
>
{busy ? "..." : "فعال‌سازی"}
</button>
<button onClick={dismiss} aria-label="بستن" className="shrink-0 opacity-70">
<X size={18} />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
import { usePushNotifications } from "~/hooks/usePushNotifications";
/**
* A settings row with a switch to enable/disable web push notifications.
* Mirrors the existing theme-toggle row styling on the settings pages.
*/
export function PushSettingToggle({ className = "" }: { className?: string }) {
const { supported, permission, isSubscribed, subscribe, disable, busy } =
usePushNotifications();
const blocked = permission === "denied";
const checked = isSubscribed && permission === "granted";
const onToggle = () => {
if (busy || blocked) return;
if (checked) {
disable();
} else {
subscribe();
}
};
return (
<div className={className}>
<div className="flex w-full items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-R12 lg:text-B16 font-bold">اعلانها</p>
<p className="text-R10 lg:text-R12 text-GRAY">
{blocked
? "اعلان‌ها در مرورگر مسدود شده‌اند. از تنظیمات مرورگر اجازه دهید."
: !supported
? "مرورگر شما از اعلان پشتیبانی نمی‌کند."
: "دریافت اعلان برای پیام‌ها و وضعیت سفارش‌ها"}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
disabled={busy || blocked || !supported}
className="sr-only peer"
aria-label="اعلان‌ها"
/>
<div className="w-11 h-6 bg-WHITE2 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-WHITE after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-WHITE after:border-GRAY3 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-VITROWN_BLUE border peer-disabled:opacity-50"></div>
</label>
</div>
</div>
);
}

View File

@ -62,8 +62,8 @@ interface AddToCartButtonProps {
const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
<Button
variant="dark"
size="lg"
className="font-bold flex items-center gap-2 bg-VITROWN_BLUE text-white"
size="xl"
className="flex-1 h-[52px] rounded-[14px] text-B14 font-bold flex items-center justify-center gap-2 bg-VITROWN_BLUE text-white"
disabled={isOutOfStock}
onClick={onClick}
>
@ -71,7 +71,7 @@ const AddToCartButton = ({ isOutOfStock, onClick }: AddToCartButtonProps) => (
<span className="text-B14 font-bold">ناموجود</span>
) : (
<>
<ShoppingCart size={24} />
<ShoppingCart size={22} />
افزودن به سبد خرید
</>
)}
@ -194,19 +194,12 @@ export const BottomBar = ({
return (
<>
<div className="max-w-md mx-auto fixed gap-2 bottom-[calc(50px+env(safe-area-inset-bottom))] left-0 right-0 border-t border-inner-border bg-WHITE p-4 z-50 flex flex-row">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col gap-1">
<AddToCartButton
isOutOfStock={isOutOfStock}
onClick={handleAddToCart}
/>
</div>
<PriceDisplay
currentPrice={currentPrice}
currentDiscountPrice={currentDiscountPrice}
/>
</div>
<div className="max-w-md mx-auto fixed gap-3 items-center bottom-[calc(50px+env(safe-area-inset-bottom))] left-0 right-0 border-t border-inner-border bg-WHITE p-4 z-50 flex flex-row lg:static lg:max-w-none lg:mx-0 lg:border lg:rounded-xl lg:mt-6 lg:z-auto">
<PriceDisplay
currentPrice={currentPrice}
currentDiscountPrice={currentDiscountPrice}
/>
<AddToCartButton isOutOfStock={isOutOfStock} onClick={handleAddToCart} />
</div>
<CartDrawer

View File

@ -1,5 +1,3 @@
import { Check } from "lucide-react";
interface ColorSelectorProps {
colors: string[] | undefined;
selectedColor: string | null;
@ -12,24 +10,23 @@ export const ColorSelector = ({
setSelectedColor,
}: ColorSelectorProps) => {
return (
<div className="flex flex-col gap-4 mt-10 px-4">
<div className="flex flex-col gap-4 mt-10 px-4 lg:px-0">
<p className="text-B18 font-bold">انتخاب رنگ</p>
<div className="gap-4 flex">
{colors?.map((enColor: string, index: number) => {
const isSelected = selectedColor === enColor;
return (
<div
<button
key={index}
className="w-10 border h-10 rounded-md flex items-center justify-center"
type="button"
aria-label={`رنگ ${index + 1}`}
aria-pressed={isSelected}
className={`w-9 h-9 rounded-full border-2 border-WHITE transition-shadow ring-offset-2 ring-offset-WHITE ${
isSelected ? "ring-2 ring-BLACK" : "ring-1 ring-WHITE3"
}`}
style={{ backgroundColor: enColor }}
onClick={() => setSelectedColor(enColor)}
onKeyDown={() => {}}
role="button"
tabIndex={0}
>
{selectedColor === enColor && (
<Check size={24} className="text-WHITE" />
)}
</div>
/>
);
})}
</div>

View File

@ -19,11 +19,14 @@ export const ContactButton = ({ product }: { product: ProductDetail }) => {
return;
}
if (!product.seller?.id) return;
createChatThread(product.seller.id, {
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
});
createChatThread(
{ participantId: product.seller.id, productId: product.id },
{
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
}
);
};
return (
<>

View File

@ -4,7 +4,6 @@ import {
MessageSquare,
Send,
Share2,
Star,
X,
Loader2,
Copy,
@ -20,8 +19,6 @@ import {
} from "~/components/ui/drawer";
import { Skeleton } from "~/components/ui/skeleton";
import {
useServiceGetProductRating,
useServiceGetComments,
useServiceBookmarkProduct,
useServiceDeleteBookmark,
useServiceCheckBookmark,
@ -62,9 +59,6 @@ export const ProductActions = ({ productId }: { productId: string }) => {
const { toast } = useToast();
const { user } = useRootData();
const { data: ratingData, isLoading: ratingLoading } =
useServiceGetProductRating(productId || "");
const { data: commentsData } = useServiceGetComments(1, productId || "");
const { data: bookmarkData, isLoading: isCheckingBookmark } =
useServiceCheckBookmark(productId || "");
const { mutate: bookmarkProduct, isPending: isBookmarking } =
@ -73,8 +67,6 @@ export const ProductActions = ({ productId }: { productId: string }) => {
useServiceDeleteBookmark();
const isBookmarked = bookmarkData?.bookmarked === true;
const averageRating = ratingData?.averageRating || 0;
const commentCount = commentsData?.count || 0;
const handleBookmark = () => {
// Check if user is logged in
@ -160,53 +152,58 @@ export const ProductActions = ({ productId }: { productId: string }) => {
}
};
const actionBtn =
"flex items-center justify-center cursor-pointer transition-colors lg:w-10 lg:h-10 lg:rounded-full lg:border lg:border-WHITE3 lg:hover:bg-WHITE2";
return (
<>
<div className="flex w-full mt-4 px-4 justify-between">
<div className="flex gap-8">
{isCheckingBookmark ? (
<Skeleton className="h-6 w-6 rounded-full" />
) : isBookmarked ? (
<>
{isDeletingBookmark ? (
<div className="flex w-full mt-4 px-4 lg:mt-0 lg:px-0">
<div className="flex gap-8 lg:gap-2.5">
<button
type="button"
onClick={handleBookmark}
aria-label="ذخیره"
className={actionBtn}
>
{isCheckingBookmark ? (
<Skeleton className="h-6 w-6 rounded-full" />
) : isBookmarked ? (
isDeletingBookmark ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Bookmark
size={20}
className="text-PRIMARY fill-current cursor-pointer"
onClick={handleBookmark}
/>
)}
</>
) : (
<>
{isBookmarking ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<BookmarkMinus
size={20}
className="cursor-pointer"
onClick={handleBookmark}
/>
)}
</>
)}
<Share2
size={20}
className="cursor-pointer"
<Bookmark size={20} className="text-PRIMARY fill-current" />
)
) : isBookmarking ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<BookmarkMinus size={20} />
)}
</button>
<button
type="button"
onClick={() => setIsShareDrawerOpen(true)}
/>
<Layers
size={20}
className="cursor-pointer"
aria-label="اشتراک‌گذاری"
className={actionBtn}
>
<Share2 size={20} />
</button>
<button
type="button"
onClick={handleAddToSet}
/>
aria-label="افزودن به ست"
className={actionBtn}
>
<Layers size={20} />
</button>
<div className="relative">
<Sparkles
size={20}
className="cursor-pointer animate-pulse text-PRIMARY"
<button
type="button"
onClick={handleOpenAIModal}
/>
aria-label="پرو با هوش مصنوعی"
className={actionBtn}
>
<Sparkles size={20} className="animate-pulse text-PRIMARY" />
</button>
{showAIFeatureTooltip && (
<div className="absolute bottom-full right-0 mb-2 z-50 animate-bounce">
<div className="relative bg-gradient-to-r from-purple-600 to-pink-500 text-WHITE px-3 py-2 rounded-lg shadow-xl whitespace-nowrap border border-white/20">
@ -232,17 +229,6 @@ export const ProductActions = ({ productId }: { productId: string }) => {
)}
</div>
</div>
<div className="flex gap-3 items-center">
<div className="flex gap-1 items-center">
{!ratingLoading && averageRating > 0 && (
<>
<p className="text-B12 font-bold text-GRAY">({commentCount})</p>
<p className="text-B12 font-bold">{averageRating.toFixed(1)}</p>
<Star className="text-BLACK fill-current" size={20} />
</>
)}
</div>
</div>
</div>
<Drawer open={isShareDrawerOpen} onOpenChange={setIsShareDrawerOpen}>

View File

@ -0,0 +1,33 @@
import { AlignRight } from "lucide-react";
interface ProductDescriptionProps {
description?: string | null;
}
/**
* Full product description block. Preserves the seller's line breaks (bullet
* lists from Instagram/WooCommerce imports come in as `\n`-separated lines),
* with a proper heading so it reads as its own section on the detail page.
*
* Renders nothing when no description is set the section header shouldn't
* appear on products the seller hasn't filled out yet.
*/
export function ProductDescription({ description }: ProductDescriptionProps) {
const body = (description || "").trim();
if (!body) return null;
return (
<section className="w-full px-4 lg:px-0 my-6">
<div className="flex items-center gap-2 mb-3">
<AlignRight size={18} className="text-BLACK" />
<h2 className="text-B16 lg:text-B20 font-bold">توضیحات محصول</h2>
</div>
<div
className="text-R14 lg:text-[15px] leading-8 text-BLACK2 whitespace-pre-line"
dir="rtl"
>
{body}
</div>
</section>
);
}

View File

@ -1,9 +1,9 @@
import { useState, useEffect, useMemo, memo } from "react";
import {
ProductHeader,
ProductImageCarousel,
ProductActions,
ProductInfo,
ProductDescription,
ColorSelector,
SizeSelector,
ContactButton,
@ -11,11 +11,40 @@ import {
BottomBar,
} from "./index";
import { ProductDetail as ProductDetailType } from "../../../src/api/types";
import { Eye, EyeOff } from "lucide-react";
import {
ArrowRight,
Eye,
EyeOff,
Heart,
Sparkles,
Star,
ShoppingCart,
Truck,
RotateCcw,
ShieldCheck,
MessageCircle,
} from "lucide-react";
import { Button } from "../ui/button";
import { useNavigate } from "@remix-run/react";
import { useNavigate, Link } from "@remix-run/react";
import { useSimilarProductsInfinite } from "~/requestHandler/use-product-hooks";
import MondrianProductList from "../MondrianProductList";
import SellerLogo from "../SellerLogo";
import {
useServiceGetProductRating,
useServiceGetComments,
useServiceCheckBookmark,
useServiceBookmarkProduct,
useServiceDeleteBookmark,
} from "~/utils/RequestHandler";
import { useAddToCart } from "~/requestHandler/use-cart-hooks";
import { useCreateChatThread } from "~/requestHandler/use-chat-hooks";
import { useRecordProductView } from "~/requestHandler/use-view-hooks";
import { findVariantId, formatPrice, safeGoBack } from "~/utils/helpers";
import { useToast } from "~/hooks/use-toast";
import { useRootData } from "~/hooks/use-root-data";
import { LoginRequiredDialog } from "../LoginRequiredDialog";
import AIPromoteModal from "../AIPromoteModal";
import { SizeGuideDialog } from "./SizeGuideDialog";
interface ProductDetailViewProps {
product: ProductDetailType;
@ -50,6 +79,10 @@ export const ProductDetailView = memo(function ProductDetailView({
setProductActiveStatus(product.isActive || false);
}, [product.isActive]);
// Record a product view for the recommendation system (skip the owner's own
// product; only tracks authenticated users, deduped per product).
useRecordProductView(product.id, { enabled: !isOwner });
// Memoize available colors and sizes
const { availableColors, availableSizes } = useMemo(
() => ({
@ -98,25 +131,63 @@ export const ProductDetailView = memo(function ProductDetailView({
[stockedVariants.length, inStockColors.length, inStockSizes.length]
);
const [selectedColor, setSelectedColor] = useState<string | null>(
hasColorVariants && inStockColors.length > 0
? (inStockColors[0].info as unknown as { detail: string })?.detail
: null
);
// Effective pricing for a variant: original price + sale price (or null when
// there's no real discount). Mirrors how the list/tile serializer computes
// discount_price so the detail page stays consistent with the tiles.
const pricingOf = (v?: (typeof stockedVariants)[number] | null) => ({
price: v?.price || product.basePrice,
discountPrice:
v?.discountPrice &&
v.discountPrice !== "0" &&
// Only surface as a "discount" when it's a real saving. A value equal
// to (or somehow larger than) `price` is stale/mis-entered state, not a
// discount — sellers occasionally end up with matching numbers when
// they set "no discount" on edit, and rendering a strikethrough on that
// fakes a promo the seller didn't intend.
Number(v.discountPrice) < Number(v.price)
? v.discountPrice
: null,
});
const [selectedSize, setSelectedSize] = useState<string | null>(
hasSizeVariants && inStockSizes.length > 0 ? inStockSizes[0].value : null
);
// Cheapest in-stock variant by final (post-discount) price — this is the
// "starting from" price the product tile shows. Used as the default
// selection and as the fallback when no exact color/size combo is matched.
const defaultVariant = useMemo(() => {
const finalOf = (v: (typeof stockedVariants)[number]) => {
const pr = pricingOf(v);
return Number(pr.discountPrice ?? pr.price ?? 0);
};
const pool =
stockedVariants.length > 0 ? stockedVariants : product.variants ?? [];
if (pool.length === 0) return null;
return pool.reduce((best, v) => (finalOf(v) < finalOf(best) ? v : best));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stockedVariants, product.variants]);
const [selectedColor, setSelectedColor] = useState<string | null>(() => {
if (!hasColorVariants || inStockColors.length === 0) return null;
const colorVal = defaultVariant?.resolvedAttributes?.COLOR;
const match =
inStockColors.find((c) => c.value === colorVal) ?? inStockColors[0];
return (match.info as unknown as { detail: string })?.detail ?? null;
});
const [selectedSize, setSelectedSize] = useState<string | null>(() => {
if (!hasSizeVariants || inStockSizes.length === 0) return null;
return defaultVariant?.resolvedAttributes?.SIZE ?? inStockSizes[0].value;
});
// Handle product status change
const handleProductStatusChange = (newStatus: boolean) => {
setProductActiveStatus(newStatus);
};
const [currentPrice, setCurrentPrice] = useState(product.basePrice);
const [currentPrice, setCurrentPrice] = useState(
() => pricingOf(defaultVariant).price
);
const [currentDiscountPrice, setCurrentDiscountPrice] = useState<
string | null
>(null);
>(() => pricingOf(defaultVariant).discountPrice);
const [currentStock, setCurrentStock] = useState<number | null>(null);
useEffect(() => {
@ -152,35 +223,28 @@ export const ProductDetailView = memo(function ProductDetailView({
});
if (matchingVariant) {
setCurrentPrice(matchingVariant.price || product.basePrice);
setCurrentDiscountPrice(
matchingVariant.discountPrice &&
matchingVariant.discountPrice !== "0" &&
Number(matchingVariant.discountPrice) !==
Number(matchingVariant.price)
? matchingVariant.discountPrice
: null
);
const pr = pricingOf(matchingVariant);
setCurrentPrice(pr.price);
setCurrentDiscountPrice(pr.discountPrice);
setCurrentStock(matchingVariant.stock || null);
} else if (
!hasColorVariants &&
!hasSizeVariants &&
stockedVariants.length > 0
) {
// No color/size dimensions → use the first available variant.
const firstVariant = stockedVariants[0];
const pr = pricingOf(firstVariant);
setCurrentPrice(pr.price);
setCurrentDiscountPrice(pr.discountPrice);
setCurrentStock(firstVariant.stock || null);
} else {
// If no matching variant found, but we have variants and no color/size requirements
// Use the first available variant (for products with no color/size variants)
if (!hasColorVariants && !hasSizeVariants && stockedVariants.length > 0) {
const firstVariant = stockedVariants[0];
setCurrentPrice(firstVariant.price || product.basePrice);
setCurrentDiscountPrice(
firstVariant.discountPrice &&
firstVariant.discountPrice !== "0" &&
Number(firstVariant.discountPrice) !== Number(firstVariant.price)
? firstVariant.discountPrice
: null
);
setCurrentStock(firstVariant.stock || null);
} else {
setCurrentPrice(product.basePrice);
setCurrentDiscountPrice(null);
setCurrentStock(null);
}
// Color/size not fully matched yet: show the cheapest variant's price +
// sale (same as the product tile) instead of basePrice with no discount.
const pr = pricingOf(defaultVariant);
setCurrentPrice(pr.price);
setCurrentDiscountPrice(pr.discountPrice);
setCurrentStock(null);
}
}, [
selectedColor,
@ -190,110 +254,489 @@ export const ProductDetailView = memo(function ProductDetailView({
hasColorVariants,
hasSizeVariants,
stockedVariants,
defaultVariant,
]);
const navigate = useNavigate();
// --- Desktop buy/actions state & data (overlays + info column) ---
const { user } = useRootData();
const { toast } = useToast();
const [qty, setQty] = useState(1);
const [isAIOpen, setIsAIOpen] = useState(false);
const [isLoginOpen, setIsLoginOpen] = useState(false);
const [isSizeGuideOpen, setIsSizeGuideOpen] = useState(false);
const hasSizeChart = !!(
product.sizeChart &&
product.sizeChart.columns?.length &&
product.sizeChart.rows?.length
);
const { data: ratingData } = useServiceGetProductRating(product.id || "");
const { data: commentsData } = useServiceGetComments(1, product.id || "");
const { data: bookmarkData } = useServiceCheckBookmark(product.id || "");
const { mutate: bookmarkProduct } = useServiceBookmarkProduct();
const { mutate: deleteBookmark } = useServiceDeleteBookmark();
const addToCartMutation = useAddToCart();
const { mutate: createChatThread } = useCreateChatThread();
const isBookmarked = bookmarkData?.bookmarked === true;
const avgRating = ratingData?.averageRating || 0;
const reviewCount = commentsData?.count || 0;
const isOutOfStock = currentStock === null || currentStock <= 0;
const selectedColorValue =
inStockColors.find(
(color) =>
(color.info as unknown as { detail: string })?.detail === selectedColor
)?.value || null;
const handleToggleBookmark = () => {
if (!user?.access_token) {
setIsLoginOpen(true);
return;
}
if (isBookmarked) deleteBookmark(product.id || "");
else bookmarkProduct(product.id || "");
};
const handleDesktopAddToCart = async () => {
if (!user?.access_token) {
setIsLoginOpen(true);
return;
}
if (isOutOfStock) return;
const variantId = findVariantId(product, selectedColorValue, selectedSize);
if (!variantId) {
toast({
title: "خطا",
description: "محصول با ویژگی‌های انتخابی شما یافت نشد",
variant: "destructive",
});
return;
}
try {
await addToCartMutation.mutateAsync({ variantId, quantity: qty });
toast({
title: "سبد خرید",
description: "محصول با موفقیت به سبد خرید اضافه شد",
});
} catch (e) {
toast({
title: "خطا",
description: "افزودن به سبد خرید با مشکل مواجه شد",
variant: "destructive",
});
}
};
const handleChat = () => {
if (!user?.access_token) {
setIsLoginOpen(true);
return;
}
if (!product.seller?.id) return;
createChatThread(
{ participantId: product.seller.id, productId: product.id },
{
onSuccess: (t) => navigate(`/profile/threads/${t.id}`),
}
);
};
return (
<div className="flex flex-col pb-20">
{!isOwner && <ProductHeader product={product} />}
<ProductImageCarousel
images={product.imageUrls || []}
productId={product.id}
storeId={product.seller?.username}
isOwner={isOwner}
borderRadius={0}
isActive={productActiveStatus}
controlsIsOn={true}
onStatusChange={handleProductStatusChange}
isChangeStatusDialogOpen={isChangeStatusDialogOpen}
setIsChangeStatusDialogOpen={setIsChangeStatusDialogOpen}
/>
{/* Inactive Product Status */}
{!productActiveStatus && isOwner ? (
<div className="flex gap-1 px-4 w-full justify-between items-center mt-2">
<div className="flex items-center w-full gap-2 p-2 bg-WHITE3 rounded-lg">
<EyeOff size={16} className="text-BLACK2 font-bold" />
<span className="text-B12 font-bold text-BLACK2">
این محصول در حال حاضر غیرفعال است
</span>
<div className="flex flex-col pb-20 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pb-12">
{/* Desktop breadcrumb */}
<nav className="hidden lg:flex items-center gap-2 text-[13px] text-GRAY mt-4 mb-4">
<Link to="/" className="hover:text-BLACK">
خانه
</Link>
<span>/</span>
{product.seller?.username ? (
<>
<Link
to={`/seller/${product.seller.username}`}
className="hover:text-BLACK"
>
{product.seller.username}
</Link>
<span>/</span>
</>
) : null}
<span className="text-BLACK truncate max-w-[320px]">
{product.title}
</span>
</nav>
{/* Top section: gallery | info (two columns on desktop) */}
<div className="lg:grid lg:grid-cols-2 lg:gap-10 lg:items-start">
{/* Gallery */}
<div className="relative lg:rounded-[14px] lg:overflow-hidden lg:shadow-sm lg:sticky lg:top-[88px] lg:aspect-[3/4] lg:bg-WHITE2 lg:[&_img]:!object-contain lg:[&_video]:!object-contain">
<ProductImageCarousel
images={product.imageUrls || []}
productId={product.id}
storeId={product.seller?.username}
isOwner={isOwner}
borderRadius={0}
isActive={productActiveStatus}
controlsIsOn={true}
showArrows={true}
onStatusChange={handleProductStatusChange}
isChangeStatusDialogOpen={isChangeStatusDialogOpen}
setIsChangeStatusDialogOpen={setIsChangeStatusDialogOpen}
/>
{!isOwner && (
<>
{/* Save (desktop overlay) */}
<button
type="button"
onClick={handleToggleBookmark}
aria-label="ذخیره"
className="hidden lg:flex absolute top-3.5 left-3.5 z-20 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center hover:bg-WHITE transition-colors"
>
<Heart
size={20}
className={isBookmarked ? "fill-RED text-RED" : "text-BLACK"}
/>
</button>
{/* AI try-on (desktop overlay) */}
<button
type="button"
onClick={() => setIsAIOpen(true)}
className="hidden lg:flex absolute bottom-4 left-1/2 -translate-x-1/2 z-20 items-center gap-2 px-5 h-11 rounded-full bg-BLACK/85 backdrop-blur text-white text-sm font-bold hover:bg-BLACK transition-colors"
>
<Sparkles size={18} />
آنلاین پرو کن
</button>
</>
)}
{/* Floating back button (mobile) */}
{!isOwner && (
<button
type="button"
onClick={() => safeGoBack(navigate)}
aria-label="بازگشت"
className="lg:hidden absolute z-20 right-4 w-10 h-10 rounded-full bg-WHITE/90 backdrop-blur shadow-md flex items-center justify-center text-BLACK"
style={{ top: "calc(env(safe-area-inset-top, 0px) + 12px)" }}
>
<ArrowRight size={22} />
</button>
)}
</div>
{/* Info */}
<div className="flex flex-col">
{/* Brand row (mobile) — moved out of the top bar, sits below image */}
{!isOwner && product.seller?.username ? (
<div className="lg:hidden flex items-center gap-3 px-4 mt-4">
<Link
to={`/seller/${product.seller.username}`}
className="flex items-center gap-2.5 min-w-0"
>
<SellerLogo
size="sm"
src={product.seller?.storeLogo}
alt="seller"
/>
<span className="font-bold text-[14px] truncate">
{product.seller.username}
</span>
</Link>
<button
type="button"
onClick={handleChat}
className="ms-auto shrink-0 h-9 px-4 rounded-full border border-BLACK text-BLACK text-[12px] font-bold flex items-center gap-1.5"
>
<MessageCircle size={16} />
ارسال پیام
</button>
</div>
) : null}
{/* Seller (desktop) */}
{!isOwner && product.seller?.username ? (
<Link
to={`/seller/${product.seller.username}`}
className="hidden lg:flex items-center gap-2.5 mb-1"
>
<SellerLogo
size="sm"
src={product.seller?.storeLogo}
alt="seller"
/>
<span className="font-bold text-[15px]">
{product.seller.username}
</span>
</Link>
) : null}
{/* Inactive Product Status */}
{!productActiveStatus && isOwner ? (
<div className="flex gap-1 px-4 w-full justify-between items-center mt-2 lg:px-0">
<div className="flex items-center w-full gap-2 p-2 bg-WHITE3 rounded-lg">
<EyeOff size={16} className="text-BLACK2 font-bold" />
<span className="text-B12 font-bold text-BLACK2">
این محصول در حال حاضر غیرفعال است
</span>
</div>
<Button
variant="dark"
size="sm"
className="text-B10 h-8 rounded-md"
onClick={() => setIsChangeStatusDialogOpen(true)}
>
فعال کردن
</Button>
</div>
) : !isOwner ? (
<div className="lg:hidden">
<ProductActions productId={product.id || ""} />
</div>
) : null}
{/* Desktop: title + rating + price */}
<div className="hidden lg:flex flex-col gap-3 mt-1">
<h1 className="text-[26px] font-extrabold leading-snug">
{product.title}
</h1>
<div className="flex items-center gap-2.5 text-[13.5px] text-BLACK2">
<span className="flex items-center">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
size={15}
className={
i <= Math.round(avgRating)
? "fill-BLACK text-BLACK"
: "text-GRAY3"
}
/>
))}
</span>
{avgRating > 0 ? (
<b className="text-BLACK">{avgRating.toFixed(1)}</b>
) : null}
<span className="w-1 h-1 rounded-full bg-GRAY2" />
<span>{reviewCount} دیدگاه</span>
</div>
<div className="flex items-center gap-3 mt-1">
<b className="text-[28px] font-extrabold">
{formatPrice(currentDiscountPrice || currentPrice)}
<span className="text-[15px] font-semibold mr-1">تومان</span>
</b>
{currentDiscountPrice ? (
<del className="text-GRAY text-[17px]">
{formatPrice(currentPrice)}
</del>
) : null}
</div>
</div>
<Button
variant="dark"
size="sm"
className="text-B10 h-8 rounded-md"
onClick={() => setIsChangeStatusDialogOpen(true)}
>
فعال کردن
</Button>
</div>
) : !isOwner ? (
<ProductActions productId={product.id || ""} />
) : null}
<ProductInfo
productDiscountPercent={Number(
!currentDiscountPrice ||
!currentPrice ||
Number(currentDiscountPrice) === Number(currentPrice)
? 0
: (
((Number(currentPrice) - Number(currentDiscountPrice)) /
Number(currentPrice)) *
100
).toFixed(2)
)}
product={{ ...product, isActive: productActiveStatus }}
/>
{hasAnyStock ? (
<>
{hasColorVariants && (
<ColorSelector
colors={inStockColors.map(
(color) => (color.info as unknown as { detail: string })?.detail
{/* Mobile: title + description */}
<div className="lg:hidden">
<ProductInfo
productDiscountPercent={Number(
!currentDiscountPrice ||
!currentPrice ||
Number(currentDiscountPrice) === Number(currentPrice)
? 0
: (
((Number(currentPrice) - Number(currentDiscountPrice)) /
Number(currentPrice)) *
100
).toFixed(2)
)}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
product={{ ...product, isActive: productActiveStatus }}
/>
</div>
{/* Mobile: rating (only when > 0) */}
{!isOwner && avgRating > 0 ? (
<div className="lg:hidden flex items-center gap-2 px-4 mt-3 text-[13px] text-BLACK2">
<span className="flex items-center">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
size={15}
className={
i <= Math.round(avgRating)
? "fill-BLACK text-BLACK"
: "text-GRAY3"
}
/>
))}
</span>
<b className="text-BLACK">{avgRating.toFixed(1)}</b>
<span className="w-1 h-1 rounded-full bg-GRAY2" />
<span>{reviewCount} دیدگاه</span>
</div>
) : null}
{hasAnyStock ? (
<>
{hasColorVariants && (
<ColorSelector
colors={inStockColors.map(
(color) =>
(color.info as unknown as { detail: string })?.detail
)}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
/>
)}
{hasSizeVariants && (
<SizeSelector
sizes={inStockSizes.map((size) => size.value || "")}
selectedSize={selectedSize}
setSelectedSize={setSelectedSize}
onSizeGuide={
hasSizeChart
? () => setIsSizeGuideOpen(true)
: undefined
}
/>
)}
</>
) : (
<div className="flex flex-col gap-4 mt-10 px-4 lg:px-0">
<p className="text-B18 font-bold">موجود نیست</p>
</div>
)}
{isOwner && (
<div className="w-full p-4 flex justify-center items-center lg:justify-start lg:px-0">
<Button
variant="dark"
size="sm"
className="text-B10 h-8 rounded-md bg-VITROWN_BLUE text-white"
onClick={() => {
navigate(`/product/${product.id}?mode=store`);
}}
>
<Eye size={16} className="text-WHITE" />
دیدن محصول از نگاه مشتری
</Button>
</div>
)}
{hasSizeVariants && (
<SizeSelector
sizes={inStockSizes.map((size) => size.value || "")}
selectedSize={selectedSize}
setSelectedSize={setSelectedSize}
/>
{/* Desktop buy row + assurances */}
{!isOwner && (
<div className="hidden lg:block">
<div className="flex items-center gap-3 mt-6">
<button
type="button"
onClick={handleChat}
aria-label="گفتگو با فروشنده"
className="w-12 h-12 shrink-0 rounded-xl border border-GRAY3 flex items-center justify-center text-BLACK hover:bg-WHITE2 transition-colors"
>
<MessageCircle size={22} />
</button>
<Button
variant="dark"
size="lg"
className="flex-1 bg-BLACK text-white font-bold gap-2"
disabled={isOutOfStock || addToCartMutation.isPending}
onClick={handleDesktopAddToCart}
>
{isOutOfStock ? (
"ناموجود"
) : (
<>
<ShoppingCart size={20} />
افزودن به سبد خرید
</>
)}
</Button>
<div className="flex items-center shrink-0 border border-GRAY3 rounded-xl overflow-hidden h-12">
<button
type="button"
onClick={() => setQty((q) => q + 1)}
className="w-11 h-full hover:bg-WHITE2 text-lg"
aria-label="افزایش"
>
+
</button>
<span className="w-10 text-center font-bold">{qty}</span>
<button
type="button"
onClick={() => setQty((q) => Math.max(1, q - 1))}
className="w-11 h-full hover:bg-WHITE2 text-lg"
aria-label="کاهش"
>
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-3 mt-6">
<div className="flex items-start gap-2.5 bg-WHITE2 rounded-xl p-3.5">
<Truck size={22} className="shrink-0 mt-0.5" />
<div>
<b className="text-[13px] block">ارسال سریع</b>
<small className="text-GRAY text-[11.5px]">
۲ تا ۴ روز کاری
</small>
</div>
</div>
<div className="flex items-start gap-2.5 bg-WHITE2 rounded-xl p-3.5">
<RotateCcw size={22} className="shrink-0 mt-0.5" />
<div>
<b className="text-[13px] block">۷ روز بازگشت</b>
<small className="text-GRAY text-[11.5px]">
ضمانت تعویض کالا
</small>
</div>
</div>
<div className="flex items-start gap-2.5 bg-WHITE2 rounded-xl p-3.5">
<ShieldCheck size={22} className="shrink-0 mt-0.5" />
<div>
<b className="text-[13px] block">پرداخت امن</b>
<small className="text-GRAY text-[11.5px]">
تضمین اصالت کالا
</small>
</div>
</div>
</div>
</div>
)}
{/* Buy box (fixed bottom bar — mobile only) */}
{!isOwner && (
<div className="lg:hidden">
<BottomBar
currentPrice={currentPrice}
currentDiscountPrice={currentDiscountPrice}
currentStock={currentStock}
product={product}
selectedColor={
inStockColors.find(
(color) =>
(color.info as unknown as { detail: string })?.detail ===
selectedColor
)?.value || null
}
selectedSize={selectedSize}
/>
</div>
)}
</>
) : (
<div className="flex flex-col gap-4 mt-10 px-4">
<p className="text-B18 font-bold">موجود نیست</p>
</div>
)}
{isOwner && (
<div className="w-full p-4 flex justify-center items-center">
<Button
variant="dark"
size="sm"
className="text-B10 h-8 rounded-md bg-VITROWN_BLUE text-white"
onClick={() => {
navigate(`/product/${product.id}?mode=store`);
}}
>
<Eye size={16} className="text-WHITE" />
دیدن محصول از نگاه مشتری
</Button>
</div>
)}
</div>
{/* Full-width below the fold: description, contact, reviews, similar */}
{productActiveStatus && (
<>
{!isOwner && <ContactButton product={product} />}
<ProductDescription description={product.description} />
{!isOwner && (
<div className="lg:hidden">
<ContactButton product={product} />
</div>
)}
<ReviewSection productId={product.id || ""} isOwner={isOwner} />
{/* Similar Products Section */}
{!isOwner && (
<section className="w-full gap-2 flex flex-col max-w-[100vw] my-6">
<div className="w-full px-4">
<p className="text-B16 font-bold">محصولات مشابه</p>
<p className="text-R14">محصولات مرتبط که ممکن است بپسندید</p>
<section className="w-full gap-2 flex flex-col max-w-[100vw] my-6 lg:max-w-none">
<div className="w-full px-4 lg:px-0">
<p className="text-B16 lg:text-B24 font-bold">محصولات مشابه</p>
<p className="text-R14 text-GRAY">
محصولات مرتبط که ممکن است بپسندید
</p>
</div>
<MondrianProductList
products={
@ -323,23 +766,25 @@ export const ProductDetailView = memo(function ProductDetailView({
/>
</section>
)}
{/* Bottom bar handles variant selection and pricing */}
</>
)}
{!isOwner && (
<BottomBar
currentPrice={currentPrice}
currentDiscountPrice={currentDiscountPrice}
currentStock={currentStock}
product={product}
selectedColor={
inStockColors.find(
(color) =>
(color.info as unknown as { detail: string })?.detail ===
selectedColor
)?.value || null
}
selectedSize={selectedSize}
{/* Desktop overlay/buy modals */}
<LoginRequiredDialog
loginTitle="برای ادامه ابتدا باید وارد حساب کاربری خود شوید."
isOpen={isLoginOpen}
onOpenChange={setIsLoginOpen}
/>
<AIPromoteModal
isOpen={isAIOpen}
onClose={() => setIsAIOpen(false)}
productId={product.id || ""}
/>
{hasSizeChart && product.sizeChart && (
<SizeGuideDialog
isOpen={isSizeGuideOpen}
onOpenChange={setIsSizeGuideOpen}
sizeChart={product.sizeChart}
/>
)}
</div>

View File

@ -24,11 +24,14 @@ export const ProductHeader = ({ product }: ProductHeaderProps) => {
return;
}
if (!product.seller?.id) return;
createChatThread(product.seller.id, {
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
});
createChatThread(
{ participantId: product.seller.id, productId: product.id },
{
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
}
);
};
return (

View File

@ -25,6 +25,8 @@ import {
EyeOff,
Eye,
ArrowRight,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import {
useDeleteProduct,
@ -44,6 +46,7 @@ interface ProductImageCarouselProps {
isOwner?: boolean;
isActive?: boolean;
controlsIsOn?: boolean;
showArrows?: boolean;
onStatusChange?: (newStatus: boolean) => void;
isChangeStatusDialogOpen?: boolean;
setIsChangeStatusDialogOpen?: (open: boolean) => void;
@ -57,6 +60,7 @@ export const ProductImageCarousel = ({
height,
borderRadius,
controlsIsOn = false,
showArrows = false,
isOwner = false,
isActive = true,
onStatusChange,
@ -159,13 +163,15 @@ export const ProductImageCarousel = ({
<>
<button
onClick={handleSettingsClick}
className="absolute top-3 left-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
style={{ top: "calc(env(safe-area-inset-top, 0px) + 12px)" }}
className="absolute left-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
>
<EllipsisVertical size={20} className="text-WHITE2" />
</button>
<button
onClick={() => safeGoBack(navigate)}
className="absolute top-3 right-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
style={{ top: "calc(env(safe-area-inset-top, 0px) + 12px)" }}
className="absolute right-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
>
<ArrowRight size={20} className="text-WHITE2" />
</button>
@ -274,6 +280,28 @@ export const ProductImageCarousel = ({
</div>
)}
{/* Prev / next arrows (desktop, opt-in) */}
{showArrows && images.length > 1 && (
<>
<button
type="button"
onClick={() => api?.scrollPrev()}
aria-label="تصویر قبلی"
className="hidden lg:flex absolute top-1/2 right-3 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center text-BLACK hover:bg-WHITE transition-colors"
>
<ChevronRight size={20} />
</button>
<button
type="button"
onClick={() => api?.scrollNext()}
aria-label="تصویر بعدی"
className="hidden lg:flex absolute top-1/2 left-3 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center text-BLACK hover:bg-WHITE transition-colors"
>
<ChevronLeft size={20} />
</button>
</>
)}
{/* Settings Drawer */}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerContent>

View File

@ -10,7 +10,7 @@ export const ProductInfo = ({
product,
productDiscountPercent,
}: ProductInfoProps) => (
<div className="flex flex-col gap-2 mt-8 px-4">
<div className="flex flex-col gap-2 mt-8 px-4 lg:mt-2">
<div className="flex gap-3 items-center">
{productDiscountPercent && productDiscountPercent > 0 ? (
<div className="flex gap-[2px] items-center">
@ -23,8 +23,10 @@ export const ProductInfo = ({
{productDiscountPercent && productDiscountPercent > 0 ? (
<div className="w-[6px] h-[6px] rounded-full bg-BLACK" />
) : null}
<p className="text-B18 font-bold">{product.title}</p>
<p className="text-B18 lg:text-[26px] font-bold">{product.title}</p>
</div>
<p className="text-R12 text-GRAY">{product.description}</p>
{/* Description moved into the dedicated ProductDescription section below
the buy actions so multi-line seller copy renders with line breaks and
appears on desktop too. */}
</div>
);

View File

@ -0,0 +1,80 @@
import { Dialog, DialogContent, DialogOverlay } from "../ui/dialog";
import type { SizeChart } from "../../../src/api/types";
interface SizeGuideDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
sizeChart: SizeChart;
}
const UNIT_LABELS: Record<string, string> = {
cm: "سانتی‌متر",
inch: "اینچ",
};
export function SizeGuideDialog({
isOpen,
onOpenChange,
sizeChart,
}: SizeGuideDialogProps) {
const { columns = [], rows = [], unit = "cm" } = sizeChart;
const unitLabel = UNIT_LABELS[unit] || unit;
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogOverlay />
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)] max-w-lg max-h-[85vh] overflow-y-auto">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-B18 font-bold">راهنمای سایز</h2>
<span className="text-R12 text-GRAY">اندازهها به {unitLabel}</span>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-center">
<thead>
<tr>
<th className="sticky right-0 z-10 bg-WHITE2 border border-GRAY3 px-3 py-2 text-R12 font-bold text-BLACK2">
سایز
</th>
{columns.map((col, ci) => (
<th
key={ci}
className="border border-GRAY3 bg-WHITE2 px-3 py-2 text-R12 font-bold text-BLACK2 whitespace-nowrap"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
<td className="sticky right-0 z-10 bg-WHITE border border-GRAY3 px-3 py-2 text-R12 font-bold">
{row.label}
</td>
{columns.map((_, ci) => (
<td
key={ci}
className="border border-GRAY3 px-3 py-2 text-R12 text-BLACK2"
dir="ltr"
>
{row.values[ci]?.trim() ? row.values[ci] : "—"}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="text-R12 text-GRAY leading-6">
اندازهها ممکن است بسته به روش اندازهگیری کمی متفاوت باشند.
</p>
</div>
</DialogContent>
</Dialog>
);
}
export default SizeGuideDialog;

View File

@ -1,16 +1,33 @@
import { Ruler } from "lucide-react";
interface SizeSelectorProps {
sizes: string[] | undefined;
selectedSize: string | null;
setSelectedSize: (size: string) => void;
/** When provided, shows a "راهنمای سایز" link that opens the size guide. */
onSizeGuide?: () => void;
}
export const SizeSelector = ({
sizes,
selectedSize,
setSelectedSize,
onSizeGuide,
}: SizeSelectorProps) => (
<div className="flex flex-col gap-4 mt-10 px-4">
<p className="text-B18 font-bold">انتخاب سایز</p>
<div className="flex items-center justify-between">
<p className="text-B18 font-bold">انتخاب سایز</p>
{onSizeGuide && (
<button
type="button"
onClick={onSizeGuide}
className="flex items-center gap-1 text-R12 text-VITROWN_BLUE hover:underline cursor-pointer"
>
<Ruler className="h-3.5 w-3.5" />
راهنمای سایز
</button>
)}
</div>
<div className="gap-4 flex">
{sizes?.map((size: string, index: number) => (
<div

View File

@ -2,6 +2,7 @@ export { ProductHeader } from "./ProductHeader";
export { ProductImageCarousel } from "./ProductImageCarousel";
export { ProductActions } from "./ProductActions";
export { ProductInfo } from "./ProductInfo";
export { ProductDescription } from "./ProductDescription";
export { ColorSelector } from "./ColorSelector";
export { SizeSelector } from "./SizeSelector";
export { ContactButton } from "./ContactButton";

View File

@ -0,0 +1,227 @@
import { useEffect, useRef, useState } from "react";
import { Check, Loader2, Pencil, X } from "lucide-react";
import { cn } from "../../lib/utils";
/* ---------- digit helpers (accept both Persian & Latin input) ---------- */
const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
const AR_DIGITS = "٠١٢٣٤٥٦٧٨٩";
function toLatinDigits(input: string): string {
return input
.replace(/[۰-۹]/g, (d) => String(FA_DIGITS.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String(AR_DIGITS.indexOf(d)));
}
/** Extract only the integer digits from any incoming value ("120000.00" -> "120000"). */
function normalizeIncoming(raw: string | null | undefined): string {
if (raw === null || raw === undefined || raw === "") return "";
const n = parseFloat(toLatinDigits(String(raw)));
if (!isFinite(n) || n <= 0) return "";
return String(Math.round(n));
}
/** Keep only digits typed by the user (drops separators, letters, Persian digits normalized). */
function onlyDigits(input: string): string {
return toLatinDigits(input).replace(/[^\d]/g, "");
}
/** Group with thousands separators for the edit input ("120000" -> "120,000"). */
function groupLatin(digits: string): string {
if (!digits) return "";
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
/** Persian-grouped display ("120000" -> "۱۲۰٬۰۰۰"). */
function toFaDisplay(digits: string): string {
if (!digits) return "";
return Number(digits).toLocaleString("fa-IR");
}
/* ------------------------------- component ------------------------------ */
interface InlinePriceEditorProps {
/** Current raw price (decimal string) or null/"" when unset. */
value: string | null | undefined;
/** Called with the new integer price string, or null when cleared (only if allowEmpty). */
onSave: (value: string | null) => void;
isPending?: boolean;
/** Allow saving an empty value as null (e.g. a variant inheriting the base price). */
allowEmpty?: boolean;
/** Muted hint shown when there is no value and we're not editing (e.g. inherited price). */
placeholder?: string;
suffix?: string;
size?: "sm" | "md";
align?: "start" | "center" | "end";
ariaLabel?: string;
className?: string;
}
export function InlinePriceEditor({
value,
onSave,
isPending = false,
allowEmpty = false,
placeholder,
suffix = "تومان",
size = "md",
align = "end",
ariaLabel = "ویرایش قیمت",
className,
}: InlinePriceEditorProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); // raw digits while editing
// Optimistic value shown during the async save so the number doesn't flicker
// back to the old server value before the refetch lands.
const [optimistic, setOptimistic] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const current = normalizeIncoming(value);
// Clear the optimistic value once the mutation settles.
useEffect(() => {
if (!isPending) setOptimistic(null);
}, [isPending]);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const openEditor = () => {
if (isPending) return;
setDraft(current);
setEditing(true);
};
const commit = () => {
const next = onlyDigits(draft);
// No-op if unchanged.
if (next === current) {
setEditing(false);
return;
}
if (!next) {
if (allowEmpty) {
setOptimistic("");
onSave(null);
} else {
// Empty not allowed (e.g. base price) → discard.
setEditing(false);
return;
}
} else {
setOptimistic(next);
onSave(next);
}
setEditing(false);
};
const cancel = () => {
setDraft(current);
setEditing(false);
};
const sizeText = size === "sm" ? "text-[13px]" : "text-[14px]";
const alignClass =
align === "center"
? "justify-center text-center"
: align === "start"
? "justify-start text-right"
: "justify-end text-right";
/* ------------------------------ edit mode ----------------------------- */
if (editing) {
return (
<div
className={cn("flex items-center gap-1.5", alignClass, className)}
dir="ltr"
>
<div className="relative flex items-center">
<input
ref={inputRef}
type="text"
inputMode="numeric"
aria-label={ariaLabel}
value={groupLatin(draft)}
onChange={(e) => setDraft(onlyDigits(e.target.value))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
placeholder={allowEmpty ? "پیش‌فرض" : "قیمت"}
className={cn(
"w-24 rounded-lg border border-VITROWN_BLUE bg-WHITE px-2 py-2 text-left tabular-nums outline-none",
"focus:ring-2 focus:ring-VITROWN_BLUE/30 transition-shadow",
sizeText
)}
/>
</div>
<button
type="button"
onClick={commit}
aria-label="ذخیره قیمت"
className="flex h-9 w-9 items-center justify-center rounded-lg bg-VITROWN_BLUE text-WHITE transition-colors hover:opacity-90 cursor-pointer"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={cancel}
aria-label="انصراف"
className="flex h-9 w-9 items-center justify-center rounded-lg bg-WHITE3 text-BLACK2 transition-colors hover:bg-GRAY3 cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
/* ---------------------------- display mode ---------------------------- */
const shown = isPending && optimistic !== null ? optimistic : current;
const hasValue = shown !== "";
return (
<button
type="button"
onClick={openEditor}
disabled={isPending}
aria-label={ariaLabel}
// Looks like an editable field (subtle border + grey fill) with an
// always-visible pencil, so it reads as tappable on touch devices where
// there is no hover to reveal the affordance.
className={cn(
"group inline-flex items-center gap-1.5 rounded-lg border border-GRAY3 bg-WHITE3 px-2.5 py-1.5 min-h-[36px]",
"transition-colors hover:border-VITROWN_BLUE hover:bg-VITROWN_BLUE/5 active:bg-VITROWN_BLUE/10",
"cursor-pointer disabled:cursor-default disabled:opacity-70",
alignClass,
className
)}
>
{isPending ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-VITROWN_BLUE" />
) : (
<Pencil className="h-3.5 w-3.5 shrink-0 text-VITROWN_BLUE/70 transition-colors group-hover:text-VITROWN_BLUE" />
)}
{hasValue ? (
<span className={cn("font-semibold tabular-nums", sizeText)}>
{toFaDisplay(shown)}
<span className="mr-1 text-GRAY font-normal">{suffix}</span>
</span>
) : (
<span className={cn("text-GRAY", sizeText)}>
{placeholder ?? "افزودن قیمت"}
</span>
)}
</button>
);
}
export default InlinePriceEditor;

View File

@ -1,4 +1,5 @@
import { Check, X } from "lucide-react";
import { useMemo } from "react";
import { useColorAttributes } from "~/requestHandler/use-product-hooks";
interface ColorSelectorProps {
@ -9,6 +10,15 @@ interface ColorSelectorProps {
onToggleUnselectedMode: (enabled: boolean) => void;
}
// The DB now carries a curated 81-color palette laid out as a 9×9 grid.
// Each AttributeValue's `info` object has:
// persian — display name in Persian
// detail — hex swatch color
// sort_order — 0..80, row-major position in the grid
// grid_row — 0..8
// grid_col — 0..8 (columns cluster by hue family)
// Anything without a numeric sort_order is treated as trailing so we don't
// accidentally hide legacy or admin-inserted values.
export const ColorSelector = ({
selectedColors,
onToggleColor,
@ -17,15 +27,34 @@ export const ColorSelector = ({
onToggleUnselectedMode,
}: ColorSelectorProps) => {
const { data: colors, isLoading } = useColorAttributes();
const sortedColors = useMemo(() => {
if (!colors) return [];
return colors
.filter((c) => c.value !== "UNSELECTED")
.slice()
.sort((a, b) => {
const ax =
typeof a.info?.sort_order === "number"
? a.info.sort_order
: Number.MAX_SAFE_INTEGER;
const bx =
typeof b.info?.sort_order === "number"
? b.info.sort_order
: Number.MAX_SAFE_INTEGER;
return ax - bx;
});
}, [colors]);
if (isLoading) {
return (
<div className="space-y-4">
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
<div className="grid grid-cols-7 gap-3">
{[...Array(20)].map((_, i) => (
<div className="grid grid-cols-9 gap-1.5">
{[...Array(81)].map((_, i) => (
<div
key={i}
className="w-12 h-12 rounded-lg border-2 border-WHITE2 bg-WHITE3 animate-pulse"
className="aspect-square rounded-md border border-WHITE2 bg-WHITE3 animate-pulse"
/>
))}
</div>
@ -33,6 +62,20 @@ export const ColorSelector = ({
);
}
// Perceived lightness of the swatch — controls whether we overlay a light
// or dark checkmark for the "selected" state so it stays visible on both
// near-white swatches (سفید, شیری) and near-black swatches (مشکی, بنفش تیره).
const isDarkHex = (hex?: string) => {
if (!hex) return false;
const h = hex.replace("#", "");
if (h.length !== 6) return false;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
// Rec. 709 luma; below 128 reads as "dark ground -> use white ink".
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128;
};
return (
<div className="space-y-4">
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
@ -54,59 +97,41 @@ export const ColorSelector = ({
</label>
</div>
{/* Color Selection Grid - Disabled when unselected mode is on */}
{/* 9×9 palette: columns are hue families, rows go light -> dark. */}
<div
className={`${unselectedMode ? "opacity-50 pointer-events-none" : ""}`}
>
<div className="grid grid-cols-7 gap-1 flex-wrap">
{colors.map((color) => (
<>
{color.value !== "UNSELECTED" && (
<div
key={color.id}
className="flex flex-col items-center gap-1"
>
<button
onClick={() =>
!unselectedMode && onToggleColor(color.value)
}
disabled={unselectedMode}
className={`relative w-12 h-12 rounded-lg border-2 transition-all ${
selectedColors.includes(color.value)
? "border-blue-500"
: "border-WHITE2 hover:border-GRAY3"
}`}
style={{ backgroundColor: color.info.detail }}
title={color.info?.persian}
>
{selectedColors.includes(color.value) && (
<div className="absolute inset-0 flex items-center justify-center">
<Check
className={`w-4 h-4 ${
color.info.detail === "#FFFFFF" ||
color.info.detail === "#F5F5DC" ||
color.info.detail === "#FAFAFA" ||
color.info.detail === "#F0F0F0"
? "text-BLACK"
: "text-WHITE"
}`}
/>
</div>
)}
</button>
<span
className={`text-xs text-center truncate w-12 ${
selectedColors.includes(color.value)
? "font-semibold text-blue-700"
: "text-BLACK2"
}`}
>
{color.info?.persian || color.value}
</span>
</div>
)}
</>
))}
<div className="grid grid-cols-9 gap-1.5">
{sortedColors.map((color) => {
const isSelected = selectedColors.includes(color.value);
const hex = color.info?.detail || "#000000";
const dark = isDarkHex(hex);
return (
<button
key={color.id ?? color.value}
type="button"
onClick={() => !unselectedMode && onToggleColor(color.value)}
disabled={unselectedMode}
title={color.info?.persian || color.value}
aria-label={color.info?.persian || color.value}
aria-pressed={isSelected}
className={`relative aspect-square rounded-md border-2 transition-all ${
isSelected
? "border-VITROWN_BLUE shadow-md"
: "border-WHITE2 hover:border-GRAY3"
}`}
style={{ backgroundColor: hex }}
>
{isSelected && (
<div className="absolute inset-0 flex items-center justify-center">
<Check
className={`w-3.5 h-3.5 ${dark ? "text-WHITE" : "text-BLACK"}`}
/>
</div>
)}
</button>
);
})}
</div>
</div>
@ -114,15 +139,16 @@ export const ColorSelector = ({
{selectedColors.length > 0 && !unselectedMode && (
<div className="flex flex-wrap gap-2 mt-3">
{selectedColors.map((colorValue) => {
const colorInfo = colors.find((c) => c.value === colorValue);
const colorInfo = sortedColors.find((c) => c.value === colorValue);
const hex = colorInfo?.info?.detail || "#EEE";
return (
<div
key={colorValue}
className="flex items-center gap-1 bg-WHITE3 px-3 py-1 rounded-full text-sm"
className="flex items-center gap-1.5 bg-WHITE3 px-3 py-1 rounded-full text-sm"
>
<div
className="w-3 h-3 rounded-full border border-GRAY3"
style={{ backgroundColor: colorValue }}
style={{ backgroundColor: hex }}
/>
<span>{colorInfo?.info?.persian || colorValue}</span>
<X

View File

@ -302,20 +302,30 @@ export function ImageEditor({
const adjustedCropWidth = cropArea.width * scaleX;
const adjustedCropHeight = cropArea.height * scaleY;
// Set canvas size to crop area
canvas.width = cropArea.width;
canvas.height = cropArea.height;
// Crop rectangle expressed in the SOURCE image's native pixels
// (adjusted* already scales the on-screen crop back up to the original).
const srcX = Math.max(0, adjustedCropX);
const srcY = Math.max(0, adjustedCropY);
const srcWidth = Math.min(adjustedCropWidth, img.width - srcX);
const srcHeight = Math.min(adjustedCropHeight, img.height - srcY);
// Size the canvas to the full-resolution crop region, NOT the editor's
// on-screen crop size. Using the display size here was downscaling phone
// photos to a few hundred pixels, which is what made uploads blurry.
// The backend still caps the final image to 1600px.
canvas.width = Math.max(1, Math.round(srcWidth));
canvas.height = Math.max(1, Math.round(srcHeight));
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw the cropped portion
// Draw the cropped portion at native resolution
ctx.drawImage(
img,
Math.max(0, adjustedCropX),
Math.max(0, adjustedCropY),
Math.min(adjustedCropWidth, img.width - Math.max(0, adjustedCropX)),
Math.min(adjustedCropHeight, img.height - Math.max(0, adjustedCropY)),
srcX,
srcY,
srcWidth,
srcHeight,
0,
0,
canvas.width,
@ -331,7 +341,7 @@ export function ImageEditor({
onClose();
},
"image/jpeg",
0.9
0.92
);
};

View File

@ -21,7 +21,7 @@ export const ProductDetailsForm: React.FC<ProductDetailsFormProps> = ({
<AppInput
inputTitle="عنوان محصول:"
inputValue={productTitle}
inputPlaceholder="مثل: کفش آدیداس"
inputPlaceholder="مثل: تیشرت بیسیک"
inputOnChange={onTitleChange}
inputDir="rtl"
/>

View File

@ -41,7 +41,6 @@ export const ProductVariantDrawer = ({
Record<string, number>
>({});
const [colorUnselectedMode, setColorUnselectedMode] = useState(false);
const [sizeUnselectedMode, setSizeUnselectedMode] = useState(false);
const initializationDoneRef = useRef<boolean>(false);
const prevIsOpenRef = useRef<boolean>(false);
@ -51,19 +50,15 @@ export const ProductVariantDrawer = ({
// Only initialize when drawer opens for the first time (not on re-renders)
if (isOpen && !prevIsOpenRef.current) {
if (initialVariants.length > 0) {
// Check if any variant has unselected values
// Color still supports the "no color" escape hatch.
const hasUnselectedColor = initialVariants.some(
(v) => v.color === "unselected"
);
const hasUnselectedSize = initialVariants.some(
(v) => v.size === "unselected"
);
// Set unselected modes
setColorUnselectedMode(hasUnselectedColor);
setSizeUnselectedMode(hasUnselectedSize);
// Extract unique colors and sizes from initial variants (excluding unselected)
// Extract unique colors and sizes from initial variants (excluding unselected).
// Legacy variants with size="unselected" are dropped here so the seller is
// forced to pick a real size (FreeSize covers one-size products).
const colors = [
...new Set(
initialVariants
@ -101,7 +96,6 @@ export const ProductVariantDrawer = ({
setBulkQuantity("1");
setCustomQuantities({});
setColorUnselectedMode(false);
setSizeUnselectedMode(false);
}
initializationDoneRef.current = true;
}
@ -114,7 +108,6 @@ export const ProductVariantDrawer = ({
setBulkQuantity("1");
setCustomQuantities({});
setColorUnselectedMode(false);
setSizeUnselectedMode(false);
initializationDoneRef.current = false;
}
@ -136,19 +129,14 @@ export const ProductVariantDrawer = ({
if (
JSON.stringify(currentVariantKeys) !== JSON.stringify(newVariantKeys)
) {
// Check if any variant has unselected values
// Color still supports the "no color" escape hatch.
const hasUnselectedColor = initialVariants.some(
(v) => v.color === "unselected"
);
const hasUnselectedSize = initialVariants.some(
(v) => v.size === "unselected"
);
// Set unselected modes
setColorUnselectedMode(hasUnselectedColor);
setSizeUnselectedMode(hasUnselectedSize);
// Extract unique colors and sizes from initial variants (excluding unselected)
// Extract unique colors and sizes from initial variants (excluding unselected).
// Legacy "unselected" size variants are ignored so the seller must pick.
const colors = [
...new Set(
initialVariants
@ -247,19 +235,12 @@ export const ProductVariantDrawer = ({
setCustomQuantities((prev) => ({ ...prev, ...newQuantities }));
};
// Get variants for quantity display based on unselected modes
// Get variants for quantity display. Size is always required (FreeSize covers
// one-size products); color still has an unselected escape hatch.
const getQuantityVariants = () => {
const variants: { color: string; size: string; key: string }[] = [];
if (colorUnselectedMode && sizeUnselectedMode) {
// Both unselected - show one general variant
variants.push({
color: "unselected",
size: "unselected",
key: "unselected-unselected",
});
} else if (colorUnselectedMode && !sizeUnselectedMode) {
// Color unselected, size selected - show variants for each selected size
if (colorUnselectedMode) {
selectedSizes.forEach((size) => {
variants.push({
color: "unselected",
@ -267,17 +248,7 @@ export const ProductVariantDrawer = ({
key: `unselected-${size}`,
});
});
} else if (!colorUnselectedMode && sizeUnselectedMode) {
// Size unselected, color selected - show variants for each selected color
selectedColors.forEach((color) => {
variants.push({
color: color,
size: "unselected",
key: `${color}-unselected`,
});
});
} else {
// Normal mode - show all combinations
selectedColors.forEach((color) => {
selectedSizes.forEach((size) => {
variants.push({
@ -293,37 +264,22 @@ export const ProductVariantDrawer = ({
};
const handleNext = () => {
// Check if at least one variant can be created
if (colorUnselectedMode && sizeUnselectedMode) {
// Both unselected - always valid
} else if (colorUnselectedMode && !sizeUnselectedMode) {
// Color unselected, need at least one size
if (selectedSizes.length === 0) {
toast({
title: "خطا",
description: "لطفاً حداقل یک سایز انتخاب کنید",
});
return;
}
} else if (!colorUnselectedMode && sizeUnselectedMode) {
// Size unselected, need at least one color
if (selectedColors.length === 0) {
toast({
title: "خطا",
description: "لطفاً حداقل یک رنگ انتخاب کنید",
});
return;
}
} else {
// Normal mode - need both color and size
if (selectedColors.length === 0 || selectedSizes.length === 0) {
toast({
title: "خطا",
description:
"لطفاً حداقل یک رنگ و یک سایز انتخاب کنید یا حالت بدون رنگ/سایز را فعال کنید",
});
return;
}
// Size is always required; use "فری سایز" for one-size products.
if (selectedSizes.length === 0) {
toast({
title: "خطا",
description:
"لطفاً حداقل یک سایز انتخاب کنید. اگر محصول تک‌سایز است، «فری سایز» را انتخاب کنید.",
});
return;
}
// Color is optional (colorUnselectedMode covers no-color products).
if (!colorUnselectedMode && selectedColors.length === 0) {
toast({
title: "خطا",
description: "لطفاً حداقل یک رنگ انتخاب کنید یا حالت بدون رنگ را فعال کنید",
});
return;
}
setStep("quantities");
};
@ -364,8 +320,6 @@ export const ProductVariantDrawer = ({
selectedSizes={selectedSizes}
onToggleSize={toggleSize}
onRemoveSize={removeSize}
unselectedMode={sizeUnselectedMode}
onToggleUnselectedMode={setSizeUnselectedMode}
/>
</div>
) : (
@ -424,17 +378,14 @@ export const ProductVariantDrawer = ({
(c) => c.value === color
);
// Helper function to get display text
// Helper function to get display text — colorless variants
// roll up under the size label; sized/colored variants show
// both.
const getDisplayText = () => {
if (colorUnselectedMode && sizeUnselectedMode) {
return "کل موجودی محصول";
} else if (colorUnselectedMode && !sizeUnselectedMode) {
if (colorUnselectedMode) {
return `موجودی سایز ${size}`;
} else if (!colorUnselectedMode && sizeUnselectedMode) {
return `موجودی ${colorInfo?.info.persian || color}`;
} else {
return null; // Normal mode - will show both color and size
}
return null; // Normal mode — render color + size below
};
const displayText = getDisplayText();

View File

@ -0,0 +1,329 @@
import { useMemo, useState } from "react";
import { Plus, X, Trash2, Ruler } from "lucide-react";
import type { SizeChart } from "../../../src/api/types";
import { cn } from "../../lib/utils";
import { FormField } from "../ui/FormField";
import {
getDefaultColumns,
getSizeSuggestions,
} from "../../lib/sizeChartSuggestions";
interface SizeChartSectionProps {
value: SizeChart | null;
onChange: (value: SizeChart | null) => void;
categoryName?: string | null;
/** Sizes the product currently offers (from its variants), used to seed rows. */
productSizes?: string[];
/** Product offers sizes → the chart is expected (visual emphasis + error). */
required?: boolean;
/** Show the "must fill" error (set when the seller tried to proceed). */
showError?: boolean;
}
export function SizeChartSection({
value,
onChange,
categoryName,
productSizes = [],
required = false,
showError = false,
}: SizeChartSectionProps) {
const [customColumn, setCustomColumn] = useState("");
const columns = value?.columns ?? [];
const rows = value?.rows ?? [];
const unit = value?.unit ?? "cm";
// Quick-add chips: category suggestions not already used as a column.
const suggestionChips = useMemo(() => {
const used = new Set(columns.map((c) => c.trim()));
return getSizeSuggestions(categoryName).filter((s) => !used.has(s));
}, [categoryName, columns]);
// Product sizes that don't yet have a row (offer to add them).
const missingSizes = useMemo(() => {
const present = new Set(rows.map((r) => r.label.trim()));
return productSizes.filter((s) => s && s.trim() && !present.has(s.trim()));
}, [productSizes, rows]);
// Offered sizes still lacking a row or any filled measurement (blocks publish).
const incompleteSizes = useMemo(() => {
return productSizes.filter((s) => {
const row = rows.find((r) => r.label.trim() === s.trim());
return !row || !row.values.some((v) => v && v.trim() !== "");
});
}, [productSizes, rows]);
const emit = (next: Partial<SizeChart>) => {
onChange({ unit, columns, rows, ...next });
};
const initialize = () => {
const seededColumns = getDefaultColumns(categoryName);
const seededRows = (productSizes.length ? productSizes : [""]).map((s) => ({
label: s,
values: seededColumns.map(() => ""),
}));
onChange({ unit: "cm", columns: seededColumns, rows: seededRows });
};
const addColumn = (name: string) => {
const trimmed = name.trim();
if (!trimmed || columns.includes(trimmed)) return;
emit({
columns: [...columns, trimmed],
rows: rows.map((r) => ({ ...r, values: [...r.values, ""] })),
});
};
const renameColumn = (index: number, name: string) => {
emit({ columns: columns.map((c, i) => (i === index ? name : c)) });
};
const removeColumn = (index: number) => {
emit({
columns: columns.filter((_, i) => i !== index),
rows: rows.map((r) => ({
...r,
values: r.values.filter((_, i) => i !== index),
})),
});
};
const addRow = (label = "") => {
emit({ rows: [...rows, { label, values: columns.map(() => "") }] });
};
const addMissingSizes = () => {
emit({
rows: [
...rows,
...missingSizes.map((label) => ({
label,
values: columns.map(() => ""),
})),
],
});
};
const setRowLabel = (index: number, label: string) => {
emit({ rows: rows.map((r, i) => (i === index ? { ...r, label } : r)) });
};
const setCell = (rowIndex: number, colIndex: number, val: string) => {
emit({
rows: rows.map((r, i) =>
i === rowIndex
? {
...r,
values: r.values.map((v, j) => (j === colIndex ? val : v)),
}
: r
),
});
};
const removeRow = (index: number) => {
emit({ rows: rows.filter((_, i) => i !== index) });
};
const cellInput =
"w-20 rounded-lg border border-GRAY3 bg-WHITE px-2 py-1.5 text-center text-R12 outline-none focus:border-VITROWN_BLUE";
return (
<FormField label="راهنمای سایز" required={required}>
<p className="text-R12 text-GRAY -mt-1 mb-2">
اندازههای هر سایز را وارد کنید تا خریدار بداند مثلاً «لارج» برای این محصول
چه اندازهای است.
</p>
{value == null ? (
<>
<button
type="button"
onClick={initialize}
className={cn(
"flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed py-4 text-R14 transition-colors cursor-pointer",
required && showError
? "border-RED text-RED"
: "border-GRAY3 text-BLACK2 hover:border-VITROWN_BLUE hover:text-VITROWN_BLUE"
)}
>
<Plus className="h-4 w-4" />
افزودن جدول سایز
</button>
{required && showError && (
<p className="mt-2 text-R12 text-RED">
این محصول سایز دارد؛ پر کردن راهنمای سایز الزامی است.
</p>
)}
</>
) : (
<div className="flex flex-col gap-3 rounded-xl border border-GRAY3 bg-WHITE p-3">
{required && showError && incompleteSizes.length > 0 && (
<p className="rounded-lg bg-RED/10 px-3 py-2 text-R12 text-RED">
برای این سایزها هنوز اندازهای وارد نشده: {incompleteSizes.join("، ")}
</p>
)}
<div className="flex items-center justify-end">
<span className="text-R12 text-GRAY">اندازهها به سانتیمتر</span>
</div>
{/* Grid */}
<div className="overflow-x-auto">
<table className="w-full border-separate border-spacing-1">
<thead>
<tr>
<th className="sticky right-0 z-10 bg-WHITE text-R12 text-GRAY font-normal">
سایز
</th>
{columns.map((col, ci) => (
<th key={ci} className="min-w-[6rem]">
<div className="flex items-center gap-1">
<input
value={col}
onChange={(e) => renameColumn(ci, e.target.value)}
placeholder="نام اندازه"
className="w-24 rounded-lg border border-GRAY3 bg-WHITE3 px-2 py-1.5 text-center text-R12 font-bold outline-none focus:border-VITROWN_BLUE"
/>
<button
type="button"
onClick={() => removeColumn(ci)}
aria-label={`حذف ستون ${col}`}
className="text-GRAY hover:text-RED transition-colors cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
<td className="sticky right-0 z-10 bg-WHITE">
<div className="flex items-center gap-1">
<input
value={row.label}
onChange={(e) => setRowLabel(ri, e.target.value)}
placeholder="سایز"
className="w-16 rounded-lg border border-GRAY3 bg-WHITE3 px-2 py-1.5 text-center text-R12 font-bold outline-none focus:border-VITROWN_BLUE"
/>
<button
type="button"
onClick={() => removeRow(ri)}
aria-label={`حذف سایز ${row.label}`}
className="text-GRAY hover:text-RED transition-colors cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
{columns.map((_, ci) => (
<td key={ci} className="text-center">
<input
value={row.values[ci] ?? ""}
onChange={(e) => setCell(ri, ci, e.target.value)}
inputMode="numeric"
placeholder="—"
dir="ltr"
className={cellInput}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Add row / add missing product sizes */}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => addRow()}
className="inline-flex items-center gap-1 rounded-lg border border-GRAY3 px-3 py-1.5 text-R12 text-BLACK2 transition-colors hover:border-VITROWN_BLUE hover:text-VITROWN_BLUE cursor-pointer"
>
<Plus className="h-3.5 w-3.5" />
افزودن سایز
</button>
{missingSizes.length > 0 && (
<button
type="button"
onClick={addMissingSizes}
className="inline-flex items-center gap-1 rounded-lg bg-VITROWN_BLUE/10 px-3 py-1.5 text-R12 text-VITROWN_BLUE transition-colors hover:bg-VITROWN_BLUE/20 cursor-pointer"
>
<Plus className="h-3.5 w-3.5" />
افزودن سایزهای محصول ({missingSizes.join("، ")})
</button>
)}
</div>
{/* Quick-add measurement columns */}
{suggestionChips.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-R12 text-GRAY">اندازههای پیشنهادی:</span>
<div className="flex flex-wrap gap-2">
{suggestionChips.map((chip) => (
<button
key={chip}
type="button"
onClick={() => addColumn(chip)}
className="inline-flex items-center gap-1 rounded-full bg-WHITE3 px-3 py-1 text-R12 text-BLACK2 transition-colors hover:bg-VITROWN_BLUE/10 hover:text-VITROWN_BLUE cursor-pointer"
>
<Plus className="h-3 w-3" />
{chip}
</button>
))}
</div>
</div>
)}
{/* Custom column */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Ruler className="absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-GRAY" />
<input
value={customColumn}
onChange={(e) => setCustomColumn(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addColumn(customColumn);
setCustomColumn("");
}
}}
placeholder="اندازه دلخواه (مثلاً دور یقه)"
className="w-full rounded-lg border border-GRAY3 bg-WHITE pr-7 pl-2 py-1.5 text-R12 outline-none focus:border-VITROWN_BLUE"
/>
</div>
<button
type="button"
onClick={() => {
addColumn(customColumn);
setCustomColumn("");
}}
disabled={!customColumn.trim()}
className="rounded-lg bg-BLACK px-3 py-1.5 text-R12 text-WHITE transition-colors hover:bg-BLACK2 disabled:opacity-40 cursor-pointer disabled:cursor-default"
>
افزودن ستون
</button>
</div>
{/* Remove whole chart */}
<button
type="button"
onClick={() => onChange(null)}
className="self-start text-R12 text-RED hover:underline cursor-pointer"
>
حذف جدول سایز
</button>
</div>
)}
</FormField>
);
}
export default SizeChartSection;

View File

@ -5,16 +5,12 @@ interface SizeSelectorProps {
selectedSizes: string[];
onToggleSize: (size: string) => void;
onRemoveSize: (size: string) => void;
unselectedMode: boolean;
onToggleUnselectedMode: (enabled: boolean) => void;
}
export const SizeSelector: React.FC<SizeSelectorProps> = ({
selectedSizes,
onToggleSize,
onRemoveSize,
unselectedMode,
onToggleUnselectedMode,
}: SizeSelectorProps) => {
const { data: sizes, isLoading } = useSizeAttributes();
if (isLoading) {
@ -36,35 +32,17 @@ export const SizeSelector: React.FC<SizeSelectorProps> = ({
return (
<div className="space-y-4">
<h4 className="font-medium text-right">انتخاب سایز های موجود:</h4>
<p className="text-xs text-GRAY text-right">
اگر محصول تکسایز است، «فری سایز» را انتخاب کنید.
</p>
{/* Unselected Mode Switch */}
<div className="flex items-center justify-between p-3 bg-GRAY3 rounded-lg">
<span className="text-sm font-medium text-BLACK2">
بدون مشخص کردن سایز
</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={unselectedMode}
onChange={(e) => onToggleUnselectedMode(e.target.checked)}
className="sr-only peer"
aria-label="حالت بدون سایز"
/>
<div className="w-11 h-6 bg-WHITE2 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-WHITE after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-WHITE after:border-GRAY3 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-VITROWN_BLUE border"></div>
</label>
</div>
{/* Size Selection Grid - Disabled when unselected mode is on */}
<div
className={`grid grid-cols-4 gap-2 ${unselectedMode ? "opacity-50 pointer-events-none" : ""}`}
>
<div className="grid grid-cols-4 gap-2">
{sizes.map((size) => (
<>
{size.value !== "UNSELECTED" && (
<button
key={size.id}
onClick={() => !unselectedMode && onToggleSize(size.value)}
disabled={unselectedMode}
onClick={() => onToggleSize(size.value)}
className={`py-3 px-4 rounded-lg border-2 text-sm font-medium transition-all ${
selectedSizes.includes(size.value)
? "border-blue-500 bg-blue-50 text-blue-700"
@ -79,7 +57,7 @@ export const SizeSelector: React.FC<SizeSelectorProps> = ({
</div>
{/* Selected Sizes */}
{selectedSizes.length > 0 && !unselectedMode && (
{selectedSizes.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedSizes.map((sizeValue) => {
const sizeInfo = sizes.find((s) => s.value === sizeValue);

View File

@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "@remix-run/react";
import { Loader2, Save, Store } from "lucide-react";
import { Loader2, Save, Store, Upload } from "lucide-react";
import ProfilePagesHeader from "../ProfilePagesHeader";
import HeaderWithSupport from "../HeaderWithSupport";
import AppInput from "../AppInput";
@ -9,6 +9,7 @@ import AppSelectBox from "../AppSelectBox";
import { Button } from "../ui/button";
import { FormField } from "../ui/FormField";
import { ImageUpload } from "./ImageUpload";
import { convertHeicToJpeg } from "~/utils/convert-heic";
import { ImageUploadDrawer } from "./ImageUploadDrawer";
import { Textarea } from "../ui/textarea";
import { useToast } from "../../hooks/use-toast";
@ -79,8 +80,26 @@ interface FormData {
selectedCity: string;
instagramId: string;
storeLogo: string;
bannerType: string;
bannerColor: string;
bannerImage: string;
}
// Preset banner colors offered alongside the custom color picker.
const BANNER_PRESETS = [
"#1d2b4d",
"#0f172a",
"#111827",
"#374151",
"#7c3aed",
"#be123c",
"#0e7490",
"#15803d",
"#b45309",
"#db2777",
];
const DEFAULT_BANNER_COLOR = "#1d2b4d";
interface StoreFormProps {
mode: "create" | "edit";
storeId?: string;
@ -97,17 +116,31 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
);
// Check store ownership for edit mode
const { isLoading: isOwnershipLoading } = useStoreOwnership(
const { isLoading: isOwnershipLoading, role } = useStoreOwnership(
mode === "edit" ? storeId : undefined,
mode
);
// Editing store info is owner-only; send staff back to the dashboard.
useEffect(() => {
if (mode === "edit" && !isOwnershipLoading && role && role !== "owner") {
navigate(`/store/${storeId}`);
}
}, [mode, isOwnershipLoading, role, storeId, navigate]);
// Form state
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Banner state (edit mode)
const bannerFileInputRef = useRef<HTMLInputElement>(null);
const [selectedBannerImage, setSelectedBannerImage] = useState<File | null>(
null
);
const [bannerPreviewUrl, setBannerPreviewUrl] = useState<string | null>(null);
// Loading modal states
const [createSellerOpen, setCreateSellerOpen] = useState(false);
const [imageUploadOpen, setImageUploadOpen] = useState(false);
@ -121,6 +154,9 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
selectedCity: "",
instagramId: "",
storeLogo: "",
bannerType: "color",
bannerColor: "",
bannerImage: "",
});
// Validation errors state
@ -156,6 +192,9 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
selectedCity: "", // Not available in SellerUpdate
instagramId: sellerData.instagramId || "",
storeLogo: sellerData.storeLogo || "",
bannerType: sellerData.bannerType || "color",
bannerColor: sellerData.bannerColor || "",
bannerImage: sellerData.bannerImage || "",
};
setFormData(initialData);
}
@ -169,11 +208,15 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
formData.storeDescription !== (sellerData.storeDescription || "") ||
formData.instagramId !== (sellerData.instagramId || "") ||
formData.storeLogo !== (sellerData.storeLogo || "") ||
selectedImage !== null; // Include image changes
formData.bannerType !== (sellerData.bannerType || "color") ||
formData.bannerColor !== (sellerData.bannerColor || "") ||
formData.bannerImage !== (sellerData.bannerImage || "") ||
selectedImage !== null || // Include image changes
selectedBannerImage !== null;
setHasChanges(hasFormChanges);
}
}, [mode, formData, sellerData, selectedImage]);
}, [mode, formData, sellerData, selectedImage, selectedBannerImage]);
// Redirect if user already has a store (create mode only)
useEffect(() => {
@ -195,6 +238,15 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
};
}, [imagePreviewUrl]);
// Clean up banner preview URL
useEffect(() => {
return () => {
if (bannerPreviewUrl) {
URL.revokeObjectURL(bannerPreviewUrl);
}
};
}, [bannerPreviewUrl]);
// Show loading while checking ownership or loading seller data (edit mode)
if (mode === "edit" && (isOwnershipLoading || isSellerLoading)) {
return (
@ -294,9 +346,13 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
!validationErrors.instagramId;
// Handle image selection
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const handleImageSelect = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const picked = event.target.files?.[0];
if (picked) {
// iPhone logos are often HEIC, which most browsers can't preview; convert.
const file = await convertHeicToJpeg(picked);
if (imagePreviewUrl) {
URL.revokeObjectURL(imagePreviewUrl);
}
@ -306,6 +362,24 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
}
};
// Handle banner image selection (from gallery; GIFs preserved)
const handleBannerSelect = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const picked = event.target.files?.[0];
if (picked) {
// convertHeicToJpeg only touches HEIC; GIF/PNG/JPG pass through untouched.
const file = await convertHeicToJpeg(picked);
if (bannerPreviewUrl) {
URL.revokeObjectURL(bannerPreviewUrl);
}
const newUrl = URL.createObjectURL(file);
setSelectedBannerImage(file);
setBannerPreviewUrl(newUrl);
setFormData((prev) => ({ ...prev, bannerType: "image" }));
}
};
// Handle drawer actions
const handleUploadClick = () => {
setIsDrawerOpen(true);
@ -375,11 +449,38 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
}
}
// Upload a newly selected banner image first (GIFs preserved by the API).
let bannerImageUrl = formData.bannerImage;
if (selectedBannerImage) {
try {
setImageUploadOpen(true);
const bannerResponse = await imageUploadMutation.mutateAsync({
file: selectedBannerImage,
usageType: "banner",
});
bannerImageUrl = bannerResponse.originalUrl || formData.bannerImage;
setImageUploadOpen(false);
} catch (error) {
console.error("Error uploading banner:", error);
setImageUploadOpen(false);
toast({
title: "خطا در آپلود بنر",
description:
"متاسفانه خطایی در آپلود بنر رخ داد. لطفا مجددا تلاش کنید.",
variant: "destructive",
});
return;
}
}
const updateData = {
storeName: formData.storeName,
storeDescription: formData.storeDescription || null,
instagramId: formData.instagramId || null,
storeLogo: logoUrl || null,
bannerType: formData.bannerType || "color",
bannerColor: formData.bannerColor || null,
bannerImage: bannerImageUrl || null,
};
updateSellerMutation.mutate({
@ -504,6 +605,116 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
);
};
// Render banner editor (edit mode only): color swatches/picker or image/GIF
const renderBannerSection = () => {
const previewImage = bannerPreviewUrl || formData.bannerImage;
const previewColor = formData.bannerColor || DEFAULT_BANNER_COLOR;
return (
<div className="flex flex-col gap-3">
<p className="text-R14">بنر فروشگاه</p>
{/* Live preview */}
<div className="relative h-28 w-full rounded-[14px] overflow-hidden border border-WHITE2 bg-WHITE3">
{formData.bannerType === "image" && previewImage ? (
<img
src={previewImage}
alt="بنر فروشگاه"
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full"
style={{ backgroundColor: previewColor }}
/>
)}
</div>
{/* Type toggle */}
<div className="flex gap-2">
<button
type="button"
onClick={() => handleInputChange("bannerType", "color")}
className={`flex-1 h-10 rounded-[10px] border text-R12 ${
formData.bannerType === "color"
? "bg-BLACK text-WHITE border-BLACK"
: "bg-WHITE2 text-BLACK border-WHITE3"
}`}
>
رنگ
</button>
<button
type="button"
onClick={() => handleInputChange("bannerType", "image")}
className={`flex-1 h-10 rounded-[10px] border text-R12 ${
formData.bannerType === "image"
? "bg-BLACK text-WHITE border-BLACK"
: "bg-WHITE2 text-BLACK border-WHITE3"
}`}
>
تصویر
</button>
</div>
{formData.bannerType === "color" ? (
<div className="flex flex-wrap items-center gap-2">
{BANNER_PRESETS.map((c) => (
<button
key={c}
type="button"
onClick={() => handleInputChange("bannerColor", c)}
aria-label={c}
className={`w-8 h-8 rounded-full border-2 transition-all ${
(formData.bannerColor || "").toLowerCase() === c.toLowerCase()
? "border-BLACK scale-110"
: "border-WHITE2"
}`}
style={{ backgroundColor: c }}
/>
))}
{/* Custom color */}
<label
className="w-8 h-8 rounded-full border-2 border-dashed border-GRAY3 overflow-hidden flex items-center justify-center cursor-pointer"
aria-label="رنگ دلخواه"
>
<input
type="color"
value={previewColor}
onChange={(e) =>
handleInputChange("bannerColor", e.target.value)
}
className="w-10 h-10 cursor-pointer border-0 bg-transparent p-0"
/>
</label>
</div>
) : (
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => bannerFileInputRef.current?.click()}
className="w-full h-11 rounded-[10px] border-2 border-dashed border-GRAY3 flex items-center justify-center gap-2 text-GRAY text-R12 hover:border-primary hover:text-primary transition-colors"
>
<Upload size={18} />
{previewImage ? "تغییر تصویر بنر" : "آپلود تصویر بنر"}
</button>
<p className="text-R10 text-BLACK2 leading-5">
اندازه پیشنهادی: ۱۶۰۰×۵۰۰ پیکسل. بنر تمامعرض نمایش داده میشود و
فقط ارتفاع آن برش میخورد؛ بخشهای مهم (متن/لوگو) را وسط قرار دهید.
فرمت JPG/PNG/WebP یا GIF متحرک.
</p>
</div>
)}
<input
ref={bannerFileInputRef}
type="file"
accept="image/*,image/gif"
onChange={handleBannerSelect}
className="hidden"
/>
</div>
);
};
// Render description field based on mode
const renderDescriptionField = () => {
if (mode === "create") {
@ -615,16 +826,22 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
const contentClass =
mode === "create"
? "flex flex-col gap-6 w-full px-4 pt-6"
: "flex flex-col gap-6 p-4 bg-WHITE";
? "flex flex-col gap-6 w-full px-4 pt-6 lg:max-w-[640px] lg:mx-auto lg:pt-8"
: "flex flex-col gap-6 p-4 bg-WHITE lg:max-w-[640px] lg:mx-auto lg:pt-8";
return (
<div className={containerClass}>
{renderHeader()}
<h1 className="hidden lg:block text-[28px] font-extrabold px-4 lg:max-w-[640px] lg:mx-auto lg:w-full lg:px-0 lg:pt-8">
{mode === "create" ? "ایجاد فروشگاه" : "ویرایش اطلاعات فروشگاه"}
</h1>
<div className={contentClass}>
{renderLogoSection()}
{/* Banner editor (edit mode) */}
{mode === "edit" && renderBannerSection()}
{/* Username Input - Only for create mode */}
{mode === "create" && (
<FormField

View File

@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { Bell, BellOff, ChevronLeft } from "lucide-react";
import { usePushNotifications } from "~/hooks/usePushNotifications";
const SNOOZE_KEY = "store_push_prompt_snoozed_until";
const SNOOZE_MS = 24 * 60 * 60 * 1000; // 24h — a soft nudge, not a dismissal.
/**
* Seller-facing card at the top of the store dashboard that nudges the owner
* (or a staff member) to turn on browser/PWA notifications. Order notifications
* are business-critical, so unlike the global buyer banner this one is NOT
* dismissible-forever a snooze hides it for a day; the prompt returns until
* push is actually subscribed. When permission was denied, we can't
* re-request programmatically, so the copy switches to a "how to re-enable in
* the browser" instruction.
*/
export function StorePushPromptCard() {
const { supported, permission, isSubscribed, subscribe, busy } =
usePushNotifications();
const [snoozed, setSnoozed] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const until = Number(localStorage.getItem(SNOOZE_KEY) || 0);
setSnoozed(!!until && Date.now() < until);
}, []);
// Nothing to show if the browser can't do push, they already subscribed,
// or they hit "later" in the last 24h.
if (!supported) return null;
if (isSubscribed) return null;
if (snoozed) return null;
const snoozeUntilTomorrow = () => {
localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS));
setSnoozed(true);
};
const enable = async () => {
await subscribe();
};
// Permission denied — we cannot prompt again from JS. Show recovery hint.
if (permission === "denied") {
return (
<div className="mb-4 rounded-2xl border border-RED/30 bg-RED/5 p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-RED/10 grid place-items-center shrink-0">
<BellOff size={20} className="text-RED" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-[14.5px] text-BLACK">
اعلانها در مرورگر مسدود شدهاند
</p>
<p className="text-[13px] text-BLACK2 leading-6 mt-1">
برای دریافت اعلان سفارشهای جدید، از تنظیمات مرورگر یا PWA اعلانها را برای این سایت فعال کنید و سپس صفحه را رفرش کنید.
</p>
</div>
</div>
</div>
);
}
// Default (never asked) OR granted-but-not-subscribed — both resolvable by
// clicking Enable, which triggers the PushManager subscribe flow.
return (
<div className="mb-4 rounded-2xl border border-VITROWN_BLUE/20 bg-VITROWN_BLUE/5 p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-VITROWN_BLUE/10 grid place-items-center shrink-0">
<Bell size={20} className="text-VITROWN_BLUE" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-[14.5px] text-BLACK">
اعلان سفارشهای جدید را فعال کنید
</p>
<p className="text-[13px] text-BLACK2 leading-6 mt-1">
تا لحظهای که یک مشتری سفارش میثبت، روی مرورگر یا موبایل خود پیامی میگیرید و هیچ سفارشی را از دست نمیدهید.
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={enable}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-xl bg-VITROWN_BLUE text-white text-[13px] font-bold px-3.5 py-2 disabled:opacity-60"
>
{busy ? "..." : "فعال‌سازی"}
<ChevronLeft size={16} />
</button>
<button
onClick={snoozeUntilTomorrow}
className="text-[13px] font-semibold text-GRAY px-2 py-2"
>
بعداً یادآوری کن
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -17,10 +17,10 @@ const ChatHeader = ({
const { theme } = useRootData();
const navigate = useNavigate();
return (
<div className="max-w-md mx-auto flex flex-row h-[65px] bg-WHITE fixed top-0 left-0 w-full items-center justify-center border-b border-inner-border z-10">
<div className="max-w-md mx-auto flex flex-row h-[calc(65px_+_env(safe-area-inset-top))] pt-[env(safe-area-inset-top)] bg-WHITE fixed top-0 left-0 w-full items-center justify-center border-b border-inner-border z-10">
<ArrowRight
onClick={() => safeGoBack(navigate)}
className="absolute top-5 right-5"
className="absolute top-[calc(1.25rem_+_env(safe-area-inset-top))] right-5"
/>
{isConnected ? (
<Link

View File

@ -1,4 +1,5 @@
import { Reply, MoreVertical, Trash2, Check } from "lucide-react";
import { Link } from "@remix-run/react";
import {
DropdownMenu,
@ -14,9 +15,17 @@ import { Button } from "../ui/button";
import { useState, useRef, useEffect } from "react";
// Message bubble component
//
// isCurrentUser: whether this message belongs to "our side" of the chat.
// In seller-side threads that includes coworkers, not just yourself.
// isCoworker: this message was written by a teammate (owner or staff) other
// than the viewer. We still put it on the right side, but we surface the
// sender's name so you can tell who typed it — and we don't offer to
// delete it (backend only lets a user delete their own messages).
export const MessageBubble = ({
message,
isCurrentUser,
isCoworker = false,
onReply,
onDelete,
replyToMessageContent,
@ -26,6 +35,7 @@ export const MessageBubble = ({
}: {
message: MessageList;
isCurrentUser: boolean;
isCoworker?: boolean;
onReply: (message: MessageList) => void;
onDelete?: (messageId: string) => void;
replyToMessageContent?: string | undefined;
@ -62,6 +72,11 @@ export const MessageBubble = ({
isCurrentUser ? "bg-BLUE2 text-WHITE" : "bg-GRAY3"
}`}
>
{isCoworker && message.senderUsername && (
<div className="text-R10 text-WHITE/80 mb-1 font-bold">
{message.senderUsername}
</div>
)}
{message.replyTo && (
<div
className={`text-xs italic mb-1 pb-1 border-b ${
@ -81,7 +96,42 @@ export const MessageBubble = ({
{replyToMessageContent || "پاسخ به پیام"}
</div>
)}
<p>{message.content}</p>
{message.product && (
<Link
to={`/product/${message.product.id}`}
className="flex items-center gap-2 mb-1 w-[210px] max-w-full rounded-lg bg-WHITE border border-GRAY3 p-2"
>
{message.product.primaryImage ? (
<img
src={message.product.primaryImage}
alt=""
className="w-12 h-12 rounded-md object-cover shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-md bg-WHITE3 shrink-0" />
)}
<div className="min-w-0 text-right">
<p className="text-R12 text-BLACK truncate">
{message.product.title}
</p>
{message.product.basePrice && (
<p className="text-R10 text-GRAY mt-0.5">
{parseInt(message.product.basePrice).toLocaleString("fa-IR")}{" "}
تومان
</p>
)}
</div>
</Link>
)}
{message.imageUrl && (
<img
src={message.imageUrl}
alt=""
className="max-w-[240px] rounded-lg mb-1 cursor-pointer"
onClick={() => window.open(message.imageUrl, "_blank")}
/>
)}
{message.content && <p className="whitespace-pre-wrap">{message.content}</p>}
</div>
<div className="flex flex-col justify-between items-start">
<div className="flex flex-col gap-1 items-start">
@ -107,7 +157,7 @@ export const MessageBubble = ({
<Reply size={14} />
<span className="text-R12">پاسخ</span>
</DropdownMenuItem>
{isCurrentUser && (
{isCurrentUser && !isCoworker && (
<DropdownMenuItem
onClick={() => {
setShowDropdownMenu(false);

View File

@ -1,12 +1,24 @@
import { ArrowRight, Loader2, Reply, SmilePlus, X } from "lucide-react";
import { ArrowRight, Image as ImageIcon, Loader2, Reply, SmilePlus, X } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { useRef, useState } from "react";
import EmojiPicker from "./EmojiPicker";
import type { MessageList } from "../../../src/api/types/models";
import { useAuthToken } from "../../hooks/use-root-data";
import { getApiBaseUrl } from "../../utils/api-config";
import { toast } from "../../hooks/use-toast";
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif"];
// Message input component
//
// `canSendImage` toggles the paperclip button — only team members (owner or
// active staff of the thread's seller) can attach images. The buyer side
// leaves it off so customers can only send text.
const MessageInput = ({
onSend,
onSendImage,
canSendImage = false,
replyToMessage,
onCancelReply,
messageText,
@ -15,6 +27,8 @@ const MessageInput = ({
setReplyToMessage,
}: {
onSend: (text: string) => void;
onSendImage?: (imageUrl: string) => void;
canSendImage?: boolean;
replyToMessage: MessageList | undefined;
onCancelReply: () => void;
inputRef: React.RefObject<HTMLInputElement>;
@ -23,6 +37,10 @@ const MessageInput = ({
isSendingMessage: boolean;
setReplyToMessage: (message: MessageList | undefined) => void;
}) => {
const token = useAuthToken();
const apiBase = getApiBaseUrl();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [caretPosition, setCaretPosition] = useState<number | null>(null);
// Track current textarea height
@ -77,6 +95,65 @@ const MessageInput = ({
setMessageText(e.target.value);
};
const handlePickImage = () => {
if (!canSendImage || isUploadingImage) return;
fileInputRef.current?.click();
};
const handleFileSelected = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0];
// Reset the input so the same file can be re-picked after cancel.
if (e.target) e.target.value = "";
if (!file || !canSendImage || !onSendImage) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
toast({
title: "فرمت پشتیبانی نمی‌شود",
description: "فقط تصاویر JPG / PNG / WebP / HEIC مجاز است.",
variant: "destructive",
});
return;
}
if (file.size > MAX_IMAGE_BYTES) {
toast({
title: "حجم زیاد",
description: "حداکثر حجم تصویر ۸ مگابایت است.",
variant: "destructive",
});
return;
}
setIsUploadingImage(true);
try {
const form = new FormData();
form.append("file", file);
form.append("usage", "chat");
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${apiBase}/api/media/v1/upload/`, {
method: "POST",
headers,
body: form,
});
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
const data = await res.json();
const url: string | undefined = data.originalUrl || data.original_url;
if (!url) throw new Error("no url in upload response");
onSendImage(url);
} catch (err) {
console.error("chat image upload failed:", err);
toast({
title: "خطا در ارسال تصویر",
description: "لطفاً دوباره تلاش کنید.",
variant: "destructive",
});
} finally {
setIsUploadingImage(false);
}
};
return (
<>
{/* Reply indicator */}
@ -91,7 +168,7 @@ const MessageInput = ({
</button>
</div>
)}
<div className="max-w-md mx-auto fixed px-2 bottom-0 py-2 w-full bg-WHITE">
<div className="max-w-md mx-auto fixed px-2 bottom-0 pt-2 pb-[calc(0.5rem_+_env(safe-area-inset-bottom))] w-full bg-WHITE">
{replyToMessage && (
<div className="bg-GRAY3 rounded-lg p-2 flex justify-between items-center mb-2">
<div className="flex items-center">
@ -130,6 +207,31 @@ const MessageInput = ({
</PopoverContent>
</Popover>
{canSendImage && (
<>
<input
ref={fileInputRef}
type="file"
accept={ALLOWED_IMAGE_TYPES.join(",")}
onChange={handleFileSelected}
className="hidden"
/>
<button
type="button"
onClick={handlePickImage}
disabled={isUploadingImage}
className="absolute right-10 bottom-3 w-6 h-6 flex items-center justify-center disabled:opacity-50"
aria-label="ارسال تصویر"
>
{isUploadingImage ? (
<Loader2 className="w-5 h-5 animate-spin text-GRAY" />
) : (
<ImageIcon className="w-5 h-5 text-GRAY hover:text-BLACK" />
)}
</button>
</>
)}
<textarea
ref={textareaRef}
value={messageText}
@ -142,16 +244,13 @@ const MessageInput = ({
}
className="hide-scrollbar overflow-y-auto h-12 flex w-full rounded-[12px] border border-input bg-transparent px-3 pr-10 py-2 text-base shadow-sm resize-none outline-none transition-height duration-100"
style={{
paddingRight: "40px",
paddingRight: canSendImage ? "72px" : "40px",
paddingLeft: "48px",
direction: "rtl",
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
// Enter inserts a newline. Send is via the button only — matches
// seller request: multi-line messages are common when replying to
// a customer with details, and accidental Enter-sends were noisy.
/>
<button
onClick={handleSend}

View File

@ -22,6 +22,8 @@ const MessageList = forwardRef(
isLoading,
isFetching,
userId,
teamUserIds,
viewerIsStore = false,
onReply,
onDelete,
onScroll,
@ -31,6 +33,13 @@ const MessageList = forwardRef(
isLoading: boolean;
isFetching: boolean;
userId: string | undefined;
// Senders on the store team. Only treated as "our side" when the VIEWER
// is store-side (viewerIsStore) — otherwise a customer would wrongly see
// the store's staff messages on their own (right) side.
teamUserIds?: readonly string[];
// True when the current viewer is on this thread's store team. Gates all
// team-grouping so the customer view stays "my messages right, store left".
viewerIsStore?: boolean;
onReply: (message: MessageListType) => void;
onDelete?: (messageId: string) => void;
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
@ -163,7 +172,18 @@ const MessageList = forwardRef(
<MessageBubble
otherParticipant={otherParticipant}
message={message}
isCurrentUser={message.sender === userId}
isCurrentUser={
message.sender === userId ||
(viewerIsStore &&
!!message.sender &&
!!teamUserIds?.includes(message.sender))
}
isCoworker={
viewerIsStore &&
message.sender !== userId &&
!!message.sender &&
!!teamUserIds?.includes(message.sender)
}
onReply={onReply}
onDelete={onDelete}
replyToMessageContent={message.replyContent || ""}

View File

@ -80,6 +80,7 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
createdAt: new Date().toISOString(),
replyTo: newMessage.reply_to_id,
replyContent: newMessage.reply_content || "",
imageUrl: newMessage.image_url || undefined,
});
resetNewMessage();
setTimeout(() => {
@ -219,6 +220,26 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
setMessageText("");
};
// Send an image the user already uploaded. Content stays empty; the bubble
// renders the image with no accompanying text.
const handleSendImage = (imageUrl: string) => {
if (isConnected) {
sendWebSocketMessage(
"",
replyToMessage?.id,
replyToMessage?.content,
imageUrl,
);
}
setReplyToMessage(undefined);
};
// Team membership check: current user in teamUserIds → can attach images.
const currentUserIsTeam = !!(
user?.id &&
threadDetails?.teamUserIds?.includes(user.id)
);
// Handle reply to message
const handleReplyClick = (message: MessageListType) => {
setReplyToMessage(message);
@ -254,7 +275,7 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
) : (
<>
<div
className={`hide-scrollbar flex-grow w-full max-h-[calc(100vh-${replyToMessage ? "120px" : "75px"})] flex flex-col`}
className={`hide-scrollbar flex-grow w-full max-h-[calc(100vh_-_${replyToMessage ? "120px" : "75px"}_-_env(safe-area-inset-top)_-_env(safe-area-inset-bottom))] flex flex-col`}
>
{/* Messages list */}
<MessageList
@ -264,6 +285,8 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
isLoading={isLoadingMessages || isLoadingThread}
isFetching={isFetchingNextPage}
userId={user?.id}
teamUserIds={threadDetails?.teamUserIds}
viewerIsStore={threadDetails?.viewerIsStore}
onReply={handleReplyClick}
onDelete={handleDeleteMessage}
onScroll={handleScroll}
@ -276,6 +299,8 @@ export default function ThreadChatPage({ threadId }: ThreadChatPageProps) {
messageText={messageText}
setMessageText={setMessageText}
onSend={handleSendMessage}
onSendImage={handleSendImage}
canSendImage={currentUserIsTeam}
replyToMessage={replyToMessage}
onCancelReply={() => setReplyToMessage(undefined)}
inputRef={inputRef}

View File

@ -1,7 +1,10 @@
import { Link } from "@remix-run/react";
import AppInput from "../AppInput";
import { useState } from "react";
import { useGetUserThreads } from "../../requestHandler/use-chat-hooks";
import {
useGetUserThreads,
useGetStoreThreads,
} from "../../requestHandler/use-chat-hooks";
import {
ThreadList,
ThreadParticipant,
@ -10,9 +13,33 @@ import {
import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks";
import UiProvider from "../UiProvider";
import SellerLogo from "../SellerLogo";
import { Search, User } from "lucide-react";
import { CheckCheck, MessageCircleWarning, Search, User } from "lucide-react";
import ProfilePagesHeader from "../ProfilePagesHeader";
// Persian-style relative timestamp for the inbox list. Older messages need a
// day label so the seller can tell "today's message" from "3 months ago"
// without reading the date.
function formatThreadTimestamp(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const now = new Date();
const startOfDay = (x: Date) =>
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const dayDiff = Math.round(
(startOfDay(now) - startOfDay(d)) / (1000 * 60 * 60 * 24),
);
const timePart = d.toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
});
if (dayDiff <= 0) return timePart; // today: just HH:MM
if (dayDiff === 1) return `دیروز ${timePart}`;
if (dayDiff < 7) return `${dayDiff} روز پیش`;
// Older than a week: show the date in Persian calendar.
return d.toLocaleDateString("fa-IR");
}
interface ThreadsPageProps {
type: "user" | "store";
storeId?: string;
@ -20,7 +47,16 @@ interface ThreadsPageProps {
export default function ThreadsPage({ type, storeId }: ThreadsPageProps) {
const [searchValue, setSearchValue] = useState("");
const { data: threads, isLoading, isError, refetch } = useGetUserThreads();
// Seller dashboard shows the STORE's inbox; the profile page shows the user's
// personal (buyer) chats. Two different endpoints so the two never mix.
const userThreads = useGetUserThreads();
const storeThreads = useGetStoreThreads(type === "store" ? storeId : undefined);
const {
data: threads,
isLoading,
isError,
refetch,
} = type === "store" ? storeThreads : userThreads;
const sortedThreads = threads?.sort((a, b) => {
const dateA = new Date(a.lastMessageTime || "2025-01-18T00:00:00.000Z");
const dateB = new Date(b.lastMessageTime || "2025-01-18T00:00:00.000Z");
@ -97,6 +133,24 @@ const ThreadsList = ({
);
const hasUnread = Number(thread.unreadCount || "0") > 0;
// Seller-inbox only. Shows what state the thread is in so the team
// can see at a glance which chats need attention.
// - "needs-reply" → last message is from the customer (or nobody
// on the team has replied yet).
// - "unread-by-customer" → team replied, customer hasn't seen it.
// - "seen" → team replied and the customer opened it.
const showStatus = type === "store";
const lastFromTeam = !!thread.lastMessageFromTeam;
const otherSeen = !!thread.otherPartySeenLast;
const status: "needs-reply" | "unread-by-customer" | "seen" | null =
!showStatus
? null
: !lastFromTeam
? "needs-reply"
: otherSeen
? "seen"
: "unread-by-customer";
// Generate link based on type
const linkTo =
type === "user"
@ -116,22 +170,32 @@ const ThreadsList = ({
)}
<div className="flex flex-col w-full">
<div className="w-full flex justify-between items-center">
<p className={`text-R14 ${hasUnread ? "font-bold" : ""}`}>
{thread.isCreator
? otherParticipant?.storeName ||
otherParticipant?.username
: otherParticipant?.name}
</p>
<div className="flex flex-col items-center justify-center">
<div className="flex items-center gap-1 min-w-0">
{status === "needs-reply" && (
<MessageCircleWarning
size={16}
className="text-RED shrink-0"
aria-label="در انتظار پاسخ شما"
/>
)}
{status === "seen" && (
<CheckCheck
size={16}
className="text-blue-500 shrink-0"
aria-label="پیام دیده شده"
/>
)}
<p className={`text-R14 truncate ${hasUnread || status === "needs-reply" ? "font-bold" : ""}`}>
{thread.isCreator
? otherParticipant?.storeName ||
otherParticipant?.username
: otherParticipant?.name}
</p>
</div>
<div className="flex flex-col items-end justify-center">
{thread.lastMessageTime && (
<p className="text-R10 text-GRAY">
{new Date(thread.lastMessageTime).toLocaleTimeString(
"fa-IR",
{
hour: "2-digit",
minute: "2-digit",
}
)}
{formatThreadTimestamp(thread.lastMessageTime)}
</p>
)}
{hasUnread && (
@ -144,7 +208,12 @@ const ThreadsList = ({
<p
className={`text-R14 ${hasUnread ? "text-BLACK font-bold" : "text-GRAY"}`}
>
{thread.lastMessage?.content || "بدون پیام"}
{thread.lastMessage?.content ||
(thread.lastMessage?.imageUrl
? "📷 تصویر"
: thread.lastMessage?.product
? `🛍 ${thread.lastMessage.product.title || "محصول"}`
: "بدون پیام")}
</p>
</div>
</div>

View File

@ -0,0 +1,76 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthToken } from "./use-root-data";
import { getApiBaseUrl } from "~/utils/api-config";
// The API returns these as camelCase (DRF's global camel-case renderer converts
// the snake_case source names). Match the wire shape here so reads aren't
// silently undefined.
export interface BadgeCounts {
chat: number;
sellerChat: Record<string, number>;
orderUpdates: number;
sellerOrders: number;
}
const EMPTY: BadgeCounts = {
chat: 0,
sellerChat: {},
orderUpdates: 0,
sellerOrders: 0,
};
export const badgeKeys = {
all: ["notif-badges"] as const,
};
/**
* Polls unread counts for the nav badges (chat / buyer order updates / seller
* new orders). Lightweight: a single endpoint, refetched on an interval and
* when the tab regains focus.
*/
export function useBadgeCounts() {
const token = useAuthToken();
const apiBase = getApiBaseUrl();
return useQuery({
queryKey: badgeKeys.all,
enabled: !!token,
refetchInterval: 30000,
refetchOnWindowFocus: true,
queryFn: async (): Promise<BadgeCounts> => {
if (!token) return EMPTY;
const res = await fetch(`${apiBase}/api/notif/v1/badges/`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return EMPTY;
return (await res.json()) as BadgeCounts;
},
});
}
/**
* Marks a badge category ("order_updates" | "seller_orders") as read, then
* refreshes the counts. Chat clears via the existing per-thread read flow.
*/
export function useMarkBadgeRead() {
const token = useAuthToken();
const apiBase = getApiBaseUrl();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (category: "order_updates" | "seller_orders") => {
if (!token) return;
await fetch(`${apiBase}/api/notif/v1/badges/mark-read/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ category }),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: badgeKeys.all });
},
});
}

View File

@ -0,0 +1,254 @@
import { useCallback, useEffect, useState } from "react";
import { useAuthToken } from "./use-root-data";
import { getApiBaseUrl } from "~/utils/api-config";
import { toast } from "./use-toast";
/**
* Web Push subscription management.
*
* Flow: ask permission -> fetch VAPID public key -> subscribe via the service
* worker's PushManager -> POST the subscription to the backend. Disabling
* unsubscribes locally and tells the backend to drop the row.
*
* Every awaited step is guarded by a timeout several of them (notably
* `pushManager.subscribe` and `serviceWorker.ready` on iOS Safari when the
* PWA isn't home-screen-installed) can return a Promise that never resolves.
* Without timeouts the toggle wedges as "..." forever.
*/
type PermissionState = NotificationPermission | "unsupported";
const STEP_TIMEOUT_MS = 15000;
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`timeout: ${label}`)), ms);
p.then(
(v) => { clearTimeout(t); resolve(v); },
(e) => { clearTimeout(t); reject(e); },
);
});
}
function isIosSafari(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
const isIos = /iPad|iPhone|iPod/.test(ua) && !("MSStream" in window);
return isIos;
}
function isStandalonePwa(): boolean {
if (typeof window === "undefined") return false;
const mm = window.matchMedia?.("(display-mode: standalone)").matches;
// iOS-specific standalone flag on the Navigator object.
const nav = navigator as Navigator & { standalone?: boolean };
return !!(mm || nav.standalone);
}
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
// Back the array with a concrete ArrayBuffer so it satisfies BufferSource.
const outputArray = new Uint8Array(new ArrayBuffer(rawData.length));
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function isPushSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
export function usePushNotifications() {
const token = useAuthToken();
const apiBase = getApiBaseUrl();
const [permission, setPermission] = useState<PermissionState>("default");
const [isSubscribed, setIsSubscribed] = useState(false);
const [busy, setBusy] = useState(false);
const authHeaders = useCallback(() => {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}, [token]);
// Reflect current permission + subscription state on mount.
//
// We *also* re-POST the sub to the backend on every mount. Reasons:
// - iOS Safari silently expires push subs after a few days of inactivity;
// the local `getSubscription()` still returns the token but the backend
// row may have been 410'd and deleted. Re-POSTing recreates it.
// - Multi-device: a sub registered on device A may not exist server-side
// for device B. Idempotent upsert (`update_or_create` by endpoint) makes
// this safe to do on every visit.
useEffect(() => {
if (!isPushSupported()) {
setPermission("unsupported");
return;
}
setPermission(Notification.permission);
withTimeout(navigator.serviceWorker.ready, STEP_TIMEOUT_MS, "serviceWorker.ready")
.then((reg) => withTimeout(reg.pushManager.getSubscription(), STEP_TIMEOUT_MS, "getSubscription"))
.then((sub) => {
setIsSubscribed(!!sub);
if (sub && token) {
// Fire-and-forget refresh; failure is non-fatal.
fetch(`${apiBase}/api/notif/v1/push/subscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(sub.toJSON()),
}).catch(() => {});
}
})
.catch(() => setIsSubscribed(false));
}, [apiBase, token, authHeaders]);
const subscribe = useCallback(async (): Promise<boolean> => {
if (!isPushSupported() || !token) return false;
// iOS Safari only allows Web Push from a home-screen-installed PWA. In a
// regular Safari tab, `pushManager.subscribe` returns a Promise that
// never resolves — indistinguishable from a hang. Bail out early with a
// clear message rather than wedging the UI.
if (isIosSafari() && !isStandalonePwa()) {
toast({
title: "برای فعال‌سازی، ابتدا سایت را به صفحه اصلی اضافه کنید",
description:
"در Safari دکمه اشتراک‌گذاری را بزنید و «Add to Home Screen» را انتخاب کنید، سپس از آیکن روی صفحه اصلی وارد شوید و دوباره تلاش کنید.",
});
return false;
}
setBusy(true);
try {
const perm = await withTimeout(
Notification.requestPermission(),
STEP_TIMEOUT_MS,
"requestPermission",
);
setPermission(perm);
if (perm !== "granted") {
if (perm === "denied") {
toast({
title: "اعلان‌ها در مرورگر مسدود شده‌اند",
description:
"از تنظیمات مرورگر یا PWA اعلان‌ها را برای این سایت فعال کنید و صفحه را رفرش کنید.",
variant: "destructive",
});
}
return false;
}
// Get the VAPID public key from the backend. The backend's view returns
// {"public_key": ...} but its global CamelCaseJSONRenderer rewrites that
// to {"publicKey": ...} on the wire — so we read camelCase here.
const keyRes = await withTimeout(
fetch(`${apiBase}/api/notif/v1/push/vapid-public-key/`),
STEP_TIMEOUT_MS,
"vapid-key fetch",
);
const { publicKey } = await keyRes.json();
if (!publicKey) return false;
const reg = await withTimeout(
navigator.serviceWorker.ready,
STEP_TIMEOUT_MS,
"serviceWorker.ready",
);
let sub = await withTimeout(
reg.pushManager.getSubscription(),
STEP_TIMEOUT_MS,
"getSubscription",
);
if (!sub) {
sub = await withTimeout(
reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
}),
STEP_TIMEOUT_MS,
"pushManager.subscribe",
);
}
const res = await withTimeout(
fetch(`${apiBase}/api/notif/v1/push/subscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(sub.toJSON()),
}),
STEP_TIMEOUT_MS,
"backend subscribe",
);
const ok = res.ok;
setIsSubscribed(ok);
if (ok) {
toast({
title: "اعلان‌ها فعال شد",
description: "از این پس اعلان‌ها را دریافت می‌کنید.",
});
}
return ok;
} catch (e) {
console.error("Push subscribe failed:", e);
// Include the step name / error text in the toast so field debugging
// doesn't need browser console access — sellers can read out what
// failed to whoever's helping them.
const detail = e instanceof Error ? e.message : String(e);
toast({
title: "فعال‌سازی اعلان با خطا مواجه شد",
description: `${detail || "خطای ناشناخته"}. لطفاً چند لحظه بعد دوباره تلاش کنید.`,
variant: "destructive",
});
return false;
} finally {
setBusy(false);
}
}, [apiBase, token, authHeaders]);
const disable = useCallback(async (): Promise<void> => {
if (!isPushSupported()) return;
setBusy(true);
try {
const reg = await withTimeout(
navigator.serviceWorker.ready,
STEP_TIMEOUT_MS,
"serviceWorker.ready",
);
const sub = await withTimeout(
reg.pushManager.getSubscription(),
STEP_TIMEOUT_MS,
"getSubscription",
);
if (sub) {
await fetch(`${apiBase}/api/notif/v1/push/unsubscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ endpoint: sub.endpoint }),
}).catch(() => {});
await sub.unsubscribe().catch(() => {});
}
setIsSubscribed(false);
} catch (e) {
console.error("Push disable failed:", e);
} finally {
setBusy(false);
}
}, [apiBase, authHeaders]);
return {
supported: permission !== "unsupported",
permission,
isSubscribed,
busy,
subscribe,
disable,
};
}

View File

@ -0,0 +1,23 @@
import { useEffect } from "react";
import { useNavigate } from "@remix-run/react";
import { useServiceGetProfile } from "~/requestHandler/use-profile-hooks";
/**
* Gate a route to platform superusers. Non-admins (or unauthenticated) are
* bounced to the home page. Server-side, `/admin` is also in `protectedRoutes`
* so logged-out users never reach here this adds the is-superuser check.
*/
export function useRequireAdmin() {
const navigate = useNavigate();
const { data, isLoading, isError } = useServiceGetProfile();
const isAdmin = !!data?.isSuperuser;
useEffect(() => {
if (isLoading) return;
if (isError || !isAdmin) {
navigate("/", { replace: true });
}
}, [isLoading, isError, isAdmin, navigate]);
return { isLoading, isAdmin };
}

View File

@ -1,46 +1,71 @@
import { useEffect } from "react";
import { useNavigate } from "@remix-run/react";
import { useServiceGetProfile } from "../requestHandler/use-profile-hooks";
import { useMyStores } from "../requestHandler/use-seller-hooks";
/**
* Guards a store dashboard route. A user may enter a store they OWN or are an
* active STAFF member of. Non-members are redirected away. Returns the user's
* role for this store so callers can gate owner-only UI.
*/
export function useStoreOwnership(storeId: string | undefined, mode?: string) {
const navigate = useNavigate();
const { data: profileData, isLoading } = useServiceGetProfile();
const { data: stores, isLoading, isFetching, isError } = useMyStores();
const current = stores?.find(
(s) => s.username?.toLowerCase() === storeId?.toLowerCase()
);
useEffect(() => {
// Wait for profile data to load
if (isLoading) return;
// If no storeId provided, redirect to home
if (!storeId && mode !== "create") {
if (isLoading || isFetching || isError || mode === "create") return;
if (!storeId) {
navigate("/");
return;
}
// If user doesn't have profile data, redirect to home
if (!profileData) {
navigate("/");
return;
if (!stores || stores.length === 0) return; // nothing to compare against yet
if (!current) {
// The user is a member of at least one OTHER store — send them there.
// If they're not a member of any store, do NOT bounce to home: staying
// put lets an owner see their (empty) dashboard rather than being
// silently kicked out on a transient/mismatched my-stores response.
navigate(`/store/${stores[0].username}`);
}
}, [
stores,
current,
storeId,
isLoading,
isFetching,
isError,
navigate,
mode,
]);
// If user doesn't have a seller account, redirect to home
if (!profileData.sellerUsername && mode !== "create") {
navigate("/");
return;
}
// If the storeId doesn't match the user's seller username, redirect to their own store
if (
profileData.sellerUsername !== storeId &&
mode !== "create" &&
storeId
) {
navigate(`/store/${profileData.sellerUsername}`);
return;
}
}, [profileData, storeId, isLoading, navigate, mode]);
const ownedOrFirst = stores?.find((s) => s.role === "owner") ?? stores?.[0];
return {
isOwner: profileData?.sellerUsername === storeId,
isLoading,
userStoreId: profileData?.sellerUsername,
isMember: !!current,
role: current?.role ?? null,
isOwner: current?.role === "owner",
userStoreId: ownedOrFirst?.username,
stores: stores ?? [],
};
}
/**
* Guard for OWNER-ONLY store pages (financial, shipping, edit, team). Redirects
* non-members (via useStoreOwnership) and staff members back to the dashboard.
* Returns { isLoading, isOwner } so callers can render a spinner meanwhile.
*/
export function useRequireOwner(storeId: string | undefined) {
const navigate = useNavigate();
const { isLoading, role } = useStoreOwnership(storeId);
useEffect(() => {
if (!isLoading && role && role !== "owner") {
navigate(`/store/${storeId}`);
}
}, [isLoading, role, storeId, navigate]);
return { isLoading, isOwner: role === "owner" };
}

View File

@ -69,8 +69,8 @@ const useWebSocket = (threadId: string, userId: string | undefined) => {
const sendWebSocketMessage = (
content: string,
replyToId?: string | null,
// messageId?: string | null,
replyContent?: string | null
replyContent?: string | null,
imageUrl?: string | null,
) => {
if (!socket || socket.readyState !== WebSocket.OPEN || !userId) {
console.error("WebSocket is not connected");
@ -80,10 +80,9 @@ const useWebSocket = (threadId: string, userId: string | undefined) => {
const message: SendMessagePayload = {
type: "new_message",
content,
// sender_id: userId, // Remove this as it's not needed in client->server communication
...(replyToId && { reply_to_id: replyToId }),
...(replyContent && { reply_content: replyContent }),
// ...(messageId && { message_id: messageId }),
...(imageUrl && { image_url: imageUrl }),
};
socket.send(JSON.stringify(message));
@ -148,6 +147,7 @@ interface SendMessagePayload extends WebSocketMessage {
content: string;
reply_to_id?: string | null;
reply_content?: string | null;
image_url?: string | null;
}
interface MarkReadPayload extends WebSocketMessage {
@ -162,6 +162,7 @@ interface NewMessageEvent extends WebSocketMessage {
created_at: string;
reply_to_id?: string | null;
reply_content?: string | null;
image_url?: string | null;
}
interface ReadReceiptEvent extends WebSocketMessage {

View File

@ -0,0 +1,71 @@
/**
* Category-aware measurement suggestions for the product size chart (راهنمای سایز).
*
* Sellers can always type their own columns; these just power the quick-add
* chips and seed a sensible default column set per garment type. Matching is by
* Persian keyword substring against the product's category name, most-specific
* groups first, falling back to a general garment set.
*/
interface SuggestionGroup {
keys: string[];
suggestions: string[];
}
// Order matters: the first group whose keyword appears in the category name wins.
const GROUPS: SuggestionGroup[] = [
// ---- bottoms ----
{ keys: ["شلوار", "شلوارک"], suggestions: ["دور کمر", "دور باسن", "فاق", "دور ران", "قد", "دمپا"] },
{ keys: ["دامن"], suggestions: ["دور کمر", "دور باسن", "قد", "دمپا"] },
{ keys: ["مایو"], suggestions: ["دور سینه", "دور کمر", "دور باسن", "قد"] },
// ---- footwear ----
{ keys: ["کفش", "صندل", "دمپایی", "پاشنه"], suggestions: ["سایز اروپایی", "طول کف پا"] },
// ---- small accessories with their own sizing ----
{ keys: ["جوراب"], suggestions: ["سایز", "طول ساق"] },
{ keys: ["کلاه"], suggestions: ["دور سر"] },
{ keys: ["کمربند"], suggestions: ["طول", "عرض"] },
{ keys: ["دستکش"], suggestions: ["دور مچ", "طول دست"] },
{ keys: ["انگشتر"], suggestions: ["سایز انگشتر", "دور انگشت"] },
{ keys: ["دستبند", "پابند"], suggestions: ["دور مچ", "طول"] },
{ keys: ["گردنبند", "آویز"], suggestions: ["طول زنجیر"] },
{ keys: ["گوشواره", "گل سینه", "زیورآلات", "سردست"], suggestions: ["طول", "عرض"] },
{ keys: ["شال", "روسری", "دستمال"], suggestions: ["طول", "عرض"] },
{ keys: ["عینک"], suggestions: ["عرض فریم", "طول دسته"] },
// ---- bags ----
{ keys: ["کیف", "کوله"], suggestions: ["طول", "عرض", "ارتفاع", "طول بند"] },
// ---- one-piece / manteau ----
{ keys: ["مانتو", "کیمونو"], suggestions: ["دور سینه", "قد", "سرشانه", "آستین", "دور کمر"] },
{ keys: ["اورال", "پیراهن و اورال"], suggestions: ["دور سینه", "دور کمر", "دور باسن", "قد", "آستین"] },
// ---- tops & outerwear (broad garment fallback) ----
{
keys: [
"کاپشن", "بارانی", "پالتو", "کت", "ژاکت", "بامبر", "جلیقه", "دورس",
"سویشرت", "هودی", "پلیور", "بلوز", "شومیز", "تیشرت", "تاپ", "پلوشرت", "پیراهن",
],
suggestions: ["دور سینه", "قد", "سرشانه", "آستین", "عرض سینه", "دور کمر"],
},
];
// Generic fallback for anything unmatched (also the base defaults the seller named).
const DEFAULT_SUGGESTIONS = ["عرض سینه", "قد", "دور کمر", "دور باسن", "سرشانه", "آستین"];
/** All measurement suggestions for a category (used for the quick-add chips). */
export function getSizeSuggestions(categoryName?: string | null): string[] {
const name = (categoryName || "").trim();
if (name) {
for (const group of GROUPS) {
if (group.keys.some((k) => name.includes(k))) return group.suggestions;
}
}
return DEFAULT_SUGGESTIONS;
}
/** The columns to seed a brand-new (empty) chart with for this category. */
export function getDefaultColumns(categoryName?: string | null): string[] {
return getSizeSuggestions(categoryName).slice(0, 2);
}

View File

@ -0,0 +1,818 @@
import {
useQuery,
useMutation,
useQueryClient,
keepPreviousData,
} from "@tanstack/react-query";
import { useAuthToken } from "~/hooks/use-root-data";
import { getApiBaseUrl } from "~/utils/api-config";
/* ------------------------------------------------------------------ *
* Admin panel data hooks (superuser only). The admin API is a new,
* fast-moving surface, so these call it directly with fetch (same
* pattern as use-seller-hooks' useMyStores) rather than the generated
* client. Responses are camelCased by the backend renderer.
* ------------------------------------------------------------------ */
export interface AdminOverview {
generatedAt: string;
users: {
total: number;
active: number;
sellers: number;
new7d: number;
new30d: number;
};
sellers: {
total: number;
pending: number;
approved: number;
rejected: number;
verified: number;
};
products: { total: number; active: number };
orders: {
total: number;
pending: number;
confirmed: number;
shipped: number;
delivered: number;
cancelled: number;
paidCount: number;
revenueTotal: number;
ordersToday: number;
revenueToday: number;
};
money: {
walletHeld: number;
sellerPayable: number;
payoutsPendingCount: number;
payoutsPendingAmount: number;
};
ordersSeries: Array<{ date: string; orders: number; revenue: number }>;
}
async function adminGet<T>(path: string, token: string): Promise<T> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error(`Admin request failed (${res.status})`);
}
return res.json();
}
async function adminPatch<T>(
path: string,
body: Record<string, unknown>,
token: string
): Promise<T> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Admin update failed (${res.status})`);
}
return res.json();
}
export function useAdminOverview() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "overview"],
enabled: !!token,
refetchInterval: 60_000,
queryFn: () => adminGet<AdminOverview>("overview/", token as string),
});
}
/* ------------------------------ boards ------------------------------ */
export interface Paginated<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
export type AdminListParams = Record<
string,
string | number | boolean | undefined
>;
function toQuery(params: AdminListParams): string {
const usp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== "" && v !== null) usp.set(k, String(v));
});
const s = usp.toString();
return s ? `?${s}` : "";
}
function useAdminList<T>(key: string, params: AdminListParams) {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", key, params],
enabled: !!token,
placeholderData: keepPreviousData,
queryFn: () =>
adminGet<Paginated<T>>(`${key}/${toQuery(params)}`, token as string),
});
}
export interface AdminOrderRow {
id: string;
createdAt: string;
status: string;
paymentStatus: string;
totalPrice: number;
userPhone: string | null;
userName: string | null;
sellerName: string | null;
sellerUsername: string | null;
}
export interface AdminSellerRow {
id: string;
username: string;
storeName: string;
storeLogo: string | null;
status: string;
isVerified: boolean;
platformFee: number;
productCount: number;
followerCount: number;
reviewAverage: number;
createdAt: string;
userPhone: string | null;
}
export interface AdminUserRow {
id: string;
phoneNumber: string;
username: string | null;
email: string | null;
isActive: boolean;
isSeller: boolean;
isStaff: boolean;
isSuperuser: boolean;
otpVerified: boolean;
createdAt: string;
}
export interface AdminProductRow {
id: string;
title: string;
basePrice: number;
isActive: boolean;
createdAt: string;
sellerName: string | null;
sellerUsername: string | null;
categoryName: string | null;
primaryImage: string | null;
}
export const useAdminOrders = (params: AdminListParams) =>
useAdminList<AdminOrderRow>("orders", params);
export const useAdminSellers = (params: AdminListParams) =>
useAdminList<AdminSellerRow>("sellers", params);
export const useAdminUsers = (params: AdminListParams) =>
useAdminList<AdminUserRow>("users", params);
export const useAdminProducts = (params: AdminListParams) =>
useAdminList<AdminProductRow>("products", params);
/* ----------------------------- actions ------------------------------ */
function useAdminUpdate(resource: "sellers" | "users" | "products") {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`${resource}/${id}/`, data, token as string),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", resource] });
qc.invalidateQueries({ queryKey: ["admin", "overview"] });
},
});
}
export const useAdminSellerUpdate = () => useAdminUpdate("sellers");
export const useAdminUserUpdate = () => useAdminUpdate("users");
export const useAdminProductUpdate = () => useAdminUpdate("products");
/* ------------------------------ ledger ------------------------------ */
export interface LedgerOverview {
walletHeld: number;
sellerPayable: number;
depositsTotal: number;
purchasesTotal: number;
payouts: {
pending: { amount: number; count: number };
completed: { amount: number; count: number };
rejected: { amount: number; count: number };
};
reconciliation: {
completedPayoutsNotDebited: number;
sellerPayableRecorded: number;
sellerPayableEstimatedTrue: number;
};
// Slice 2c — balances derived from the append-only journal.
journal: {
sellerPayable: number;
walletHeld: number;
platformRevenue: number;
sellerPayableDrift: number;
walletHeldDrift: number;
};
}
export interface LedgerEntryRow {
id: string;
createdAt: string;
entryType: string;
account: string;
delta: number;
sellerName: string | null;
userPhone: string | null;
order: string | null;
note: string | null;
}
export interface LedgerTransactionRow {
id: string;
transactionType: string;
amount: number;
userPhone: string | null;
order: string | null;
authority: string | null;
createdAt: string;
}
export interface LedgerPayoutRow {
id: string;
sellerName: string | null;
sellerUsername: string | null;
amount: number;
status: string;
requestedAt: string;
processedAt: string | null;
iban: string | null;
bankName: string | null;
accountHolder: string | null;
shomareMarja: string | null;
shomareErja: string | null;
}
export interface LedgerSellerRow {
sellerId: string;
storeName: string | null;
username: string | null;
availableFunds: number;
completedPayouts: number;
pendingPayouts: number;
}
export interface LedgerSellerDetail {
seller: { id: string; storeName: string; username: string; platformFee: number };
recordedBalance: number;
accruedFromOrders: number;
completedPayouts: number;
pendingPayouts: number;
derivedBalance: number;
discrepancy: number;
journalBalance: number;
journalDiscrepancy: number;
bankAccounts: Array<{
id: string;
accountHolder: string;
iban: string;
cardNumber: string;
bankName: string;
isVerified: boolean;
}>;
payouts: LedgerPayoutRow[];
journal: LedgerEntryRow[];
}
export function useLedgerOverview() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ledger", "overview"],
enabled: !!token,
queryFn: () => adminGet<LedgerOverview>("ledger/overview/", token as string),
});
}
export const useLedgerTransactions = (params: AdminListParams) =>
useAdminList<LedgerTransactionRow>("ledger/transactions", params);
export const useLedgerPayouts = (params: AdminListParams) =>
useAdminList<LedgerPayoutRow>("ledger/payouts", params);
export const useLedgerSellers = (params: AdminListParams) =>
useAdminList<LedgerSellerRow>("ledger/sellers", params);
export const useLedgerJournal = (params: AdminListParams) =>
useAdminList<LedgerEntryRow>("ledger/journal", params);
/* ------------------------------ audit log ------------------------------ */
export interface AuditLogRow {
id: string;
createdAt: string;
actorPhone: string | null;
action: string;
targetType: string;
targetId: string | null;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
note: string;
}
export const useAuditLog = (params: AdminListParams) =>
useAdminList<AuditLogRow>("audit", params);
/* -------------------- discounts & collections (3b) -------------------- */
export interface AdminDiscountRow {
id: string;
name: string;
code: number;
discountType: string;
value: number;
minPurchaseAmount: number | null;
maxDiscountAmount: number | null;
validFrom: string;
validTo: string;
isActive: boolean;
eligibleUsers: string[] | null;
maxCount: number | null;
isValid: boolean;
}
export interface AdminCollectionRow {
id: string;
title: string;
description: string | null;
creatorType: string;
creatorPhone: string | null;
isPublic: boolean;
coverImageUrl: string | null;
productCount: number;
createdAt: string;
}
/* --------------------------- moderation (3d) --------------------------- */
export interface AdminCommentRow {
id: string;
productId: string;
productTitle: string | null;
userPhone: string | null;
comment: string;
parentId: string | null;
isReply: boolean;
repliesCount: number;
isHidden: boolean;
isVerifiedBuyer: boolean;
createdAt: string;
}
export interface AdminChatMessageRow {
id: string;
content: string;
senderPhone: string | null;
threadId: string | null;
productId: string | null;
imageUrl: string | null;
isDeleted: boolean;
createdAt: string;
}
export const useAdminComments = (params: AdminListParams) =>
useAdminList<AdminCommentRow>("moderation/comments", params);
export const useAdminChatMessages = (params: AdminListParams) =>
useAdminList<AdminChatMessageRow>("moderation/chat", params);
export function useAdminCommentModerate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, isHidden }: { id: string; isHidden: boolean }) =>
adminPatch(`moderation/comments/${id}/`, { isHidden }, token as string),
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["admin", "moderation/comments"] }),
});
}
export function useAdminCommentDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminDelete(`moderation/comments/${id}/`, token as string),
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["admin", "moderation/comments"] }),
});
}
/* -------------------------- notifications (3e) -------------------------- */
export interface NotificationTypeRow {
id: number;
code: string;
title: string;
defaultSms: boolean;
defaultInApp: boolean;
template: string;
variables: string[];
}
export interface NotificationRow {
id: number;
typeCode: string | null;
typeTitle: string | null;
receiverPhone: string | null;
title: string;
body: string;
read: boolean;
sentSms: boolean;
sentInApp: boolean;
smsLineNumber: number | null;
createdAt: string;
}
export interface SmsBlacklistRow {
id: number;
mobile: string;
lineNumber: number;
reason: string | null;
createdAt: string;
}
export interface NotificationsOverview {
total: number;
smsSent: number;
inAppSent: number;
unread: number;
types: number;
pushSubscriptions: number;
blacklistedLines: number;
byType: { code: string; title: string; count: number }[];
}
export function useNotificationsOverview() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "notifications", "overview"],
enabled: !!token,
queryFn: () => adminGet<NotificationsOverview>("notifications/overview/", token as string),
});
}
export const useNotificationTypes = (params: AdminListParams) =>
useAdminList<NotificationTypeRow>("notifications/types", params);
export const useNotifications = (params: AdminListParams) =>
useAdminList<NotificationRow>("notifications/sent", params);
export const useSmsBlacklist = (params: AdminListParams) =>
useAdminList<SmsBlacklistRow>("notifications/blacklist", params);
export function useNotificationTypeCreate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
adminPost("notifications/types/", data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/types"] }),
});
}
export function useNotificationTypeUpdate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
adminPatch(`notifications/types/${id}/`, data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/types"] }),
});
}
export function useNotificationTypeDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => adminDelete(`notifications/types/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/types"] }),
});
}
export function useSmsBlacklistDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => adminDelete(`notifications/blacklist/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/blacklist"] }),
});
}
/* --------------------- homepage / merchandising (3f) --------------------- */
export interface HomeBannerRow {
id: number;
title: string;
linkType: string;
linkId: number;
isActive: boolean;
isVisible: boolean;
imageUrl: string | null;
startDate: string;
endDate: string;
createdAt: string;
}
export interface HomeCategoryRow {
id: string;
categoryId: string;
categoryName: string | null;
imageUrl: string | null;
isActive: boolean;
}
export interface HomeSellerTileRow {
id: string;
sellerId: string;
sellerName: string | null;
imageUrl: string | null;
isActive: boolean;
order: number;
createdAt: string;
}
export const useHomeBanners = (params: AdminListParams) =>
useAdminList<HomeBannerRow>("homepage/banners", params);
export const useHomeCategories = (params: AdminListParams) =>
useAdminList<HomeCategoryRow>("homepage/categories", params);
export const useHomeSellerTiles = (params: AdminListParams) =>
useAdminList<HomeSellerTileRow>("homepage/tiles", params);
/** PATCH/DELETE a homepage entry. `kind` maps to the URL segment. */
function useHomeMutations(kind: "banners" | "categories" | "tiles") {
const token = useAuthToken();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ["admin", `homepage/${kind}`] });
const update = useMutation({
mutationFn: ({ id, data }: { id: string | number; data: Record<string, unknown> }) =>
adminPatch(`homepage/${kind}/${id}/`, data, token as string),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string | number) => adminDelete(`homepage/${kind}/${id}/`, token as string),
onSuccess: invalidate,
});
return { update, remove };
}
export const useHomeBannerMutations = () => useHomeMutations("banners");
export const useHomeCategoryMutations = () => useHomeMutations("categories");
export const useHomeSellerTileMutations = () => useHomeMutations("tiles");
/* ------------------------ catalog attributes (3g) ------------------------ */
export interface CategoryLite {
id: string;
name: string;
}
export interface AttributeRow {
id: string;
name: string;
categories: string[];
categoryNames: string[];
valueCount: number;
}
export interface AttributeValueRow {
id: string;
attribute: string;
attributeName: string;
value: string;
info: unknown;
}
export function useCatalogCategories() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "catalog", "categories"],
enabled: !!token,
queryFn: () => adminGet<CategoryLite[]>("catalog/categories/", token as string),
});
}
export const useAttributes = (params: AdminListParams) =>
useAdminList<AttributeRow>("catalog/attributes", params);
export const useAttributeValues = (params: AdminListParams) =>
useAdminList<AttributeValueRow>("catalog/attribute-values", params);
function useCatalogMutations(kind: "attributes" | "attribute-values", invalidateKey: string) {
const token = useAuthToken();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ["admin", invalidateKey] });
const create = useMutation({
mutationFn: (data: Record<string, unknown>) =>
adminPost(`catalog/${kind}/`, data, token as string),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`catalog/${kind}/${id}/`, data, token as string),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => adminDelete(`catalog/${kind}/${id}/`, token as string),
onSuccess: invalidate,
});
return { create, update, remove };
}
export const useAttributeMutations = () =>
useCatalogMutations("attributes", "catalog/attributes");
export const useAttributeValueMutations = () =>
useCatalogMutations("attribute-values", "catalog/attribute-values");
/* ---------------------- AI pipelines health (3c) ---------------------- */
interface FailureRow {
id: string;
error: string;
createdAt?: string;
updatedAt?: string;
productId?: string;
sellerId?: string;
}
export interface AiHealthOverview {
generatedAt: string;
tryon: {
total: number;
byStatus: Record<string, number>;
last24h: number;
last7d: number;
recentFailures: FailureRow[];
};
metadata: {
total: number;
byStatus: Record<string, number>;
last24h: number;
last7d: number;
recentFailures: FailureRow[];
};
instagram: {
accountsTotal: number;
accountsActive: number;
postsTotal: number;
postsPendingAi: number;
postsConverted: number;
importedLast7d: number;
recentImports: { account: string; date: string; importedCount: number }[];
};
apgs: {
total: number;
success: number;
failed: number;
last24h: number;
last7d: number;
recentFailures: FailureRow[];
};
}
export function useAiHealth() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ai-health"],
enabled: !!token,
refetchInterval: 60_000,
queryFn: () => adminGet<AiHealthOverview>("ai-health/overview/", token as string),
});
}
export const useAdminDiscounts = (params: AdminListParams) =>
useAdminList<AdminDiscountRow>("discounts", params);
export const useAdminCollections = (params: AdminListParams) =>
useAdminList<AdminCollectionRow>("collections", params);
export function useAdminDiscountCreate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
adminPost("discounts/", data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "discounts"] }),
});
}
export function useAdminDiscountUpdate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`discounts/${id}/`, data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "discounts"] }),
});
}
export function useAdminDiscountDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminDelete(`discounts/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "discounts"] }),
});
}
export function useAdminCollectionUpdate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`collections/${id}/`, data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "collections"] }),
});
}
export function useAdminCollectionDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminDelete(`collections/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "collections"] }),
});
}
export function useLedgerSellerDetail(id: string | undefined) {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ledger", "seller", id],
enabled: !!token && !!id,
queryFn: () =>
adminGet<LedgerSellerDetail>(`ledger/sellers/${id}/`, token as string),
});
}
async function adminPost<T>(
path: string,
body: Record<string, unknown>,
token: string
): Promise<T> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = `Admin action failed (${res.status})`;
try {
const j = await res.json();
if (j?.detail) detail = j.detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
return res.json();
}
async function adminDelete(path: string, token: string): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok && res.status !== 204) {
let detail = `Admin delete failed (${res.status})`;
try {
const j = await res.json();
if (j?.detail) detail = j.detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
}
/** Approve (complete → debits balance + journals) or reject a pending payout. */
export function useLedgerPayoutAction() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, action }: { id: string; action: "complete" | "reject" }) =>
adminPost(`ledger/payouts/${id}/action/`, { action }, token as string),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "ledger"] });
qc.invalidateQueries({ queryKey: ["admin", "overview"] });
},
});
}

View File

@ -31,6 +31,30 @@ export function useGetUserThreads() {
});
}
/**
* List a STORE's inbox (owner + staff), scoped to the store's own threads.
* Distinct from useGetUserThreads(), which returns the user's personal buyer chats.
*/
export function useGetStoreThreads(storeId?: string) {
const token = useAuthToken();
return useQuery({
queryKey: ["getStoreThreads", storeId],
queryFn: async () => {
if (!token) {
throw new Error("Authentication token is required");
}
const chatApi = createChatApi(token);
return await chatApi.apiChatV1ThreadsListList({ storeId });
},
enabled: !!token && !!storeId,
retry: 1,
retryDelay: 3000,
refetchOnWindowFocus: false,
refetchInterval: 20 * 1000,
});
}
/**
* Server-side function to get thread details
*/
@ -182,15 +206,21 @@ export function useGetThreadMessages(threadId: string) {
export function useCreateChatThread() {
const token = useAuthToken();
return useMutation({
mutationFn: async (participantId: string) => {
mutationFn: async (
input: string | { participantId: string; productId?: string }
) => {
if (!token) {
throw new Error("Authentication token and user ID are required");
}
// Back-compat: callers may pass a bare participantId string.
const { participantId, productId } =
typeof input === "string" ? { participantId: input, productId: undefined } : input;
try {
const chatApi = createChatApi(token);
return await chatApi.apiChatV1ThreadsCreateCreate({
data: {
participantId: participantId,
participantId,
...(productId ? { productId } : {}),
},
});
} catch (error) {

View File

@ -75,7 +75,7 @@ export function useGetHomeDiscounts() {
}
return undefined;
},
staleTime: 5 * 60 * 1000, // 5 minutes cache
staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
refetchOnWindowFocus: false,
});
}
@ -114,7 +114,7 @@ export function useGetHomeForYou() {
}
return undefined;
},
staleTime: 5 * 60 * 1000, // 5 minutes cache
staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
refetchOnWindowFocus: false,
});
}
@ -153,7 +153,7 @@ export function useGetHomeTopSellers() {
}
return undefined;
},
staleTime: 5 * 60 * 1000, // 5 minutes cache
staleTime: 60 * 1000, // 60s — prices/discounts must stay reasonably fresh
refetchOnWindowFocus: false,
});
}

View File

@ -14,6 +14,10 @@ import {
SellerProductListItem,
VariantStockUpdateRequest,
VariantStockUpdateResponse,
ProductBasePriceUpdateRequest,
ProductBasePriceUpdateResponse,
VariantPriceUpdateRequest,
VariantPriceUpdateResponse,
PaginatedSimilarProduct,
} from "../../src/api/types";
import { useToast } from "../hooks/use-toast";
@ -366,6 +370,107 @@ export const useUpdateVariantStock = () => {
});
};
/**
* Custom hook to update a product's base price (quick edit).
*/
export const useUpdateProductBasePrice = () => {
const token = useAuthToken();
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async ({
productId,
basePrice,
}: {
productId: string;
basePrice: string;
}): Promise<ProductBasePriceUpdateResponse> => {
if (!token) throw new Error("Authentication required");
const productApi = createProductManagementApi(token);
const data: ProductBasePriceUpdateRequest = {
productId,
basePrice,
};
return await productApi.apiProductV1ProductsBasePricePartialUpdate({
data,
});
},
onSuccess: (data) => {
toast({
title: "موفق",
description: "قیمت با موفقیت به‌روزرسانی شد",
variant: "default",
});
queryClient.invalidateQueries({ queryKey: ["sellerProductsList"] });
queryClient.invalidateQueries({ queryKey: ["products"] });
if (data.id) {
queryClient.invalidateQueries({ queryKey: ["product", data.id] });
}
},
onError: (error: Error) => {
console.error("Base price update failed:", error);
toast({
title: "خطا",
description: "خطا در به‌روزرسانی قیمت. لطفاً دوباره تلاش کنید",
variant: "destructive",
});
},
});
};
/**
* Custom hook to update a single variant's price override (quick edit).
* Pass `price: null` to clear the override and inherit the base price.
*/
export const useUpdateVariantPrice = () => {
const token = useAuthToken();
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async ({
variantId,
price,
}: {
variantId: string;
price: string | null;
}): Promise<VariantPriceUpdateResponse> => {
if (!token) throw new Error("Authentication required");
const productApi = createProductManagementApi(token);
const data: VariantPriceUpdateRequest = {
variantId,
price,
};
return await productApi.apiProductV1ProductsVariantsPricePartialUpdate({
data,
});
},
onSuccess: (data) => {
toast({
title: "موفق",
description: "قیمت با موفقیت به‌روزرسانی شد",
variant: "default",
});
queryClient.invalidateQueries({ queryKey: ["sellerProductsList"] });
queryClient.invalidateQueries({ queryKey: ["products"] });
if (data.product) {
queryClient.invalidateQueries({ queryKey: ["product", data.product] });
}
},
onError: (error: Error) => {
console.error("Variant price update failed:", error);
toast({
title: "خطا",
description: "خطا در به‌روزرسانی قیمت. لطفاً دوباره تلاش کنید",
variant: "destructive",
});
},
});
};
/**
* Custom hook to fetch similar products for a given product ID
*/

View File

@ -15,6 +15,109 @@ import {
createFollowsManagementApi,
createSellerManagementApi,
} from "~/utils/api-client-factory";
import { getApiBaseUrl } from "../utils/api-config";
// ── Multi-user team ─────────────────────────────────────────────────────────
export interface MyStore {
username: string;
storeName: string;
storeLogo: string | null;
role: "owner" | "staff" | null;
}
export interface StoreMember {
id: string;
userId: string | null;
name: string | null;
phone: string;
role: string;
status: "active" | "pending";
createdAt: string;
}
/** Stores the current user can manage (owned + memberships), with their role. */
export function useMyStores() {
const token = useAuthToken();
return useQuery({
queryKey: ["myStores", token ?? "anon"],
queryFn: async (): Promise<MyStore[]> => {
const res = await fetch(`${getApiBaseUrl()}/api/seller/v1/my-stores/`, {
headers: { Authorization: `Bearer ${token}` },
});
// Throw on failure so a transient error stays an ERROR state (data kept,
// retried) instead of resolving to [] — otherwise a blip would look like
// "no stores" and bounce an owner off their own dashboard.
if (!res.ok) throw new Error(`my-stores failed: ${res.status}`);
return res.json();
},
enabled: !!token,
retry: 1,
staleTime: 60 * 1000,
});
}
/** A store's team members (owner only). */
export function useStoreMembers(username?: string) {
const token = useAuthToken();
return useQuery({
queryKey: ["storeMembers", username],
queryFn: async (): Promise<StoreMember[]> => {
const res = await fetch(
`${getApiBaseUrl()}/api/seller/v1/members/${username}/`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error("failed to load members");
return res.json();
},
enabled: !!token && !!username,
});
}
/** Invite a team member by phone number (owner only). */
export function useInviteMember(username?: string) {
const token = useAuthToken();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (phone: string) => {
const res = await fetch(
`${getApiBaseUrl()}/api/seller/v1/members/${username}/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ phone }),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || "خطا در دعوت عضو جدید");
}
return res.json();
},
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["storeMembers", username] }),
});
}
/** Remove a team member (owner only). */
export function useRemoveMember(username?: string) {
const token = useAuthToken();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (memberId: string) => {
const res = await fetch(
`${getApiBaseUrl()}/api/seller/v1/members/${username}/${memberId}/`,
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error("خطا در حذف عضو");
return res.json();
},
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["storeMembers", username] }),
});
}
/**
* Custom hook to fetch seller data by sellerId using React Query
@ -148,6 +251,13 @@ export const useGetSellerProducts = (
},
initialPageParam: 1,
enabled: !!sellerId,
// Public storefront: prices/discounts must reflect the seller's latest
// edits. Always treat as stale and revalidate on mount/refocus so the
// store list can't keep showing a pre-edit price (the detail page is SSR
// and already fresh, which caused the list-vs-detail mismatch).
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
});
};
@ -259,6 +369,9 @@ export const useSellerUpdate = () => {
storeDescription?: string | null;
storeLogo?: string | null;
instagramId?: string | null;
bannerType?: string | null;
bannerColor?: string | null;
bannerImage?: string | null;
};
}) => {
if (!token) throw new Error("Authentication required");

View File

@ -0,0 +1,69 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useAuthToken } from "../hooks/use-root-data";
import { getApiBaseUrl } from "../utils/api-config";
/**
* Records that the authenticated user viewed a product. Used as a signal for
* the recommendation system (alongside bookmarks, follows, orders, semantic
* proximity). Fire-and-forget: failures are swallowed so tracking never
* disrupts the browsing experience.
*
* NOTE: this hits /api/engagement/v1/views/ via a direct fetch because the
* endpoint is newer than the generated OpenAPI client. Swap to the generated
* EngagementApi method once the client is regenerated.
*/
export function useTrackProductView() {
const token = useAuthToken();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId: string) => {
if (!token || !productId) return { skipped: true };
const res = await fetch(`${getApiBaseUrl()}/api/engagement/v1/views/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ productId }),
});
if (!res.ok) {
throw new Error(`Failed to track view: ${res.status}`);
}
return res.json();
},
onSuccess: () => {
// Keep "recently viewed" fresh if/when it's rendered.
queryClient.invalidateQueries({ queryKey: ["recentlyViewedProducts"] });
},
// View tracking is best-effort; never surface errors to the user.
onError: () => {},
});
}
/**
* Fires a single product-view event when a product page is opened. Dedupes per
* productId so React strict-mode double-mounts / re-renders don't double-count
* within the same mount, and skips when not authenticated.
*/
export function useRecordProductView(
productId: string | undefined,
options?: { enabled?: boolean }
) {
const token = useAuthToken();
const { mutate } = useTrackProductView();
const lastTrackedRef = useRef<string | null>(null);
const enabled = options?.enabled ?? true;
useEffect(() => {
if (!enabled) return;
if (!token || !productId) return;
if (lastTrackedRef.current === productId) return;
lastTrackedRef.current = productId;
mutate(productId);
}, [productId, token, enabled, mutate]);
}

View File

@ -136,6 +136,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
/>
<meta name="color-scheme" content="light dark" />
<meta name="mobile-web-app-capable" content="yes" />
{/* iOS-specific: standalone mode. Required for the status-bar-style
below to take effect without it iOS ignores black-translucent,
so the app isn't edge-to-edge and env(safe-area-inset-top) doesn't
line up with the headers. (Android uses mobile-web-app-capable.) */}
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"

View File

@ -75,32 +75,82 @@ export default function HomePage() {
return (
<>
<div className="flex flex-col pb-12">
<HomePageTopBar />
<div className="lg:hidden">
<HomePageTopBar />
</div>
{/* Adding padding to account for fixed header */}
<div className="flex flex-col gap-10">
{/* Banners Section */}
<div className="w-full mt-2 h-[260px]">
{isLoadingBanners ? (
<Skeleton className="w-full h-full" />
) : (
<ProductImageCarousel
images={banners?.map((banner) => banner.imageUrl || "") || []}
hideIndex={true}
borderRadius={0}
/>
)}
<div className="flex flex-col gap-10 lg:gap-14 lg:max-w-[1320px] lg:w-full lg:mx-auto lg:px-8">
{/* Hero: mobile = full-bleed banner carousel; desktop = main + 2 promos */}
<div className="w-full mt-2 lg:mt-7 lg:grid lg:grid-cols-3 lg:gap-4">
<div className="h-[260px] lg:h-[408px] lg:col-span-2 lg:rounded-[22px] lg:overflow-hidden lg:shadow-md">
{isLoadingBanners ? (
<Skeleton className="w-full h-full" />
) : (
<ProductImageCarousel
images={banners?.map((banner) => banner.imageUrl || "") || []}
hideIndex={true}
borderRadius={0}
/>
)}
</div>
{/* Desktop-only side promos */}
<div className="hidden lg:grid lg:grid-rows-2 lg:gap-4">
<Link
to="/explore"
className="relative block rounded-[22px] overflow-hidden shadow-sm group"
>
<div className="absolute inset-0 bg-gradient-to-br from-[#6f7359] to-[#454935] transition-transform duration-500 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-5 text-white">
<small className="text-[12px] font-bold opacity-90">
تازه رسید
</small>
<h3 className="text-[20px] font-extrabold mt-1 leading-snug">
کالکشن جدید
</h3>
</div>
</Link>
<Link
to="/"
className="relative block rounded-[22px] overflow-hidden shadow-sm group"
>
<div className="absolute inset-0 bg-gradient-to-br from-[#b06a3c] to-[#7c4220] transition-transform duration-500 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-5 text-white">
<small className="text-[12px] font-bold opacity-90">
فروش ویژه
</small>
<h3 className="text-[20px] font-extrabold mt-1 leading-snug">
تا ۵۰٪ تخفیف
</h3>
</div>
</Link>
</div>
</div>
<section
id="section1"
className="w-full gap-2 flex flex-col max-w-[100vw]"
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
>
<div className="w-full px-4">
<p className="text-B16 font-bold">تخفیفها</p>
<p className="text-R14">
فرصتهایی محدود برای انتخابهای هوشمندانه
</p>
<div className="w-full px-4 lg:px-0 flex items-center justify-between">
<div>
<p className="text-B16 lg:text-B24 font-bold">تخفیفها</p>
<p className="text-R14 lg:text-R16 text-GRAY">
فرصتهایی محدود برای انتخابهای هوشمندانه
</p>
</div>
<Link
to="/discounts"
className="flex items-center gap-1 text-R14 text-gray-500 hover:text-gray-800 hover:underline transition-colors group"
>
<span>مشاهده همه</span>
<ChevronLeft
size={16}
className="group-hover:-translate-x-1 transition-transform"
/>
</Link>
</div>
<HorizontalProductList
products={
@ -113,6 +163,9 @@ export default function HomePage() {
discountPercent: product?.discountPercent || 0,
discountPrice: product?.discountPrice || 0,
basePrice: product?.basePrice || 0,
sellerName: product?.sellerName,
sellerLogo: product?.sellerLogo,
sellerUsername: product?.sellerUsername,
})) || []
}
isLoading={isLoadingDiscounts}
@ -122,12 +175,12 @@ export default function HomePage() {
</section>
<section
id="section2"
className="w-full gap-2 flex flex-col max-w-[100vw]"
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
>
<div className="w-full px-4 flex items-center justify-between">
<div className="w-full px-4 lg:px-0 flex items-center justify-between">
<div>
<p className="text-B16 font-bold">برترین فروشگاهها</p>
<p className="text-R14">
<p className="text-B16 lg:text-B24 font-bold">برترین فروشگاهها</p>
<p className="text-R14 lg:text-R16 text-GRAY">
برگرفته از بالاترین سطح رضایت کاربران
</p>
</div>
@ -152,11 +205,13 @@ export default function HomePage() {
</section>
<section
id="collections"
className="w-full gap-2 flex flex-col max-w-[100vw]"
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
>
<div className="w-full px-4">
<p className="text-B16 font-bold">کالکشنها</p>
<p className="text-R14">مجموعههای منتخب از بهترین محصولات</p>
<div className="w-full px-4 lg:px-0">
<p className="text-B16 lg:text-B24 font-bold">کالکشنها</p>
<p className="text-R14 lg:text-R16 text-GRAY">
مجموعههای منتخب از بهترین محصولات
</p>
</div>
<HorizontalCollectionsList
collections={
@ -176,11 +231,13 @@ export default function HomePage() {
</section>
<section
id="section3"
className="w-full gap-2 flex flex-col max-w-[100vw]"
className="w-full gap-2 flex flex-col max-w-[100vw] lg:max-w-none lg:gap-5"
>
<div className="w-full px-4">
<p className="text-B16 font-bold">برای شما</p>
<p className="text-R14">با الهام از انتخابهای شما</p>
<div className="w-full px-4 lg:px-0">
<p className="text-B16 lg:text-B24 font-bold">برای شما</p>
<p className="text-R14 lg:text-R16 text-GRAY">
با الهام از انتخابهای شما
</p>
</div>
<MondrianProductList
products={
@ -193,6 +250,9 @@ export default function HomePage() {
discountPercent: product?.discountPercent || 0,
discountPrice: product?.discountPrice || 0,
basePrice: product?.basePrice || 0,
sellerName: product?.sellerName,
sellerLogo: product?.sellerLogo,
sellerUsername: product?.sellerUsername,
})) || []
}
fetchNextPage={fetchNextForYou}
@ -233,7 +293,9 @@ export const meta: MetaFunction = () => {
},
{ name: "author", content: "Vitrown" },
{ name: "robots", content: "index, follow" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
// viewport is defined globally in root.tsx (with viewport-fit=cover for
// iOS safe-area handling); don't override it here or the home page loses
// edge-to-edge / notch support.
// Open Graph
{ property: "og:type", content: "website" },

153
app/routes/admin._index.tsx Normal file
View File

@ -0,0 +1,153 @@
import type { MetaFunction } from "@remix-run/node";
import {
Loader2,
Wallet,
Landmark,
ShoppingBag,
TrendingUp,
Users as UsersIcon,
Store,
Package,
Clock,
} from "lucide-react";
import { useAdminOverview } from "~/requestHandler/use-admin-hooks";
import { KpiCard } from "~/components/admin/KpiCard";
import { OrdersBarChart } from "~/components/admin/OrdersBarChart";
export const meta: MetaFunction = () => [
{ title: "نمای کلی | پنل مدیریت ویترون" },
{ name: "robots", content: "noindex, nofollow" },
];
const faNum = (n?: number) => Math.round(n || 0).toLocaleString("fa-IR");
const faToman = (n?: number) => `${faNum(n)} تومان`;
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<h2 className="text-[15px] font-extrabold mt-7 mb-3 first:mt-0">
{children}
</h2>
);
}
export default function AdminOverview() {
const { data, isLoading, isError } = useAdminOverview();
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
}
if (isError || !data) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-RED text-[14px]">خطا در بارگذاری آمار</p>
</div>
);
}
const { users, sellers, products, orders, money, ordersSeries } = data;
return (
<div>
<div className="hidden lg:flex items-end justify-between mb-6">
<h1 className="text-[28px] font-extrabold">نمای کلی</h1>
<span className="text-[12px] text-GRAY">بهروزرسانی خودکار هر دقیقه</span>
</div>
{/* Money + today */}
<SectionTitle>امروز و مالی</SectionTitle>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard
label="درآمد امروز"
value={faToman(orders.revenueToday)}
icon={TrendingUp}
accent="green"
/>
<KpiCard
label="سفارش امروز"
value={faNum(orders.ordersToday)}
icon={ShoppingBag}
/>
<KpiCard
label="کل درآمد (پرداخت‌شده)"
value={faToman(orders.revenueTotal)}
sub={`${faNum(orders.paidCount)} سفارش پرداخت‌شده`}
icon={TrendingUp}
/>
<KpiCard
label="برداشت‌های در انتظار"
value={faNum(money.payoutsPendingCount)}
sub={faToman(money.payoutsPendingAmount)}
icon={Clock}
accent={money.payoutsPendingCount > 0 ? "red" : "default"}
/>
<KpiCard
label="موجودی کیف پول کاربران"
value={faToman(money.walletHeld)}
icon={Wallet}
/>
<KpiCard
label="مانده قابل پرداخت فروشندگان"
value={faToman(money.sellerPayable)}
icon={Landmark}
/>
</div>
{/* Chart */}
<div className="mt-4">
<OrdersBarChart data={ordersSeries} />
</div>
{/* Orders status */}
<SectionTitle>سفارشها</SectionTitle>
<div className="grid grid-cols-2 lg:grid-cols-6 gap-3">
<KpiCard label="کل سفارش‌ها" value={faNum(orders.total)} icon={ShoppingBag} />
<KpiCard label="در انتظار" value={faNum(orders.pending)} />
<KpiCard label="تأییدشده" value={faNum(orders.confirmed)} />
<KpiCard label="ارسال‌شده" value={faNum(orders.shipped)} />
<KpiCard label="تحویل‌شده" value={faNum(orders.delivered)} accent="green" />
<KpiCard label="لغوشده" value={faNum(orders.cancelled)} accent="red" />
</div>
{/* Sellers + Users + Products */}
<SectionTitle>فروشگاهها و کاربران</SectionTitle>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard
label="فروشگاه‌ها"
value={faNum(sellers.total)}
sub={`${faNum(sellers.verified)} تأییدشده`}
icon={Store}
/>
<KpiCard
label="در انتظار تأیید"
value={faNum(sellers.pending)}
icon={Clock}
accent={sellers.pending > 0 ? "red" : "default"}
/>
<KpiCard
label="کاربران"
value={faNum(users.total)}
sub={`${faNum(users.active)} فعال`}
icon={UsersIcon}
/>
<KpiCard
label="کاربران جدید (۳۰ روز)"
value={faNum(users.new30d)}
sub={`${faNum(users.new7d)} در ۷ روز`}
icon={UsersIcon}
/>
<KpiCard
label="محصولات"
value={faNum(products.total)}
sub={`${faNum(products.active)} فعال`}
icon={Package}
/>
<KpiCard label="فروشندگان" value={faNum(users.sellers)} icon={Store} />
</div>
</div>
);
}

View File

@ -0,0 +1,182 @@
import { Loader2, Sparkles, FileText, Instagram, Bot } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useAiHealth, type AiHealthOverview } from "~/requestHandler/use-admin-hooks";
import { KpiCard } from "~/components/admin/KpiCard";
import { AdminPageTitle, faNum, faDate } from "~/components/admin/DataTable";
const STATUS_LABEL: Record<string, string> = {
pending: "در انتظار",
processing: "در حال پردازش",
success: "موفق",
ready: "آماده",
failed: "ناموفق",
};
const STATUS_TONE: Record<string, string> = {
pending: "bg-yellow-500/10 text-yellow-600",
processing: "bg-VITROWN_BLUE/10 text-VITROWN_BLUE",
success: "bg-GREEN/10 text-GREEN",
ready: "bg-GREEN/10 text-GREEN",
failed: "bg-RED/10 text-RED",
};
function StatusPills({ byStatus }: { byStatus: Record<string, number> }) {
const entries = Object.entries(byStatus);
if (entries.length === 0) return <p className="text-GRAY text-[12px]">دادهای نیست</p>;
return (
<div className="flex flex-wrap gap-2">
{entries.map(([k, v]) => (
<span
key={k}
className={`text-[12px] font-semibold px-2.5 py-1 rounded-full ${
STATUS_TONE[k] || "bg-WHITE3 text-BLACK2"
}`}
>
{STATUS_LABEL[k] || k}: {faNum(v)}
</span>
))}
</div>
);
}
function Failures({
rows,
dateKey,
}: {
rows: AiHealthOverview["tryon"]["recentFailures"];
dateKey: "createdAt" | "updatedAt";
}) {
if (rows.length === 0)
return <p className="text-GREEN text-[12px] mt-2">خطای اخیری ثبت نشده </p>;
return (
<div className="mt-3 flex flex-col gap-1.5">
<p className="text-[12px] font-semibold text-RED">خطاهای اخیر</p>
{rows.map((r) => (
<div key={r.id} className="rounded-xl border border-RED/20 bg-RED/5 p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-GRAY text-[10.5px] tabular-nums" dir="ltr">
{r.id.slice(0, 8)}
</span>
<span className="text-GRAY text-[10.5px] whitespace-nowrap">
{faDate(r[dateKey])}
</span>
</div>
<p className="text-[11.5px] text-BLACK2 mt-1 break-words" dir="ltr">
{r.error || "—"}
</p>
</div>
))}
</div>
);
}
function Section({
title,
icon: Icon,
children,
}: {
title: string;
icon: LucideIcon;
children: React.ReactNode;
}) {
return (
<section className="rounded-2xl border border-WHITE3 bg-WHITE p-4 sm:p-5">
<div className="flex items-center gap-2 mb-4">
<div className="h-9 w-9 rounded-xl bg-WHITE2 grid place-items-center">
<Icon size={18} />
</div>
<h2 className="font-extrabold text-[15px]">{title}</h2>
</div>
{children}
</section>
);
}
export default function AdminAiHealth() {
const { data, isLoading, isError } = useAiHealth();
if (isLoading)
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
if (isError || !data)
return <p className="text-RED text-[14px]">خطا در بارگذاری وضعیت سرویسهای هوش مصنوعی</p>;
return (
<div>
<AdminPageTitle>سلامت سرویسهای هوش مصنوعی</AdminPageTitle>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Section title="پرو مجازی" icon={Sparkles}>
<div className="grid grid-cols-3 gap-3">
<KpiCard label="کل" value={faNum(data.tryon.total)} />
<KpiCard label="۲۴ ساعت" value={faNum(data.tryon.last24h)} />
<KpiCard label="۷ روز" value={faNum(data.tryon.last7d)} />
</div>
<div className="mt-3">
<StatusPills byStatus={data.tryon.byStatus} />
</div>
<Failures rows={data.tryon.recentFailures} dateKey="createdAt" />
</Section>
<Section title="متادیتای محصولات" icon={FileText}>
<div className="grid grid-cols-3 gap-3">
<KpiCard label="کل" value={faNum(data.metadata.total)} />
<KpiCard label="۲۴ ساعت" value={faNum(data.metadata.last24h)} />
<KpiCard label="۷ روز" value={faNum(data.metadata.last7d)} />
</div>
<div className="mt-3">
<StatusPills byStatus={data.metadata.byStatus} />
</div>
<Failures rows={data.metadata.recentFailures} dateKey="updatedAt" />
</Section>
<Section title="ایمپورت اینستاگرام" icon={Instagram}>
<div className="grid grid-cols-3 gap-3">
<KpiCard
label="اکانت‌ها"
value={faNum(data.instagram.accountsTotal)}
sub={`${faNum(data.instagram.accountsActive)} فعال`}
/>
<KpiCard
label="در صف AI"
value={faNum(data.instagram.postsPendingAi)}
accent={data.instagram.postsPendingAi > 0 ? "red" : "default"}
/>
<KpiCard label="تبدیل‌شده" value={faNum(data.instagram.postsConverted)} accent="green" />
</div>
<p className="text-[12px] text-GRAY mt-3">
ایمپورت ۷ روز اخیر: <b className="tabular-nums text-BLACK2">{faNum(data.instagram.importedLast7d)}</b>
</p>
{data.instagram.recentImports.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{data.instagram.recentImports.map((im, i) => (
<div key={i} className="flex items-center justify-between text-[12px] border-b border-WHITE3 py-1.5 last:border-0">
<span className="tabular-nums" dir="ltr">@{im.account}</span>
<span className="text-GRAY">
{faNum(im.importedCount)} {faDate(im.date)}
</span>
</div>
))}
</div>
)}
</Section>
<Section title="تولید محصول با هوش مصنوعی" icon={Bot}>
<div className="grid grid-cols-3 gap-3">
<KpiCard label="کل" value={faNum(data.apgs.total)} />
<KpiCard label="موفق" value={faNum(data.apgs.success)} accent="green" />
<KpiCard
label="ناموفق"
value={faNum(data.apgs.failed)}
accent={data.apgs.failed > 0 ? "red" : "default"}
/>
</div>
<Failures rows={data.apgs.recentFailures} dateKey="createdAt" />
</Section>
</div>
</div>
);
}

166
app/routes/admin.audit.tsx Normal file
View File

@ -0,0 +1,166 @@
import { useState } from "react";
import { useAuditLog, type AuditLogRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const ACTION: Record<string, { label: string; tone: "green" | "blue" | "red" | "gray" }> = {
"seller.update": { label: "ویرایش فروشنده", tone: "blue" },
"user.update": { label: "ویرایش کاربر", tone: "blue" },
"product.update": { label: "ویرایش محصول", tone: "blue" },
"payout.complete": { label: "تکمیل تسویه", tone: "green" },
"payout.reject": { label: "رد تسویه", tone: "red" },
"ledger.backfill": { label: "بازسازی دفتر", tone: "gray" },
};
const TARGET_LABEL: Record<string, string> = {
seller: "فروشنده",
user: "کاربر",
product: "محصول",
payout: "تسویه",
ledger: "دفتر مالی",
};
/** Compact before→after diff of the touched fields. */
function Changes({ before, after }: Pick<AuditLogRow, "before" | "after">) {
const keys = Array.from(
new Set([...Object.keys(before || {}), ...Object.keys(after || {})])
);
const changed = keys.filter(
(k) => JSON.stringify(before?.[k]) !== JSON.stringify(after?.[k])
);
if (changed.length === 0)
return <span className="text-GRAY"></span>;
return (
<div className="flex flex-col gap-0.5" dir="ltr">
{changed.map((k) => (
<span key={k} className="text-[11px] tabular-nums whitespace-nowrap">
<span className="text-GRAY">{k}: </span>
<span className="text-RED">{fmt(before?.[k])}</span>
<span className="text-GRAY"> </span>
<span className="text-GREEN">{fmt(after?.[k])}</span>
</span>
))}
</div>
);
}
function fmt(v: unknown) {
if (v === null || v === undefined) return "∅";
if (typeof v === "boolean") return v ? "true" : "false";
return String(v);
}
export default function AdminAudit() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [action, setAction] = useState("");
const [targetType, setTargetType] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAuditLog({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
action,
target_type: targetType,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<AuditLogRow>[] = [
{
header: "عملیات",
render: (r) => {
const a = ACTION[r.action] || { label: r.action, tone: "gray" as const };
return <StatusBadge label={a.label} tone={a.tone} />;
},
},
{
header: "هدف",
render: (r) => (
<span className="text-[12px] whitespace-nowrap">
{TARGET_LABEL[r.targetType] || r.targetType}
{r.targetId && (
<span className="text-GRAY tabular-nums"> · {r.targetId.slice(0, 8)}</span>
)}
</span>
),
},
{
header: "تغییرات",
render: (r) => <Changes before={r.before} after={r.after} />,
},
{
header: "مدیر",
render: (r) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.actorPhone || "سیستم"}
</span>
),
},
{
header: "تاریخ",
render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span>,
},
];
return (
<div>
<AdminPageTitle>گزارش فعالیت مدیران</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی تلفن مدیر / شناسه هدف…"
/>
<AdminSelect
value={action}
onChange={(v) => reset(() => setAction(v))}
options={[
{ value: "", label: "همه عملیات‌ها" },
{ value: "seller.update", label: "ویرایش فروشنده" },
{ value: "user.update", label: "ویرایش کاربر" },
{ value: "product.update", label: "ویرایش محصول" },
{ value: "payout.complete", label: "تکمیل تسویه" },
{ value: "payout.reject", label: "رد تسویه" },
{ value: "ledger.backfill", label: "بازسازی دفتر" },
]}
/>
<AdminSelect
value={targetType}
onChange={(v) => reset(() => setTargetType(v))}
options={[
{ value: "", label: "همه هدف‌ها" },
{ value: "seller", label: "فروشنده" },
{ value: "user", label: "کاربر" },
{ value: "product", label: "محصول" },
{ value: "payout", label: "تسویه" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(r) => r.id}
emptyText="فعالیتی ثبت نشده است"
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -0,0 +1,247 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, X, Tag, ChevronLeft } from "lucide-react";
import {
useCatalogCategories,
useAttributes,
useAttributeValues,
useAttributeMutations,
useAttributeValueMutations,
type AttributeRow,
type AttributeValueRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminPageTitle,
StatusBadge,
faNum,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
export default function AdminCatalog() {
const [selected, setSelected] = useState<AttributeRow | null>(null);
return (
<div>
<AdminPageTitle>ویژگیهای کاتالوگ</AdminPageTitle>
{selected ? (
<ValuesPanel attribute={selected} onBack={() => setSelected(null)} />
) : (
<AttributesPanel onSelect={setSelected} />
)}
</div>
);
}
/* ------------------------------ attributes ------------------------------ */
function AttributesPanel({ onSelect }: { onSelect: (a: AttributeRow) => void }) {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<AttributeRow | null>(null);
const [creating, setCreating] = useState(false);
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAttributes({ page, page_size: PAGE_SIZE, search: debouncedSearch });
const { remove } = useAttributeMutations();
const columns: Column<AttributeRow>[] = [
{ header: "نام", render: (r) => <span className="font-semibold">{r.name}</span> },
{
header: "دسته‌ها",
render: (r) => (
<div className="flex flex-wrap gap-1 max-w-[260px]">
{r.categoryNames.length ? (
r.categoryNames.map((c) => <StatusBadge key={c} label={c} tone="gray" />)
) : (
<span className="text-GRAY text-[11px]"></span>
)}
</div>
),
},
{
header: "مقادیر",
render: (r) => (
<button onClick={() => onSelect(r)} className="flex items-center gap-1 text-VITROWN_BLUE text-[12px] font-semibold">
{faNum(r.valueCount)} مقدار <ChevronLeft size={14} />
</button>
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button onClick={() => setEditing(r)} className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2" aria-label="ویرایش">
<Pencil size={14} />
</button>
<button
onClick={() => confirm(`حذف ویژگی «${r.name}»؟ مقادیر و انتساب‌های محصولات نیز حذف می‌شود.`) && remove.mutate(r.id)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<div className="flex justify-end mb-3">
<button onClick={() => setCreating(true)} className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-3.5 py-2 text-[13px] font-semibold hover:opacity-90">
<Plus size={16} /> ویژگی جدید
</button>
</div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => { setSearch(v); setPage(1); }} placeholder="جستجوی نام ویژگی…" />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="ویژگی‌ای ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
{(creating || editing) && <AttributeModal row={editing} onClose={() => { setCreating(false); setEditing(null); }} />}
</div>
);
}
function AttributeModal({ row, onClose }: { row: AttributeRow | null; onClose: () => void }) {
const [name, setName] = useState(row?.name ?? "");
const [cats, setCats] = useState<string[]>(row?.categories ?? []);
const [error, setError] = useState("");
const { data: categories } = useCatalogCategories();
const { create, update } = useAttributeMutations();
const busy = create.isPending || update.isPending;
const toggleCat = (id: string) =>
setCats((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
const submit = () => {
setError("");
if (!name.trim()) return setError("نام الزامی است.");
const payload = { name: name.trim(), categories: cats };
const onErr = (e: unknown) => setError(e instanceof Error ? e.message : "خطا در ذخیره");
if (row) update.mutate({ id: row.id, data: payload }, { onSuccess: onClose, onError: onErr });
else create.mutate(payload, { onSuccess: onClose, onError: onErr });
};
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-BLACK/40 sm:p-4" onClick={onClose}>
<div className="bg-WHITE w-full sm:max-w-md rounded-t-2xl sm:rounded-2xl max-h-[92vh] overflow-y-auto hide-scrollbar" onClick={(e) => e.stopPropagation()}>
<div className="sticky top-0 bg-WHITE flex items-center justify-between px-5 py-4 border-b border-WHITE3">
<h3 className="font-extrabold text-[16px]">{row ? "ویرایش ویژگی" : "ویژگی جدید"}</h3>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg hover:bg-WHITE2"><X size={18} /></button>
</div>
<div className="p-5 flex flex-col gap-3">
<div>
<label className="block text-[12px] text-GRAY mb-1">نام</label>
<input className="w-full rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="block text-[12px] text-GRAY mb-1">دستههای مرتبط</label>
<div className="max-h-[220px] overflow-y-auto hide-scrollbar rounded-xl border border-WHITE3 p-2 flex flex-col gap-0.5">
{(categories || []).map((c) => (
<label key={c.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-WHITE2 cursor-pointer">
<input type="checkbox" checked={cats.includes(c.id)} onChange={() => toggleCat(c.id)} className="h-4 w-4" />
<span className="text-[13px]">{c.name}</span>
</label>
))}
{!categories?.length && <p className="text-GRAY text-[12px] p-2">دستهای موجود نیست.</p>}
</div>
</div>
{error && <p className="text-RED text-[12px]">{error}</p>}
</div>
<div className="sticky bottom-0 bg-WHITE flex gap-2 px-5 py-4 border-t border-WHITE3">
<button onClick={onClose} className="flex-1 rounded-xl border border-WHITE3 py-2.5 text-[13.5px] font-semibold hover:bg-WHITE2">انصراف</button>
<button onClick={submit} disabled={busy} className="flex-1 rounded-xl bg-BLACK text-white py-2.5 text-[13.5px] font-semibold hover:opacity-90 disabled:opacity-50">
{busy ? "در حال ذخیره…" : "ذخیره"}
</button>
</div>
</div>
</div>
);
}
/* --------------------------- attribute values --------------------------- */
function ValuesPanel({ attribute, onBack }: { attribute: AttributeRow; onBack: () => void }) {
const [page, setPage] = useState(1);
const { data, isLoading } = useAttributeValues({ page, page_size: PAGE_SIZE, attribute: attribute.id });
const { create, update, remove } = useAttributeValueMutations();
const [newValue, setNewValue] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const add = () => {
if (!newValue.trim()) return;
create.mutate({ attribute: attribute.id, value: newValue.trim() }, { onSuccess: () => setNewValue("") });
};
const saveEdit = (id: string) => {
update.mutate({ id, data: { value: editingText.trim() } }, { onSuccess: () => setEditingId(null) });
};
const columns: Column<AttributeValueRow>[] = [
{
header: "مقدار",
render: (r) =>
editingId === r.id ? (
<input
autoFocus
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && saveEdit(r.id)}
className="rounded-lg border border-GRAY px-2 py-1 text-[13px]"
/>
) : (
<span className="font-semibold">{r.value}</span>
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
{editingId === r.id ? (
<button onClick={() => saveEdit(r.id)} className="rounded-lg bg-BLACK text-white px-3 h-8 text-[12px] font-semibold">ذخیره</button>
) : (
<button onClick={() => { setEditingId(r.id); setEditingText(r.value); }} className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2" aria-label="ویرایش">
<Pencil size={14} />
</button>
)}
<button
onClick={() => confirm(`حذف مقدار «${r.value}»؟`) && remove.mutate(r.id)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<button onClick={onBack} className="flex items-center gap-1 text-GRAY text-[13px] mb-3 hover:text-BLACK">
<ChevronLeft size={16} className="rotate-180" /> بازگشت به ویژگیها
</button>
<div className="flex items-center gap-2 mb-4">
<div className="h-9 w-9 rounded-xl bg-BLACK text-white grid place-items-center"><Tag size={16} /></div>
<h2 className="font-extrabold text-[17px]">مقادیر «{attribute.name}»</h2>
</div>
<div className="flex gap-2 mb-4">
<input
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder="مقدار جدید (مثلاً قرمز)…"
className="flex-1 rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY"
/>
<button onClick={add} disabled={create.isPending} className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-4 text-[13px] font-semibold hover:opacity-90 disabled:opacity-50">
<Plus size={16} /> افزودن
</button>
</div>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="مقداری ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -0,0 +1,153 @@
import { useState } from "react";
import { Trash2, Eye, EyeOff } from "lucide-react";
import {
useAdminCollections,
useAdminCollectionUpdate,
useAdminCollectionDelete,
type AdminCollectionRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const CREATOR: Record<string, string> = {
seller: "فروشنده",
admin: "مدیر",
user: "کاربر",
};
export default function AdminCollections() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [creatorType, setCreatorType] = useState("");
const [isPublic, setIsPublic] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminCollections({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
creator_type: creatorType,
is_public: isPublic,
});
const update = useAdminCollectionUpdate();
const del = useAdminCollectionDelete();
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const toggle = (r: AdminCollectionRow) =>
update.mutate({ id: r.id, data: { isPublic: !r.isPublic } });
const onDelete = (r: AdminCollectionRow) => {
if (confirm(`حذف مجموعه «${r.title}»؟`)) del.mutate(r.id);
};
const columns: Column<AdminCollectionRow>[] = [
{
header: "عنوان",
render: (r) => (
<div className="flex items-center gap-2 min-w-0">
{r.coverImageUrl ? (
<img src={r.coverImageUrl} alt="" className="h-9 w-9 rounded-lg object-cover shrink-0" />
) : (
<div className="h-9 w-9 rounded-lg bg-WHITE3 shrink-0" />
)}
<p className="font-semibold truncate">{r.title}</p>
</div>
),
},
{ header: "سازنده", render: (r) => <span className="text-[12px]">{CREATOR[r.creatorType] || r.creatorType}</span> },
{
header: "محصولات",
render: (r) => <span className="tabular-nums">{faNum(r.productCount)}</span>,
},
{
header: "نمایش",
render: (r) =>
r.isPublic ? (
<StatusBadge label="عمومی" tone="green" />
) : (
<StatusBadge label="خصوصی" tone="gray" />
),
},
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => toggle(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
aria-label={r.isPublic ? "خصوصی کردن" : "عمومی کردن"}
title={r.isPublic ? "خصوصی کردن" : "عمومی کردن"}
>
{r.isPublic ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
onClick={() => onDelete(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<AdminPageTitle>مجموعهها</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی عنوان / تلفن سازنده…"
/>
<AdminSelect
value={creatorType}
onChange={(v) => reset(() => setCreatorType(v))}
options={[
{ value: "", label: "همه سازنده‌ها" },
{ value: "seller", label: "فروشنده" },
{ value: "admin", label: "مدیر" },
{ value: "user", label: "کاربر" },
]}
/>
<AdminSelect
value={isPublic}
onChange={(v) => reset(() => setIsPublic(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "عمومی" },
{ value: "false", label: "خصوصی" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(r) => r.id}
emptyText="مجموعه‌ای ثبت نشده است"
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -0,0 +1,335 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, X } from "lucide-react";
import {
useAdminDiscounts,
useAdminDiscountCreate,
useAdminDiscountUpdate,
useAdminDiscountDelete,
type AdminDiscountRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const TYPE: Record<string, string> = {
user_based: "کاربر-محور",
count_based: "تعداد-محور",
value_based: "مبلغ-محور",
};
type FormState = {
name: string;
code: string;
discountType: string;
value: string;
minPurchaseAmount: string;
maxDiscountAmount: string;
validFrom: string;
validTo: string;
maxCount: string;
isActive: boolean;
};
const EMPTY: FormState = {
name: "",
code: "",
discountType: "value_based",
value: "",
minPurchaseAmount: "",
maxDiscountAmount: "",
validFrom: "",
validTo: "",
maxCount: "",
isActive: true,
};
const toLocal = (iso: string | null) => (iso ? iso.slice(0, 16) : "");
function fromRow(r: AdminDiscountRow): FormState {
return {
name: r.name,
code: String(r.code),
discountType: r.discountType,
value: String(r.value),
minPurchaseAmount: r.minPurchaseAmount != null ? String(r.minPurchaseAmount) : "",
maxDiscountAmount: r.maxDiscountAmount != null ? String(r.maxDiscountAmount) : "",
validFrom: toLocal(r.validFrom),
validTo: toLocal(r.validTo),
maxCount: r.maxCount != null ? String(r.maxCount) : "",
isActive: r.isActive,
};
}
export default function AdminDiscounts() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [active, setActive] = useState("");
const [editing, setEditing] = useState<AdminDiscountRow | null>(null);
const [creating, setCreating] = useState(false);
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminDiscounts({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_active: active,
});
const del = useAdminDiscountDelete();
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const onDelete = (r: AdminDiscountRow) => {
if (confirm(`حذف تخفیف «${r.name}»؟`)) del.mutate(r.id);
};
const columns: Column<AdminDiscountRow>[] = [
{
header: "نام",
render: (r) => (
<div>
<p className="font-semibold">{r.name}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
#{faNum(r.code)}
</p>
</div>
),
},
{ header: "نوع", render: (r) => <span className="text-[12px]">{TYPE[r.discountType] || r.discountType}</span> },
{
header: "مقدار",
render: (r) => <span className="tabular-nums whitespace-nowrap">{faNum(r.value)}</span>,
},
{
header: "اعتبار",
render: (r) => (
<span className="text-GRAY text-[11px] whitespace-nowrap">
{faDate(r.validFrom)} {faDate(r.validTo)}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isValid ? (
<StatusBadge label="فعال" tone="green" />
) : r.isActive ? (
<StatusBadge label="خارج از بازه" tone="yellow" />
) : (
<StatusBadge label="غیرفعال" tone="gray" />
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => setEditing(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
aria-label="ویرایش"
>
<Pencil size={14} />
</button>
<button
onClick={() => onDelete(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<div className="flex items-center justify-between gap-3">
<AdminPageTitle>تخفیفها</AdminPageTitle>
<button
onClick={() => setCreating(true)}
className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-3.5 py-2 text-[13px] font-semibold hover:opacity-90"
>
<Plus size={16} /> تخفیف جدید
</button>
</div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی نام تخفیف…"
/>
<AdminSelect
value={active}
onChange={(v) => reset(() => setActive(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "فعال" },
{ value: "false", label: "غیرفعال" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(r) => r.id}
emptyText="تخفیفی ثبت نشده است"
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
{(creating || editing) && (
<DiscountModal
row={editing}
onClose={() => {
setCreating(false);
setEditing(null);
}}
/>
)}
</div>
);
}
function DiscountModal({ row, onClose }: { row: AdminDiscountRow | null; onClose: () => void }) {
const [form, setForm] = useState<FormState>(row ? fromRow(row) : EMPTY);
const [error, setError] = useState("");
const create = useAdminDiscountCreate();
const update = useAdminDiscountUpdate();
const busy = create.isPending || update.isPending;
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const submit = () => {
setError("");
if (!form.name || !form.code || !form.value || !form.validFrom || !form.validTo) {
setError("نام، کد، مقدار و بازه اعتبار الزامی است.");
return;
}
const payload = {
name: form.name,
code: Number(form.code),
discountType: form.discountType,
value: Number(form.value),
minPurchaseAmount: form.minPurchaseAmount ? Number(form.minPurchaseAmount) : null,
maxDiscountAmount: form.maxDiscountAmount ? Number(form.maxDiscountAmount) : null,
validFrom: form.validFrom,
validTo: form.validTo,
maxCount: form.maxCount ? Number(form.maxCount) : null,
isActive: form.isActive,
};
const onErr = (e: unknown) => setError(e instanceof Error ? e.message : "خطا در ذخیره");
if (row) {
update.mutate({ id: row.id, data: payload }, { onSuccess: onClose, onError: onErr });
} else {
create.mutate(payload, { onSuccess: onClose, onError: onErr });
}
};
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-BLACK/40 p-0 sm:p-4" onClick={onClose}>
<div
className="bg-WHITE w-full sm:max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[92vh] overflow-y-auto hide-scrollbar"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-WHITE flex items-center justify-between px-5 py-4 border-b border-WHITE3">
<h3 className="font-extrabold text-[16px]">{row ? "ویرایش تخفیف" : "تخفیف جدید"}</h3>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg hover:bg-WHITE2">
<X size={18} />
</button>
</div>
<div className="p-5 grid grid-cols-2 gap-3">
<Field label="نام" className="col-span-2">
<input className={inputCls} value={form.name} onChange={(e) => set("name", e.target.value)} />
</Field>
<Field label="کد (عدد یکتا)">
<input type="number" className={inputCls} value={form.code} onChange={(e) => set("code", e.target.value)} dir="ltr" />
</Field>
<Field label="نوع">
<select className={inputCls} value={form.discountType} onChange={(e) => set("discountType", e.target.value)}>
<option value="value_based">مبلغ-محور</option>
<option value="user_based">کاربر-محور</option>
<option value="count_based">تعداد-محور</option>
</select>
</Field>
<Field label="مقدار">
<input type="number" className={inputCls} value={form.value} onChange={(e) => set("value", e.target.value)} dir="ltr" />
</Field>
<Field label="حداکثر دفعات (اختیاری)">
<input type="number" className={inputCls} value={form.maxCount} onChange={(e) => set("maxCount", e.target.value)} dir="ltr" />
</Field>
<Field label="حداقل مبلغ خرید (اختیاری)">
<input type="number" className={inputCls} value={form.minPurchaseAmount} onChange={(e) => set("minPurchaseAmount", e.target.value)} dir="ltr" />
</Field>
<Field label="حداکثر مبلغ تخفیف (اختیاری)">
<input type="number" className={inputCls} value={form.maxDiscountAmount} onChange={(e) => set("maxDiscountAmount", e.target.value)} dir="ltr" />
</Field>
<Field label="از تاریخ">
<input type="datetime-local" className={inputCls} value={form.validFrom} onChange={(e) => set("validFrom", e.target.value)} dir="ltr" />
</Field>
<Field label="تا تاریخ">
<input type="datetime-local" className={inputCls} value={form.validTo} onChange={(e) => set("validTo", e.target.value)} dir="ltr" />
</Field>
<label className="col-span-2 flex items-center gap-2 mt-1 cursor-pointer">
<input type="checkbox" checked={form.isActive} onChange={(e) => set("isActive", e.target.checked)} className="h-4 w-4" />
<span className="text-[13px] font-semibold">فعال</span>
</label>
{error && <p className="col-span-2 text-RED text-[12px]">{error}</p>}
</div>
<div className="sticky bottom-0 bg-WHITE flex gap-2 px-5 py-4 border-t border-WHITE3">
<button onClick={onClose} className="flex-1 rounded-xl border border-WHITE3 py-2.5 text-[13.5px] font-semibold hover:bg-WHITE2">
انصراف
</button>
<button
onClick={submit}
disabled={busy}
className="flex-1 rounded-xl bg-BLACK text-white py-2.5 text-[13.5px] font-semibold hover:opacity-90 disabled:opacity-50"
>
{busy ? "در حال ذخیره…" : "ذخیره"}
</button>
</div>
</div>
</div>
);
}
const inputCls =
"w-full rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY";
function Field({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={className}>
<label className="block text-[12px] text-GRAY mb-1">{label}</label>
{children}
</div>
);
}

View File

@ -0,0 +1,219 @@
import { useState } from "react";
import { Trash2, ImageOff } from "lucide-react";
import {
useHomeBanners,
useHomeCategories,
useHomeSellerTiles,
useHomeBannerMutations,
useHomeCategoryMutations,
useHomeSellerTileMutations,
type HomeBannerRow,
type HomeCategoryRow,
type HomeSellerTileRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
type Column,
} from "~/components/admin/DataTable";
import { cn } from "~/lib/utils";
const PAGE_SIZE = 25;
function Thumb({ url }: { url: string | null }) {
if (!url)
return (
<div className="h-11 w-11 rounded-lg border border-WHITE3 bg-WHITE2 grid place-items-center text-GRAY">
<ImageOff size={16} />
</div>
);
return <img src={url} alt="" className="h-11 w-11 rounded-lg object-cover border border-WHITE3" />;
}
function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={cn(
"relative h-6 w-11 rounded-full transition-colors",
on ? "bg-GREEN" : "bg-GRAY2"
)}
aria-pressed={on}
>
<span
className={cn(
"absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all",
on ? "left-0.5" : "right-0.5"
)}
/>
</button>
);
}
function DeleteBtn({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
);
}
export default function AdminHomepage() {
const [tab, setTab] = useState<"banners" | "categories" | "tiles">("banners");
const TABS = [
{ k: "banners", label: "بنرها" },
{ k: "categories", label: "دسته‌ها" },
{ k: "tiles", label: "فروشگاه‌ها" },
] as const;
return (
<div>
<AdminPageTitle>صفحه اصلی</AdminPageTitle>
<p className="text-GRAY text-[12px] -mt-2 mb-4">
مدیریت آیتمهای موجود (فعال/غیرفعال، ترتیب، حذف). افزودن آیتم جدید از طریق آپلود تصویر انجام میشود.
</p>
<div className="flex gap-1 mb-5 border-b border-WHITE3">
{TABS.map((t) => (
<button
key={t.k}
onClick={() => setTab(t.k)}
className={cn(
"px-4 py-2 text-[13.5px] font-semibold border-b-2 -mb-px transition-colors",
tab === t.k ? "border-BLACK text-BLACK" : "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t.label}
</button>
))}
</div>
{tab === "banners" && <BannersTab />}
{tab === "categories" && <CategoriesTab />}
{tab === "tiles" && <TilesTab />}
</div>
);
}
function BannersTab() {
const [page, setPage] = useState(1);
const { data, isLoading } = useHomeBanners({ page, page_size: PAGE_SIZE });
const { update, remove } = useHomeBannerMutations();
const columns: Column<HomeBannerRow>[] = [
{ header: "", render: (r) => <Thumb url={r.imageUrl} /> },
{
header: "بنر",
render: (r) => (
<div>
<p className="font-semibold text-[13px]">{r.title || "—"}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.linkType}:{r.linkId}
</p>
</div>
),
},
{
header: "بازه",
render: (r) => (
<span className="text-GRAY text-[11px] whitespace-nowrap">
{faDate(r.startDate)} {faDate(r.endDate)}
</span>
),
},
{
header: "نمایش",
render: (r) =>
r.isVisible ? <StatusBadge label="در حال نمایش" tone="green" /> : <StatusBadge label="غیرفعال" tone="gray" />,
},
{
header: "فعال",
render: (r) => (
<Toggle on={r.isActive} onClick={() => update.mutate({ id: r.id, data: { isActive: !r.isActive } })} />
),
},
{
header: "",
render: (r) => (
<DeleteBtn onClick={() => confirm("حذف این بنر؟") && remove.mutate(r.id)} />
),
},
];
return (
<div>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="بنری ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function CategoriesTab() {
const [page, setPage] = useState(1);
const { data, isLoading } = useHomeCategories({ page, page_size: PAGE_SIZE });
const { update, remove } = useHomeCategoryMutations();
const columns: Column<HomeCategoryRow>[] = [
{ header: "", render: (r) => <Thumb url={r.imageUrl} /> },
{ header: "دسته", render: (r) => <span className="font-semibold text-[13px]">{r.categoryName || "—"}</span> },
{
header: "فعال",
render: (r) => (
<Toggle on={r.isActive} onClick={() => update.mutate({ id: r.id, data: { isActive: !r.isActive } })} />
),
},
{ header: "", render: (r) => <DeleteBtn onClick={() => confirm("حذف این دسته از صفحه اصلی؟") && remove.mutate(r.id)} /> },
];
return (
<div>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="دسته‌ای ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function TilesTab() {
const [page, setPage] = useState(1);
const { data, isLoading } = useHomeSellerTiles({ page, page_size: PAGE_SIZE });
const { update, remove } = useHomeSellerTileMutations();
const columns: Column<HomeSellerTileRow>[] = [
{ header: "", render: (r) => <Thumb url={r.imageUrl} /> },
{ header: "فروشگاه", render: (r) => <span className="font-semibold text-[13px]">{r.sellerName || "—"}</span> },
{
header: "ترتیب",
render: (r) => (
<input
type="number"
defaultValue={r.order}
onBlur={(e) => {
const v = Number(e.target.value);
if (v !== r.order) update.mutate({ id: r.id, data: { order: v } });
}}
className="w-16 rounded-lg border border-WHITE3 px-2 py-1 text-[13px] tabular-nums text-center"
dir="ltr"
/>
),
},
{
header: "فعال",
render: (r) => (
<Toggle on={r.isActive} onClick={() => update.mutate({ id: r.id, data: { isActive: !r.isActive } })} />
),
},
{ header: "", render: (r) => <DeleteBtn onClick={() => confirm("حذف این کاشی فروشگاه؟") && remove.mutate(r.id)} /> },
];
return (
<div>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="کاشی فروشگاهی ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -0,0 +1,148 @@
import {
Loader2,
Wallet,
Landmark,
ArrowDownCircle,
ArrowUpCircle,
AlertTriangle,
Clock,
CheckCircle2,
} from "lucide-react";
import { useLedgerOverview } from "~/requestHandler/use-admin-hooks";
import { KpiCard } from "~/components/admin/KpiCard";
import { faToman, faNum } from "~/components/admin/DataTable";
export default function LedgerOverview() {
const { data, isLoading, isError } = useLedgerOverview();
if (isLoading)
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
if (isError || !data)
return <p className="text-RED text-[14px]">خطا در بارگذاری دفتر مالی</p>;
const { payouts, reconciliation, journal } = data;
const hasDiscrepancy = reconciliation.completedPayoutsNotDebited > 0;
const driftClean =
Math.abs(journal.sellerPayableDrift) < 1 && Math.abs(journal.walletHeldDrift) < 1;
return (
<div>
{/* Money held / owed */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard label="موجودی کیف پول کاربران" value={faToman(data.walletHeld)} icon={Wallet} />
<KpiCard label="مانده قابل پرداخت فروشندگان" value={faToman(data.sellerPayable)} icon={Landmark} />
<KpiCard label="کل واریزها (شارژ کیف پول)" value={faToman(data.depositsTotal)} icon={ArrowDownCircle} accent="green" />
<KpiCard label="کل خریدها" value={faToman(data.purchasesTotal)} icon={ArrowUpCircle} />
</div>
{/* Payouts by status */}
<h2 className="text-[15px] font-extrabold mt-7 mb-3">برداشتها</h2>
<div className="grid grid-cols-3 gap-3">
<KpiCard
label="در انتظار"
value={faToman(payouts.pending.amount)}
sub={`${faNum(payouts.pending.count)} درخواست`}
icon={Clock}
accent={payouts.pending.count > 0 ? "red" : "default"}
/>
<KpiCard
label="پرداخت‌شده"
value={faToman(payouts.completed.amount)}
sub={`${faNum(payouts.completed.count)} مورد`}
icon={CheckCircle2}
accent="green"
/>
<KpiCard
label="ردشده"
value={faToman(payouts.rejected.amount)}
sub={`${faNum(payouts.rejected.count)} مورد`}
/>
</div>
{/* Journal-derived truth (Slice 2c) */}
<div className="mt-7 flex items-center gap-2 mb-3">
<h2 className="text-[15px] font-extrabold">دفتر روزنامهای (منبع حقیقت)</h2>
<span
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full ${
driftClean ? "bg-GREEN/10 text-GREEN" : "bg-yellow-500/10 text-yellow-600"
}`}
>
{driftClean ? "هم‌خوان با ثبت‌شده" : "نیازمند بک‌فیل"}
</span>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<KpiCard
label="مانده فروشندگان (روزنامه)"
value={faToman(journal.sellerPayable)}
sub={`اختلاف با ثبت‌شده: ${faToman(journal.sellerPayableDrift)}`}
icon={Landmark}
accent={Math.abs(journal.sellerPayableDrift) < 1 ? "green" : "red"}
/>
<KpiCard
label="کیف پول کاربران (روزنامه)"
value={faToman(journal.walletHeld)}
sub={`اختلاف با ثبت‌شده: ${faToman(journal.walletHeldDrift)}`}
icon={Wallet}
accent={Math.abs(journal.walletHeldDrift) < 1 ? "green" : "red"}
/>
<KpiCard
label="درآمد پلتفرم (کارمزد)"
value={faToman(journal.platformRevenue)}
icon={ArrowUpCircle}
accent="green"
/>
</div>
{/* Reconciliation warning */}
{hasDiscrepancy && (
<div className="mt-7 rounded-2xl border border-RED/30 bg-RED/5 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-RED shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-extrabold text-[15px] text-RED">
مغایرت حسابداری شناسایی شد
</h3>
<p className="text-[13px] text-BLACK2 mt-1 leading-6">
مبلغ{" "}
<b className="tabular-nums">
{faToman(reconciliation.completedPayoutsNotDebited)}
</b>{" "}
بهعنوان برداشت «پرداختشده» ثبت شده اما از مانده فروشندگان کسر
نشده است. به همین دلیل «مانده قابل پرداخت» فعلی احتمالاً بیش از
مقدار واقعی است.
</p>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
<p className="text-[12px] text-GRAY">مانده ثبتشده</p>
<p className="font-bold tabular-nums mt-1">
{faToman(reconciliation.sellerPayableRecorded)}
</p>
</div>
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
<p className="text-[12px] text-GRAY">برآورد مانده واقعی</p>
<p className="font-bold tabular-nums mt-1 text-GREEN">
{faToman(reconciliation.sellerPayableEstimatedTrue)}
</p>
</div>
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
<p className="text-[12px] text-GRAY">اختلاف</p>
<p className="font-bold tabular-nums mt-1 text-RED">
{faToman(reconciliation.completedPayoutsNotDebited)}
</p>
</div>
</div>
<p className="text-[12px] text-GRAY mt-3">
این مورد در فاز بعدی (دفتر روزنامهای و اصلاح جریان پول) رفع خواهد
شد.
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,156 @@
import { useState } from "react";
import {
useLedgerJournal,
type LedgerEntryRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
// Journal entry_type → Persian label + tone.
const ENTRY: Record<string, { label: string; tone: "green" | "blue" | "red" }> = {
deposit: { label: "واریز", tone: "green" },
purchase: { label: "خرید", tone: "blue" },
seller_credit: { label: "اعتبار فروشنده", tone: "green" },
platform_fee: { label: "کارمزد پلتفرم", tone: "blue" },
refund: { label: "بازپرداخت", tone: "red" },
seller_reversal: { label: "برگشت فروشنده", tone: "red" },
payout: { label: "تسویه", tone: "red" },
};
const ACCOUNT: Record<string, string> = {
user_wallet: "کیف پول کاربر",
seller_balance: "موجودی فروشنده",
platform: "پلتفرم",
external: "خارجی",
};
export default function LedgerJournal() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [account, setAccount] = useState("");
const [entryType, setEntryType] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useLedgerJournal({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
account,
entry_type: entryType,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<LedgerEntryRow>[] = [
{
header: "نوع",
render: (e) => {
const t = ENTRY[e.entryType] || { label: e.entryType, tone: "blue" as const };
return <StatusBadge label={t.label} tone={t.tone} />;
},
},
{
header: "حساب",
render: (e) => (
<span className="text-GRAY text-[12px] whitespace-nowrap">
{ACCOUNT[e.account] || e.account}
</span>
),
},
{
header: "مبلغ",
render: (e) => (
<span
className={cnDelta(e.delta)}
dir="ltr"
>
{e.delta >= 0 ? "+" : ""}
{faToman(Math.abs(e.delta))}
</span>
),
},
{
header: "طرف",
render: (e) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{e.sellerName || e.userPhone || "—"}
</span>
),
},
{
header: "سفارش",
render: (e) => (
<span className="text-GRAY tabular-nums">{e.order ? e.order.slice(0, 8) : "—"}</span>
),
},
{
header: "تاریخ",
render: (e) => <span className="text-GRAY whitespace-nowrap">{faDate(e.createdAt)}</span>,
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی فروشگاه / تلفن / توضیح…"
/>
<AdminSelect
value={account}
onChange={(v) => reset(() => setAccount(v))}
options={[
{ value: "", label: "همه حساب‌ها" },
{ value: "user_wallet", label: "کیف پول کاربر" },
{ value: "seller_balance", label: "موجودی فروشنده" },
{ value: "platform", label: "پلتفرم" },
{ value: "external", label: "خارجی" },
]}
/>
<AdminSelect
value={entryType}
onChange={(v) => reset(() => setEntryType(v))}
options={[
{ value: "", label: "همه انواع" },
{ value: "deposit", label: "واریز" },
{ value: "purchase", label: "خرید" },
{ value: "seller_credit", label: "اعتبار فروشنده" },
{ value: "platform_fee", label: "کارمزد پلتفرم" },
{ value: "refund", label: "بازپرداخت" },
{ value: "seller_reversal", label: "برگشت فروشنده" },
{ value: "payout", label: "تسویه" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(e) => e.id}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function cnDelta(delta: number) {
return `tabular-nums whitespace-nowrap font-semibold ${
delta >= 0 ? "text-GREEN" : "text-RED"
}`;
}

View File

@ -0,0 +1,163 @@
import { useState } from "react";
import { Check, X, Loader2 } from "lucide-react";
import {
useLedgerPayouts,
useLedgerPayoutAction,
type LedgerPayoutRow,
} from "~/requestHandler/use-admin-hooks";
import { useToast } from "~/hooks/use-toast";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
pending: { label: "در انتظار", tone: "yellow" },
completed: { label: "پرداخت‌شده", tone: "green" },
rejected: { label: "ردشده", tone: "red" },
};
export default function LedgerPayouts() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const debouncedSearch = useDebouncedValue(search);
const action = useLedgerPayoutAction();
const { toast } = useToast();
const { data, isLoading } = useLedgerPayouts({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
status,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const run = (id: string, act: "complete" | "reject") =>
action.mutate(
{ id, action: act },
{
onSuccess: () =>
toast({
title: act === "complete" ? "برداشت تأیید شد" : "برداشت رد شد",
description:
act === "complete"
? "مبلغ از مانده فروشنده کسر و در دفتر ثبت شد."
: undefined,
}),
onError: (e: Error) =>
toast({ title: "خطا", description: e.message, variant: "destructive" }),
}
);
const pendingId = action.isPending ? action.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<LedgerPayoutRow>[] = [
{
header: "فروشگاه",
render: (p) => (
<div className="min-w-[130px]">
<p className="font-semibold">{p.sellerName || "—"}</p>
<p className="text-GRAY text-[11px]" dir="ltr">@{p.sellerUsername}</p>
</div>
),
},
{
header: "مبلغ",
render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.amount)}</span>,
},
{
header: "وضعیت",
render: (p) => {
const s = STATUS[p.status] || { label: p.status, tone: "yellow" as const };
return <StatusBadge label={s.label} tone={s.tone} />;
},
},
{
header: "بانک",
render: (p) => (
<div className="min-w-[140px] text-[11px] text-GRAY" dir="ltr">
<p>{p.bankName || "—"}</p>
<p className="tabular-nums">{p.iban || ""}</p>
</div>
),
},
{
header: "درخواست",
render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.requestedAt)}</span>,
},
{
header: "پرداخت",
render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.processedAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (p) => {
if (p.status !== "pending") return <span className="text-GRAY"></span>;
if (pendingId === p.id)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex items-center gap-1.5 justify-end">
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => run(p.id, "complete")}
>
<Check size={13} /> تأیید
</button>
<button
className={`${actBtn} bg-RED/10 text-RED hover:bg-RED/20`}
onClick={() => run(p.id, "reject")}
>
<X size={13} /> رد
</button>
</div>
);
},
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی فروشگاه…"
/>
<AdminSelect
value={status}
onChange={(v) => reset(() => setStatus(v))}
options={[
{ value: "", label: "همه وضعیت‌ها" },
{ value: "pending", label: "در انتظار" },
{ value: "completed", label: "پرداخت‌شده" },
{ value: "rejected", label: "ردشده" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(p) => p.id}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -0,0 +1,203 @@
import { Link, useParams } from "@remix-run/react";
import { Loader2, ArrowRight, BadgeCheck } from "lucide-react";
import { useLedgerSellerDetail } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
StatusBadge,
faToman,
faNum,
faDate,
type Column,
} from "~/components/admin/DataTable";
import type {
LedgerPayoutRow,
LedgerEntryRow,
} from "~/requestHandler/use-admin-hooks";
import { cn } from "~/lib/utils";
const ENTRY_LABEL: Record<string, string> = {
deposit: "واریز",
purchase: "خرید",
seller_credit: "اعتبار فروشنده",
platform_fee: "کارمزد پلتفرم",
refund: "بازپرداخت",
seller_reversal: "برگشت فروشنده",
payout: "تسویه",
};
const PAYOUT_STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
pending: { label: "در انتظار", tone: "yellow" },
completed: { label: "پرداخت‌شده", tone: "green" },
rejected: { label: "ردشده", tone: "red" },
};
function Stat({
label,
value,
accent,
}: {
label: string;
value: string;
accent?: "red" | "green";
}) {
return (
<div className="rounded-2xl border border-WHITE3 bg-WHITE p-4">
<p className="text-[12px] text-GRAY">{label}</p>
<p
className={cn(
"font-extrabold tabular-nums mt-1 text-[18px]",
accent === "red" && "text-RED",
accent === "green" && "text-GREEN"
)}
>
{value}
</p>
</div>
);
}
export default function LedgerSellerDetail() {
const { id } = useParams();
const { data, isLoading, isError } = useLedgerSellerDetail(id);
if (isLoading)
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
if (isError || !data)
return <p className="text-RED text-[14px]">خطا در بارگذاری اطلاعات فروشنده</p>;
const discrepant = Math.abs(data.discrepancy) > 1;
const journalDiscrepant = Math.abs(data.journalDiscrepancy) > 1;
const journalColumns: Column<LedgerEntryRow>[] = [
{
header: "نوع",
render: (e) => (
<span className="whitespace-nowrap">{ENTRY_LABEL[e.entryType] || e.entryType}</span>
),
},
{
header: "مبلغ",
render: (e) => (
<span
className={cn(
"tabular-nums whitespace-nowrap font-semibold",
e.delta >= 0 ? "text-GREEN" : "text-RED"
)}
dir="ltr"
>
{e.delta >= 0 ? "+" : ""}
{faToman(Math.abs(e.delta))}
</span>
),
},
{ header: "تاریخ", render: (e) => <span className="text-GRAY whitespace-nowrap">{faDate(e.createdAt)}</span> },
];
const payoutColumns: Column<LedgerPayoutRow>[] = [
{ header: "مبلغ", render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.amount)}</span> },
{
header: "وضعیت",
render: (p) => {
const s = PAYOUT_STATUS[p.status] || { label: p.status, tone: "yellow" as const };
return <StatusBadge label={s.label} tone={s.tone} />;
},
},
{ header: "درخواست", render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.requestedAt)}</span> },
{ header: "پرداخت", render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.processedAt)}</span> },
];
return (
<div>
<div className="flex items-center gap-2 mb-4">
<Link
to="/admin/ledger/sellers"
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
>
<ArrowRight size={16} />
</Link>
<div>
<h2 className="font-extrabold text-[18px]">{data.seller.storeName}</h2>
<p className="text-GRAY text-[12px]" dir="ltr">
@{data.seller.username} · کمیسیون {faNum(data.seller.platformFee)}٪
</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<Stat label="مانده ثبت‌شده" value={faToman(data.recordedBalance)} />
<Stat label="محاسبه‌شده از سفارش‌ها" value={faToman(data.accruedFromOrders)} />
<Stat label="مانده واقعی (تخمینی)" value={faToman(data.derivedBalance)} accent="green" />
<Stat
label="مانده روزنامه‌ای"
value={faToman(data.journalBalance)}
accent={journalDiscrepant ? "red" : "green"}
/>
<Stat label="برداشت‌های پرداخت‌شده" value={faToman(data.completedPayouts)} />
<Stat label="در انتظار برداشت" value={faToman(data.pendingPayouts)} />
<Stat
label="اختلاف"
value={faToman(data.discrepancy)}
accent={discrepant ? "red" : "green"}
/>
</div>
{discrepant && (
<p className="mt-3 text-[12px] text-RED">
مانده ثبتشده با مقدار محاسبهشده مغایرت دارد (بهاحتمال زیاد بهدلیل عدم
کسر برداشتهای پرداختشده از مانده).
</p>
)}
{/* Bank accounts */}
<h3 className="text-[15px] font-extrabold mt-7 mb-3">حسابهای بانکی</h3>
{data.bankAccounts.length === 0 ? (
<p className="text-GRAY text-[13px]">حساب بانکی ثبت نشده است.</p>
) : (
<div className="flex flex-col gap-2">
{data.bankAccounts.map((b) => (
<div
key={b.id}
className="rounded-xl border border-WHITE3 bg-WHITE p-3 flex items-center justify-between"
>
<div className="text-[13px]">
<p className="font-semibold">{b.accountHolder}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{b.iban} · {b.bankName}
</p>
</div>
{b.isVerified ? (
<span className="flex items-center gap-1 text-[12px] text-VITROWN_BLUE">
<BadgeCheck size={15} /> تأییدشده
</span>
) : (
<StatusBadge label="تأییدنشده" tone="gray" />
)}
</div>
))}
</div>
)}
{/* Payout history */}
<h3 className="text-[15px] font-extrabold mt-7 mb-3">تاریخچه برداشت</h3>
<AdminTable
columns={payoutColumns}
rows={data.payouts}
getRowKey={(p) => p.id}
emptyText="برداشتی ثبت نشده است"
/>
{/* Journal history (Slice 2c) */}
<h3 className="text-[15px] font-extrabold mt-7 mb-3">دفتر روزنامهای</h3>
<AdminTable
columns={journalColumns}
rows={data.journal}
getRowKey={(e) => e.id}
emptyText="ثبتی در دفتر روزنامه وجود ندارد (پس از اجرای بک‌فیل نمایش داده می‌شود)"
/>
</div>
);
}

View File

@ -0,0 +1,95 @@
import { useState } from "react";
import { Link } from "@remix-run/react";
import { ChevronLeft } from "lucide-react";
import {
useLedgerSellers,
type LedgerSellerRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
faToman,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
export default function LedgerSellers() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useLedgerSellers({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<LedgerSellerRow>[] = [
{
header: "فروشگاه",
render: (s) => (
<div className="min-w-[140px]">
<p className="font-semibold">{s.storeName || "—"}</p>
<p className="text-GRAY text-[11px]" dir="ltr">@{s.username}</p>
</div>
),
},
{
header: "مانده",
render: (s) => (
<span className="tabular-nums font-semibold whitespace-nowrap">
{faToman(s.availableFunds)}
</span>
),
},
{
header: "برداشت‌شده",
render: (s) => <span className="tabular-nums whitespace-nowrap text-GRAY">{faToman(s.completedPayouts)}</span>,
},
{
header: "در انتظار برداشت",
render: (s) => <span className="tabular-nums whitespace-nowrap text-GRAY">{faToman(s.pendingPayouts)}</span>,
},
{
header: "",
className: "text-left w-10",
render: (s) => (
<Link
to={`/admin/ledger/sellers/${s.sellerId}`}
className="inline-grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-BLACK2 hover:bg-WHITE2"
aria-label="جزئیات"
>
<ChevronLeft size={16} />
</Link>
),
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی فروشگاه…"
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(s) => s.sellerId}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -0,0 +1,102 @@
import { useState } from "react";
import {
useLedgerTransactions,
type LedgerTransactionRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const TYPE: Record<string, { label: string; tone: "green" | "blue" }> = {
deposit: { label: "واریز", tone: "green" },
purchase: { label: "خرید", tone: "blue" },
};
export default function LedgerTransactions() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [type, setType] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useLedgerTransactions({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
transaction_type: type,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<LedgerTransactionRow>[] = [
{
header: "نوع",
render: (t) => {
const ty = TYPE[t.transactionType] || { label: t.transactionType, tone: "blue" as const };
return <StatusBadge label={ty.label} tone={ty.tone} />;
},
},
{
header: "مبلغ",
render: (t) => <span className="tabular-nums whitespace-nowrap">{faToman(t.amount)}</span>,
},
{
header: "کاربر",
render: (t) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{t.userPhone || "—"}
</span>
),
},
{
header: "سفارش",
render: (t) => (
<span className="text-GRAY tabular-nums">{t.order ? t.order.slice(0, 8) : "—"}</span>
),
},
{
header: "تاریخ",
render: (t) => <span className="text-GRAY whitespace-nowrap">{faDate(t.createdAt)}</span>,
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی تلفن کاربر…"
/>
<AdminSelect
value={type}
onChange={(v) => reset(() => setType(v))}
options={[
{ value: "", label: "همه انواع" },
{ value: "deposit", label: "واریز" },
{ value: "purchase", label: "خرید" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(t) => t.id}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -0,0 +1,41 @@
import { Link, Outlet, useLocation } from "@remix-run/react";
import { cn } from "~/lib/utils";
import { AdminPageTitle } from "~/components/admin/DataTable";
const TABS = [
{ label: "نمای کلی", to: "/admin/ledger", exact: true },
{ label: "برداشت‌ها", to: "/admin/ledger/payouts" },
{ label: "تراکنش‌ها", to: "/admin/ledger/transactions" },
{ label: "دفتر روزنامه", to: "/admin/ledger/journal" },
{ label: "فروشندگان", to: "/admin/ledger/sellers" },
];
export default function LedgerLayout() {
const loc = useLocation();
const active = (t: (typeof TABS)[number]) =>
t.exact ? loc.pathname === t.to : loc.pathname.startsWith(t.to);
return (
<div>
<AdminPageTitle>دفتر مالی</AdminPageTitle>
<div className="flex gap-1 mb-5 border-b border-WHITE3 overflow-x-auto hide-scrollbar">
{TABS.map((t) => (
<Link
key={t.to}
to={t.to}
prefetch="intent"
className={cn(
"px-4 py-2 text-[13.5px] font-semibold whitespace-nowrap border-b-2 -mb-px transition-colors",
active(t)
? "border-BLACK text-BLACK"
: "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t.label}
</Link>
))}
</div>
<Outlet />
</div>
);
}

View File

@ -0,0 +1,228 @@
import { useState } from "react";
import { Eye, EyeOff, Trash2 } from "lucide-react";
import {
useAdminComments,
useAdminChatMessages,
useAdminCommentModerate,
useAdminCommentDelete,
type AdminCommentRow,
type AdminChatMessageRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
import { cn } from "~/lib/utils";
const PAGE_SIZE = 25;
export default function AdminModeration() {
const [tab, setTab] = useState<"comments" | "chat">("comments");
return (
<div>
<AdminPageTitle>مدیریت محتوا</AdminPageTitle>
<div className="flex gap-1 mb-5 border-b border-WHITE3">
{(["comments", "chat"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"px-4 py-2 text-[13.5px] font-semibold border-b-2 -mb-px transition-colors",
tab === t ? "border-BLACK text-BLACK" : "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t === "comments" ? "نظرات" : "گفتگوها"}
</button>
))}
</div>
{tab === "comments" ? <CommentsTab /> : <ChatTab />}
</div>
);
}
function CommentsTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [hidden, setHidden] = useState("");
const [kind, setKind] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminComments({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_hidden: hidden,
kind,
});
const moderate = useAdminCommentModerate();
const del = useAdminCommentDelete();
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const onDelete = (r: AdminCommentRow) => {
if (confirm("حذف این نظر؟ این عمل بازگشت‌پذیر نیست.")) del.mutate(r.id);
};
const columns: Column<AdminCommentRow>[] = [
{
header: "نظر",
render: (r) => (
<div className="max-w-[340px]">
<p className={cn("text-[13px] break-words", r.isHidden && "text-GRAY line-through")}>
{r.comment}
</p>
<p className="text-GRAY text-[11px] mt-0.5 truncate">
{r.productTitle || "—"}
{r.isReply && <span className="text-VITROWN_BLUE"> · پاسخ</span>}
{!r.isReply && r.repliesCount > 0 && <span> · {r.repliesCount} پاسخ</span>}
</p>
</div>
),
},
{
header: "کاربر",
render: (r) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.userPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isHidden ? (
<StatusBadge label="پنهان" tone="red" />
) : (
<StatusBadge label="نمایش" tone="green" />
),
},
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => moderate.mutate({ id: r.id, isHidden: !r.isHidden })}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
aria-label={r.isHidden ? "نمایش" : "پنهان کردن"}
title={r.isHidden ? "نمایش" : "پنهان کردن"}
>
{r.isHidden ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<button
onClick={() => onDelete(r)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی متن نظر / کاربر…" />
<AdminSelect
value={hidden}
onChange={(v) => reset(() => setHidden(v))}
options={[
{ value: "", label: "همه" },
{ value: "false", label: "نمایش‌داده‌شده" },
{ value: "true", label: "پنهان‌شده" },
]}
/>
<AdminSelect
value={kind}
onChange={(v) => reset(() => setKind(v))}
options={[
{ value: "", label: "همه" },
{ value: "top", label: "نظر اصلی" },
{ value: "reply", label: "پاسخ" },
]}
/>
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="نظری ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function ChatTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [deleted, setDeleted] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminChatMessages({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_deleted: deleted,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<AdminChatMessageRow>[] = [
{
header: "پیام",
render: (r) => (
<div className="max-w-[360px]">
<p className={cn("text-[13px] break-words", r.isDeleted && "text-GRAY line-through")}>
{r.content || (r.imageUrl ? "🖼 تصویر" : "—")}
</p>
</div>
),
},
{
header: "فرستنده",
render: (r) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{r.senderPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (r) =>
r.isDeleted ? <StatusBadge label="حذف‌شده" tone="gray" /> : <StatusBadge label="فعال" tone="green" />,
},
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی متن پیام / فرستنده…" />
<AdminSelect
value={deleted}
onChange={(v) => reset(() => setDeleted(v))}
options={[
{ value: "", label: "همه" },
{ value: "false", label: "فعال" },
{ value: "true", label: "حذف‌شده" },
]}
/>
</AdminToolbar>
<p className="text-GRAY text-[12px] mb-2">مشاهدهی فقطخواندنی گفتگوها.</p>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="پیامی یافت نشد" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -0,0 +1,321 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, X, Ban } from "lucide-react";
import {
useNotificationsOverview,
useNotificationTypes,
useNotifications,
useSmsBlacklist,
useNotificationTypeCreate,
useNotificationTypeUpdate,
useNotificationTypeDelete,
useSmsBlacklistDelete,
type NotificationTypeRow,
type NotificationRow,
type SmsBlacklistRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
import { KpiCard } from "~/components/admin/KpiCard";
import { cn } from "~/lib/utils";
const PAGE_SIZE = 25;
export default function AdminNotifications() {
const [tab, setTab] = useState<"types" | "sent" | "blacklist">("types");
const TABS = [
{ k: "types", label: "قالب‌ها" },
{ k: "sent", label: "ارسال‌ها" },
{ k: "blacklist", label: "لیست سیاه" },
] as const;
return (
<div>
<AdminPageTitle>اعلانها</AdminPageTitle>
<div className="flex gap-1 mb-5 border-b border-WHITE3">
{TABS.map((t) => (
<button
key={t.k}
onClick={() => setTab(t.k)}
className={cn(
"px-4 py-2 text-[13.5px] font-semibold border-b-2 -mb-px transition-colors",
tab === t.k ? "border-BLACK text-BLACK" : "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t.label}
</button>
))}
</div>
{tab === "types" && <TypesTab />}
{tab === "sent" && <SentTab />}
{tab === "blacklist" && <BlacklistTab />}
</div>
);
}
/* ------------------------------- templates ------------------------------- */
function TypesTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<NotificationTypeRow | null>(null);
const [creating, setCreating] = useState(false);
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useNotificationTypes({ page, page_size: PAGE_SIZE, search: debouncedSearch });
const del = useNotificationTypeDelete();
const columns: Column<NotificationTypeRow>[] = [
{
header: "عنوان",
render: (r) => (
<div>
<p className="font-semibold">{r.title}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">{r.code}</p>
</div>
),
},
{
header: "کانال‌ها",
render: (r) => (
<div className="flex gap-1">
{r.defaultInApp && <StatusBadge label="این‌اپ" tone="blue" />}
{r.defaultSms && <StatusBadge label="پیامک" tone="green" />}
</div>
),
},
{
header: "قالب",
render: (r) => (
<span className="text-GRAY text-[11px] line-clamp-1 max-w-[220px]">{r.template || "—"}</span>
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button onClick={() => setEditing(r)} className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2" aria-label="ویرایش">
<Pencil size={14} />
</button>
<button
onClick={() => confirm(`حذف قالب «${r.title}»؟`) && del.mutate(r.id)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<div className="flex justify-end mb-3">
<button
onClick={() => setCreating(true)}
className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-3.5 py-2 text-[13px] font-semibold hover:opacity-90"
>
<Plus size={16} /> قالب جدید
</button>
</div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => { setSearch(v); setPage(1); }} placeholder="جستجوی کد / عنوان…" />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="قالبی ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
{(creating || editing) && (
<TypeModal row={editing} onClose={() => { setCreating(false); setEditing(null); }} />
)}
</div>
);
}
function TypeModal({ row, onClose }: { row: NotificationTypeRow | null; onClose: () => void }) {
const [code, setCode] = useState(row?.code ?? "");
const [title, setTitle] = useState(row?.title ?? "");
const [defaultSms, setDefaultSms] = useState(row?.defaultSms ?? false);
const [defaultInApp, setDefaultInApp] = useState(row?.defaultInApp ?? true);
const [template, setTemplate] = useState(row?.template ?? "");
const [variables, setVariables] = useState((row?.variables ?? []).join(", "));
const [error, setError] = useState("");
const create = useNotificationTypeCreate();
const update = useNotificationTypeUpdate();
const busy = create.isPending || update.isPending;
const submit = () => {
setError("");
if (!code || !title) return setError("کد و عنوان الزامی است.");
const payload = {
code,
title,
defaultSms,
defaultInApp,
template,
variables: variables.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
const onErr = (e: unknown) => setError(e instanceof Error ? e.message : "خطا در ذخیره");
if (row) update.mutate({ id: row.id, data: payload }, { onSuccess: onClose, onError: onErr });
else create.mutate(payload, { onSuccess: onClose, onError: onErr });
};
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-BLACK/40 sm:p-4" onClick={onClose}>
<div className="bg-WHITE w-full sm:max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[92vh] overflow-y-auto hide-scrollbar" onClick={(e) => e.stopPropagation()}>
<div className="sticky top-0 bg-WHITE flex items-center justify-between px-5 py-4 border-b border-WHITE3">
<h3 className="font-extrabold text-[16px]">{row ? "ویرایش قالب" : "قالب جدید"}</h3>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg hover:bg-WHITE2"><X size={18} /></button>
</div>
<div className="p-5 flex flex-col gap-3">
<div>
<label className={labelCls}>کد (یکتا)</label>
<input className={inputCls} value={code} onChange={(e) => setCode(e.target.value)} dir="ltr" />
</div>
<div>
<label className={labelCls}>عنوان</label>
<input className={inputCls} value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={defaultInApp} onChange={(e) => setDefaultInApp(e.target.checked)} className="h-4 w-4" />
<span className="text-[13px] font-semibold">ایناپ پیشفرض</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={defaultSms} onChange={(e) => setDefaultSms(e.target.checked)} className="h-4 w-4" />
<span className="text-[13px] font-semibold">پیامک پیشفرض</span>
</label>
</div>
<div>
<label className={labelCls}>قالب پیام</label>
<textarea className={cn(inputCls, "min-h-[90px] resize-y")} value={template} onChange={(e) => setTemplate(e.target.value)} />
</div>
<div>
<label className={labelCls}>متغیرها (با کاما جدا شوند)</label>
<input className={inputCls} value={variables} onChange={(e) => setVariables(e.target.value)} dir="ltr" placeholder="seller.username, order.id" />
</div>
{error && <p className="text-RED text-[12px]">{error}</p>}
</div>
<div className="sticky bottom-0 bg-WHITE flex gap-2 px-5 py-4 border-t border-WHITE3">
<button onClick={onClose} className="flex-1 rounded-xl border border-WHITE3 py-2.5 text-[13.5px] font-semibold hover:bg-WHITE2">انصراف</button>
<button onClick={submit} disabled={busy} className="flex-1 rounded-xl bg-BLACK text-white py-2.5 text-[13.5px] font-semibold hover:opacity-90 disabled:opacity-50">
{busy ? "در حال ذخیره…" : "ذخیره"}
</button>
</div>
</div>
</div>
);
}
/* --------------------------------- sent --------------------------------- */
function SentTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [read, setRead] = useState("");
const [sentSms, setSentSms] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data: ov } = useNotificationsOverview();
const { data, isLoading } = useNotifications({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
read,
sent_sms: sentSms,
});
const reset = (fn: () => void) => { fn(); setPage(1); };
const columns: Column<NotificationRow>[] = [
{
header: "عنوان",
render: (r) => (
<div className="max-w-[280px]">
<p className="font-semibold text-[13px] truncate">{r.title}</p>
<p className="text-GRAY text-[11px]">{r.typeTitle || r.typeCode || "—"}</p>
</div>
),
},
{ header: "گیرنده", render: (r) => <span className="text-GRAY text-[11px] tabular-nums" dir="ltr">{r.receiverPhone || "—"}</span> },
{
header: "کانال",
render: (r) => (
<div className="flex gap-1">
{r.sentInApp && <StatusBadge label="این‌اپ" tone="blue" />}
{r.sentSms && <StatusBadge label="پیامک" tone="green" />}
{!r.sentInApp && !r.sentSms && <span className="text-GRAY text-[11px]"></span>}
</div>
),
},
{ header: "خوانده", render: (r) => (r.read ? <StatusBadge label="بله" tone="gray" /> : <StatusBadge label="خیر" tone="yellow" />) },
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
];
return (
<div>
{ov && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<KpiCard label="کل اعلان‌ها" value={faNum(ov.total)} />
<KpiCard label="پیامک ارسال‌شده" value={faNum(ov.smsSent)} accent="green" />
<KpiCard label="خوانده‌نشده" value={faNum(ov.unread)} accent={ov.unread > 0 ? "red" : "default"} />
<KpiCard label="اشتراک پوش" value={faNum(ov.pushSubscriptions)} />
</div>
)}
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی گیرنده / عنوان…" />
<AdminSelect value={read} onChange={(v) => reset(() => setRead(v))} options={[{ value: "", label: "همه" }, { value: "false", label: "خوانده‌نشده" }, { value: "true", label: "خوانده‌شده" }]} />
<AdminSelect value={sentSms} onChange={(v) => reset(() => setSentSms(v))} options={[{ value: "", label: "همه" }, { value: "true", label: "با پیامک" }, { value: "false", label: "بدون پیامک" }]} />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="اعلانی یافت نشد" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
/* ------------------------------- blacklist ------------------------------- */
function BlacklistTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useSmsBlacklist({ page, page_size: PAGE_SIZE, search: debouncedSearch });
const del = useSmsBlacklistDelete();
const columns: Column<SmsBlacklistRow>[] = [
{ header: "موبایل", render: (r) => <span className="tabular-nums" dir="ltr">{r.mobile}</span> },
{ header: "خط", render: (r) => <span className="tabular-nums" dir="ltr">{faNum(r.lineNumber)}</span> },
{ header: "دلیل", render: (r) => <span className="text-GRAY text-[11px] line-clamp-1 max-w-[220px]">{r.reason || "—"}</span> },
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
{
header: "",
render: (r) => (
<button
onClick={() => confirm("حذف از لیست سیاه (رفع مسدودی)؟") && del.mutate(r.id)}
className="flex items-center gap-1 rounded-lg border border-WHITE3 px-2.5 h-8 text-[12px] hover:bg-WHITE2"
>
<Ban size={13} /> رفع مسدودی
</button>
),
},
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => { setSearch(v); setPage(1); }} placeholder="جستجوی موبایل…" />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="لیست سیاه خالی است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
const inputCls = "w-full rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY";
const labelCls = "block text-[12px] text-GRAY mb-1";

150
app/routes/admin.orders.tsx Normal file
View File

@ -0,0 +1,150 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { useAdminOrders } from "~/requestHandler/use-admin-hooks";
import type { AdminOrderRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "سفارش‌ها | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
const STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" | "blue" | "gray" }> = {
pending: { label: "در انتظار", tone: "yellow" },
confirmed: { label: "تأییدشده", tone: "blue" },
shipped: { label: "ارسال‌شده", tone: "blue" },
delivered: { label: "تحویل‌شده", tone: "green" },
cancelled: { label: "لغوشده", tone: "red" },
};
const PAYMENT: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
paid: { label: "پرداخت‌شده", tone: "green" },
pending: { label: "در انتظار", tone: "yellow" },
failed: { label: "ناموفق", tone: "red" },
};
export default function AdminOrders() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [payment, setPayment] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminOrders({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
status,
payment_status: payment,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<AdminOrderRow>[] = [
{
header: "کد",
render: (o) => <span className="tabular-nums text-GRAY">{o.id.slice(0, 8)}</span>,
},
{
header: "خریدار",
render: (o) => (
<div className="min-w-[120px]">
<p className="font-semibold">{o.userName || "—"}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{o.userPhone || ""}
</p>
</div>
),
},
{
header: "فروشگاه",
render: (o) => <span>{o.sellerName || o.sellerUsername || "—"}</span>,
},
{
header: "مبلغ",
render: (o) => <span className="tabular-nums whitespace-nowrap">{faToman(o.totalPrice)}</span>,
},
{
header: "پرداخت",
render: (o) => {
const p = PAYMENT[o.paymentStatus] || { label: o.paymentStatus, tone: "gray" as const };
return <StatusBadge label={p.label} tone={p.tone} />;
},
},
{
header: "وضعیت",
render: (o) => {
const s = STATUS[o.status] || { label: o.status, tone: "gray" as const };
return <StatusBadge label={s.label} tone={s.tone} />;
},
},
{
header: "تاریخ",
render: (o) => <span className="text-GRAY whitespace-nowrap">{faDate(o.createdAt)}</span>,
},
];
return (
<div>
<AdminPageTitle>سفارشها</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی خریدار یا فروشگاه…"
/>
<AdminSelect
value={status}
onChange={(v) => reset(() => setStatus(v))}
options={[
{ value: "", label: "همه وضعیت‌ها" },
{ value: "pending", label: "در انتظار" },
{ value: "confirmed", label: "تأییدشده" },
{ value: "shipped", label: "ارسال‌شده" },
{ value: "delivered", label: "تحویل‌شده" },
{ value: "cancelled", label: "لغوشده" },
]}
/>
<AdminSelect
value={payment}
onChange={(v) => reset(() => setPayment(v))}
options={[
{ value: "", label: "همه پرداخت‌ها" },
{ value: "paid", label: "پرداخت‌شده" },
{ value: "pending", label: "در انتظار" },
{ value: "failed", label: "ناموفق" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(o) => o.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

View File

@ -0,0 +1,160 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { Link } from "@remix-run/react";
import { Loader2, Eye, EyeOff } from "lucide-react";
import {
useAdminProducts,
useAdminProductUpdate,
} from "~/requestHandler/use-admin-hooks";
import type { AdminProductRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "محصولات | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
export default function AdminProducts() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [active, setActive] = useState("");
const debouncedSearch = useDebouncedValue(search);
const update = useAdminProductUpdate();
const { data, isLoading } = useAdminProducts({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_active: active,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const pendingId = update.isPending ? update.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<AdminProductRow>[] = [
{
header: "محصول",
render: (p) => (
<Link
to={`/product/${p.id}`}
className="flex items-center gap-2 min-w-[180px] hover:opacity-80"
>
<div className="h-10 w-10 rounded-lg bg-WHITE3 overflow-hidden shrink-0">
{p.primaryImage && (
<img src={p.primaryImage} alt="" className="h-full w-full object-cover" />
)}
</div>
<p className="font-semibold truncate max-w-[220px]">{p.title}</p>
</Link>
),
},
{
header: "فروشگاه",
render: (p) => <span>{p.sellerName || p.sellerUsername || "—"}</span>,
},
{
header: "دسته",
render: (p) => <span className="text-GRAY">{p.categoryName || "—"}</span>,
},
{
header: "قیمت",
render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.basePrice)}</span>,
},
{
header: "وضعیت",
render: (p) =>
p.isActive ? (
<StatusBadge label="فعال" tone="green" />
) : (
<StatusBadge label="غیرفعال" tone="gray" />
),
},
{
header: "تاریخ",
render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.createdAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (p) => {
const busy = pendingId === p.id;
if (busy)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex justify-end">
{p.isActive ? (
<button
className={`${actBtn} bg-WHITE2 text-BLACK2 hover:bg-WHITE3`}
onClick={() => update.mutate({ id: p.id, data: { isActive: false } })}
>
<EyeOff size={13} /> غیرفعال
</button>
) : (
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => update.mutate({ id: p.id, data: { isActive: true } })}
>
<Eye size={13} /> فعال
</button>
)}
</div>
);
},
},
];
return (
<div>
<AdminPageTitle>محصولات</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی عنوان یا فروشگاه…"
/>
<AdminSelect
value={active}
onChange={(v) => reset(() => setActive(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "فعال" },
{ value: "false", label: "غیرفعال" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(p) => p.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

View File

@ -0,0 +1,193 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { Check, X, BadgeCheck, Loader2 } from "lucide-react";
import {
useAdminSellers,
useAdminSellerUpdate,
} from "~/requestHandler/use-admin-hooks";
import type { AdminSellerRow } from "~/requestHandler/use-admin-hooks";
import SellerLogo from "~/components/SellerLogo";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "فروشگاه‌ها | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
const STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
pending: { label: "در انتظار", tone: "yellow" },
approved: { label: "تأییدشده", tone: "green" },
rejected: { label: "ردشده", tone: "red" },
};
export default function AdminSellers() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [verified, setVerified] = useState("");
const debouncedSearch = useDebouncedValue(search);
const update = useAdminSellerUpdate();
const { data, isLoading } = useAdminSellers({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
status,
is_verified: verified,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const pendingId = update.isPending ? update.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<AdminSellerRow>[] = [
{
header: "فروشگاه",
render: (s) => (
<div className="flex items-center gap-2 min-w-[160px]">
<SellerLogo size="sm" src={s.storeLogo || undefined} alt={s.storeName} />
<div className="min-w-0">
<p className="font-semibold truncate">{s.storeName}</p>
<p className="text-GRAY text-[11px]" dir="ltr">@{s.username}</p>
</div>
</div>
),
},
{
header: "تلفن",
render: (s) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{s.userPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (s) => {
const st = STATUS[s.status] || { label: s.status, tone: "yellow" as const };
return (
<div className="flex items-center gap-1.5">
<StatusBadge label={st.label} tone={st.tone} />
{s.isVerified && <BadgeCheck size={15} className="text-VITROWN_BLUE" />}
</div>
);
},
},
{
header: "کمیسیون",
render: (s) => <span className="tabular-nums">{faNum(s.platformFee)}٪</span>,
},
{
header: "محصولات",
render: (s) => <span className="tabular-nums">{faNum(s.productCount)}</span>,
},
{
header: "دنبال‌کننده",
render: (s) => <span className="tabular-nums">{faNum(s.followerCount)}</span>,
},
{
header: "تاریخ",
render: (s) => <span className="text-GRAY whitespace-nowrap">{faDate(s.createdAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (s) => {
const busy = pendingId === s.id;
if (busy)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex items-center gap-1.5 justify-end">
{s.status !== "approved" && (
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => update.mutate({ id: s.id, data: { status: "approved" } })}
>
<Check size={13} /> تأیید
</button>
)}
{s.status !== "rejected" && (
<button
className={`${actBtn} bg-RED/10 text-RED hover:bg-RED/20`}
onClick={() => update.mutate({ id: s.id, data: { status: "rejected" } })}
>
<X size={13} /> رد
</button>
)}
<button
className={`${actBtn} bg-WHITE2 text-BLACK2 hover:bg-WHITE3`}
onClick={() => update.mutate({ id: s.id, data: { isVerified: !s.isVerified } })}
>
{s.isVerified ? "لغو احراز" : "احراز هویت"}
</button>
</div>
);
},
},
];
return (
<div>
<AdminPageTitle>فروشگاهها</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی نام یا آیدی فروشگاه…"
/>
<AdminSelect
value={status}
onChange={(v) => reset(() => setStatus(v))}
options={[
{ value: "", label: "همه وضعیت‌ها" },
{ value: "pending", label: "در انتظار" },
{ value: "approved", label: "تأییدشده" },
{ value: "rejected", label: "ردشده" },
]}
/>
<AdminSelect
value={verified}
onChange={(v) => reset(() => setVerified(v))}
options={[
{ value: "", label: "احراز: همه" },
{ value: "true", label: "احرازشده" },
{ value: "false", label: "احرازنشده" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(s) => s.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

160
app/routes/admin.tsx Normal file
View File

@ -0,0 +1,160 @@
import { Link, Outlet, useLocation } from "@remix-run/react";
import {
LayoutDashboard,
ClipboardList,
Store,
Users,
Package,
Tags,
Ticket,
LayoutGrid,
Landmark,
LayoutTemplate,
Bell,
Activity,
MessageSquareWarning,
ScrollText,
ArrowRight,
ShieldCheck,
Loader2,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useRequireAdmin } from "~/hooks/useRequireAdmin";
interface NavItem {
label: string;
to: string;
icon: LucideIcon;
exact?: boolean;
soon?: boolean; // planned but not built yet
}
const NAV: NavItem[] = [
{ label: "نمای کلی", to: "/admin", icon: LayoutDashboard, exact: true },
{ label: "سفارش‌ها", to: "/admin/orders", icon: ClipboardList },
{ label: "فروشگاه‌ها", to: "/admin/sellers", icon: Store },
{ label: "کاربران", to: "/admin/users", icon: Users },
{ label: "محصولات", to: "/admin/products", icon: Package },
{ label: "ویژگی‌ها", to: "/admin/catalog", icon: Tags },
{ label: "تخفیف‌ها", to: "/admin/discounts", icon: Ticket },
{ label: "مجموعه‌ها", to: "/admin/collections", icon: LayoutGrid },
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
{ label: "صفحه اصلی", to: "/admin/homepage", icon: LayoutTemplate },
{ label: "اعلان‌ها", to: "/admin/notifications", icon: Bell },
{ label: "سلامت AI", to: "/admin/ai-health", icon: Activity },
{ label: "مدیریت محتوا", to: "/admin/moderation", icon: MessageSquareWarning },
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
];
export default function AdminLayout() {
const location = useLocation();
const { isLoading, isAdmin } = useRequireAdmin();
const isActive = (item: NavItem) =>
item.exact
? location.pathname === item.to
: location.pathname.startsWith(item.to);
if (isLoading || !isAdmin) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
}
const NavLinks = ({ onNavigate }: { onNavigate?: () => void }) => (
<>
{NAV.map((n) => {
const active = isActive(n);
const cls = `flex items-center gap-3 px-3.5 py-3 rounded-xl text-[14.5px] font-semibold transition-colors ${
active ? "bg-BLACK text-white" : "text-BLACK2 hover:bg-WHITE2"
} ${n.soon ? "opacity-45 cursor-not-allowed" : ""}`;
const inner = (
<>
<n.icon size={20} />
{n.label}
{n.soon && (
<span className="ms-auto text-[10px] text-GRAY font-normal">
بهزودی
</span>
)}
</>
);
return n.soon ? (
<div key={n.to} className={cls} aria-disabled>
{inner}
</div>
) : (
<Link
key={n.to}
to={n.to}
prefetch="intent"
onClick={onNavigate}
className={cls}
>
{inner}
</Link>
);
})}
<Link
to="/"
className="flex items-center gap-3 px-3.5 py-3 rounded-xl text-[14.5px] font-semibold text-BLACK2 hover:bg-WHITE2 transition-colors"
>
<ArrowRight size={20} />
بازگشت به سایت
</Link>
</>
);
return (
<div className="lg:max-w-[1440px] lg:mx-auto lg:w-full lg:px-8 lg:py-8 lg:grid lg:grid-cols-[248px_1fr] lg:gap-7 lg:items-start">
{/* Desktop sidebar */}
<aside className="hidden lg:block lg:sticky lg:top-6 border border-WHITE3 rounded-2xl overflow-hidden">
<div className="p-4 border-b border-WHITE3 flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-BLACK text-white grid place-items-center shrink-0">
<ShieldCheck size={20} />
</div>
<div className="min-w-0">
<p className="font-bold text-[15px] truncate">پنل مدیریت</p>
<p className="text-GRAY text-[12px]">ویترون</p>
</div>
</div>
<nav className="p-2.5 flex flex-col gap-0.5">
<NavLinks />
</nav>
</aside>
{/* Mobile top bar + horizontal nav */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)] border-b border-WHITE3">
<div className="flex items-center gap-2 px-4 h-[56px]">
<div className="h-8 w-8 rounded-lg bg-BLACK text-white grid place-items-center">
<ShieldCheck size={16} />
</div>
<p className="font-bold text-[15px]">پنل مدیریت</p>
</div>
<div className="flex gap-2 overflow-x-auto px-4 pb-2 hide-scrollbar">
{NAV.map((n) => {
const active = isActive(n);
const cls = `whitespace-nowrap px-3 py-1.5 rounded-full text-[13px] font-semibold ${
active ? "bg-BLACK text-white" : "bg-WHITE2 text-BLACK2"
} ${n.soon ? "opacity-45" : ""}`;
return n.soon ? (
<span key={n.to} className={cls}>
{n.label}
</span>
) : (
<Link key={n.to} to={n.to} prefetch="intent" className={cls}>
{n.label}
</Link>
);
})}
</div>
</div>
<div className="min-w-0 px-4 py-4 lg:p-0">
<Outlet />
</div>
</div>
);
}

167
app/routes/admin.users.tsx Normal file
View File

@ -0,0 +1,167 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { Loader2, Ban, RotateCcw } from "lucide-react";
import {
useAdminUsers,
useAdminUserUpdate,
} from "~/requestHandler/use-admin-hooks";
import type { AdminUserRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "کاربران | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
export default function AdminUsers() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [active, setActive] = useState("");
const [role, setRole] = useState("");
const debouncedSearch = useDebouncedValue(search);
const update = useAdminUserUpdate();
const { data, isLoading } = useAdminUsers({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_active: active,
is_seller: role === "seller" ? "true" : undefined,
is_staff: role === "staff" ? "true" : undefined,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const pendingId = update.isPending ? update.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<AdminUserRow>[] = [
{
header: "تلفن",
render: (u) => (
<span className="tabular-nums font-semibold" dir="ltr">
{u.phoneNumber}
</span>
),
},
{
header: "نام کاربری",
render: (u) => <span>{u.username || "—"}</span>,
},
{
header: "نقش",
render: (u) => (
<div className="flex items-center gap-1 flex-wrap">
{u.isSuperuser && <StatusBadge label="سوپرادمین" tone="red" />}
{u.isStaff && !u.isSuperuser && <StatusBadge label="ادمین" tone="blue" />}
{u.isSeller && <StatusBadge label="فروشنده" tone="gray" />}
{!u.isSeller && !u.isStaff && !u.isSuperuser && (
<span className="text-GRAY text-[12px]">کاربر</span>
)}
</div>
),
},
{
header: "وضعیت",
render: (u) =>
u.isActive ? (
<StatusBadge label="فعال" tone="green" />
) : (
<StatusBadge label="مسدود" tone="red" />
),
},
{
header: "عضویت",
render: (u) => <span className="text-GRAY whitespace-nowrap">{faDate(u.createdAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (u) => {
const busy = pendingId === u.id;
if (busy)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex justify-end">
{u.isActive ? (
<button
className={`${actBtn} bg-RED/10 text-RED hover:bg-RED/20`}
onClick={() => update.mutate({ id: u.id, data: { isActive: false } })}
>
<Ban size={13} /> مسدود
</button>
) : (
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => update.mutate({ id: u.id, data: { isActive: true } })}
>
<RotateCcw size={13} /> فعالسازی
</button>
)}
</div>
);
},
},
];
return (
<div>
<AdminPageTitle>کاربران</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی تلفن، نام کاربری یا ایمیل…"
/>
<AdminSelect
value={active}
onChange={(v) => reset(() => setActive(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "فعال" },
{ value: "false", label: "مسدود" },
]}
/>
<AdminSelect
value={role}
onChange={(v) => reset(() => setRole(v))}
options={[
{ value: "", label: "همه نقش‌ها" },
{ value: "seller", label: "فروشنده" },
{ value: "staff", label: "ادمین" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(u) => u.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

View File

@ -262,63 +262,67 @@ export default function CartDetail() {
isError={isError}
onRetry={refetch}
>
<>
{step === 0 && (
<div className="flex flex-col w-full">
{cart?.items?.map((item, index) => (
<CartItem
key={item.id}
item={item}
onQuantityChange={handleQuantityChange}
onRemoveItem={handleRemoveItem}
isLoading={removeMutation.isPending}
hasBorder={
!!(cart?.items && index !== cart?.items?.length - 1)
}
/>
))}
</div>
)}
{step === 1 && (
<ShippingInformation
sellerId={cart?.seller?.username || ""}
total={calculateTotal(cart)}
setSelectedAddressInfo={setSelectedAddressInfo}
setSelectedCourierInfo={setSelectedCourierInfo}
setShippingInfoIsLoading={setShippingInfoIsLoading}
selectedAddressInfo={selectedAddressInfo}
cartId={cartId}
/>
)}
{step === 2 && (
<ReceiverInformation
discountValue={Number(cart?.discountValue) || null}
cartItems={cart?.items || []}
selectedAddressInfo={selectedAddressInfo}
selectedCourierInfo={selectedCourierInfo}
<div className="lg:grid lg:grid-cols-[1fr_360px] lg:gap-7 lg:items-start lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:py-6">
<div className="flex flex-col w-full">
{step === 0 && (
<div className="flex flex-col w-full">
{cart?.items?.map((item, index) => (
<CartItem
key={item.id}
item={item}
onQuantityChange={handleQuantityChange}
onRemoveItem={handleRemoveItem}
isLoading={removeMutation.isPending}
hasBorder={
!!(cart?.items && index !== cart?.items?.length - 1)
}
/>
))}
</div>
)}
{step === 1 && (
<ShippingInformation
sellerId={cart?.seller?.username || ""}
total={calculateTotal(cart)}
setSelectedAddressInfo={setSelectedAddressInfo}
setSelectedCourierInfo={setSelectedCourierInfo}
setShippingInfoIsLoading={setShippingInfoIsLoading}
selectedAddressInfo={selectedAddressInfo}
cartId={cartId}
/>
)}
{step === 2 && (
<ReceiverInformation
discountValue={Number(cart?.discountValue) || null}
cartItems={cart?.items || []}
selectedAddressInfo={selectedAddressInfo}
selectedCourierInfo={selectedCourierInfo}
total={calculateTotal(
cart,
selectedCourierInfo?.courierIsFreightCollect ? 0 : selectedCourierInfo?.deliveryPrice,
Number(cart?.discountValue)
)}
/>
)}
</div>
<div className="lg:sticky lg:top-[88px]">
<CartSummary
total={calculateTotal(
cart,
selectedCourierInfo?.courierIsFreightCollect ? 0 : selectedCourierInfo?.deliveryPrice,
Number(cart?.discountValue)
step === 2 && !selectedCourierInfo?.courierIsFreightCollect ? selectedCourierInfo?.deliveryPrice : 0,
step === 2 ? Number(cart?.discountValue) : 0
)}
onContinue={() => continueHandler()}
step={step}
isLoading={
isCreatingOrder ||
cashOutMutation.isPending ||
shippingInfoIsLoading
}
/>
)}
<CartSummary
total={calculateTotal(
cart,
step === 2 && !selectedCourierInfo?.courierIsFreightCollect ? selectedCourierInfo?.deliveryPrice : 0,
step === 2 ? Number(cart?.discountValue) : 0
)}
onContinue={() => continueHandler()}
step={step}
isLoading={
isCreatingOrder ||
cashOutMutation.isPending ||
shippingInfoIsLoading
}
/>
</>
</div>
</div>
</UiProvider>
</div>
</div>

View File

@ -59,11 +59,16 @@ export default function CartIndex() {
};
return (
<div className="bg-background">
{/* Header */}
<ProfilePagesHeader hideBackButton={true} title="لیست سبد خرید" />
{/* Header (mobile) */}
<div className="lg:hidden">
<ProfilePagesHeader hideBackButton={true} title="لیست سبد خرید" />
</div>
{/* Cart Content */}
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-4 p-4 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:py-8">
<h1 className="hidden lg:block text-[28px] font-extrabold mb-2">
سبد خرید
</h1>
<UiProvider
isLoading={isLoading}
isError={isError}
@ -73,7 +78,7 @@ export default function CartIndex() {
}}
type="cart"
>
<div className="space-y-4 w-full">
<div className="space-y-4 w-full lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-5">
{carts.map((cart) => {
return (
<div
@ -111,6 +116,9 @@ export default function CartIndex() {
<p className="text-B12 font-bold">
{cart.seller?.name}
</p>
<span className="ms-auto shrink-0 text-R10 font-semibold text-green-600">
ارسال جداگانه
</span>
</a>
<p className="text-B12 font-bold line-clamp-1">
{cart.items

View File

@ -61,12 +61,14 @@ export default function CollectionDetail() {
return (
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
{/* Header */}
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
</div>
</div>
{/* Loading Skeleton */}
@ -94,12 +96,14 @@ export default function CollectionDetail() {
return (
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
{/* Header */}
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
</div>
</div>
{/* Error State */}
@ -129,21 +133,23 @@ export default function CollectionDetail() {
return (
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
{/* Header */}
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
{/* Header (mobile) */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
</div>
</div>
{/* Content */}
<div className="flex flex-col">
<div className="flex flex-col lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pt-6">
{/* Collection Header */}
<div className="flex flex-col gap-4 p-4 border-b border-inner-border">
<div className="flex flex-col gap-4 p-4 border-b border-inner-border lg:grid lg:grid-cols-[360px_1fr] lg:gap-8 lg:items-start lg:p-0 lg:py-6 lg:border-0">
{/* Cover Image */}
<div className="relative overflow-hidden rounded-xl w-full mx-auto h-[65vh] bg-gray-100">
<div className="relative overflow-hidden rounded-xl w-full mx-auto h-[65vh] bg-gray-100 lg:h-auto lg:aspect-[3/4]">
{collection.coverImageUrl ? (
<img
src={collection.coverImageUrl}
@ -159,7 +165,7 @@ export default function CollectionDetail() {
{/* Collection Info */}
<div className="flex flex-col gap-3">
<h1 className="text-B20 font-bold text-black">
<h1 className="text-B20 lg:text-[28px] font-bold text-black">
{collection.title || "بدون عنوان"}
</h1>

View File

@ -41,7 +41,7 @@ export default function Collections() {
// Loading skeleton
const CollectionSkeleton = () => (
<div className="flex flex-col gap-4 px-4">
<div className="flex flex-col gap-4 px-4 lg:grid lg:grid-cols-4 lg:gap-5 lg:px-0">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
@ -117,17 +117,22 @@ export default function Collections() {
return (
<div className="flex flex-col bg-WHITE pb-20">
{/* Header - مطابق با ProfilePagesHeader */}
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">کالکشنها</p>
{/* Header (mobile) */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">کالکشنها</p>
</div>
</div>
{/* Content */}
<div className="flex flex-col py-6">
<div className="flex flex-col py-6 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8">
<h1 className="hidden lg:block text-[28px] font-extrabold mb-6">
کالکشنها
</h1>
{status === "pending" ? (
<CollectionSkeleton />
) : status === "error" ? (
@ -136,7 +141,7 @@ export default function Collections() {
<EmptyState />
) : (
<>
<div className="flex flex-col gap-4 px-4">
<div className="flex flex-col gap-4 px-4 lg:grid lg:grid-cols-4 lg:gap-5 lg:px-0">
{allCollections.map((collection) => (
<Link
key={collection.id}
@ -145,7 +150,7 @@ export default function Collections() {
>
<div className="flex flex-col gap-3 p-4 bg-WHITE2 rounded-2xl border border-inner-border hover:border-gray-300 transition-all duration-300 hover:shadow-lg">
{/* Collection Image - بزرگ */}
<div className="relative overflow-hidden rounded-xl w-full h-48 bg-gray-100">
<div className="relative overflow-hidden rounded-xl w-full h-48 lg:h-auto lg:aspect-[3/4] bg-gray-100">
{collection.coverImageUrl ? (
<img
src={collection.coverImageUrl}

104
app/routes/discounts.tsx Normal file
View File

@ -0,0 +1,104 @@
import { MetaFunction } from "@remix-run/node";
import { useNavigate } from "@remix-run/react";
import { ArrowRight, Tag } from "lucide-react";
import { safeGoBack } from "~/utils/helpers";
import { useGetHomeDiscounts } from "~/requestHandler/use-home-hooks";
import MondrianProductList from "~/components/MondrianProductList";
export default function Discounts() {
const navigate = useNavigate();
const {
data,
isLoading,
isError,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetHomeDiscounts();
const products =
data?.pages.flatMap((page) => (page?.results as unknown[]) || []) || [];
const handleBack = () => safeGoBack(navigate);
return (
<div className="flex flex-col bg-WHITE pb-20">
{/* Header (mobile) */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">تخفیفها</p>
</div>
</div>
{/* Content */}
<div className="flex flex-col py-6 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8">
<div className="hidden lg:block mb-6 px-4 lg:px-0">
<h1 className="text-[28px] font-extrabold">تخفیفها</h1>
<p className="text-R16 text-GRAY mt-1">
فرصتهایی محدود برای انتخابهای هوشمندانه
</p>
</div>
<MondrianProductList
// eslint-disable-next-line @typescript-eslint/no-explicit-any
products={(products as any[]).map((product) => ({
id: product?.id || "",
title: product?.title || "",
images: Array.isArray(product?.imageUrls) ? product.imageUrls : [],
basePrice: product?.basePrice || 0,
discountPercent: product?.discountPercent || 0,
discountPrice: product?.discountPrice || 0,
sellerName: product?.sellerName,
sellerLogo: product?.sellerLogo,
sellerUsername: product?.sellerUsername,
}))}
fetchNextPage={fetchNextPage}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
isError={isError}
refetch={refetch}
emptyState={{
image: <Tag size={80} />,
title: "در حال حاضر تخفیفی موجود نیست",
description: "به زودی پیشنهادهای ویژه‌ای اضافه خواهد شد",
}}
/>
</div>
</div>
);
}
export const meta: MetaFunction = () => {
const title = "تخفیف‌ها | پیشنهادهای ویژه ویترون";
const description =
"جدیدترین محصولات تخفیف‌دار ویترون. خرید با بهترین قیمت از فروشگاه‌های معتبر.";
const imageUrl =
"https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/SVG-06.svg?versionId=";
return [
{ title },
{ name: "description", content: description },
{
name: "keywords",
content: "تخفیف، حراج، فروش ویژه، خرید ارزان، ویترون",
},
{ name: "robots", content: "index, follow" },
{ property: "og:type", content: "website" },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:image", content: imageUrl },
{ property: "og:site_name", content: "ویترون" },
{ property: "og:locale", content: "fa_IR" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
];
};

View File

@ -9,11 +9,13 @@ export default function Explore() {
const [searchValue, setSearchValue] = useState("");
return (
<div className="flex flex-col">
<ExploreTopBar
setIsActiveSearchBar={setIsActiveSearchBar}
searchValue={searchValue}
setSearchValue={setSearchValue}
/>
<div className="lg:hidden">
<ExploreTopBar
setIsActiveSearchBar={setIsActiveSearchBar}
searchValue={searchValue}
setSearchValue={setSearchValue}
/>
</div>
{isActiveSearchBar ? (
<SearchSection searchValue={searchValue} />
) : (

View File

@ -21,6 +21,7 @@ import AppInput from "~/components/AppInput";
import { useToast } from "~/hooks/use-toast";
import { useSubmit, useLoaderData } from "@remix-run/react";
import { User } from "~/utils/token-manager.server";
import logo from "~/assets/logo/SVG-07.svg";
// Only allow same-site, path-relative redirects. Rejects absolute URLs
// (https://evil.com) and protocol-relative URLs (//evil.com) to prevent
@ -106,8 +107,14 @@ export default function LoginPage() {
};
return (
<div className="flex flex-col gap-6 px-[20px] py-12 items-center w-full">
{enableVerifySection ? (
<div className="lg:min-h-screen lg:flex lg:items-center lg:justify-center lg:bg-WHITE2 lg:py-12">
<div className="flex flex-col gap-6 px-[20px] py-12 items-center w-full lg:max-w-[460px] lg:bg-WHITE lg:rounded-2xl lg:border lg:border-WHITE3 lg:shadow-lg lg:px-8 lg:py-10 lg:my-12">
{/* Brand (desktop) */}
<div className="hidden lg:flex items-center gap-2.5 mb-1">
<img src={logo} alt="ویترون" className="w-7 h-9 object-contain" />
<b className="text-[22px] font-extrabold text-VITROWN_BLUE">ویترون</b>
</div>
{enableVerifySection ? (
<>
<div className="flex flex-col gap-4 w-full items-center">
<img alt="" src={verifyBanner} className="w-[85px] h-[70px]" />
@ -127,7 +134,7 @@ export default function LoginPage() {
</div>
<div className="w-full mt-2 flex justify-center">
<InputOTP
maxLength={4}
maxLength={6}
onComplete={(finalValue) => {
const convertedValue = convertPersianToEnglish(finalValue);
setOtpValue(convertedValue);
@ -138,17 +145,19 @@ export default function LoginPage() {
setOtpValue(convertedValue);
}}
>
<InputOTPGroup dir="ltr" className="gap-4">
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPGroup dir="ltr" className="gap-1.5 sm:gap-3">
<InputOTPSlot index={0} className="!w-9 sm:!w-12" />
<InputOTPSlot index={1} className="!w-9 sm:!w-12" />
<InputOTPSlot index={2} className="!w-9 sm:!w-12" />
<InputOTPSlot index={3} className="!w-9 sm:!w-12" />
<InputOTPSlot index={4} className="!w-9 sm:!w-12" />
<InputOTPSlot index={5} className="!w-9 sm:!w-12" />
</InputOTPGroup>
</InputOTP>
</div>
<div className="gap-4 w-full flex flex-col items-center">
<Button
disabled={verifyOtpMutation.isPending || !(otpValue.length === 4)}
disabled={verifyOtpMutation.isPending || !(otpValue.length === 6)}
className="w-[90%]"
size={"xl"}
variant="dark"
@ -235,6 +244,7 @@ export default function LoginPage() {
</Button>
</>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { MetaFunction } from "@remix-run/node";
import { MetaFunction, redirect, type LoaderFunctionArgs } from "@remix-run/node";
import { sessionStorage } from "~/sessions.server";
import {
BookmarkMinus,
ChevronLeft,
@ -14,9 +15,13 @@ import {
CircleUserRound,
FileQuestion,
Layers,
Pencil,
} from "lucide-react";
import { Link, useNavigate } from "@remix-run/react";
import { useServiceGetProfile } from "~/utils/RequestHandler";
import { useMyStores } from "~/requestHandler/use-seller-hooks";
import { useServiceGetWallet } from "~/requestHandler/use-wallet-hooks";
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
import { Skeleton } from "~/components/ui/skeleton";
import { useState } from "react";
import { Button } from "~/components/ui/button";
@ -32,19 +37,76 @@ interface MenuItemType {
pageName: string;
hasBorder?: boolean;
image: React.ReactNode;
badge?: number | null;
}
// Guard: the account hub requires login (sub-pages are already protected via
// validateTokens; this covers the /profile index itself).
export async function loader({ request }: LoaderFunctionArgs) {
const session = await sessionStorage.getSession(
request.headers.get("cookie")
);
const user = session.get("user");
if (!user?.access_token) {
throw redirect("/login");
}
return null;
}
export default function Profile() {
const { user, theme: initialTheme } = useRootData();
const [storeModalOpen, setStoreModalOpen] = useState(false);
const [loginModalOpen, setLoginModalOpen] = useState(false);
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
const { data: profileData } = useServiceGetProfile();
const { data: myStores } = useMyStores();
const { data: walletData } = useServiceGetWallet();
const { data: badgeCounts } = useBadgeCounts();
const { data: followedSellers, isLoading: isFollowedSellersLoading } =
useServiceGetFollowedSellers();
// Calculate the count of followed sellers
const followedCount = followedSellers?.length || 0;
const walletBalance = walletData?.balance
? new Intl.NumberFormat("fa-IR").format(parseInt(walletData.balance))
: "۰";
// Top quick-actions (also live in the menu, but surfaced as a grid)
const quickActions = [
{
label: "سفارش‌ها",
link: "/profile/orders",
icon: <ShoppingBag size={22} />,
badge: (badgeCounts?.orderUpdates || null) as number | null,
},
{
label: "نشان‌ها",
link: "/profile/bookmarks",
icon: <BookmarkMinus size={22} />,
badge: null,
},
{
label: "دنبال‌ها",
link: "/profile/following",
icon: <CircleFadingPlus size={22} />,
badge: followedCount || null,
},
{
label: "آدرس‌ها",
link: "/profile/location",
icon: <MapPin size={22} />,
badge: null,
},
];
// The menu list below drops the items surfaced as quick actions / wallet card.
const quickPages = new Set([
"orders",
"bookmarks",
"following",
"location",
"wallet",
]);
// Create dynamic items with the followed count
const menuItems: MenuItemType[] = [
{
@ -67,6 +129,7 @@ export default function Profile() {
link: "/profile/threads",
pageName: "messages",
image: <MessageCircle size={20} />,
badge: badgeCounts?.chat || null,
},
{
title: "کیف پول",
@ -126,34 +189,107 @@ export default function Profile() {
const navigate = useNavigate();
return (
<div className="flex flex-col gap-0">
<ProfileTopBar user={user} />
<div
className={`flex flex-row h-[55px] w-full items-center gap-2 justify-between px-[16px] active:bg-hover transition`}
>
<div className="flex flex-row gap-2 items-center">
<p className={"text-R12"}>
{/* Desktop: welcome (the sidebar provides the menu) */}
<div className="hidden lg:flex flex-col items-center justify-center text-center py-24 border border-WHITE3 rounded-2xl">
<CircleUserRound size={64} className="text-GRAY mb-4" />
<h2 className="text-[22px] font-extrabold mb-1">حساب کاربری</h2>
<p className="text-GRAY">یک گزینه را از منوی کناری انتخاب کنید</p>
</div>
{/* Mobile: account hub */}
<div className="flex flex-col gap-0 lg:hidden">
{/* Header: avatar + name + phone + edit */}
<div className="flex items-center gap-3.5 px-4 pt-[calc(1rem_+_env(safe-area-inset-top))] pb-3">
<CircleUserRound size={56} className="text-GRAY shrink-0" />
<div className="min-w-0 flex-1">
<h2 className="text-B16 font-bold truncate">
{profileData?.username || "کاربر ویترون"}
</h2>
{profileData?.phoneNumber ? (
<p className="text-GRAY text-R12 mt-0.5" dir="ltr">
{profileData.phoneNumber}
</p>
) : null}
</div>
<Link
to="/profile/setting"
aria-label="ویرایش"
className="w-10 h-10 rounded-xl border border-WHITE3 grid place-items-center text-BLACK shrink-0"
>
<Pencil size={18} />
</Link>
</div>
{/* Wallet card */}
<Link
to="/profile/wallet"
className="mx-4 mt-1 rounded-[18px] p-5 bg-gradient-to-br from-[#14213d] to-VITROWN_BLUE text-white flex items-center"
>
<div className="flex-1 min-w-0">
<small className="opacity-80 text-R12">موجودی کیف پول</small>
<b className="block text-[22px] font-extrabold mt-1">
{walletBalance}
<span className="text-[13px] font-semibold"> تومان</span>
</b>
</div>
<span className="bg-white text-BLACK rounded-xl px-4 py-2.5 text-R12 font-bold shrink-0">
افزایش موجودی
</span>
</Link>
{/* Quick actions */}
<div className="grid grid-cols-4 gap-2.5 px-4 pt-5 pb-1">
{quickActions.map((qa) => (
<Link
key={qa.link}
to={qa.link}
className="flex flex-col items-center gap-1.5"
>
<div className="relative w-[52px] h-[52px] rounded-2xl bg-WHITE2 grid place-items-center text-BLACK">
{qa.badge ? (
<span className="absolute -top-1 -left-1 min-w-[18px] h-[18px] px-1 rounded-full bg-RED text-white text-[10px] font-bold grid place-items-center border-2 border-WHITE">
{qa.badge}
</span>
) : null}
{qa.icon}
</div>
<span className="text-R12 text-BLACK2">{qa.label}</span>
</Link>
))}
</div>
<p className="text-R12 font-bold text-GRAY px-4 mt-4 mb-1">حساب من</p>
{/* Theme toggle */}
<div className="flex flex-row h-[55px] w-full items-center gap-2 justify-between px-[16px]">
<p className="text-R12">
{initialTheme === "dark" ? "حالت تاریک" : "حالت روشن"}
</p>
<SunMoonToggle theme={initialTheme} />
</div>
<SunMoonToggle theme={initialTheme} />
</div>
<MenuItems
items={menuItems}
onStoreClick={() => {
if (profileData?.sellerUsername) {
navigate(`/store/${profileData.sellerUsername}`);
} else {
if (user?.access_token) {
<MenuItems
items={menuItems.filter((m) => !quickPages.has(m.pageName))}
onStoreClick={() => {
// Enter a store the user can manage: their own if they own one,
// otherwise the first store they're a staff member of.
const owned = myStores?.find((s) => s.role === "owner");
const target = owned || myStores?.[0];
if (profileData?.sellerUsername) {
navigate(`/store/${profileData.sellerUsername}`);
} else if (target) {
navigate(`/store/${target.username}`);
} else if (user?.access_token) {
setStoreModalOpen(true);
} else {
setLoginModalOpen(true);
}
}
}}
onLogoutClick={() => {
setLogoutModalOpen(true);
}}
/>
}}
onLogoutClick={() => {
setLogoutModalOpen(true);
}}
/>
</div>
<StoreModal open={storeModalOpen} onOpenChange={setStoreModalOpen} />
<LogoutModal open={logoutModalOpen} onOpenChange={setLogoutModalOpen} />
<LoginRequiredDialog
@ -181,7 +317,7 @@ const MenuItems = ({
};
return (
<div className="flex flex-col gap-0 w-full">
<div className="flex flex-col gap-0 w-full lg:grid lg:grid-cols-2 lg:gap-3 lg:mt-4">
{items.map((item: MenuItemType, index: number) => {
// Special handling for "support" item
if (item.pageName === "support") {
@ -239,13 +375,20 @@ const MenuItem = ({
const MenuItemContent = ({ item }: { item: MenuItemType }) => {
return (
<div
className={`flex flex-row gap-2 items-center justify-between px-[16px] active:bg-hover transition cursor-pointer ${item.hasBorder && "border-t-[2px]"} h-[55px] w-full`}
className={`flex flex-row gap-2 items-center justify-between px-[16px] active:bg-hover transition cursor-pointer ${item.hasBorder && "border-t-[2px]"} h-[55px] w-full lg:h-auto lg:py-4 lg:px-5 lg:border lg:border-WHITE3 lg:rounded-xl lg:border-t lg:hover:bg-WHITE2`}
>
<div className="flex flex-row gap-2 items-center">
{item.image}
<p className={"text-R12"}>{item.title}</p>
</div>
<ChevronLeft className="size-4" />
<div className="flex items-center gap-2">
{item.badge ? (
<span className="min-w-[18px] h-[18px] px-1 rounded-full bg-RED text-white text-[10px] font-bold grid place-items-center">
{item.badge > 99 ? "99+" : item.badge}
</span>
) : null}
<ChevronLeft className="size-4" />
</div>
</div>
);
};

View File

@ -47,12 +47,14 @@ export default function AboutPage() {
return (
<div className="flex flex-col pb-20">
{/* Header */}
<HeaderWithSupport
hideSupportButton={true}
title="درباره ویترون"
onBack={() => safeGoBack(navigate)}
/>
{/* Header (mobile) */}
<div className="lg:hidden">
<HeaderWithSupport
hideSupportButton={true}
title="درباره ویترون"
onBack={() => safeGoBack(navigate)}
/>
</div>
{/* Content */}
<div className="flex flex-col gap-8 p-4 max-w-2xl mx-auto w-full">
{/* Logo Section */}

View File

@ -18,8 +18,11 @@ export default function Bookmarks() {
const bookmarks = data?.pages.flatMap((page) => page.results) || [];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:py-6">
<ProfilePagesHeader title="ذخیره شده‌ها" />
<h1 className="hidden lg:block text-[28px] font-extrabold">
ذخیره شدهها
</h1>
{/* Content */}
<MondrianProductList

View File

@ -135,11 +135,13 @@ export default function FAQ() {
return (
<div>
<HeaderWithSupport
hideSupportButton={true}
title="سوالات متداول"
onBack={() => safeGoBack(navigate)}
/>
<div className="lg:hidden">
<HeaderWithSupport
hideSupportButton={true}
title="سوالات متداول"
onBack={() => safeGoBack(navigate)}
/>
</div>
<div className="max-w-4xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<h1 className="text-3xl font-bold mb-4">سوالات متداول</h1>

View File

@ -15,8 +15,11 @@ export default function Following() {
} = useServiceGetFollowedSellers();
return (
<div className="flex flex-col gap-0 pb-20">
<div className="flex flex-col gap-0 pb-20 lg:max-w-[720px] lg:mx-auto lg:w-full lg:px-6 lg:py-6">
<ProfilePagesHeader title="فروشگاه‌های دنبال شده" />
<h1 className="hidden lg:block text-[28px] font-extrabold mb-4">
فروشگاههای دنبال شده
</h1>
<UiProvider
isLoading={isLoading}
isEmpty={!followedSellers || followedSellers.length === 0}

View File

@ -47,10 +47,16 @@ export const meta: MetaFunction = () => {
export default function Locaton() {
return (
<div className="flex flex-col gap-4 w-full items-center">
<div className="flex flex-col gap-4 w-full items-center lg:max-w-[820px] lg:mx-auto lg:pt-6">
<ProfilePagesHeader title="آدرس‌های من" />
<h1 className="hidden lg:block w-full text-[28px] font-extrabold px-4">
آدرسهای من
</h1>
<MainContent />
<Link to={"/profile/location/add"} className="fixed bottom-16 z-10">
<Link
to={"/profile/location/add"}
className="fixed bottom-16 z-10 lg:static lg:bottom-auto lg:z-auto lg:mt-4"
>
<Button size={"xl"} variant="dark">
<Plus className="text-WHITE size-6" />
افزودن آدرس جدید

Some files were not shown because too many files have changed in this diff Show More