Compare commits

...

27 Commits

Author SHA1 Message Date
payacom
3b89bbfec3 reels 2026-09-19 12:28:28 +03:30
payacom
76d5652252 fix(reels): story-share icon hides with collapse, tap caption toggles, bottom-anchor video
- ReelsStoryShareButton was rendered unconditionally above the expand/
  collapse group, so it stayed visible while every other action icon
  hid on collapse; it's now the first item inside that same animated
  group and sized to match the rest (28px instead of 36px).
- Clicking the caption body (not just the more/less button) now toggles
  expand/collapse in the reels variant of ExpandableCaption.
- object-contain videos/poster default to centering, so on different
  viewport aspect ratios the visible bottom edge of the video (and
  anything anchored near it) landed at a different height; added
  object-bottom so it's always flush with the frame's bottom edge.
- Action-icon column's bottom offset is now measured off the caption
  block's real rendered position (ResizeObserver + getBoundingClientRect)
  instead of the fixed bottom-36, so the collapse toggle stays level
  with the caption's first line regardless of caption length.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 14:23:40 +03:30
payacom
2da2defde7 fix(reels): collapse-icons button no longer shifts position when toggled
My earlier commit (1d1d3cd) made the 3-dot menu conditionally hidden when
collapsed, but placed it *below* the toggle button in the flex column —
removing it shrank the column and pulled the toggle down to the new
bottom edge, so the button visibly jumped lower each time you collapsed.
Reordering so the menu renders *above* the toggle (which is now always
the last child) means removing it no longer changes the toggle's position
at all, matching the already-correct RN ordering (and restoring the exact
stability the file's own prior comment described fixing once already).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 20:38:08 +03:30
payacom
e9a5c4b7b3 fix(reels): auto-scroll advances photo reels after 5s
Photo posts have no onEnded event (video-only), so auto-scroll never
advanced past them. Added a 5s timer (isActive && autoScrollEnabled &&
type !== "video") calling onRequestNext, mirroring the video's onEnded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:32:34 +03:30
payacom
e7ce4d6808 chore: bump version to 4.1.5
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:31:58 +03:30
payacom
0602bde788 feat(reels): user info/caption also hide on collapse; auto-scroll + speed; save-block deterrent
- Username/caption block, and the long-video progress bar, only checked
  chromeHidden (the long-press hide-everything state) — not
  actionsExpanded (the 3-dot-adjacent collapse toggle), so collapsing icons
  never hid them. Now both also respect actionsExpanded.
- Wired playbackSpeed (via a playbackRate effect on the <video> ref, since
  it isn't a JSX prop) and autoScrollEnabled (loop={!autoScrollEnabled} +
  onEnded calling the new onRequestNext prop, which both reels feeds
  (ExploreReelsView, PostFeedView) implement by scrollTo-ing to the next
  item).
- Added onContextMenu prevention (+ draggable=false on the poster image)
  when author_allow_save is false — this is a best-effort deterrent against
  casual right-click/drag saving only. No browser exposes an API to block
  actual OS-level screenshots; that isn't something any website can do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:13:26 +03:30
payacom
1d1d3cd461 feat(reels): collapsing icons hides the 3-dot menu too; add auto-scroll + playback speed
- 3-dot menu button previously rendered unconditionally alongside the
  collapse-toggle ("مثل اندروید" per the old comment, mirroring RN's
  pre-fix behavior) — now hidden when collapsed, matching RN's fix.
- Extended ExploreReelsUiContext with autoScrollEnabled (default off) and
  playbackSpeed (0.5x/1x/1.5x/2x), exposed via new rows in the options menu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:03:16 +03:30
payacom
83c2102afe fix(nav): settings tab now correctly returns to /settings from any sub-page
SettingsShell always passed the literal string "/settings" as currentPage
to TabNavigation, regardless of the actual sub-route. TabNavigation's
isExactRoot check (currentPage === tab.href) was therefore always true for
the settings tab, so tapping it while on any settings sub-page (e.g.
/settings/wallet) always hit the "already here" branch and called
preventDefault() instead of navigating — the user had to tap a different
tab first (which uses a different pathname) before tapping Settings again
would actually work. Now passes the real pathname; tab highlighting is
unaffected since TabNavigation already does its own startsWith() check for
that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 16:22:56 +03:30
payacom
0c080d22cd feat(shop): show pinterest badge top-right on tagged product cards
Aligned with the discount badge (same top offset, opposite corner), shown
only when listing.is_pinterest is true. Verified live: initially used
BoldIcon's nonexistent color prop (silently ignored, icon rendered black
via the default dark-mode invert filter) — switched to tinted + a
text-[#E60023] class, confirmed the mask picks up the color correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 16:19:45 +03:30
payacom
9342494a36 feat(ui): add online-booking shortcut and fix icon alignment in billboards list
- Each icon (add/search/filter) had no consistent-height box, so vertical
  alignment depended on each tag's (Link vs button) own default box model.
  Now all 4 icons share an identical h-9 w-9 flex box.
- Added a 4th icon linking to /settings/booking, next to the filter icon,
  mirroring the RN fix for this same screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 16:19:29 +03:30
payacom
c22df8231e fix(ui): academy card rate/score align with category/level, course title visible
Rate/score row lacked justify-between (title and stats just clustered
together with a small gap), so it wasn't flush with category/level's shared
edge like the RN card already is. Also added text-foreground to the course
title h2 — it had no color class and was invisible against the dark theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 16:19:11 +03:30
payacom
9e0760d9c5 fix(ui): workroom status buttons use RoundedButton's standard primary style
Each status button had its own hardcoded pastel inline style on selection.
Replaced with variant="primary"/"default" on RoundedButton, matching every
other selection button in the app (mirrors the RN fix for this same screen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 16:18:58 +03:30
payacom
c1189164b8 fix(ui): show course level ("normal"/"pro") next to price in academy card
The type field was destructured but never rendered (only used for the
pro-tier shadow styling) — RN's equivalent card already needed this shown
next to price, aligned with category via the existing justify-between row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:18:35 +03:30
payacom
efad306617 fix(ui): PageTitle stays screen-centered regardless of action button width
Right column was minmax(2.5rem,auto), growing with wider action content
(e.g. a text button) and dragging the title's visual center off true
screen center. Both side columns are now a fixed 2.5rem, matching
EditPageHeader.tsx's already-correct pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:18:23 +03:30
payacom
1efb435dad fix(ui): edit/verify sizes & colors selection buttons use rounded-2xl
These raw <button> selection chips used rounded-full (always a full pill)
instead of the app's standard rounded-2xl radius used by every other
button/chip (btnPrimary/btnDefault/filterChipClass). Gender and
cooperation-type pages already used AuthNextButton (already rounded-2xl),
so only sizes/colors needed the radius change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:01:01 +03:30
payacom
43896b4caf fix(ui): .btn-modern--selected uses tint style, not solid fill
Root-level fix: this un-layered CSS class backs toggleBtnClass,
filterChipClass, selectionCardClass, and ~13 direct call sites (send-post/
shop/product modals, edit & verify sizes/colors/gender/cooperation-type).
Changing it here propagates the apply-filter button's tint+border+colored-
text look everywhere at once, including overriding the redundant
"text-white" utility class some call sites still pass (verified live: since
this class isn't inside a Tailwind @layer, its color always wins the cascade).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:00:50 +03:30
payacom
f18e19aff7 fix(ui): shop buyer/seller toggle and tabs use btnPrimary/btnDefault directly
Replaces toggleBtnClass/filterChipClass (solid btn-modern--selected fill +
white text) with the exact same classes as the apply-filter button
(tint background, colored border/text) so the selected state matches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:24:58 +03:30
payacom
3e3351a9c3 fix(ui): shop buyer/seller toggle and tabs use h-10 to match apply-filter ratio
rounded-2xl at h-9 is still close to the pill ceiling; the apply/clear
filter buttons use h-10, so bumping this screen's toggle+tabs to h-10 gives
the same roundness ratio without touching the other 12 call sites of
toggleBtnClass/filterChipClass that weren't flagged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:16:24 +03:30
payacom
e484b85bf5 fix(ui): shared filter-chip/toggle radius matches CTA button (rounded-2xl)
btnFilterChip/btnFilterChipActive/toggleBtnClass rendered rounded-3xl (full
pill) at h-9, while primary/default buttons (apply/clear filter, etc.) use
rounded-2xl. Aligning the shared radius fixes this across every screen that
uses filterChipClass/toggleBtnClass (shop settings buyer/seller toggle and
order sub-tabs, expertise filters, project role modal, etc.) in one place.

Reverted the earlier per-button className override in FilterModal.tsx since
the shared class now already outputs rounded-2xl.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:07:49 +03:30
payacom
1b8eadbf19 fix(ui): expertise filter chips match apply/clear button roundness
filterChipClass renders rounded-3xl (full pill) while the apply/clear
RoundedButtons below it render rounded-2xl, a visible mismatch in the same
modal. Scoped override via className (twMerge) instead of touching the
shared filterChipClass/btnFilterChip used by 24+ other call sites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:56:58 +03:30
payacom
e31a08df41 Fix financial page mobile layout, restore shop-discover category chips, unify chip height
- financial/page.tsx: the title+date and amount+status rows used a hard
  flex-col/flex-row breakpoint switch (sm:), so below 640px every row
  stacked into 4 separate lines instead of wrapping gracefully — now
  always flex-row with flex-wrap, min-w-0/shrink-0/truncate so long
  content wraps instead of the date/badge visually colliding
- shops/discover/page.tsx: restored the real category chips (fetched from
  /shop-categories) alongside All/Pinterest — my earlier pass replaced
  the whole filter row with just those two, dropping the categories the
  page never actually had a chance to keep
- buttonStyles.ts: btnFilterChip/btnFilterChipActive now have an explicit
  h-9 (matching toggleBtnClass, used right above them on the shop
  dashboard) instead of deriving height from padding alone, which made
  them visibly shorter than the buyer/seller toggle

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:31:08 +03:30
payacom
71da2b53fe Add coin-flies-to-wallet animation to the gift reveal modal
Web mirror of the RN change — same timing constants, same idea: the amount
flies from its pop-in position toward a wallet badge that pulses on
arrival, and the "credited" hint fades in at that moment instead of
showing immediately. Wallet crediting itself was already correctly
deferred server-side until POST /gifts/:id/open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 14:22:51 +03:30
payacom
e652c255c9 Fix Projects nav double-click, shop verify badge, add send-to-users everywhere
- new-project/layout.tsx was hardcoding currentPage="/projects" while the
  real route is /new-project, so TabNavigation's isExactRoot guard thought
  it was already on the Projects tab and preventDefault'd the first click
  with no navigation — now passes the real path (isActive's highlight logic
  already had a dedicated case for /new-project, so nothing else changes)
- resolveShopVerificationTier: shop verify checkmarks always render green
  regardless of the underlying tier, applied at all 3 shop render sites
- Add a "send to users" icon/button next to share on the product detail
  page, the owner product-manage page, and the shop profile pages (public +
  owner), reusing/mirroring the existing SendProductModal pattern
- New SendShopModal + sharedShop chat-bubble kind (ChatMessageCard via
  SharedShopBubble) so a whole shop can be sent into a chat, not just one
  product — backed by the new POST /shops/:id/send endpoint

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 14:06:57 +03:30
payacom
60d1aac55b Fix missing "All" filter translation key on shop discover page
t("common.all") didn't resolve to anything on web (unlike the RN app,
which does have that key) — verified in the browser, where the chip
rendered as the literal string "common.all". Added a proper
shops.allFilter key instead of guessing at an existing one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 10:32:45 +03:30
payacom
d7051c6b16 Add Pinterest product tag: discover filter, Reels badge, and images-step checkbox
- Pinterest filter toggle on the Shop discover page, alongside "All",
  backed by the backend's new ?pinterest=true param on
  /shop-products/discover
- Pinterest badge shown above the favorite button in ShopReelsView's
  action rail for products tagged is_pinterest
- "Pinterest product" checkbox at the bottom of the product images step
  (create wizard and edit flow share the same route)
- New pinterest bold-icon SVG under public/images/icons/vuesax/bold

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 10:24:12 +03:30
payacom
d2f3a96ddf Enable Next.js image optimization across ~60 remaining unoptimized <Image> spots
- Remove `unoptimized` from every Image that was pointing at a real remote/
  static file (Reels image carousel, story avatars/viewer, shop product
  grids/details, chat shared-post/product/story bubbles, notification/
  visitor rows, tag/mention pickers, etc.) so Next's own resize+avif/webp
  pipeline (already configured in next.config.ts) actually applies —
  previously only a handful of components used it, everything else shipped
  full-resolution originals regardless of the tiny thumbnail size shown
- Add a `sizes` prop wherever `fill` was used without one, so the optimizer
  picks an appropriately small srcset entry instead of defaulting to 100vw
- Fix ProfileAvatar to request the actual displayed size instead of always
  requesting 280x280 regardless of the `size` prop
- Left `unoptimized` on the handful of genuinely local/ephemeral sources
  (blob:/data: URIs from in-progress uploads, third-party QR API images,
  static SVG icons) where Next's optimizer can't or shouldn't apply
- Add next-story prefetch to StoryViewer (previously only Reels prefetched
  upcoming media; swiping to the next story always cold-started before)
- Fix .claude/launch.json's dev server port (3000 -> 3004) to match
  package.json's actual `next dev --port 3004`

Verified via `tsc --noEmit` (no new errors) and by diffing this app's
console/network behavior against a git-stashed baseline in the browser
preview — the one pre-existing hydration warning and 401s (unauthenticated
session) are present on both, and /_next/image requests now succeed for
avatars/thumbnails that previously bypassed optimization entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 18:40:45 +03:30
payacom
9310062616 Fix shop UI RTL/layout consistency and tab reselect behavior
- Align shop header layout (logo, name, verification badge, share button)
  and RTL/LTR positioning across shop, listing, billboard, wallet, gift,
  and academy pages with the Android app's equivalent screens
- Fix TabNavigation's tab-reselect scroll-to-top to only fire on the
  exact tab route, not any active sub-route
- Add verification badge to shop owner name; add brand icon assets

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 17:29:24 +03:30
117 changed files with 1682 additions and 548 deletions

View File

@@ -5,7 +5,7 @@
"name": "modstagram-next-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
"port": 3004
}
]
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "modstagram-next",
"version": "0.1.0",
"version": "4.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modstagram-next",
"version": "0.1.0",
"version": "4.1.5",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "modstagram-next",
"version": "0.1.0",
"version": "4.1.5",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3004",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 102 102" id="instagram"><defs><radialGradient id="a" cx="6.601" cy="99.766" r="129.502" gradientUnits="userSpaceOnUse"><stop offset=".09" stop-color="#fa8f21"></stop><stop offset=".78" stop-color="#d82d7e"></stop></radialGradient><radialGradient id="b" cx="70.652" cy="96.49" r="113.963" gradientUnits="userSpaceOnUse"><stop offset=".64" stop-color="#8c3aaa" stop-opacity="0"></stop><stop offset="1" stop-color="#8c3aaa"></stop></radialGradient></defs><path fill="url(#a)" d="M25.865,101.639A34.341,34.341,0,0,1,14.312,99.5a19.329,19.329,0,0,1-7.154-4.653A19.181,19.181,0,0,1,2.5,87.694,34.341,34.341,0,0,1,.364,76.142C.061,69.584,0,67.617,0,51s.067-18.577.361-25.14A34.534,34.534,0,0,1,2.5,14.312,19.4,19.4,0,0,1,7.154,7.154,19.206,19.206,0,0,1,14.309,2.5,34.341,34.341,0,0,1,25.862.361C32.422.061,34.392,0,51,0s18.577.067,25.14.361A34.534,34.534,0,0,1,87.691,2.5a19.254,19.254,0,0,1,7.154,4.653A19.267,19.267,0,0,1,99.5,14.309a34.341,34.341,0,0,1,2.14,11.553c.3,6.563.361,8.528.361,25.14s-.061,18.577-.361,25.14A34.5,34.5,0,0,1,99.5,87.694,20.6,20.6,0,0,1,87.691,99.5a34.342,34.342,0,0,1-11.553,2.14c-6.557.3-8.528.361-25.14.361s-18.577-.058-25.134-.361"></path><path fill="url(#b)" d="M25.865,101.639A34.341,34.341,0,0,1,14.312,99.5a19.329,19.329,0,0,1-7.154-4.653A19.181,19.181,0,0,1,2.5,87.694,34.341,34.341,0,0,1,.364,76.142C.061,69.584,0,67.617,0,51s.067-18.577.361-25.14A34.534,34.534,0,0,1,2.5,14.312,19.4,19.4,0,0,1,7.154,7.154,19.206,19.206,0,0,1,14.309,2.5,34.341,34.341,0,0,1,25.862.361C32.422.061,34.392,0,51,0s18.577.067,25.14.361A34.534,34.534,0,0,1,87.691,2.5a19.254,19.254,0,0,1,7.154,4.653A19.267,19.267,0,0,1,99.5,14.309a34.341,34.341,0,0,1,2.14,11.553c.3,6.563.361,8.528.361,25.14s-.061,18.577-.361,25.14A34.5,34.5,0,0,1,99.5,87.694,20.6,20.6,0,0,1,87.691,99.5a34.342,34.342,0,0,1-11.553,2.14c-6.557.3-8.528.361-25.14.361s-18.577-.058-25.134-.361"></path><path fill="#fff" d="M461.114,477.413a12.631,12.631,0,1,1,12.629,12.632,12.631,12.631,0,0,1-12.629-12.632m-6.829,0a19.458,19.458,0,1,0,19.458-19.458,19.457,19.457,0,0,0-19.458,19.458m35.139-20.229a4.547,4.547,0,1,0,4.549-4.545h0a4.549,4.549,0,0,0-4.547,4.545m-30.99,51.074a20.943,20.943,0,0,1-7.037-1.3,12.547,12.547,0,0,1-7.193-7.19,20.923,20.923,0,0,1-1.3-7.037c-.184-3.994-.22-5.194-.22-15.313s.04-11.316.22-15.314a21.082,21.082,0,0,1,1.3-7.037,12.54,12.54,0,0,1,7.193-7.193,20.924,20.924,0,0,1,7.037-1.3c3.994-.184,5.194-.22,15.309-.22s11.316.039,15.314.221a21.082,21.082,0,0,1,7.037,1.3,12.541,12.541,0,0,1,7.193,7.193,20.926,20.926,0,0,1,1.3,7.037c.184,4,.22,5.194.22,15.314s-.037,11.316-.22,15.314a21.023,21.023,0,0,1-1.3,7.037,12.547,12.547,0,0,1-7.193,7.19,20.925,20.925,0,0,1-7.037,1.3c-3.994.184-5.194.22-15.314.22s-11.316-.037-15.309-.22m-.314-68.509a27.786,27.786,0,0,0-9.2,1.76,19.373,19.373,0,0,0-11.083,11.083,27.794,27.794,0,0,0-1.76,9.2c-.187,4.04-.229,5.332-.229,15.623s.043,11.582.229,15.623a27.793,27.793,0,0,0,1.76,9.2,19.374,19.374,0,0,0,11.083,11.083,27.813,27.813,0,0,0,9.2,1.76c4.042.184,5.332.229,15.623.229s11.582-.043,15.623-.229a27.8,27.8,0,0,0,9.2-1.76,19.374,19.374,0,0,0,11.083-11.083,27.716,27.716,0,0,0,1.76-9.2c.184-4.043.226-5.332.226-15.623s-.043-11.582-.226-15.623a27.786,27.786,0,0,0-1.76-9.2,19.379,19.379,0,0,0-11.08-11.083,27.748,27.748,0,0,0-9.2-1.76c-4.041-.185-5.332-.229-15.621-.229s-11.583.043-15.626.229" transform="translate(-422.637 -426.196)"></path></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{fill:#00FF00;}
.st1{fill:#FFFFFF;}
</style>
<path class="st0" d="M9.3,48h29.3c5.2,0,9.3-4.2,9.3-9.3V9.3C48,4.2,43.8,0,38.7,0H9.3C4.2,0,0,4.2,0,9.3v29.3C0,43.8,4.2,48,9.3,48
z"/>
<path class="st1" d="M39.3,33.4l-6.1-4.1c-0.8-0.5-1.7-0.5-2.4,0c0,0,0,0-2.6,1.5c-0.4,0.2-0.7,0.2-1,0.2c-0.3-0.1-0.6-0.3-0.6-0.3
c-1.4-1.1-3.1-2.5-4.9-4.3c-1.8-1.8-3.2-3.5-4.3-4.9c0,0-0.2-0.3-0.3-0.6c0-0.3,0-0.6,0.2-1c1.5-2.6,1.5-2.6,1.5-2.6
c0.4-0.8,0.4-1.7,0-2.4l-4.1-6.1c-0.5-0.8-1.5-1-2.3-0.4c0,0-1.9,1.3-2.6,1.8c-1,0.7-1.3,1.8-1.3,3.3c0,2.1,1.8,8.8,9.6,16.6l0,0
l0,0l0,0l0,0c7.8,7.8,14.5,9.6,16.6,9.6c1.5,0,2.6-0.3,3.3-1.3c0.4-0.6,1.8-2.6,1.8-2.6C40.2,34.9,40,33.9,39.3,33.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 953 B

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{fill:#29B6F6;}
.st1{fill:#FFFFFF;}
.st2{fill:#B0BEC5;}
.st3{fill:#CFD8DC;}
</style>
<path class="st0" d="M24,0C10.7,0,0,10.7,0,24s10.7,24,24,24s24-10.7,24-24S37.3,0,24,0z"/>
<path class="st1" d="M35.9,13.2l-4.5,23c0,0-0.2,1-1.5,1c-0.7,0-1-0.3-1-0.3l-9.7-8.1l-4.8-2.4l-6.1-1.6c0,0-1.1-0.3-1.1-1.2
c0-0.8,1.1-1.1,1.1-1.1l25.6-10.2c0,0,0.8-0.3,1.4-0.3c0.4,0,0.8,0.1,0.8,0.6C36,12.9,35.9,13.2,35.9,13.2z"/>
<path class="st2" d="M22.8,31.8l-4.1,4c0,0-0.2,0.1-0.4,0.1c-0.1,0-0.2,0-0.3-0.1l1.2-7.2L22.8,31.8z"/>
<path class="st3" d="M31.1,17c-0.2-0.3-0.6-0.3-0.8-0.1l-15.8,9.5c0,0,2.5,7.1,2.9,8.3c0.4,1.2,0.7,1.3,0.7,1.3l1.2-7.2L31,17.9
C31.2,17.7,31.3,17.3,31.1,17z"/>
</svg>

After

Width:  |  Height:  |  Size: 955 B

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#CFD8DC;}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#40C351;}
</style>
<path class="st0" d="M1.3,46.8l3.2-11.7c-2-3.4-3-7.3-3-11.3c0-12.4,10.1-22.5,22.6-22.5c6,0,11.7,2.4,16,6.6
c4.3,4.3,6.6,9.9,6.6,16c0,12.4-10.1,22.5-22.6,22.5c0,0,0,0,0,0h0c-3.8,0-7.5-0.9-10.8-2.7L1.3,46.8z"/>
<path class="st0" d="M1.3,47.4c-0.2,0-0.3-0.1-0.4-0.2c-0.1-0.2-0.2-0.4-0.2-0.6l3.1-11.4c-1.9-3.5-3-7.4-3-11.4
c0-12.8,10.4-23.1,23.2-23.1c6.2,0,12,2.4,16.4,6.8c4.4,4.4,6.8,10.2,6.8,16.4C47.2,36.6,36.9,47,24.1,47c-3.8,0-7.5-0.9-10.9-2.7
L1.5,47.4C1.4,47.4,1.4,47.4,1.3,47.4z"/>
<path class="st1" d="M24.1,1.3c6,0,11.7,2.4,16,6.6c4.3,4.3,6.6,9.9,6.6,16c0,12.4-10.1,22.5-22.6,22.5h0c-3.8,0-7.5-0.9-10.8-2.7
l-12,3.1l3.2-11.7c-2-3.4-3-7.3-3-11.3C1.5,11.4,11.7,1.3,24.1,1.3 M24.1,46.4L24.1,46.4L24.1,46.4 M24.1,46.4L24.1,46.4L24.1,46.4
M24.1,0.1L24.1,0.1C11,0.1,0.4,10.8,0.3,23.9c0,4,1,7.9,2.9,11.4L0.2,46.5c-0.1,0.4,0,0.8,0.3,1.1C0.7,47.9,1,48,1.3,48
c0.1,0,0.2,0,0.3,0l11.5-3c3.4,1.7,7.1,2.7,10.9,2.7c13.1,0,23.7-10.6,23.8-23.7c0-6.3-2.5-12.3-6.9-16.8
C36.4,2.6,30.4,0.1,24.1,0.1L24.1,0.1z"/>
<path class="st2" d="M37.4,10.6c-3.5-3.5-8.2-5.5-13.3-5.5c-10.3,0-18.8,8.4-18.8,18.7c0,3.5,1,7,2.9,10l0.4,0.7l-1.9,6.9l7.1-1.9
l0.7,0.4c2.9,1.7,6.2,2.6,9.5,2.6h0c10.3,0,18.7-8.4,18.7-18.7C42.8,18.9,40.9,14.2,37.4,10.6z"/>
<path class="st0" d="M18.5,14.4c-0.4-0.9-0.9-1-1.3-1c-0.3,0-0.7,0-1.1,0c-0.4,0-1,0.1-1.5,0.7c-0.5,0.6-2,1.9-2,4.7s2,5.5,2.3,5.8
s3.9,6.2,9.6,8.5c4.8,1.9,5.7,1.5,6.8,1.4c1-0.1,3.3-1.4,3.8-2.7c0.5-1.3,0.5-2.4,0.3-2.7c-0.1-0.2-0.5-0.4-1.1-0.7
c-0.6-0.3-3.3-1.6-3.9-1.8s-0.9-0.3-1.3,0.3c-0.4,0.6-1.5,1.8-1.8,2.2c-0.3,0.4-0.7,0.4-1.2,0.1c-0.6-0.3-2.4-0.9-4.5-2.8
c-1.7-1.5-2.8-3.3-3.1-3.9c-0.3-0.6,0-0.9,0.2-1.2c0.3-0.3,0.6-0.7,0.8-1c0.3-0.3,0.4-0.6,0.6-0.9c0.2-0.4,0.1-0.7,0-1
C20.1,18.3,19,15.5,18.5,14.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12C2 16.25 4.67 19.9 8.44 21.34C8.35 20.56 8.28 19.36 8.47 18.5C8.65 17.72 9.63 13.6 9.63 13.6C9.63 13.6 9.33 13 9.33 12.09C9.33 10.66 10.16 9.6 11.2 9.6C12.08 9.6 12.5 10.26 12.5 11.05C12.5 11.93 11.93 13.26 11.63 14.5C11.39 15.53 12.15 16.38 13.16 16.38C14.99 16.38 16.4 14.45 16.4 11.66C16.4 9.19 14.62 7.46 12.08 7.46C9.14 7.46 7.42 9.66 7.42 11.94C7.42 12.82 7.76 13.77 8.19 14.29C8.27 14.39 8.28 14.48 8.26 14.58C8.19 14.86 8.04 15.46 8.01 15.59C7.97 15.77 7.87 15.81 7.68 15.72C6.44 15.14 5.67 13.34 5.67 11.9C5.67 8.75 7.95 5.86 12.24 5.86C15.68 5.86 18.36 8.31 18.36 11.62C18.36 15.07 16.19 17.85 13.16 17.85C12.13 17.85 11.16 17.31 10.83 16.68C10.83 16.68 10.32 18.65 10.2 19.1C9.97 19.97 9.35 21.07 8.94 21.74C9.9 22.04 10.93 22.2 12 22.2C17.52 22.2 22 17.72 22 12.2C22 6.68 17.52 2 12 2Z" fill="#292D32"/>
</svg>

After

Width:  |  Height:  |  Size: 952 B

View File

@@ -8,7 +8,7 @@
"start_url": "/?source=pwa&lang=fa",
"id": "modstagram-pwa",
"display": "standalone",
"display_override": ["standalone", "minimal-ui", "browser"],
"display_override": ["fullscreen", "standalone", "minimal-ui", "browser"],
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [

View File

@@ -106,7 +106,7 @@ function Colors() {
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
@@ -116,7 +116,7 @@ function Colors() {
{t("auth.eyeColor")}
</button>
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"

View File

@@ -98,7 +98,7 @@ function Sizes() {
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
@@ -115,7 +115,7 @@ function Sizes() {
{t("auth.height")}
</button>
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
@@ -132,7 +132,7 @@ function Sizes() {
{t("auth.weight")}
</button>
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 3
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
@@ -174,7 +174,7 @@ function Sizes() {
{sizes.map((sizeOption) => (
<button
key={sizeOption}
className={`p-2 py-1 rounded-full border text-sm full ${
className={`p-2 py-1 rounded-2xl border text-sm full ${
size === sizeOption
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"

View File

@@ -524,9 +524,11 @@ export default function CourseDetail({
}
if (paymentUrl) {
window.open(paymentUrl, "_blank", "noopener,noreferrer");
toast.success(t("academy.course.redirectedToPayment"));
// window.open بعدِ یه await در سافاری (دسکتاپ و خصوصاً iOS) پاپ‌آپ‌بلاک می‌شه —
// چون دیگه synchronously به همون کلیکِ کاربر متصل نیست. window.location.href هم
// مطمئن‌تره، هم با بقیه‌ی مسیرهای پرداختِ این اپ (booking، پروژه، بیلبورد) یکی می‌مونه
window.location.href = paymentUrl;
return;
} else {
console.error("ساختار پاسخ نامعتبر:", response);
toast.error(

View File

@@ -82,7 +82,9 @@ function FailedProject() {
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -1,20 +1,25 @@
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { generateSeoPageMetadata } from "@/lib/i18n/server";
export async function generateMetadata() {
return generateSeoPageMetadata("newProject", {
index: false,
});
}
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { generateSeoPageMetadata } from "@/lib/i18n/server";
export async function generateMetadata() {
return generateSeoPageMetadata("newProject", {
index: false,
});
}
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<section className="pb-24">
<Header />
<LocalePageShell>{children}</LocalePageShell>
{/* واقعاً توی /new-project هستیم، نه /projects — قبلاً اینجا "/projects" هاردکد
شده بود که باعث می‌شد تبِ پروژه‌ها فکر کنه از قبل توی روتِ خودشه (isExactRoot)
و اولین کلیک روی اون تب preventDefault بشه بدون navigate (کاربر مجبور می‌شد
دوبار بزنه). isActive توی TabNavigation.tsx از قبل شرطِ جداگانه برای
"/new-project" داره، پس هایلایتِ تب دست‌نخورده می‌مونه */}
<TabNavigation currentPage="/new-project" />
</section>
);
}

View File

@@ -97,7 +97,6 @@ function SpecialistCard({
fill
className="object-cover"
sizes={isMain ? "280px" : "120px"}
unoptimized
/>
)
) : (
@@ -144,7 +143,6 @@ function SpecialistCard({
fill
className="object-cover"
sizes={isMain ? "64px" : "36px"}
unoptimized
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">

View File

@@ -66,7 +66,9 @@ function BillboardPayment({ params }: IBillboardProps) {
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -56,7 +56,9 @@ function FailedBillboard() {
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);
@@ -80,7 +82,9 @@ function FailedBillboard() {
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -9,7 +9,6 @@ import useAxios from "@/hooks/useAxios";
import { filterChipClass } from "@/lib/ui/buttonStyles";
import { User } from "@/types/types";
import React, { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -25,7 +24,6 @@ function GiftPage({ params }: GiftPageProps) {
const { t } = useTranslation("common");
const { userId } = React.use(params);
const { request } = useAxios();
const router = useRouter();
const [userDetail, setUserDetail] = useState<User>();
const [selectedAmount, setSelectedAmount] = useState<number | null>(
@@ -67,10 +65,12 @@ function GiftPage({ params }: GiftPageProps) {
"/gifts/initiate",
{ receiverId: userId, amount: finalAmount, message }
);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
if (response?.paymentUrl) {
router.push(response.paymentUrl);
window.location.href = response.paymentUrl;
} else if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
} else {
toast.error(t("giftPage.paymentInfoError"));
}

View File

@@ -749,22 +749,30 @@ select {
--btn-accent-text: #ffffff;
}
.btn-modern {
border: 1px solid var(--btn-border-color);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
}
.btn-modern:active:not(:disabled) {
transform: scale(0.98);
}
.dark .btn-modern {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
}
.dark .btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.38);
/* داخلِ @layer components (برخلافِ btn-modern--primary/--selected زیر که عمداً
بیرونِ لایه‌ست) تا رنگِ بوردرِ اینجا (خاکستریِ پیش‌فرض) رویِ کلاس‌های Tailwind
مثلِ border-[#FC8EAC] که کامپوننت‌ها (مثلِ btnPrimary) پاس می‌دن سوار نشه —
قبلاً چون این رول بیرونِ هر @layer بود، همیشه برنده می‌شد و باعث می‌شد بوردرِ
دکمه‌های انتخاب‌شده (مثلاً btnPrimary توی تنظیماتِ فروشگاه) هیچ‌وقت صورتی نشه،
با اینکه رنگِ متنش درست صورتی می‌شد (چون فقط border ست شده بود، نه color) */
@layer components {
.btn-modern {
border: 1px solid var(--btn-border-color);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
}
.btn-modern:active:not(:disabled) {
transform: scale(0.98);
}
.dark .btn-modern {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
}
.dark .btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.38);
}
}
.btn-modern--primary {
@@ -776,14 +784,16 @@ select {
background: var(--btn-accent-hover);
}
/* عینِ btnPrimary (دکمه «اعمال فیلتر»): تینتِ کم‌رنگِ رنگِ اصلی + بوردر/متنِ رنگِ
اصلی، نه پرشدنِ توپر + متنِ سفید — چون این کلاس خارج از @layer تعریف شده،
رویِ هر text-white که کنارش پاس داده بشه اولویت داره (لایه‌های CSS Tailwind v4) */
.btn-modern--selected {
background: var(--btn-accent);
color: var(--btn-accent-text);
background: rgba(252, 142, 172, 0.1);
color: var(--btn-accent);
border-color: var(--btn-accent);
}
.btn-modern--selected:hover:not(:disabled) {
background: var(--btn-accent-hover);
border-color: var(--btn-accent-hover);
background: rgba(252, 142, 172, 0.16);
}
.glass-modal-overlay {

View File

@@ -158,13 +158,14 @@ function TicketChat({ params }: ITicketChatProps) {
setHasUsedFreeOffer(true);
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paymentUrl) {
window.open(data.paymentUrl, "_blank", "noopener,noreferrer");
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپ‌آپ‌بلاک می‌شه چون دیگه
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت می‌کنه، نه تبِ
// اصلی — پس با _blank دو تب باز می‌موند. window.location.href هم امن‌تره
// هم با بقیه‌ی مسیرهای پرداختِ این اپ یکی می‌مونه
window.location.href = data.paymentUrl;
} else if (data.authority) {
window.open(
`https://www.zarinpal.com/pg/StartPay/${data.authority}`,
"_blank",
"noopener,noreferrer"
);
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
} else {
toast.error(t("offerPage.paymentInfoError"));
}

View File

@@ -297,9 +297,11 @@ function Chats() {
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
{isOnline ? onlineLabel : item.last_online || offlineLabel}
</span>
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
{/* طبقِ خواسته‌ی کاربر فعلاً کامنت شد — این متن همیشه ثابت
نمایش داده می‌شد (نه پیشنمایشِ واقعیِ آخرین پیام) */}
{/* <span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
{E2EE_LOCKED_PREVIEW}
</span>
</span> */}
{item.unread_messages_count ? (
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
{item.unread_messages_count}

View File

@@ -152,7 +152,6 @@ function ArchivePage() {
fill
className="object-cover"
sizes="50vw"
unoptimized
/>
)}
</button>

View File

@@ -66,8 +66,8 @@ function BlockedUsersPage() {
}
alt=""
fill
sizes="44px"
className="object-cover"
unoptimized
/>
</div>
<div className="min-w-0">

View File

@@ -103,7 +103,7 @@ function Colors() {
{/* Buttons for categories */}
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
@@ -113,7 +113,7 @@ function Colors() {
{t("settings.edit.colors.eyeColor")}
</button>
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
className={`p-1 rounded-2xl w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"

View File

@@ -78,7 +78,7 @@ function Sizes() {
<>
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
@@ -89,7 +89,7 @@ function Sizes() {
{t("settings.edit.sizes.height")}
</button>
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
@@ -100,7 +100,7 @@ function Sizes() {
{t("settings.edit.sizes.weight")}
</button>
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 3
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
@@ -136,7 +136,7 @@ function Sizes() {
{sizes.map((sizeOption) => (
<button
key={sizeOption}
className={`p-2 py-1 rounded-full border text-sm full ${
className={`p-2 py-1 rounded-2xl border text-sm full ${
size === sizeOption
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"

View File

@@ -103,33 +103,33 @@ export default function Financial() {
key={item._id}
className="p-5 border rounded-3xl border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-sm hover:shadow-md transition-all duration-200"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
<div className="font-medium text-base md:text-lg text-neutral-900 dark:text-white flex items-center gap-2">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<div className="min-w-0 font-medium text-base md:text-lg text-neutral-900 dark:text-white flex items-center gap-2">
{getTransactionType(item.type)}
{item.project_title && (
<span className="text-sm text-neutral-500 dark:text-neutral-400">
<span className="truncate text-sm text-neutral-500 dark:text-neutral-400">
({item.project_title})
</span>
)}
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-400 whitespace-nowrap">
<div className="shrink-0 text-sm text-neutral-500 dark:text-neutral-400 whitespace-nowrap">
{formatDate(item.createdAt)}
</div>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div className="font-semibold text-lg text-neutral-900 dark:text-white">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 font-semibold text-lg text-neutral-900 dark:text-white">
{t("settings.financial.amountPaid")}:{" "}
{Number(item.amount).toLocaleString(amountLocale)}{" "}
{t("settings.toman")}
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<span className="text-neutral-600 dark:text-neutral-300">
{t("settings.status")}:
</span>
<span
className={`font-medium px-4 py-1.5 rounded-full text-sm ${getStatusClass(
className={`font-medium px-4 py-1.5 rounded-full text-sm whitespace-nowrap ${getStatusClass(
item.status
)}`}
>

View File

@@ -8,8 +8,9 @@ import Header from "@/components/main/Header";
import ShopListCard from "@/components/shops/ShopListCard";
import BoldIcon from "@/components/ui/BoldIcon";
import useAxios from "@/hooks/useAxios";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
type MyShop = {
@@ -28,15 +29,19 @@ export default function ShopSettingsPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const [myShops, setMyShops] = useState<MyShop[] | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
request<{ shops: MyShop[] }>("GET", "/shops/mine")
.then((res) => setMyShops(res?.shops || []))
.catch(() => setMyShops([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// طبق خواسته‌ی کاربر: بعدِ برگشت از ویرایشِ فروشگاه بدونِ رفرشِ دستی تغییرات دیده
// بشن — قبلاً با useEffect(..., []) فقط یه‌بار موقعِ اولین mount فچ می‌شد؛ عینِ
// useInfiniteScroll (لیستِ کالاها) با refetchOnMount:"always" هر بار برگردیم به
// این صفحه دوباره فچ می‌شه
const { data } = useQuery({
queryKey: ["shops-mine"],
queryFn: () => request<{ shops: MyShop[] }>("GET", "/shops/mine"),
staleTime: 0,
refetchOnMount: "always",
});
const myShops = data?.shops ?? null;
const filteredShops = useMemo(() => {
if (!myShops) return [];

View File

@@ -17,7 +17,7 @@ import ShopCartTab from "@/components/shops/ShopCartTab";
import ShopFavoritesTab from "@/components/shops/ShopFavoritesTab";
import useAxios from "@/hooks/useAxios";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { toggleBtnClass, filterChipClass } from "@/lib/ui/buttonStyles";
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { staticIconUrl } from "@/components/main/BaseUrl";
import Image from "next/image";
@@ -164,13 +164,13 @@ export default function ShopSettingsPage() {
</div>
<div className="mx-auto mt-4 grid w-full max-w-md grid-cols-2 gap-4">
<RoundedButton
className={cn(toggleBtnClass(filter === FILTER_SELLER), "h-9")}
className={cn(filter === FILTER_SELLER ? btnPrimary : btnDefault, "h-10 text-xs")}
onClick={() => setFilter(FILTER_SELLER)}
>
{t("shops.sellerTab")}
</RoundedButton>
<RoundedButton
className={cn(toggleBtnClass(filter === FILTER_BUYER), "h-9")}
className={cn(filter === FILTER_BUYER ? btnPrimary : btnDefault, "h-10 text-xs")}
onClick={() => setFilter(FILTER_BUYER)}
>
{t("shops.buyerTab")}
@@ -184,7 +184,7 @@ export default function ShopSettingsPage() {
key={tabId}
type="button"
onClick={() => setSellerTab(tabId)}
className={filterChipClass(sellerTab === tabId, "shrink-0")}
className={cn(sellerTab === tabId ? btnPrimary : btnDefault, "h-10 text-xs shrink-0")}
>
{t(`shops.sellerTabs.${tabId}`)}
</button>
@@ -199,7 +199,7 @@ export default function ShopSettingsPage() {
key={tabId}
type="button"
onClick={() => setBuyerTab(tabId)}
className={filterChipClass(buyerTab === tabId, "shrink-0")}
className={cn(buyerTab === tabId ? btnPrimary : btnDefault, "h-10 text-xs shrink-0")}
>
{t(`shops.buyerTabs.${tabId}`)}
</button>

View File

@@ -8,7 +8,7 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -31,7 +31,6 @@ type WalletSummary = {
export default function WalletSettingsPage() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const { request, loading } = useAxios();
@@ -88,7 +87,9 @@ export default function WalletSettingsPage() {
{ amount }
);
if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
} catch (err: unknown) {
const message =

View File

@@ -18,15 +18,18 @@ import {
removeProjectDraft,
} from "@/lib/projectDrafts";
// رنگِ pastel هرکدوم حذف شد: طبق خواسته‌ی کاربر، این چیپ‌ها باید استایلِ استانداردِ اپ
// (variant="primary" یِ RoundedButton، عینِ بقیه‌ی دکمه‌های انتخاب) رو داشته باشن، نه
// رنگِ مخصوصِ هر وضعیت
const STATUS_OPTIONS = [
{ key: "received", value: "دریافتی", color: "#B89FFF" },
{ key: "unpaid", value: "پرداخت نشده", color: "#ddd" },
{ key: "reviewing", value: "در دست بررسی", color: "#E0D891" },
{ key: "sent", value: "ارسال شده", color: "#ADF5FF" },
{ key: "published", value: "منتشر شده", color: "#ADF5FF" },
{ key: "inProgress", value: "در دست اقدام", color: "#E0D891" },
{ key: "completed", value: "اتمام پروژه", color: "#B7FFB6" },
{ key: "cancelled", value: "کنسل شده", color: "#FFC5C5" },
{ key: "received", value: "دریافتی" },
{ key: "unpaid", value: "پرداخت نشده" },
{ key: "reviewing", value: "در دست بررسی" },
{ key: "sent", value: "ارسال شده" },
{ key: "published", value: "منتشر شده" },
{ key: "inProgress", value: "در دست اقدام" },
{ key: "completed", value: "اتمام پروژه" },
{ key: "cancelled", value: "کنسل شده" },
] as const;
const Workroom = () => {
@@ -76,38 +79,24 @@ const Workroom = () => {
<div className="px-4 text-xs md:text-sm">
<div className="grid grid-cols-3 gap-2 px-4 text-sm">
<RoundedButton
variant={showDrafts ? "primary" : "default"}
onClick={() => {
setShowDrafts(true);
setStatusFilter("");
}}
className={`p-1 text-xs sm:p-2 md:text-sm ${
showDrafts ? "text-primary-dark" : ""
}`}
style={{
backgroundColor: showDrafts ? "#FFD6E7" : "",
borderColor: showDrafts ? "#FFD6E7" : "",
}}
className="p-1 text-xs sm:p-2 md:text-sm"
>
{t("settings.workroom.drafts")}
</RoundedButton>
{STATUS_OPTIONS.map(({ key, value, color }) => (
{STATUS_OPTIONS.map(({ key, value }) => (
<RoundedButton
key={value}
variant={!showDrafts && statusFilter === value ? "primary" : "default"}
onClick={() => {
setShowDrafts(false);
setStatusFilter(value);
}}
className={`p-1 text-xs sm:p-2 md:text-sm ${
!showDrafts && statusFilter === value
? " text-primary-dark"
: ""
}`}
style={{
backgroundColor:
!showDrafts && statusFilter === value ? color : "",
borderColor:
!showDrafts && statusFilter === value ? color : "",
}}
className="p-1 text-xs sm:p-2 md:text-sm"
>
{t(`settings.workroom.statuses.${key}`)}
</RoundedButton>

View File

@@ -6,8 +6,12 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import Header from "@/components/main/Header";
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import ShareShopModal from "@/components/shops/ShareShopModal";
import SendShopModal from "@/components/shops/SendShopModal";
import ShopProductListRow from "@/components/shops/ShopProductListRow";
import PageTitle from "@/components/settings/PageTitle";
import VerificationBadge, {
resolveShopVerificationTier,
} from "@/components/main/VerificationBadge";
import BoldIcon from "@/components/ui/BoldIcon";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
@@ -25,10 +29,12 @@ type ShopHeader = {
_id: string;
name: string;
logo?: string | null;
owner?: { verify_badge?: string | null } | null;
};
export default function ShopProductsPage() {
const { t } = useTranslation("common");
const { t, i18n } = useTranslation("common");
const isFa = (i18n.language || "fa").toLowerCase().startsWith("fa");
const router = useRouter();
const params = useParams<{ shopId: string }>();
const shopId = params.shopId;
@@ -37,6 +43,7 @@ export default function ShopProductsPage() {
const [search, setSearch] = useState("");
const [onlyInStock, setOnlyInStock] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSendModal, setShowSendModal] = useState(false);
useEffect(() => {
request<{ shop: ShopHeader }>("GET", `/shops/${shopId}`)
@@ -73,43 +80,66 @@ export default function ShopProductsPage() {
<Header />
<LocalePageShell>
<Container className="pb-28">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowShareModal(true)}
aria-label={t("shops.share.title")}
className="flex h-8 w-8 shrink-0 items-center justify-center"
{/* طبق خواسته‌ی کاربر: لوگو راست (فرزند اول → فیزیکی راست زیرِ RTL)، بعدش نامِ
فروشگاه (فرزند دوم، flex-1 باقی فضا رو پر می‌کنه و کلیک‌پذیره مثلِ لوگو)،
و اشتراک‌گذاری سمتِ چپِ کادر و روبروی نام (فرزند سوم/آخر → فیزیکی چپ‌ترین) —
عینِ اندروید (ShopProductsScreen.tsx) */}
<div className="flex items-center gap-2">
{shopId && (
<Link
href={`/shops/profile/${shopId}`}
aria-label={t("shops.viewPublicProfile")}
className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800"
>
<BoldIcon name="share" size={18} tinted />
</button>
{shopId && (
<Link
href={`/shops/profile/${shopId}`}
aria-label={t("shops.viewPublicProfile")}
className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800"
>
{shop?.logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + shop.logo}
alt={shop.name}
className="h-full w-full object-cover"
/>
) : (
<Image
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
width={16}
height={16}
alt=""
className="dark:invert"
/>
)}
</Link>
)}
</div>
{shop?.logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + shop.logo}
alt={shop.name}
className="h-full w-full object-cover"
/>
) : (
<Image
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
width={16}
height={16}
alt=""
className="dark:invert"
/>
)}
</Link>
)}
<span className="truncate text-sm font-semibold">{shop?.name}</span>
<Link
href={`/shops/profile/${shopId}`}
className={cn(
"flex flex-1 items-center gap-1 truncate text-sm font-semibold",
isFa ? "text-right" : "text-left"
)}
>
{shop?.name}
<VerificationBadge
verifyBadge={resolveShopVerificationTier(shop?.owner?.verify_badge)}
size={13}
/>
</Link>
<button
type="button"
onClick={() => setShowSendModal(true)}
aria-label={t("shops.sendShopTitle")}
className="flex h-8 w-8 shrink-0 items-center justify-center"
>
<BoldIcon name="send-2" size={18} tinted />
</button>
<button
type="button"
onClick={() => setShowShareModal(true)}
aria-label={t("shops.share.title")}
className="flex h-8 w-8 shrink-0 items-center justify-center"
>
<BoldIcon name="share" size={18} tinted />
</button>
</div>
<PageTitle>{t("shops.myProductsTitle")}</PageTitle>
@@ -197,6 +227,15 @@ export default function ShopProductsPage() {
shopLogo={shop.logo}
/>
)}
{shop && (
<SendShopModal
open={showSendModal}
onClose={() => setShowSendModal(false)}
shopId={shop._id}
shopName={shop.name}
logo={shop.logo}
/>
)}
</>
);
}

View File

@@ -6,11 +6,13 @@ import Header from "@/components/main/Header";
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
import BackButton from "@/components/ui/BackButton";
import SendProductModal from "@/components/shops/SendProductModal";
import DiscountBadge from "@/components/ui/DiscountBadge";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
import { saveProductWizardId } from "@/hooks/useProductWizardId";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/chat/getCopyableMessageText";
import { IShopProductListing, IShopProductVariant } from "@/types/types";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
@@ -52,6 +54,7 @@ export default function ProductManagePage() {
const queryClient = useQueryClient();
const [listing, setListing] = useState<IShopProductListing | null>(null);
const [showSendModal, setShowSendModal] = useState(false);
const [selectedColor, setSelectedColor] = useState<string | null>(null);
const [selectedSize, setSelectedSize] = useState<string | null>(null);
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
@@ -197,6 +200,23 @@ export default function ProductManagePage() {
router.push(`/shops/${shopId}/products/new/name?edit=1`);
};
// طبق خواسته‌ی کاربر: اشتراک‌گذاری لینکِ خودِ کالا — عینِ ListingDetailClient.tsx
const shareProduct = async () => {
if (!listing?._id) return;
const productUrl = `https://modstagram.com/shops/listing/${listing._id}`;
if (typeof navigator !== "undefined" && navigator.share) {
try {
await navigator.share({ url: productUrl, title: listing.title });
return;
} catch {
return;
}
}
const ok = await copyTextToClipboard(productUrl);
if (ok) toast.success(t("shops.share.copied"));
else toast.error(t("shops.share.copyFailed"));
};
if (!listing) return null;
const images = listing.images || [];
@@ -213,33 +233,60 @@ export default function ProductManagePage() {
<Header />
<LocalePageShell>
<Container className="pb-28">
<div className="flex items-center justify-between gap-2">
<div className="flex w-full">
<BackButton />
<span className="text-sm font-bold">{shopInfo?.name}</span>
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
{shopInfo?.logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + shopInfo.logo}
alt={shopInfo.name}
className="h-full w-full object-cover"
/>
) : (
<Image
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
width={18}
height={18}
alt=""
className="dark:invert"
/>
)}
</span>
</div>
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-3xl border border-neutral-200 p-4 dark:border-neutral-700">
{/* طبق خواسته‌ی کاربر: نام+لوگوی فروشگاه راست (فرزند اول)، عینِ اندروید، و
اشتراک‌گذاری روبروی نام سمت چپ (فرزند آخر) */}
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
{shopInfo?.logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + shopInfo.logo}
alt={shopInfo.name}
className="h-full w-full object-cover"
/>
) : (
<Image
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
width={18}
height={18}
alt=""
className="dark:invert"
/>
)}
</span>
<span className="text-sm font-bold">{shopInfo?.name}</span>
</div>
<button
type="button"
onClick={() => setShowSendModal(true)}
aria-label={t("shops.sendProductTitle")}
className="flex h-9 w-9 shrink-0 items-center justify-center"
>
<BoldIcon name="send-2" size={18} tinted />
</button>
<button
type="button"
onClick={() => void shareProduct()}
aria-label={t("shops.share.title")}
className="flex h-9 w-9 shrink-0 items-center justify-center"
>
<BoldIcon name="share" size={18} tinted />
</button>
</div>
{/* گردی گوشه‌های کادر ۱۶px (rounded-2xl) عینِ اندروید (ProductDetailScreen.tsx:
card.borderRadius=16) — قبلاً rounded-3xl (۲۴px) بود */}
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-700">
{/* عینِ اندروید: عنوان راست (فرزند اول)، بجِ تخفیف چپ (فرزند آخر) — قبلاً
برعکس بود (بج قبل از عنوان) */}
<div className="flex items-center justify-center gap-2">
{discountPercent != null && <DiscountBadge percent={discountPercent} />}
<h1 className="text-xl font-bold">{listing.title}</h1>
{discountPercent != null && <DiscountBadge percent={discountPercent} />}
</div>
{images.length > 0 && (
@@ -259,7 +306,6 @@ export default function ProductManagePage() {
fill
className="object-cover"
sizes="400px"
unoptimized
/>
</div>
))}
@@ -429,6 +475,14 @@ export default function ProductManagePage() {
</Container>
</LocalePageShell>
<TabNavigation currentPage="/settings" />
{listing ? (
<SendProductModal
open={showSendModal}
onClose={() => setShowSendModal(false)}
listing={listing}
/>
) : null}
</>
);
}

View File

@@ -36,16 +36,21 @@ function ProductImagesPage() {
const [images, setImages] = useState<PickedImage[]>([]);
const [primaryIndex, setPrimaryIndex] = useState(0);
const [isPinterest, setIsPinterest] = useState(false);
useEffect(() => {
if (!listingId) return;
request<{ listing: { images: string[]; primaryImageIndex: number } }>(
"GET",
`/shop-products/${listingId}`
).then((res) => {
request<{
listing: {
images: string[];
primaryImageIndex: number;
is_pinterest?: boolean;
};
}>("GET", `/shop-products/${listingId}`).then((res) => {
if (!res?.listing) return;
setImages((res.listing.images || []).map((path) => ({ type: "existing", path })));
setPrimaryIndex(res.listing.primaryImageIndex || 0);
setIsPinterest(Boolean(res.listing.is_pinterest));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listingId]);
@@ -83,6 +88,7 @@ function ProductImagesPage() {
newFiles.forEach((img) => formData.append("images", img.file, img.file.name));
formData.append("existing_images", JSON.stringify(existingImages));
formData.append("primaryImageIndex", String(primaryIndex));
formData.append("is_pinterest", String(isPinterest));
try {
await request("PATCH", `/shop-products/${listingId}`, formData);
@@ -172,6 +178,34 @@ function ProductImagesPage() {
onChange={handleSelect}
/>
</div>
<button
type="button"
onClick={() => setIsPinterest((v) => !v)}
className="mt-4 flex w-full max-w-sm items-center gap-2.5 rounded-2xl border border-neutral-200 bg-neutral-50 px-3.5 py-3 text-right dark:border-neutral-700 dark:bg-neutral-900"
>
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2",
isPinterest
? "border-[#E60023] bg-[#E60023]"
: "border-neutral-300 dark:border-neutral-600"
)}
>
{isPinterest ? (
<BoldIcon name="tick-square" size={12} tinted className="text-white" />
) : null}
</span>
<BoldIcon name="pinterest" size={18} tinted className="text-[#E60023]" />
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{t("shops.pinterestProductLabel")}
</span>
<span className="block text-[11px] text-neutral-500">
{t("shops.pinterestProductHint")}
</span>
</span>
</button>
</AuthPageContent>
<AuthFormFooter onSkip={goNext} skipDisabled={loading}>
<AuthNextButton

View File

@@ -138,7 +138,11 @@ function ProductNamePage() {
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
{/* پیش‌فرضِ router.back() اینجا کاربر رو برمی‌گردوند به هرجایی که ازش وارد این
ویزارد شده — که برای ویرایش، صفحه‌ی نمایشِ کالا
(shops/[shopId]/products/[listingId]) بود، نه لیستِ کالاها. طبقِ خواسته‌ی
کاربر همیشه صریحاً می‌ره به لیستِ کالاهای همین فروشگاه */}
<BackButton href={`/shops/${shopId}`} />
</div>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.productNameTitle")}</span>

View File

@@ -297,6 +297,7 @@ function ProductVariantsPage() {
loading={loading}
disabled={loading || rows.length === 0}
onClick={handleSubmit}
className="border-[#ef4444] bg-[#ef4444] text-white hover:bg-[#ef4444]/90 dark:bg-[#ef4444] dark:hover:bg-[#ef4444]/90"
>
{t("shops.saveProduct")}
</AuthNextButton>

View File

@@ -6,7 +6,7 @@ import PageTitle from "@/components/settings/PageTitle";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import Image from "next/image";
import toast from "react-hot-toast";
@@ -34,7 +34,6 @@ type OrderDetail = {
export default function CheckoutPage() {
const { t } = useTranslation("common");
const router = useRouter();
const params = useParams<{ orderId: string }>();
const { request, loading } = useAxios();
const [order, setOrder] = useState<OrderDetail | null>(null);
@@ -54,7 +53,10 @@ export default function CheckoutPage() {
{ orderId: params.orderId }
);
if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
// router.push (next/navigation) برای URLهای بیرونی رفتارِ تضمین‌شده‌ای نداره —
// window.location.href مطمئن‌ترین راهه (خصوصاً سافاری) و با بقیه‌ی مسیرهای
// پرداختِ این اپ (booking، آکادمی) هم یکی می‌مونه
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
} catch (err: unknown) {
const message =
@@ -94,7 +96,6 @@ export default function CheckoutPage() {
fill
className="object-cover"
sizes="64px"
unoptimized
/>
</div>
)}

View File

@@ -1,21 +1,54 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
import ShopProductGrid from "@/components/shops/ShopProductGrid";
import BoldIcon from "@/components/ui/BoldIcon";
import { cn } from "@/lib/utils";
import useAxios from "@/hooks/useAxios";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { IShopProductListing } from "@/types/types";
import { useTranslation } from "react-i18next";
const ALL_ID = "__all__";
const PINTEREST_ID = "__pinterest__";
type ShopCategory = { category: string; sub_categories?: unknown[] };
export default function ShopDiscoverPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [activeFilter, setActiveFilter] = useState<string>(ALL_ID);
const [categories, setCategories] = useState<string[]>([]);
useEffect(() => {
request<{ categories?: ShopCategory[] }>("GET", "/shop-categories", null, {
noToast: true,
})
.then((res) =>
setCategories(
(res?.categories ?? []).map((c) => c.category).filter(Boolean)
)
)
.catch(() => setCategories([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const chips = useMemo(() => [ALL_ID, PINTEREST_ID, ...categories], [categories]);
const { data, isLoading } = useInfiniteScroll({
endpoint: "/shop-products/discover",
queryKey: ["shop-products-discover"],
queryKey: ["shop-products-discover", activeFilter],
params:
activeFilter === PINTEREST_ID
? { pinterest: "true" }
: activeFilter === ALL_ID
? {}
: { category: activeFilter },
});
const listings: IShopProductListing[] =
@@ -27,6 +60,40 @@ export default function ShopDiscoverPage() {
<LocalePageShell>
<Container className="pb-28">
<PageTitle>{t("shops.discoverTitle")}</PageTitle>
<div className="mb-3 flex items-center gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{chips.map((c) => {
const active = activeFilter === c;
const isPinterest = c === PINTEREST_ID;
const label =
c === ALL_ID
? t("shops.allFilter")
: isPinterest
? t("shops.pinterestFilter")
: c;
return (
<button
key={c}
type="button"
onClick={() => setActiveFilter(c)}
className={cn(
"flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-2xl border px-3.5 py-2 text-sm font-medium transition",
active
? isPinterest
? "border-[#E60023] bg-[#E60023]/10 text-[#E60023]"
: "border-pink-400 bg-pink-50 text-pink-600 dark:bg-pink-950/30"
: "border-neutral-200 text-neutral-700 dark:border-neutral-700 dark:text-neutral-300"
)}
>
{isPinterest ? (
<BoldIcon name="pinterest" size={14} tinted />
) : null}
{label}
</button>
);
})}
</div>
<ShopProductGrid
listings={listings}
isLoading={isLoading}

View File

@@ -5,12 +5,16 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import Header from "@/components/main/Header";
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import ProductImageLightbox from "@/components/shops/ProductImageLightbox";
import SendProductModal from "@/components/shops/SendProductModal";
import BoldIcon from "@/components/ui/BoldIcon";
import BackButton from "@/components/ui/BackButton";
import DiscountBadge from "@/components/ui/DiscountBadge";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import { copyTextToClipboard } from "@/lib/chat/getCopyableMessageText";
import { cn } from "@/lib/utils";
import { btnDefault, btnPrimary } from "@/lib/ui/buttonStyles";
import { IShopProductListing, IShopProductVariant } from "@/types/types";
import Cookies from "js-cookie";
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
@@ -50,8 +54,10 @@ export default function ListingDetailClient() {
const router = useRouter();
const params = useParams<{ listingId: string }>();
const { request, loading } = useAxios();
const requireAuth = useRequireAuth();
const [listing, setListing] = useState<IShopProductListing | null>(null);
const [showSendModal, setShowSendModal] = useState(false);
const [selectedColor, setSelectedColor] = useState<string | null>(null);
const [selectedSize, setSelectedSize] = useState<string | null>(null);
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
@@ -94,6 +100,24 @@ export default function ListingDetailClient() {
const shopInfo: ShopHeader | null =
listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null;
// طبق خواسته‌ی کاربر: اشتراک‌گذاری لینکِ خودِ کالا، نه فروشگاه — عینِ shareProduct
// توی ProductDetailScreen.tsx اندروید (Web Share API با fallback به کپیِ لینک)
const shareProduct = async () => {
if (!listing?._id) return;
const productUrl = `https://modstagram.com/shops/listing/${listing._id}`;
if (typeof navigator !== "undefined" && navigator.share) {
try {
await navigator.share({ url: productUrl, title: listing.title });
return;
} catch {
return;
}
}
const ok = await copyTextToClipboard(productUrl);
if (ok) toast.success(t("shops.share.copied"));
else toast.error(t("shops.share.copyFailed"));
};
useEffect(() => {
if (!listing?._id) return;
const token = Cookies.get("token") || "";
@@ -164,6 +188,10 @@ export default function ListingDetailClient() {
}, [matchedVariant?._id]);
const enabledShippingMethods = shopInfo?.shipping_methods?.filter((m) => m.enabled) || [];
// هزینه‌ی روشِ ارسالِ انتخاب‌شده — قبلاً هیچ‌جا خونده نمی‌شد، پس انتخابِ روشِ ارسال
// هیچ تاثیری روی قیمتِ نمایش‌داده‌شده نداشت (فقط موقعِ ثبتِ سفارش/handleBuy چک می‌شد)
const shippingCost =
enabledShippingMethods.find((m) => m.method === shippingMethod)?.cost || 0;
const categoryLine = listing
? [listing.category, listing.sub_category, listing.sub_sub_category]
@@ -256,34 +284,58 @@ export default function ListingDetailClient() {
<div className="flex w-full">
<BackButton />
</div>
{/* طبق خواسته‌ی کاربر: دکمه‌ی اشتراک‌گذاریِ خودِ کالا (نه فروشگاه) روبروی نام
فروشگاه سمت چپ — عینِ ProductDetailScreen.tsx اندروید */}
{shopInfo?._id ? (
<Link
href={`/shops/profile/${shopInfo._id}`}
className="flex items-center justify-end gap-2"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
{shopInfo?.logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + shopInfo.logo}
alt={shopInfo.name}
className="h-full w-full object-cover"
/>
) : (
<Image
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
width={18}
height={18}
alt=""
className="dark:invert"
/>
)}
</span>
<span className="text-sm font-bold">{shopInfo?.name}</span>
</Link>
<div className="flex w-full items-center justify-between gap-2">
<Link
href={`/shops/profile/${shopInfo._id}`}
className="flex items-center gap-2"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
{shopInfo?.logo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={IMAGE_BASE_URL + shopInfo.logo}
alt={shopInfo.name}
className="h-full w-full object-cover"
/>
) : (
<Image
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
width={18}
height={18}
alt=""
className="dark:invert"
/>
)}
</span>
<span className="text-sm font-bold">{shopInfo?.name}</span>
</Link>
<button
type="button"
onClick={() => {
if (!requireAuth()) return;
setShowSendModal(true);
}}
aria-label={t("shops.sendProductTitle")}
className="flex h-9 w-9 shrink-0 items-center justify-center"
>
<BoldIcon name="send-2" size={18} tinted />
</button>
<button
type="button"
onClick={() => void shareProduct()}
aria-label={t("shops.share.title")}
className="flex h-9 w-9 shrink-0 items-center justify-center"
>
<BoldIcon name="share" size={18} tinted />
</button>
</div>
) : null}
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-3xl border border-neutral-200 p-4 dark:border-neutral-700">
{/* گردی گوشه‌های کادر ۱۶px (rounded-2xl) عینِ اندروید — قبلاً rounded-3xl (۲۴px) بود */}
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-700">
<div className="flex items-center justify-between gap-2">
<h1 className="text-right text-xl font-bold">{listing.title}</h1>
{discountPercent != null && (
@@ -310,7 +362,6 @@ export default function ListingDetailClient() {
fill
className="object-cover"
sizes="400px"
unoptimized
/>
</button>
))}
@@ -432,14 +483,14 @@ export default function ListingDetailClient() {
</SpecRow>
)}
{/* عین ProductDetailScreen.tsx اندروید: دکمه‌ی ثبت خرید با colors.success (#387E65)،
{/* عین ProductDetailScreen.tsx اندروید: دکمه‌ی ثبت خرید با colors.danger (#ef4444)،
و اسپینر تعداد یک پیل بوردردار خنثی با دکمه‌های +/- متنی، نه دو دکمه‌ی دایره‌ای رنگی */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleBuy()}
disabled={loading || !matchedVariant}
className="flex-1 rounded-2xl bg-[#387E65] py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
className="flex-1 rounded-2xl bg-[#ef4444] py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
>
{t("shops.placeOrderButton")}
</button>
@@ -471,15 +522,15 @@ export default function ListingDetailClient() {
{matchedVariant.discount_price ? (
<span className="flex items-center gap-2">
<span className="text-xs text-neutral-400 line-through">
{(matchedVariant.price * quantity).toLocaleString()} {t("settings.toman")}
{(matchedVariant.price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
</span>
<span className="text-sm font-bold text-red-500">
{(matchedVariant.discount_price * quantity).toLocaleString()} {t("settings.toman")}
{(matchedVariant.discount_price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
</span>
</span>
) : (
<span className="text-sm font-bold">
{(matchedVariant.price * quantity).toLocaleString()} {t("settings.toman")}
{(matchedVariant.price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
</span>
)}
</SpecRow>
@@ -487,19 +538,19 @@ export default function ListingDetailClient() {
</div>
<div className="mx-auto mb-6 mt-8 flex w-full max-w-md gap-3">
{/* عین ProductDetailScreen.tsx اندروید: سبد خرید با colors.info (#17337B) توپر،
علاقه‌مندی فقط حاشیه‌دارِ همون رنگ (نه پرشده) */}
{/* طبق خواسته‌ی کاربر: همون رنگ/استایلِ دکمه‌های اعمال/پاک‌کردنِ فیلترها —
سبد خرید مثل «اعمال» (btnPrimary)، علاقه‌مندی مثل «پاک کردن» (btnDefault) */}
<button
type="button"
onClick={handleAddToCart}
className="flex-1 rounded-2xl bg-[#17337B] py-3 text-sm font-bold text-white"
className={cn(btnPrimary, "flex-1 py-3 text-sm")}
>
{t("shops.addToCartButton")}
</button>
<button
type="button"
onClick={handleToggleFavorite}
className="flex-1 rounded-2xl border border-[#17337B] py-3 text-sm font-bold text-[#17337B]"
className={cn(btnDefault, "flex-1 py-3 text-sm")}
>
{favorite ? t("shops.removeFromFavoritesButton") : t("shops.addToFavoritesButton")}
</button>
@@ -537,6 +588,12 @@ export default function ListingDetailClient() {
open={showLightbox}
onClose={() => setShowLightbox(false)}
/>
<SendProductModal
open={showSendModal}
onClose={() => setShowSendModal(false)}
listing={listing}
/>
</>
);
}

View File

@@ -129,8 +129,62 @@ function ShopLocationPage() {
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.locationTitle")}</span>
{/* طبق خواسته‌ی کاربر: استان/شهر همیشه باز و اجباری‌ان (چه حضوری داشته باشه چه
نه)، فقط نقشه/محله/آدرسِ دقیق مخصوصِ حالتِ حضوریه و مخفی می‌شن؛ تیکِ «فروشگاه
حضوری ندارم» هم رفت زیرِ آدرس — عینِ اندروید (NewShopLocationScreen.tsx) */}
<div className="flex w-full max-w-sm flex-col gap-2">
<label className="mb-2 flex items-center gap-2">
<CountryProvinceCitySelect
value={geoSelection}
onChange={setGeoSelection}
lockToUserCountry
/>
{hasPhysicalLocation && (
<>
<div className="mt-2 w-full overflow-hidden rounded-2xl">
<Map
style={{ height: "220px" }}
initialViewState={{
longitude: marker?.lng || 51.375433528216654,
latitude: marker?.lat || 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={handleMapClick}
>
<GeolocateControl />
{marker && (
<Marker latitude={marker.lat} longitude={marker.lng}>
<Image
alt="location icon"
className="-mt-5"
width={25}
height={25}
src="/images/icons/location.svg"
/>
</Marker>
)}
</Map>
</div>
<RoundedInput
type="text"
className="mt-2 text-xs"
placeholder={t("shops.neighbourhoodPlaceholder")}
value={neighbourhood}
onChange={(e) => setNeighbourhood(e.target.value)}
/>
<textarea
className="mt-2 h-24 w-full rounded-3xl border border-neutral-950 bg-white p-4 font-medium dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
placeholder={t("shops.addressPlaceholder")}
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</>
)}
<label className="mt-2 flex items-center gap-2">
<input
type="checkbox"
className="scale-125"
@@ -139,53 +193,6 @@ function ShopLocationPage() {
/>
<span className="text-sm">{t("shops.noPhysicalShop")}</span>
</label>
<CountryProvinceCitySelect
value={geoSelection}
onChange={setGeoSelection}
lockToUserCountry
/>
<div className="mt-2 w-full overflow-hidden rounded-2xl">
<Map
style={{ height: "220px" }}
initialViewState={{
longitude: marker?.lng || 51.375433528216654,
latitude: marker?.lat || 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={handleMapClick}
>
<GeolocateControl />
{marker && (
<Marker latitude={marker.lat} longitude={marker.lng}>
<Image
alt="location icon"
className="-mt-5"
width={25}
height={25}
src="/images/icons/location.svg"
/>
</Marker>
)}
</Map>
</div>
<RoundedInput
type="text"
className="mt-2 text-xs"
placeholder={t("shops.neighbourhoodPlaceholder")}
value={neighbourhood}
onChange={(e) => setNeighbourhood(e.target.value)}
/>
<textarea
className="mt-2 h-24 w-full rounded-3xl border border-neutral-950 bg-white p-4 font-medium dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
placeholder={t("shops.addressPlaceholder")}
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div>
</AuthPageContent>
<AuthFormFooter>

View File

@@ -46,8 +46,17 @@ function ShopLogoPage() {
const goNext = () => router.push("/shops/new/shipping");
const handleUpload = async () => {
if (!logoFile || !shopId) {
toast.error(t("shops.logoRequired"));
if (!shopId) return;
// اگه کاربر فایلِ جدید انتخاب نکرده ولی از قبل لوگو داره (ویرایشِ فروشگاهِ
// موجود، logoPreview از GET /shops/:id پر شده)، نباید پیامِ «لطفاً لوگو رو
// انتخاب کنید» نشون داده بشه — قبلاً این‌جا فقط logoFile چک می‌شد نه
// logoPreview، پس با وجودِ لوگوی از قبل‌ثبت‌شده هم گیر می‌کرد
if (!logoFile) {
if (logoPreview) {
goNext();
} else {
toast.error(t("shops.logoRequired"));
}
return;
}
const formData = new FormData();

View File

@@ -356,7 +356,6 @@ export default function OrderDetailPage() {
fill
className="object-cover"
sizes="64px"
unoptimized
/>
</div>
)}

View File

@@ -43,7 +43,9 @@ export default function ShopPaymentFailedPage() {
{ orderId }
);
if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
} catch {
// toast already shown by the axios interceptor

View File

@@ -85,12 +85,11 @@ export default function ProductComparisonPage() {
fill
className="object-cover"
sizes="64px"
unoptimized
/>
{discountPercent != null && (
<DiscountBadge
percent={discountPercent}
className="absolute right-1 top-1"
className="absolute left-1 top-1"
/>
)}
</div>

View File

@@ -9,8 +9,12 @@ import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import ExpandableBio from "@/components/profile/ExpandableBio";
import ShareShopModal from "@/components/shops/ShareShopModal";
import SendShopModal from "@/components/shops/SendShopModal";
import ShopContactInfoModal from "@/components/shops/ShopContactInfoModal";
import ShopMyOrdersModal from "@/components/shops/ShopMyOrdersModal";
import VerificationBadge, {
resolveShopVerificationTier,
} from "@/components/main/VerificationBadge";
import useAxios from "@/hooks/useAxios";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useUser } from "@/hooks/useUser";
@@ -52,6 +56,7 @@ type PublicShop = {
owner?: {
_id: string;
user_name: string;
verify_badge?: string | null;
} | null;
productCount: number;
rating: { average: number; count: number };
@@ -94,6 +99,7 @@ export default function ShopProfileClient() {
const [showOrdersModal, setShowOrdersModal] = useState(false);
const [showLocationModal, setShowLocationModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSendModal, setShowSendModal] = useState(false);
useEffect(() => {
request<{ shop: PublicShop }>("GET", `/shops/public/${shopId}`)
@@ -205,45 +211,52 @@ export default function ShopProfileClient() {
/>
</div>
<div className="grid w-full grid-cols-3 items-end py-2 text-xs font-semibold md:text-sm">
<div className="flex items-center">
<span>{shop.rating.count > 0 ? shop.rating.average.toFixed(1) : "0"}</span>
<Image width={22} height={22} alt="star icon" src="/images/icons/star1.png" />
</div>
<div className="flex items-center justify-center">
<span>{shop.rating.count}</span>
<Image width={22} height={22} alt="rate icon" src="/images/icons/medal-star.png" />
{/* طبق خواسته‌ی کاربر: توضیحاتِ فروشگاه درست زیرِ نام، و برچسبِ نوعِ فروش
(«حضوری»/«آنلاین») درست زیرِ ریت‌وامتیاز — یعنی دو ستونِ عمودی کنارِ هم
به‌جای یک ردیفِ مشترک؛ عینِ اندروید (ShopProfileScreen.tsx: identityRow) */}
<div className="flex w-full items-start justify-between gap-2 py-2 text-xs font-semibold md:text-sm">
<div className="flex min-w-0 shrink-0 flex-col gap-1">
<div className="flex items-center gap-2">
<div className="flex items-center">
<span>{shop.rating.count > 0 ? shop.rating.average.toFixed(1) : "0"}</span>
<Image width={22} height={22} alt="star icon" src="/images/icons/star1.png" />
</div>
<div className="flex items-center">
<span>{shop.rating.count}</span>
<Image width={22} height={22} alt="rate icon" src="/images/icons/medal-star.png" />
</div>
</div>
<div
className={cn(
"shrink-0 leading-6 text-[#387E65]",
isFa ? "text-right" : "text-left"
)}
>
{shop.has_physical_location ? t("shops.inPerson") : t("shops.onlineOnly")}
</div>
</div>
<div
className={cn(
"flex min-w-0 max-w-full flex-col",
"flex min-w-0 max-w-full flex-col gap-1",
isFa ? "items-end" : "items-start"
)}
>
<h3
className={cn(
"max-w-full truncate whitespace-nowrap",
"flex max-w-full items-center gap-1 truncate whitespace-nowrap",
isFa ? "text-right" : "text-left"
)}
>
{shop.name}
<VerificationBadge
verifyBadge={resolveShopVerificationTier(shop.owner?.verify_badge)}
size={16}
/>
</h3>
</div>
</div>
<div className="w-full py-2 text-xs font-semibold md:text-sm">
<div
className={cn(
"flex w-full gap-3",
hasDescription
? "items-baseline justify-between"
: "items-center justify-end"
)}
>
{hasDescription ? (
<div
className={cn(
"min-w-0 flex-1 leading-6",
"min-w-0 max-w-full leading-6",
isFa ? "text-right" : "text-left"
)}
>
@@ -253,14 +266,6 @@ export default function ShopProfileClient() {
/>
</div>
) : null}
<div
className={cn(
"shrink-0 leading-6 text-[#387E65]",
isFa ? "text-right" : "text-left"
)}
>
{shop.has_physical_location ? t("shops.inPerson") : t("shops.onlineOnly")}
</div>
</div>
</div>
@@ -299,6 +304,16 @@ export default function ShopProfileClient() {
{t("shops.showLocation")}
</button>
)}
<button
type="button"
onClick={() => {
if (!requireAuth()) return;
setShowSendModal(true);
}}
className={profileActionBtnClass}
>
{t("shops.sendShopTitle")}
</button>
<button
type="button"
onClick={() => setShowShareModal(true)}
@@ -318,7 +333,15 @@ export default function ShopProfileClient() {
{t("shops.noProductsYet")}
</p>
) : (
<div className="grid grid-cols-3 gap-0.5 sm:gap-1">
// چیدمانِ این صفحه ambient dir معکوسیه (dir={isFa?"ltr":"rtl"} چند خط
// بالاتر، برای بخش‌های دیگه‌ی همین صفحه). گریدِ عکسِ کالاها باید عینِ
// اندروید (چیدمانِ عادی/غیرمعکوس، راست‌به‌چپ برای فارسی) باشه، پس اینجا
// با dir عادی override می‌شه. گردی هم قبلاً responsive (۶px زیرِ sm، ۱۶px
// بالاش) بود، الان همیشه ۱۶px — عینِ tileImg توی ShopProfileScreen.tsx
<div
className="grid grid-cols-3 gap-0.5 sm:gap-1"
dir={isFa ? "rtl" : "ltr"}
>
{listings.map((listing) => {
const primaryImage =
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
@@ -330,7 +353,7 @@ export default function ShopProfileClient() {
onClick={() =>
router.push(`/shops/profile/${shopId}/reel/${tileId}`)
}
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-2xl dark:bg-neutral-800"
className="relative aspect-square overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800"
>
{listing.isTaggedPost && (
<span className="absolute right-1 top-1 z-10 rounded-full bg-black/50 px-1.5 py-0.5 text-[9px] font-semibold text-white">
@@ -344,7 +367,6 @@ export default function ShopProfileClient() {
fill
className="object-cover"
sizes="33vw"
unoptimized
/>
)}
</button>
@@ -366,6 +388,13 @@ export default function ShopProfileClient() {
shopLogo={shop.logo}
responseSchedule={shop.response_schedule}
/>
<SendShopModal
open={showSendModal}
onClose={() => setShowSendModal(false)}
shopId={shop._id}
shopName={shop.name}
logo={shop.logo}
/>
<ShopContactInfoModal
open={showContactModal}
onClose={() => setShowContactModal(false)}

View File

@@ -51,16 +51,25 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
(currentPage.startsWith("/projects") ||
currentPage === "/new-project")) ||
(tab.href === "/academy" && currentPage.startsWith("/academy"));
// isActive فقط برای رنگ/هایلایتِ آیکونه — شاملِ زیرصفحه‌ها هم می‌شه (مثلاً
// /settings/wallet). اگه کلیک روی خودِ همین isActive شرط بشه، وقتی از یه
// زیرصفحه‌ی تنظیمات روی آیکونِ تنظیمات بزنی، چون isActive از قبل true هست
// preventDefault صدا زده می‌شه و Link اصلاً navigate نمی‌کنه — کاربر مجبور
// می‌شد اول یه تبِ دیگه بزنه بعد دوباره تنظیمات، تا واقعاً برگرده به /settings.
// اینترسپت (برای اسکرول‌به‌بالا/رویدادِ تب) فقط وقتی درسته که دقیقاً همون
// مسیرِ رَوتِ تب باشیم؛ وگرنه باید بذاریم Link عادی navigate کنه
const isExactRoot = currentPage === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
onClick={(e) => {
// وقتی تبی که از قبل توش هستیم دوباره زده می‌شه، Next.js چون مسیر عوض
// نمی‌شه هیچ اسکرول/رفرشی انجام نمی‌ده — طبق درخواست کاربر، مثل اینستاگرام
// باید صفحه بره بالا و (برای تبِ خانه) جدیدترین پست‌ها هم لود بشن
if (isActive) {
// وقتی تبی که از قبل توش هستیم (دقیقاً همون صفحه‌ی روتش) دوباره زده
// می‌شه، Next.js چون مسیر عوض نمی‌شه هیچ اسکرول/رفرشی انجام نمی‌ده —
// طبق درخواست کاربر، مثل اینستاگرام باید صفحه بره بالا و (برای تبِ
// خانه) جدیدترین پست‌ها هم لود بشن
if (isExactRoot) {
e.preventDefault();
window.dispatchEvent(
new CustomEvent("app:tab-reselect", {

View File

@@ -426,9 +426,14 @@ function MainModelCard({
<DiscountBadge percent={Number(postData.offer)} className="mr-2" />
) : null}
</span>
{type ? (
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
{type}
</span>
) : null}
</div>
<div className="flex items-center gap-1 mt-1">
<h2 className="max-sm:text-xs inline-flex items-center gap-1">
<div className="flex items-center justify-between gap-1 mt-1">
<h2 className="max-sm:text-xs inline-flex items-center gap-1 truncate text-foreground">
{cuorse_name}
<VerificationBadge verifyBadge={verify_badge} />
</h2>

View File

@@ -118,8 +118,15 @@ function BillboardsFilter() {
return (
<div>
<div className="flex items-center justify-end gap-3 text-sm">
<Link href="/billboards/new" aria-label={t("billboards.createAria")}>
{/* h-9 w-9 flex ثابت روی هر ۴ دکمه — قبلاً هرکدوم بدون کادرِ هم‌اندازه بودن (Link خام،
button خام)، پس هم‌ترازیِ دقیقِ عمودی‌شون به رفتار پیش‌فرضِ مرورگر برای هر تگ
وابسته بود؛ الان هر ۴ تا دقیقاً یه اندازه و یه‌راستا هستن */}
<div className="flex items-center justify-end gap-1 text-sm">
<Link
href="/billboards/new"
aria-label={t("billboards.createAria")}
className="flex h-9 w-9 items-center justify-center"
>
<BoldIcon
name="add-square"
size={25}
@@ -127,7 +134,11 @@ function BillboardsFilter() {
className="text-neutral-700 dark:text-neutral-200 min-w-[25px]"
/>
</Link>
<button onClick={() => setShowSearchBox((v) => !v)} aria-label={t("billboards.search")}>
<button
onClick={() => setShowSearchBox((v) => !v)}
aria-label={t("billboards.search")}
className="flex h-9 w-9 items-center justify-center"
>
<Image
width={25}
height={25}
@@ -136,7 +147,11 @@ function BillboardsFilter() {
className="dark:invert min-w-[25px]"
/>
</button>
<button onClick={() => setShowFilterModal(true)} aria-label={t("billboards.filter")}>
<button
onClick={() => setShowFilterModal(true)}
aria-label={t("billboards.filter")}
className="flex h-9 w-9 items-center justify-center"
>
<Image
width={25}
height={25}
@@ -145,6 +160,18 @@ function BillboardsFilter() {
className="dark:invert min-w-[25px]"
/>
</button>
<Link
href="/settings/booking"
aria-label={t("billboards.onlineBookingAria")}
className="flex h-9 w-9 items-center justify-center"
>
<BoldIcon
name="calendar"
size={25}
tinted
className="text-neutral-700 dark:text-neutral-200 min-w-[25px]"
/>
</Link>
</div>
{showSearchBox && (

View File

@@ -138,7 +138,6 @@ function MainBillboardCard({
height={120}
alt={billboard?.title}
src={`${IMAGE_BASE_URL}${billboard.images[0]}`}
unoptimized
/>
) : (
<div className="rounded-full bg-gray-300 flex items-center justify-center h-[86px] w-[86px] md:h-[120px] md:w-[120px] mt-4">

View File

@@ -5,7 +5,6 @@ import Link from "next/link";
import { useState } from "react";
import ChatBoldIcon from "./ChatBoldIcon";
import ProfileAvatar from "../main/ProfileAvatar";
import { useRouter } from "next/navigation";
import ChatHeaderMenu from "./ChatHeaderMenu";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
@@ -21,8 +20,11 @@ export const chatHeaderCircleBtn = cn(
"dark:text-neutral-100"
);
// اندازه‌ی آواتار از propِ sizePx روی ProfileAvatar میاد (کلاسِ CSS اینجا بی‌اثره
// چون این کامپوننت با userId کار می‌کنه) — فعلاً 38px؛ pr پایین هم ۳px هست تا با
// فاصله‌ی بالا/پایینِ خودکار (h-11=44px منهایِ ۳۸px آواتار، تقسیم‌بر۲) یکی بشه
export const chatHeaderInfoPill = cn(
"chat-header-glass gentle-transition flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full py-[5px] pr-[5px] pl-3",
"chat-header-glass gentle-transition flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full py-[5px] pr-[3px] pl-3",
"active:scale-[0.98]"
);
@@ -42,7 +44,6 @@ function ChatHeader({
deleteMode?: boolean;
}) {
const { t } = useTranslation("common");
const router = useRouter();
const [searchOpen, setSearchOpen] = useState(false);
const onlineLabel = t("chats.online");
@@ -59,15 +60,17 @@ function ChatHeader({
dir="rtl"
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
>
<button
type="button"
onClick={() => router.back()}
{/* router.back() نامطمئن بود — اگه کاربر مستقیم (لینک/نوتیفیکیشن) وارد این
صفحه شده باشه، تاریخچه‌ی مرورگر برای برگشت وجود نداره و دکمه هیچ کاری
نمی‌کرد. عینِ هدرِ گروه (rooms/[roomId]/page.tsx) یه مقصدِ ثابت داره */}
<Link
href="/settings/chats"
className={chatHeaderCircleBtn}
style={chatHeaderGlassStyle}
aria-label={t("chats.actions.back")}
>
<ChatBoldIcon name="back" size={22} className="text-current" />
</button>
</Link>
<Link
href={user?.blocked_you ? "#" : `/users/${user?.user_name}`}
@@ -104,14 +107,13 @@ function ChatHeader({
<ProfileAvatar
src={user.profile_image}
alt={user.user_name || ""}
size="xs"
sizePx={38}
rounded="full"
className="h-[34px] w-[34px] shrink-0"
userId={user._id}
verifyBadge={user.verify_badge}
/>
) : (
<div className="h-[34px] w-[34px] shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-600" />
<div className="h-[38px] w-[38px] shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-600" />
)}
</Link>

View File

@@ -39,6 +39,7 @@ import {
} from "@/lib/chat/messageGrouping";
import SharedPostBubble, { parseSharedPost } from "./SharedPostBubble";
import SharedProductBubble, { parseSharedProduct } from "./SharedProductBubble";
import SharedShopBubble, { parseSharedShop } from "./SharedShopBubble";
import SharedGiftBubble, { parseSharedGift } from "./SharedGiftBubble";
import SharedStoryBubble, {
parseSharedStory,
@@ -202,6 +203,65 @@ function PreviewOverlay({
);
}
/** عینِ PreviewOverlay: با دکمه‌ی بکِ مرورگر/موبایل هم باید finishViewOnce صدا زده
* بشه (که فایلِ یکبارمصرف رو سمتِ سرور کامل/حذف می‌کنه) — قبلاً این مودال فقط با
* کلیکِ صریح بسته می‌شد، پس با بکِ سیستم، فایل سمتِ سرور هیچ‌وقت finalize نمی‌شد */
function ViewOnceOverlay({
url,
type,
onClose,
}: {
url: string;
type: "image" | "video";
onClose: () => void;
}) {
useEffect(() => {
window.history.pushState({ chatMediaPreview: true }, "");
const onPopState = () => onClose();
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [onClose]);
const requestClose = () => {
if (window.history.state?.chatMediaPreview) {
window.history.back();
return;
}
onClose();
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="glass-modal-overlay glass-modal-overlay--dark fixed inset-0 z-[9999] flex items-center justify-center"
onClick={requestClose}
>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
className="max-h-[92vh] max-w-[95vw] p-4"
onClick={(e) => e.stopPropagation()}
>
{type === "video" ? (
<video
src={url}
controls
autoPlay
muted
playsInline
className="max-h-[88vh] rounded-lg"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt="" className="max-h-[88vh] object-contain" />
)}
</motion.div>
</motion.div>
);
}
function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
if (!content || isE2eeEnvelope(content)) return null;
try {
@@ -351,6 +411,7 @@ const ChatMessageCard = ({
message.forwardedFrom || parseForwarded(message.content || "");
const sharedPost = parseSharedPost(message.content || "");
const sharedProduct = parseSharedProduct(message.content || "");
const sharedShop = parseSharedShop(message.content || "");
const sharedGift = parseSharedGift(message.content || "");
const sharedStory = parseSharedStory(message.content || "");
const storyReactionText = sharedStory
@@ -754,6 +815,16 @@ const ChatMessageCard = ({
</div>
)}
{sharedShop && (
<div
className={cn("p-1", isSender ? "chat-bubble-out" : "chat-bubble-in")}
style={bubbleStyle}
>
<SharedShopBubble data={sharedShop} />
<TimeBelow />
</div>
)}
{sharedGift && (
<div
className={cn("p-1", isSender ? "chat-bubble-out" : "chat-bubble-in")}
@@ -824,38 +895,11 @@ const ChatMessageCard = ({
<AnimatePresence>
{viewOncePreview && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="glass-modal-overlay glass-modal-overlay--dark fixed inset-0 z-[9999] flex items-center justify-center"
onClick={() => void finishViewOnce()}
>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
className="max-h-[92vh] max-w-[95vw] p-4"
onClick={(e) => e.stopPropagation()}
>
{viewOncePreview.type === "video" ? (
<video
src={viewOncePreview.url}
controls
autoPlay
muted
playsInline
className="max-h-[88vh] rounded-lg"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={viewOncePreview.url}
alt=""
className="max-h-[88vh] object-contain"
/>
)}
</motion.div>
</motion.div>
<ViewOnceOverlay
url={viewOncePreview.url}
type={viewOncePreview.type}
onClose={() => void finishViewOnce()}
/>
)}
{preview && (
<PreviewOverlay

View File

@@ -1,7 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
@@ -38,7 +37,6 @@ export default function GiftInChatModal({
}: GiftInChatModalProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const [selectedAmount, setSelectedAmount] = useState<number | null>(PRESET_AMOUNTS[0]);
const [customAmount, setCustomAmount] = useState("");
@@ -79,7 +77,9 @@ export default function GiftInChatModal({
reset();
onClose();
} else if (response?.paymentUrl) {
router.push(response.paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = response.paymentUrl;
} else {
toast.error(t("giftPage.paymentInfoError"));
}

View File

@@ -7,6 +7,11 @@ import RoundedButton from "@/components/elements/RoundedButton";
import { useTranslation } from "react-i18next";
const PARTICLES = ["🎉", "🎊", "✨", "🎈", "🎁"];
// طبقِ خواسته‌ی کاربر: بعدِ باز شدنِ کادو، مبلغ به‌صورتِ انیمیشنی «پرواز» می‌کنه و توی
// آیکونِ کیف‌پول فرود میاد — همون زمان‌بندی نسخه‌ی اندروید (GiftRevealModal.tsx: Reanimated)
const COIN_FLY_DELAY_S = 0.75;
const COIN_FLY_DURATION_S = 0.55;
const WALLET_ARRIVAL_S = COIN_FLY_DELAY_S + COIN_FLY_DURATION_S;
type Particle = {
id: number;
@@ -89,14 +94,46 @@ export default function GiftRevealModal({
🎁
</motion.div>
<motion.p
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 220, damping: 14 }}
className="text-2xl font-extrabold text-white drop-shadow"
<div className="relative flex items-center justify-center">
<motion.p
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 220, damping: 14 }}
className="text-2xl font-extrabold text-white drop-shadow"
>
{amount.toLocaleString()} {t("settings.toman")}
</motion.p>
<motion.span
initial={{ opacity: 1, y: 6, scale: 1 }}
animate={{ y: 96, scale: 0.45, opacity: 0 }}
transition={{
delay: COIN_FLY_DELAY_S,
duration: COIN_FLY_DURATION_S,
ease: "easeIn",
}}
className="pointer-events-none absolute top-1 text-2xl"
>
🪙
</motion.span>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.25, type: "spring", stiffness: 240, damping: 18 }}
className="flex items-center gap-1.5 rounded-full bg-white/10 px-3.5 py-1.5"
>
{amount.toLocaleString()} {t("settings.toman")}
</motion.p>
<motion.span
animate={{ scale: [1, 1, 1.25, 1] }}
transition={{ delay: WALLET_ARRIVAL_S, duration: 0.35, times: [0, 0.4, 0.7, 1] }}
className="text-lg"
>
👛
</motion.span>
<span className="text-xs font-medium text-white">
{t("chats.gift.walletLabel")}
</span>
</motion.div>
{message ? (
<motion.p
@@ -109,7 +146,14 @@ export default function GiftRevealModal({
</motion.p>
) : null}
<p className="text-xs text-white/70">{t("chats.gift.creditedHint")}</p>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: WALLET_ARRIVAL_S, duration: 0.3 }}
className="text-xs text-white/70"
>
{t("chats.gift.creditedHint")}
</motion.p>
<RoundedButton
type="button"

View File

@@ -52,8 +52,8 @@ export default function SharedPostBubble({ data }: { data: SharedPostPayload })
src={preview}
alt=""
fill
sizes="240px"
className="object-cover"
unoptimized
/>
)}
</div>

View File

@@ -38,7 +38,7 @@ export default function SharedProductBubble({ data }: { data: SharedProductPaylo
>
{preview && (
<div className="relative aspect-[4/3] w-full min-w-[200px] max-w-[240px] bg-neutral-900">
<Image src={preview} alt="" fill className="object-cover" unoptimized />
<Image src={preview} alt="" fill sizes="240px" className="object-cover" />
</div>
)}
<div className="px-2 py-1.5">

View File

@@ -0,0 +1,45 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { useTranslation } from "react-i18next";
export interface SharedShopPayload {
shopId: string;
name?: string;
logo?: string;
url?: string;
}
export function parseSharedShop(content: string): SharedShopPayload | null {
if (!content) return null;
try {
const line = content.split("\n")[0];
const j = JSON.parse(line);
if (j?.e2ee) return null;
return j.sharedShop ?? null;
} catch {
return null;
}
}
export default function SharedShopBubble({ data }: { data: SharedShopPayload }) {
const { t } = useTranslation("common");
const href = data.url || `/shops/profile/${data.shopId}`;
const logo = data.logo ? IMAGE_BASE_URL + data.logo : null;
return (
<Link
href={href}
className="flex items-center gap-2 overflow-hidden rounded-2xl border border-white/20 bg-black/10 p-2 min-w-[200px] max-w-[240px]"
>
<span className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-800">
{logo && <Image src={logo} alt="" fill sizes="40px" className="object-cover" />}
</span>
<p className="truncate text-[11px] font-semibold opacity-90">
{data.name || t("shops.viewShop")}
</p>
</Link>
);
}

View File

@@ -79,8 +79,8 @@ export default function SharedStoryBubble({
src={preview}
alt=""
fill
sizes="120px"
className="object-cover"
unoptimized
/>
)}
</div>

View File

@@ -326,12 +326,9 @@ export function CourseManager() {
}
if (paymentUrl) {
window.open(paymentUrl, "_blank", "noopener,noreferrer");
toast.success(t("academyCourse.redirectedToPayment"));
setTimeout(() => {
setIsDialogOpen1?.(false);
}, 1000);
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپ‌آپ‌بلاک می‌شه چون دیگه
// synchronously به کلیکِ کاربر متصل نیست — window.location.href مطمئن‌تره
window.location.href = paymentUrl;
} else {
console.error("ساختار پاسخ نامعتبر:", response);
toast.error(

View File

@@ -406,6 +406,20 @@ export default function ExploreReelsView({
<ReelsPostCard
postData={item.post}
isActive={item.key === activeKey}
preloadPriority={
item.key === activeKey
? "active"
: Math.abs(index - activeIndex) <= 1
? "nearby"
: "idle"
}
onRequestNext={() => {
if (!scrollRef.current || index + 1 >= items.length) return;
scrollRef.current.scrollTo({
top: (index + 1) * getReelsStepHeight(scrollRef.current),
behavior: "smooth",
});
}}
/>
) : (
<ReelsAcademyCard

View File

@@ -96,7 +96,6 @@ export default function ExploreVideoThumb({
fill
className="object-cover"
sizes="50vw"
unoptimized
/>
) : !frameReady ? (
<div className="absolute inset-0 animate-pulse bg-neutral-800" />

View File

@@ -321,8 +321,8 @@ export default function ReelsAcademyCard({
src={buildStorageUrl(item.profile_image)}
alt=""
fill
sizes="40px"
className="object-cover"
unoptimized
/>
) : null}
</div>

View File

@@ -30,6 +30,10 @@ type ProfileAvatarProps = {
src?: string | null;
alt?: string;
size?: keyof typeof sizeMap;
/** Exact pixel size, overriding the `size` preset — for spots that need a value
* between presets (e.g. 38px). Works whether or not `userId`/StoryRing is used,
* unlike `className` (see note below). */
sizePx?: number;
rounded?: "2xl" | "xl" | "full";
className?: string;
fallback?: string;
@@ -42,6 +46,7 @@ function ProfileAvatar({
src,
alt = "",
size = "md",
sizePx,
rounded = "2xl",
className,
fallback = "/images/fake-avatar.png",
@@ -56,23 +61,25 @@ function ProfileAvatar({
: "rounded-2xl";
const imageSrc = src ? buildStorageUrl(src) : fallback;
const resolvedSizePx = sizePx ?? sizePxMap[size];
const { hasStory, hasUnviewed } = useStoryMetaForUser(userId);
// When wrapped in a StoryRing, `className` (margins, shrink, size overrides) must land
// on the ring's outer box, not this inner <Image> — the ring's middle layer is a fixed-size
// overflow-hidden box, so any margin applied here gets silently clipped instead of creating
// visible spacing (see the search-list "avatar stuck to text" regression).
// visible spacing (see the search-list "avatar stuck to text" regression). `sizePx` isn't
// affected by that — it always drives the actual pixel size via inline style below.
const image = (
<Image
src={imageSrc}
alt={alt}
width={280}
height={280}
unoptimized
width={resolvedSizePx}
height={resolvedSizePx}
style={sizePx ? { width: sizePx, height: sizePx } : undefined}
className={cn(
"profile-avatar aspect-square object-cover shrink-0",
sizeMap[size],
!sizePx && sizeMap[size],
roundedClass,
!userId && className
)}
@@ -83,15 +90,15 @@ function ProfileAvatar({
return image;
}
// Keep the total rendered footprint equal to sizePxMap[size] in both states.
// Keep the total rendered footprint equal to resolvedSizePx in both states.
// StoryRing only adds ring padding when hasStory is true, so when it *is* true we
// pre-shrink the photo by the ring's width; when it's false we pass the full size
// (StoryRing then renders it 1:1, matching a plain avatar exactly). Either way the
// outer box never grows/shrinks relative to a plain avatar, so wrapping a row's
// avatar with a ring never shifts that row's height or the next element's position.
const avatarSizeForRing = hasStory
? Math.max(0, sizePxMap[size] - STORY_RING_PX * 2)
: sizePxMap[size];
? Math.max(0, resolvedSizePx - STORY_RING_PX * 2)
: resolvedSizePx;
const cornerRadius = rounded === "full" ? avatarSizeForRing / 2 : rounded === "xl" ? 12 : 16;
return (

View File

@@ -40,7 +40,6 @@ const ServiceItem: React.FC<ServiceItemProps> = ({ service, onDelete }) => {
height={50}
src={buildStorageUrl(image) || image}
alt={title}
unoptimized
className="min-w-[50px] min-h-[50px] rounded-lg object-cover"
/>
) : (

View File

@@ -34,6 +34,14 @@ export function resolveVerificationStatus(
return verifyBadge as VerifyBadgeTier;
}
/** طبق خواسته‌ی صریح کاربر: تیکِ وریفایِ فروشگاه (برخلافِ کاربر که چهار سطح رنگی داره)
* همیشه سبز باشه، مستقل از سطح واقعیِ verify_badge — فقط برای رندرِ فروشگاه استفاده بشه */
export function resolveShopVerificationTier(
verifyBadge?: string | null
): "green" | null {
return resolveVerificationStatus(verifyBadge) ? "green" : null;
}
const VERIFICATION_ICON_SRC: Record<Exclude<VerifyBadgeTier, "none">, string> = {
gray: "/images/icons/verify-gray.svg",
blue: "/images/icons/verify-blue.svg",

View File

@@ -43,8 +43,8 @@ export default function AppearanceColorSelect({
src={selected.src}
alt={selected.label}
fill
sizes="36px"
className="object-cover"
unoptimized
/>
</div>
) : (
@@ -93,8 +93,8 @@ export default function AppearanceColorSelect({
src={opt.src}
alt={opt.label}
fill
sizes="56px"
className="object-cover"
unoptimized
/>
</div>
<span className="text-[10px] text-neutral-500">{opt.label}</span>

View File

@@ -222,9 +222,9 @@ function MainModelCard({
}
alt={user_name}
fill
sizes="72px"
className="aspect-square rounded-2xl object-cover"
priority
unoptimized
/>
</div>
</StoryRing>

View File

@@ -96,9 +96,13 @@ function MainModelCardActions({ postData }: { postData: Post }) {
/>
))}
<div className="mt-2 flex w-full flex-row items-center justify-between px-6 sm:px-8">
<SocialIconButton
<SocialStatButton
layout="horizontal"
onClick={() => setShowServicesModal(true)}
requestCount={social.offersCount}
onClick={() => {
social.trackOffer();
setShowServicesModal(true);
}}
aria-label={t("models.actions.collaborationAria")}
icon={
<PostSocialIcon

View File

@@ -120,7 +120,6 @@ const SwipeImageSlider: React.FC<SwipeImageSliderProps> = ({
loading={idx === 0 ? "eager" : "lazy"}
className="min-h-[30vh] w-full rounded-2xl object-contain shadow-lg transition-opacity duration-300"
onLoadingComplete={() => setImageLoading?.(false)}
unoptimized
/>
)}
</div>

View File

@@ -143,7 +143,6 @@ function ModelContentPosts({
height={300}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105 group-active:scale-100"
loading="lazy"
unoptimized
/>
) : poster ? (
<Image
@@ -157,7 +156,6 @@ function ModelContentPosts({
height={300}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105 group-active:scale-100"
loading="lazy"
unoptimized
/>
) : (
<video

View File

@@ -111,8 +111,8 @@ export default function ProfileUserListModal({
}
alt=""
fill
sizes="40px"
className="object-cover"
unoptimized
/>
</div>
<div className="min-w-0">

View File

@@ -199,16 +199,14 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
>
<BoldIcon name="grid-3" size={TOOLBAR_ICON_SIZE} className="block" />
</button>
{/* طبق خواسته‌ی صریح کاربر: فعلاً که تعداد فروشگاه‌ها کمه این آیکون مخفیه؛
هر وقت تعداد فروشگاه‌ها زیاد شد از کامنت درش بیارید */}
{/* <button
<button
type="button"
onClick={() => router.push("/shops/discover")}
aria-label={t("filters.toolbar.shop")}
className={toolbarIconButtonClass}
>
<BoldIcon name="shop" size={TOOLBAR_ICON_SIZE} className="block" />
</button> */}
</button>
</div>
{showFilterModal && (
<FilterModal

View File

@@ -77,9 +77,10 @@ export default function ExpandableCaption({
<>
{body.trim() ? (
<div
onClick={isReels ? onToggle : undefined}
className={cn(
"text-sm font-medium leading-6",
isReels ? "text-white/95" : "text-justify",
isReels ? "cursor-pointer text-white/95" : "text-justify",
className
)}
>

View File

@@ -733,7 +733,9 @@ export default function PostFeedView({
{showReelsTabs ? (
<div
className={cn(
"pointer-events-none fixed inset-x-0 top-0 z-[90] pt-[calc(2.25rem+env(safe-area-inset-top))] transition-[transform,opacity] duration-300 ease-out",
// ارتفاعِ pt طوری حساب شده که مرکزِ عمودیِ متنِ تب‌ها با مرکزِ عمودیِ
// دکمه‌ی بک (h-9=36px, top: 1rem+safe-area) هم‌تراز بشه
"pointer-events-none fixed inset-x-0 top-0 z-[90] pt-[calc(1.5rem+env(safe-area-inset-top))] transition-[transform,opacity] duration-300 ease-out",
tabsHidden ? "-translate-y-full opacity-0" : "translate-y-0 opacity-100"
)}
>
@@ -800,6 +802,13 @@ export default function PostFeedView({
isActive={String(post._id) === String(activePostId)}
postPage={feedMode === "user" && showClose}
preloadPriority={preloadPriority}
onRequestNext={() => {
if (!scrollRef.current || index + 1 >= displayPosts.length) return;
scrollRef.current.scrollTo({
top: (index + 1) * getReelsStepHeight(scrollRef.current),
behavior: "smooth",
});
}}
/>
</ReelsScrollSlot>
);

View File

@@ -63,8 +63,8 @@ function LinkListItem({
src={imageSrc}
alt=""
fill
sizes="40px"
className="object-cover"
unoptimized
/>
) : (
<BoldIcon name={fallbackIcon} size={20} className="dark:invert" />

View File

@@ -153,11 +153,11 @@ export default function ReelsImageCarousel({
src={mediaFiles[0].src}
alt=""
fill
sizes="100vw"
className="object-contain select-none"
priority={priority}
draggable={false}
onLoad={() => onLoadingComplete?.()}
unoptimized
/>
</div>
);
@@ -182,6 +182,7 @@ export default function ReelsImageCarousel({
src={file.src}
alt=""
fill
sizes="100vw"
className="object-contain select-none"
priority={priority && Math.abs(idx - index) <= 1}
loading={
@@ -191,7 +192,6 @@ export default function ReelsImageCarousel({
onLoad={() => {
if (idx === index) onLoadingComplete?.();
}}
unoptimized
/>
</div>
))}

View File

@@ -12,7 +12,7 @@ import ModelServiceModal from "@/components/models/ModelServiceModal";
import { useUserById } from "@/hooks/getUserById";
import SharePostModal from "@/components/main/SharePostModal";
import SendPostModal from "@/components/posts/SendPostModal";
import { buildStorageUrl, staticIconUrl } from "@/components/main/BaseUrl";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { SocialStatButton, SocialIconButton } from "@/components/ui/SocialStatButton";
import {
POST_SOCIAL_ICONS,
@@ -59,15 +59,18 @@ function ReelsProfileFollowButton({
src={profileImage ? buildStorageUrl(profileImage) : "/images/fake-avatar.png"}
alt={userName}
fill
sizes="48px"
className="rounded-full object-cover"
unoptimized
/>
</div>
);
{/* mb-[7px]: چون بجِ فالو با -bottom-[7px] از جعبه‌ی آواتار بیرون می‌زنه، فاصله‌ی
واقعی‌ش تا آیکونِ بعدی (فنجون) از gap-4 ستون کمتر می‌شد؛ این مارجین همون ۷px
رو جبران می‌کنه تا فاصله با همه‌ی آیکون‌های دیگه یکی بشه */}
if (isOwnPost) {
return (
<Link href={`/users/${userName}`} className="mb-0 block">
<Link href={`/users/${userName}`} className="mb-[7px] block">
{avatar}
</Link>
);
@@ -77,11 +80,14 @@ function ReelsProfileFollowButton({
<button
type="button"
onClick={onToggleFollow}
className="relative mb-0 block"
className="relative mb-[7px] block"
aria-label={following ? t("posts.unfollow") : t("posts.follow")}
>
{avatar}
<span className="absolute -bottom-1 left-1/2 flex h-5 w-5 -translate-x-1/2 items-center justify-center rounded-full border border-white bg-white shadow-md">
{/* -bottom-1(-4px) بود؛ طبقِ خواسته‌ی کاربر ۳px پایین‌تر رفت (مثلِ اندروید) —
چون بزرگ‌ترشدنِ مقدارِ منفیِ bottom روی absolute یعنی پایین‌ترِ لبه‌ی
پرنت، عددِ درست -7px بود نه -1px (اشتباهِ قبلی: جهتِ عکس رفته بود) */}
<span className="absolute -bottom-[7px] left-1/2 flex h-5 w-5 -translate-x-1/2 items-center justify-center rounded-full border border-white bg-white shadow-md">
{following ? (
<BoldIcon
name="tick-circle"
@@ -118,13 +124,15 @@ function ReelsStoryShareButton({
className="inline-flex shrink-0 items-center justify-center disabled:opacity-60"
aria-label={t("posts.addToStory")}
>
<Image
src={staticIconUrl("/images/icons/story.png")}
alt=""
width={36}
height={36}
className="h-9 w-9 invert brightness-125 drop-shadow-lg"
unoptimized
{/* قبلاً story.png (رستر) با فیلترِ invert/brightness رنگ می‌گرفت که رنگ/ضخامتش
با بقیه‌ی آیکون‌های ستون (که ماسکِ bg-current رو رویِ SVG وکتور می‌زنن) یکی
نمی‌شد؛ همون آیکونِ story.svg از ست vuesax/bold با تکنیکِ ماسکِ BoldIcon
استفاده می‌شه تا دقیقاً هم‌رنگ و هم‌ضخامتِ بقیه بشه */}
<BoldIcon
name="story"
size={28}
tinted
className="text-white drop-shadow-lg"
/>
</button>
);
@@ -208,9 +216,13 @@ export default function ReelsPostActions({
const postPageActions = (
<>
<SocialIconButton
<SocialStatButton
layout="horizontal"
onClick={() => setShowServicesModal(true)}
requestCount={social.offersCount}
onClick={() => {
social.trackOffer();
setShowServicesModal(true);
}}
className={actionButtonClass}
aria-label={t("posts.collaborationRequest")}
icon={<PostSocialIcon {...POST_SOCIAL_ICONS.collaboration(postIconSize)} />}
@@ -286,6 +298,12 @@ export default function ReelsPostActions({
const reelsActions = (
<>
{onShareToStory ? (
<ReelsStoryShareButton
disabled={sharingStory}
onClick={onShareToStory}
/>
) : null}
<ReelsProfileFollowButton
userName={user_name}
profileImage={user?.profile_image}
@@ -293,9 +311,13 @@ export default function ReelsPostActions({
isOwnPost={social.isOwnPost}
onToggleFollow={social.toggleFollow}
/>
<SocialIconButton
<SocialStatButton
layout="vertical"
onClick={() => setShowServicesModal(true)}
requestCount={social.offersCount}
onClick={() => {
social.trackOffer();
setShowServicesModal(true);
}}
className={reelsActionButtonClass}
aria-label={t("posts.collaborationRequest")}
icon={
@@ -306,7 +328,7 @@ export default function ReelsPostActions({
layout="vertical"
likeCount={social.likesCountState}
onClick={social.toggleLike}
className={reelsActionButtonClass}
className={cn(reelsActionButtonClass, "-mt-[3px]")}
aria-label={t("posts.like")}
icon={
<PostSocialIcon
@@ -407,18 +429,13 @@ export default function ReelsPostActions({
/* عرضِ ستون ثابت (w-12، هم‌اندازه‌ی آواتار که پهن‌ترین آیتمشه) — وگرنه توی
حالتِ جمع‌شده که آواتار نیست، ستون به‌اندازه‌ی آیکونِ باریک‌تر جمع می‌شد و
دکمه‌ی جمع‌شونده (که items-center وسط‌چینش می‌کنه) چند پیکسل به چپ می‌رفت */
<div className="absolute bottom-36 left-3 z-30 flex w-12 flex-col items-center gap-4">
{onShareToStory ? (
<ReelsStoryShareButton
disabled={sharingStory}
onClick={onShareToStory}
/>
) : null}
{/* دکمه‌ی جمع‌شونده و منوی سه‌نقطه همیشه توی همین جای ثابت رندر می‌شن (مثل
اندروید) — قبلاً با AnimatePresence mode="wait" بین دو درختِ جدا سوییچ
می‌شد (جمع‌شده: [دکمه، منو] / بازشده: [آیکون‌ها، دکمه، منو]) که باعث می‌شد
جای دکمه و منو موقع باز/بسته‌کردن جابه‌جا بشه؛ الان فقط آیکون‌های اکسترا
بالای این دو تا اضافه/کم می‌شن، بدون جابه‌جایی */}
<div className="absolute bottom-[72px] left-3 z-30 flex w-12 flex-col items-center gap-4">
{/* دکمه‌ی جمع‌شونده همیشه باید آخرین فرزندِ این ستون باشه (نه هرچیزِ دیگه‌ای زیرش)
— این ستون absolute + bottom-36 هست، پس لبه‌ی پایینِ خودِ ستون همیشه ثابته،
اما وقتی فرزندی که زیرِ دکمه‌ی toggle قرار داره (مثلاً منوی سه‌نقطه) شرطی
حذف/اضافه بشه، توتال‌هایتِ ستون تغییر می‌کنه و دکمه‌ی toggle (که دیگه آخرین
فرزند نیست) جابه‌جا می‌شه. با آوردنِ منو *قبل* از toggle، دکمه‌ی toggle
همیشه آخرین فرزند و جاش کاملاً ثابت می‌مونه، مطابقِ اندروید */}
<AnimatePresence>
{expanded ? (
<motion.div
@@ -433,21 +450,39 @@ export default function ReelsPostActions({
</motion.div>
) : null}
</AnimatePresence>
{/* طبق خواسته‌ی کاربر: با جمع‌کردن آیکون‌ها فقط خودِ دکمه‌ی جمع‌کردن باقی بمونه،
پس منوی سه‌نقطه هم باید مثل بقیه‌ی اکشن‌ها مخفی بشه — ولی چون *بالای* toggle
می‌آد (نه زیرش)، حذف/اضافه‌شدنش دیگه جای toggle رو عوض نمی‌کنه */}
{expanded ? optionsMenu : null}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-center"
aria-label={expanded ? t("posts.close") : t("posts.showActions")}
>
<Image
width={28}
height={28}
alt=""
src="/images/icons/actions-toggle.svg"
className={cn("drop-shadow-lg invert", expanded && "opacity-80")}
{/* قبلاً با Image + فیلترِ invert/brightness رنگ می‌گرفت — چون fill خودِ
SVG (#292D32) دقیقاً سیاهِ خالص نیست، بسته به مرورگر گاهی به‌جای
سفیدِ خالص یه سایه‌ی نزدیک‌به‌سفید درمی‌اومد. با ماسکِ bg-current
(عینِ بقیه‌ی آیکون‌های این ستون) دیگه به fill اصلیِ svg وابسته
نیست و همیشه دقیقاً هم‌رنگِ بقیه سفیدِ خالصه */}
<span
aria-hidden
className={cn(
"inline-block h-7 w-7 shrink-0 bg-white drop-shadow-lg",
expanded && "opacity-80"
)}
style={{
WebkitMaskImage: "url(/images/icons/actions-toggle.svg)",
maskImage: "url(/images/icons/actions-toggle.svg)",
WebkitMaskRepeat: "no-repeat",
maskRepeat: "no-repeat",
WebkitMaskPosition: "center",
maskPosition: "center",
WebkitMaskSize: "contain",
maskSize: "contain",
}}
/>
</button>
{optionsMenu}
</div>
)}
</>

View File

@@ -130,8 +130,8 @@ export default function ReelsPostAnalyticsModal({
src={previewSrc}
alt=""
fill
sizes="112px"
className="object-cover"
unoptimized
/>
</div>
) : null}

View File

@@ -68,6 +68,8 @@ interface ReelsPostCardProps {
isActive?: boolean;
postPage?: boolean;
preloadPriority?: VideoPreloadPriority;
/** وقتی پیمایش خودکار فعاله و این ریلز (فعال) به انتهای ویدیو رسید */
onRequestNext?: () => void;
}
export default function ReelsPostCard({
@@ -75,6 +77,7 @@ export default function ReelsPostCard({
isActive = false,
postPage = false,
preloadPriority = "idle",
onRequestNext,
}: ReelsPostCardProps) {
const { t } = useTranslation("common");
const {
@@ -117,6 +120,7 @@ export default function ReelsPostCard({
useEffect(() => {
setCaptionExpanded(false);
}, [postData._id]);
const [imageLoading, setImageLoading] = useState(() => (files?.length ?? 0) > 0);
const [commentsOpen, setCommentsOpen] = useState(false);
// وقتی postPage=true، کامنت‌ها توی CommentsModal جدا (نه شیت این کامپوننت) باز می‌شه
@@ -146,8 +150,21 @@ export default function ReelsPostCard({
const social = usePostSocialState(postData);
const requireAuth = useRequireAuth();
const chromeHidden = exploreUi ? exploreUi.chromeHidden : uiHidden;
// اطلاعات کاربر/کپشن هم باید با دکمه‌ی جمع‌کردن آیکون‌ها مخفی بشن، نه فقط با
// hold روی تصویر (chromeHidden) — قبلاً این بلوک فقط chromeHidden رو چک می‌کرد
const actionsExpanded = exploreUi ? exploreUi.actionsExpanded : true;
const autoScrollEnabled = exploreUi ? exploreUi.autoScrollEnabled : false;
const playbackSpeed = exploreUi ? exploreUi.playbackSpeed : 1;
const allowSave = postData.author_allow_save !== false;
// عکس onEnded نداره (فقط ویدیو) — با پیمایش خودکار فعال، بعد از ۵ ثانیه خودش می‌ره
// ریلز بعدی؛ اگه این پست ویدیوعه این تایمر کاری نمی‌کنه (onEnded خودِ ویدیو مسئوله)
useEffect(() => {
if (!isActive || !autoScrollEnabled || type === "video") return;
const timer = window.setTimeout(() => onRequestNext?.(), 5000);
return () => window.clearTimeout(timer);
}, [isActive, autoScrollEnabled, type, onRequestNext]);
const useCommentsSheet = !postPage;
const anyCommentsOpen = commentsOpen || commentsModalOpen;
const showLongVideoBar =
@@ -155,7 +172,8 @@ export default function ReelsPostCard({
videoDuration > LONG_VIDEO_SEC &&
isActive &&
!chromeHidden &&
!anyCommentsOpen;
!anyCommentsOpen &&
actionsExpanded;
const mediaFiles =
files?.map((file) => ({
@@ -315,6 +333,12 @@ export default function ReelsPostCard({
setVideoPaused(false);
}, [videoSrc]);
// <video> عنصرِ HTML سرعتِ پخش رو به‌عنوان JSX prop نمی‌گیره، باید مستقیم روی خودِ
// المنت ست بشه — هم موقع تغییرِ سرعت از منو، هم موقع لود شدنِ منبعِ جدید
useEffect(() => {
if (videoRef.current) videoRef.current.playbackRate = playbackSpeed;
}, [playbackSpeed, videoSrc]);
useEffect(() => {
if (type !== "video" || !videoSrc) return;
if (preloadPriority === "active" || preloadPriority === "nearby") {
@@ -335,6 +359,13 @@ export default function ReelsPostCard({
}
}, [isActive, muted, type, commentsOpen, videoPaused]);
// videoPaused هم برای «کاربر دستی پاز کرده» و هم برای پازِ خودکارِ همین افکت (وقتی
// isActive می‌شه false) استفاده می‌شه؛ بدون این ریست، برگشتن به ریلزی که قبلاً دیده
// شده autoplay نمی‌کرد چون videoPaused از آخرین بار true مونده بود
useEffect(() => {
if (!isActive) setVideoPaused(false);
}, [isActive]);
useEffect(() => {
if (!isActive) {
setCommentsOpen(false);
@@ -437,23 +468,37 @@ export default function ReelsPostCard({
src={videoPoster}
alt=""
fill
className="object-contain"
unoptimized
sizes="100vw"
// object-bottom: طبق خواسته‌ی کاربر، لبه‌ی پایینِ محتوای ویدیو باید همیشه
// دقیقاً روی لبه‌ی پایینِ کادر بشینه (نه وسط‌چین) — تا صرف‌نظر از نسبتِ
// ابعادِ صفحه‌نمایشِ گوشی، این خط ثابت بمونه؛ پوستر هم باید با همون
// لنگرگاه نمایش داده بشه، وگرنه لحظه‌ی لود شدنِ ویدیو یه پرش دیده می‌شه
className="object-contain object-bottom"
priority={isActive || preloadPriority === "nearby"}
draggable={!allowSave ? false : undefined}
onContextMenu={
!allowSave ? (e) => e.preventDefault() : undefined
}
/>
)}
<video
ref={videoRef}
src={videoSrc ?? mediaFiles[0]?.src}
poster={videoPoster}
loop
loop={!autoScrollEnabled}
playsInline
preload={videoPreload}
controlsList="nodownload"
className={cn(
"h-full w-full object-contain",
"h-full w-full object-contain object-bottom",
videoPoster && !videoReady && "opacity-0"
)}
onClick={handleVideoClick}
// مرورگر نمی‌تونه از اسکرین‌شات واقعی جلوگیری کنه (هیچ API مرورگری برای این
// وجود نداره)؛ این فقط راه‌های ساده‌ی دانلود/ذخیره (کلیک راست، درگ) رو می‌بندد
onContextMenu={
!allowSave ? (e) => e.preventDefault() : undefined
}
onLoadedData={() => {
setImageLoading(false);
setVideoReady(true);
@@ -469,6 +514,9 @@ export default function ReelsPostCard({
onProgress={handleVideoTimeUpdate}
onPlay={() => setVideoPaused(false)}
onPause={() => setVideoPaused(true)}
onEnded={() => {
if (isActive && autoScrollEnabled) onRequestNext?.();
}}
muted={muted}
/>
{videoPaused && isActive && !commentsOpen && (
@@ -578,14 +626,20 @@ export default function ReelsPostCard({
)}
<AnimatePresence>
{!chromeHidden && !anyCommentsOpen && (
{!chromeHidden && !anyCommentsOpen && actionsExpanded && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent px-4 pt-16",
// pl (نه px یکسان): زیرِ dir=rtl سمتِ فیزیکیِ چپ همون سمتیه که ستونِ
// آیکون‌ها (left-3 در ReelsPostActions) روشه؛ چون آخرین آیتمِ ردیفِ
// justify-between (ریت/امتیاز) زیرِ RTL فیزیکاً همون سمت می‌شینه، این
// فاصله‌ی اضافه (۶۴px، هم‌اندازه‌ی عرضِ ستونِ آیکون‌ها) از افتادنِ
// ریت/امتیاز زیرِ دکمه‌ی جمع‌شونده جلوگیری می‌کنه
"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent pl-16 pr-4 pt-16",
// وقتی نوار پیشرفت ویدیو نمایش داده می‌شه، این بلوک باید بالای خطِ اون
// بشینه نه روش؛ وگرنه (تصویر/ویدیوی کوتاه) فقط فاصله‌ی پایینِ معمولی کافیه
showLongVideoBar
@@ -611,8 +665,8 @@ export default function ReelsPostCard({
}
alt={user_name}
fill
sizes="40px"
className="rounded-full object-cover"
unoptimized
/>
</div>
</StoryRing>

View File

@@ -13,6 +13,11 @@ import { useTranslation } from "react-i18next";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import {
useExploreReelsUi,
type PlaybackSpeed,
} from "@/contexts/ExploreReelsUiContext";
const REPORT_REASON_IDS = [
"inappropriate",
@@ -22,6 +27,8 @@ const REPORT_REASON_IDS = [
"other",
] as const;
const SPEED_OPTIONS: PlaybackSpeed[] = [0.5, 1, 1.5, 2];
type Props = {
postData: Post;
isOwnPost: boolean;
@@ -42,6 +49,7 @@ export default function ReelsPostOptionsMenu({
const queryClient = useQueryClient();
const router = useRouter();
const canEdit = isOwnPost;
const exploreUi = useExploreReelsUi();
const [open, setOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [analyticsOpen, setAnalyticsOpen] = useState(false);
@@ -193,6 +201,55 @@ export default function ReelsPostOptionsMenu({
count: viewCount.toLocaleString(numberLocale),
})}
</button>
{exploreUi ? (
<>
<div className="flex items-center justify-between px-5 py-3.5 text-sm">
<span>{t("posts.autoScrollLabel")}</span>
<button
type="button"
role="switch"
aria-checked={exploreUi.autoScrollEnabled}
onClick={() =>
exploreUi.setAutoScrollEnabled(!exploreUi.autoScrollEnabled)
}
className={cn(
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
exploreUi.autoScrollEnabled ? "bg-[#FC8EAC]" : "bg-neutral-300 dark:bg-neutral-700"
)}
>
<span
className={cn(
"absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform",
exploreUi.autoScrollEnabled ? "translate-x-0.5" : "translate-x-[22px]"
)}
/>
</button>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 px-5 py-3.5 text-sm">
<span>{t("posts.playbackSpeedLabel")}</span>
<div className="flex items-center gap-1.5">
{SPEED_OPTIONS.map((option) => {
const active = exploreUi.playbackSpeed === option;
return (
<button
key={option}
type="button"
onClick={() => exploreUi.setPlaybackSpeed(option)}
className={cn(
"flex h-8 min-w-[40px] items-center justify-center rounded-2xl border px-2 text-xs font-semibold",
active
? "border-[#FC8EAC] bg-[rgba(252,142,172,0.1)] text-[#FC8EAC]"
: "border-[#C3C3C3] text-foreground"
)}
>
{option}x
</button>
);
})}
</div>
</div>
</>
) : null}
{isOwnPost ? (
<>
{canEdit ? (

View File

@@ -137,8 +137,8 @@ export default function SendPostModal({
src={preview}
alt=""
fill
sizes="48px"
className="object-cover"
unoptimized
/>
</div>
)}
@@ -181,8 +181,8 @@ export default function SendPostModal({
}
alt=""
fill
sizes="40px"
className="object-cover"
unoptimized
/>
) : null}
</div>

View File

@@ -114,8 +114,8 @@ export default function TagUsersPicker({
src={src}
alt=""
fill
sizes="28px"
className="object-cover"
unoptimized
/>
) : null}
</div>
@@ -178,8 +178,8 @@ export default function TagUsersPicker({
src={src}
alt=""
fill
sizes="40px"
className="object-cover"
unoptimized
/>
) : null}
</div>

View File

@@ -107,7 +107,6 @@ function SpecialistCard({
loading="lazy"
className="object-cover"
sizes="(min-width: 1024px) 220px, (min-width: 640px) 30vw, 78vw"
unoptimized
/>
)
) : (
@@ -126,7 +125,6 @@ function SpecialistCard({
loading="lazy"
className="object-cover"
sizes="56px"
unoptimized
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">

View File

@@ -82,7 +82,6 @@ function SpecialistCard({
fill
className="object-cover"
sizes={isMain ? "280px" : "120px"}
unoptimized
/>
)
) : (
@@ -129,7 +128,6 @@ function SpecialistCard({
fill
className="object-cover"
sizes={isMain ? "64px" : "36px"}
unoptimized
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">

View File

@@ -8,7 +8,6 @@ import ScoreRateStats from "@/components/main/ScoreRateStats";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { IProjectRequest } from "@/types/types";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import BoldIcon from "@/components/ui/BoldIcon";
@@ -24,7 +23,6 @@ function ProjectRequestsAction({
}) {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [itemToAccept, setItemToAccept] = useState<IProjectRequest>();
const [viewerUrl, setViewerUrl] = useState<string | null>(null);
@@ -47,10 +45,12 @@ function ProjectRequestsAction({
projectId,
}
);
// لینک خارجی — router.push (next/navigation) رفتارِ تضمین‌شده‌ای برای URLهای
// بیرونی نداره؛ window.location.href مطمئن‌تره (خصوصاً سافاری)
if (response?.paymentUrl) {
router.push(response.paymentUrl);
window.location.href = response.paymentUrl;
} else if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -39,7 +39,9 @@ function ProjectWorkroomActions({
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -73,10 +73,13 @@ function ProjectWorkroomRate({
"/projects/initiate-completion-payment/web",
{ projectId }
);
// لینک خارجی — router.push (next/navigation) رفتارِ تضمین‌شده‌ای برای URLهای
// بیرونی نداره؛ window.location.href مطمئن‌تره (خصوصاً سافاری). sessionStorage
// بالا هنوز کار می‌کنه چون همین تب navigate می‌شه، نه یه تبِ جدید
if (response?.paymentUrl) {
router.push(response.paymentUrl);
window.location.href = response.paymentUrl;
} else if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
} catch (error: unknown) {
const message =

View File

@@ -149,7 +149,6 @@ export default function ActivityPostGrid({ endpoint }: Props) {
fill
className="object-cover"
sizes="50vw"
unoptimized
/>
)}
</button>

View File

@@ -58,8 +58,8 @@ export default function NotificationRow({
src={avatarSrc}
alt={actor?.user_name || t("common.user")}
fill
sizes="48px"
className="object-cover"
unoptimized
/>
) : (
<div className="flex h-full w-full items-center justify-center">

View File

@@ -12,8 +12,12 @@ type PageTitleProps = {
* Optional right-side controls (e.g. a page-specific action icon). Pass content
* here instead of wrapping <PageTitle> in an outer flex row — an outer wrapper
* shrinks this component to its own content width, which pushes the back button
* away from the true screen edge. The back button's column is always a fixed
* 2.5rem regardless of what's in this slot, so its position never moves.
* away from the true screen edge. Both side columns are a fixed 2.5rem so the
* title's center point never shifts — previously the right column was
* minmax(2.5rem,auto), which grew with wider action content (e.g. a text
* button) and dragged the visual center of the title off the true screen
* center. Content wider than 2.5rem still renders at its natural size and
* simply overflows its column instead of resizing it.
*/
action?: React.ReactNode;
};
@@ -28,7 +32,7 @@ const PageTitle: React.FC<PageTitleProps> = ({
usePageTitle(titleText);
return (
<div className="my-7 grid w-full grid-cols-[2.5rem_1fr_minmax(2.5rem,auto)] items-center gap-2">
<div className="my-7 grid w-full grid-cols-[2.5rem_1fr_2.5rem] items-center gap-2">
{showBack ? (
<BackButton href={backHref} className="justify-self-start" />
) : (

View File

@@ -30,13 +30,13 @@ export default function SettingsShell({
transition={{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
className="fixed bottom-0 left-0 right-0 z-50"
>
<TabNavigation
currentPage={
pathname?.startsWith("/settings/chats")
? "/settings/chats"
: "/settings"
}
/>
{/* pathname واقعی رو پاس می‌دیم، نه همیشه "/settings" ثابت — قبلاً برای هر
زیرصفحه‌ی تنظیمات (مثلاً /settings/wallet) هم "/settings" پاس داده می‌شد،
پس isExactRoot توی TabNavigation.tsx همیشه true می‌شد و کلیکِ روی آیکونِ
تنظیمات preventDefault می‌شد (به‌جای navigate واقعی)، چون فکر می‌کرد از
قبل دقیقاً روی روتِ /settings هستیم. isActive/هایلایتِ تب همچنان درست
کار می‌کنه چون TabNavigation خودش startsWith("/settings") رو چک می‌کنه */}
<TabNavigation currentPage={pathname ?? "/settings"} />
</motion.div>
)}
</AnimatePresence>

View File

@@ -40,8 +40,8 @@ export default function VisitorRow({ visitor }: Props) {
src={avatarSrc}
alt={visitor.user_name || t("common.user")}
fill
sizes="48px"
className="object-cover"
unoptimized
/>
</Link>

View File

@@ -42,7 +42,9 @@ function MyBillboardStatusActions({
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);
@@ -65,7 +67,9 @@ function MyBillboardStatusActions({
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -54,7 +54,9 @@ export default function ProductCategoryPicker({
type="button"
className={cn(
toggleBtnClass(category === item.category),
"min-h-9 p-1.5 text-[11px] leading-tight"
// گردی toggleBtnClass پیش‌فرض rounded-3xl(24px) هست؛ اینجا عینِ pill
// توی NewProductCategoryScreen.tsx اندروید (borderRadius:16) override می‌شه
"min-h-9 p-1.5 text-[11px] leading-tight !rounded-2xl"
)}
onClick={() => onCategorySelect(item.category)}
>
@@ -76,7 +78,9 @@ export default function ProductCategoryPicker({
type="button"
className={cn(
toggleBtnClass(subCategory === item.name),
"min-h-9 p-1.5 text-[11px] leading-tight"
// گردی toggleBtnClass پیش‌فرض rounded-3xl(24px) هست؛ اینجا عینِ pill
// توی NewProductCategoryScreen.tsx اندروید (borderRadius:16) override می‌شه
"min-h-9 p-1.5 text-[11px] leading-tight !rounded-2xl"
)}
onClick={() => onSubCategorySelect(item.name)}
>
@@ -100,7 +104,9 @@ export default function ProductCategoryPicker({
type="button"
className={cn(
toggleBtnClass(subSubCategory === item.name),
"min-h-9 p-1.5 text-[11px] leading-tight"
// گردی toggleBtnClass پیش‌فرض rounded-3xl(24px) هست؛ اینجا عینِ pill
// توی NewProductCategoryScreen.tsx اندروید (borderRadius:16) override می‌شه
"min-h-9 p-1.5 text-[11px] leading-tight !rounded-2xl"
)}
onClick={() => onSubSubCategorySelect(item.name)}
>

View File

@@ -71,7 +71,6 @@ export default function ProductImageLightbox({
fill
className="object-contain"
sizes="100vw"
unoptimized
/>
</div>
))}

View File

@@ -125,8 +125,8 @@ export default function SendProductModal({
src={IMAGE_BASE_URL + preview}
alt=""
fill
sizes="48px"
className="object-cover"
unoptimized
/>
</div>
)}
@@ -169,8 +169,8 @@ export default function SendProductModal({
}
alt=""
fill
sizes="40px"
className="object-cover"
unoptimized
/>
) : null}
</div>

View File

@@ -0,0 +1,205 @@
"use client";
import { btnPrimary } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { useTranslation } from "react-i18next";
import type { ChatUserItem } from "./SendProductModal";
interface SendShopModalProps {
open: boolean;
onClose: () => void;
shopId: string;
shopName: string;
logo?: string | null;
}
// عینِ SendProductModal.tsx، فقط برای خودِ فروشگاه (نه یه کالای خاص) — بک‌اند
// shopController.js: sendShopToUsers
export default function SendShopModal({
open,
onClose,
shopId,
shopName,
logo,
}: SendShopModalProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const [users, setUsers] = useState<ChatUserItem[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const loadUsers = async () => {
setLoading(true);
try {
const res = await request<{ filteredUsersData: ChatUserItem[] }>(
"GET",
"/messages?limit=100",
null,
{ noToast: true }
);
setUsers((res?.filteredUsersData || []).filter((u) => Boolean(u.user_name)));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) {
loadUsers();
setSelected(new Set());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (next.size < 50) next.add(id);
else toast.error(t("posts.maxUsers"));
return next;
});
};
const send = async () => {
if (selected.size === 0) return;
setSending(true);
try {
const res = await request<{ sentCount?: number }>(
"POST",
`/shops/${shopId}/send`,
{ receiverIds: Array.from(selected) },
{ noToast: true }
);
toast.success(
t("shops.sentShopToCount", { count: res?.sentCount ?? selected.size })
);
onClose();
setSelected(new Set());
} catch {
toast.error(t("shops.sendShopError"));
} finally {
setSending(false);
}
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="glass-modal-overlay fixed inset-0 z-[200] flex items-end justify-center sm:items-center"
onClick={onClose}
>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
className="glass-modal-panel w-full max-w-md rounded-t-3xl p-4 sm:rounded-3xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-3 text-center text-sm font-bold">
{t("shops.sendShopTitle")}
</h3>
<div className="mb-4 flex items-center gap-3 rounded-2xl bg-neutral-100 p-2 dark:bg-neutral-800">
{logo && (
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-full">
<Image
src={IMAGE_BASE_URL + logo}
alt=""
fill
sizes="48px"
className="object-cover"
/>
</div>
)}
<p className="line-clamp-2 text-xs text-neutral-600 dark:text-neutral-300">
{shopName}
</p>
</div>
{loading ? (
<div className="flex justify-center py-8">
<IOSSpinner />
</div>
) : users.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("posts.noChatUsers")}
</p>
) : (
<ul className="max-h-[45vh] space-y-1 overflow-y-auto">
{users.map((u) => {
const name =
[u.first_name, u.last_name].filter(Boolean).join(" ") ||
u.user_name;
const checked = selected.has(u._id);
return (
<li key={u._id}>
<button
type="button"
onClick={() => toggle(u._id)}
className={`flex w-full items-center gap-3 rounded-2xl px-2 py-2 text-right transition ${
checked ? "bg-pink-50 dark:bg-pink-950/30" : ""
}`}
>
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
{u.profile_image ? (
<Image
src={
u.profile_image.startsWith("http")
? u.profile_image
: IMAGE_BASE_URL + u.profile_image
}
alt=""
fill
sizes="40px"
className="object-cover"
/>
) : null}
</div>
<span className="flex-1 text-sm font-medium">{name}</span>
<span
className={cn(
"flex h-5 w-5 items-center justify-center rounded-full border-2",
checked ? "btn-modern--selected border-[#FC8EAC]" : "border-neutral-300"
)}
>
{checked ? (
<span className="text-[10px] text-white">✓</span>
) : null}
</span>
</button>
</li>
);
})}
</ul>
)}
<button
type="button"
disabled={selected.size === 0 || sending}
onClick={send}
className={cn(btnPrimary, "mt-4 w-full py-3 text-sm font-bold disabled:opacity-40")}
>
{sending
? t("posts.sending")
: t("posts.sendWithCount", { count: selected.size })}
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -114,7 +114,7 @@ export default function ShopCartTab() {
{discountPercent != null && (
<DiscountBadge
percent={discountPercent}
className="absolute right-1 top-1"
className="absolute left-1 top-1"
/>
)}
</button>

View File

@@ -26,22 +26,32 @@ const ShopContactInfoModal: React.FC<IShopContactInfoModalProps> = ({
contactInfo,
}) => {
const { t } = useTranslation("common");
// عین ShopProfileScreen.tsx اندروید: آیکونِ برندِ رنگی (نه آیکونِ عمومیِ تک‌رنگِ
// قبلی که با dark:invert معکوس می‌شد)
const contactMethods = [
{ key: "mobile", icon: "mobile.svg", link: `tel:${contactInfo?.mobile}` },
{ key: "landline", icon: "phone.svg", link: `tel:${contactInfo?.landline}` },
{
key: "mobile",
icon: "brand/phone-brand.svg",
link: `tel:${contactInfo?.mobile}`,
},
{
key: "landline",
icon: "brand/phone-brand.svg",
link: `tel:${contactInfo?.landline}`,
},
{
key: "whatsapp",
icon: "whatsapp.svg",
icon: "brand/whatsapp-brand.svg",
link: `https://wa.me/${contactInfo?.whatsapp}`,
},
{
key: "telegram",
icon: "telegram.svg",
icon: "brand/telegram-brand.svg",
link: `https://t.me/${contactInfo?.telegram}`,
},
{
key: "instagram",
icon: "instagram.svg",
icon: "brand/instagram-brand.svg",
link: `https://instagram.com/${contactInfo?.instagram}`,
},
];
@@ -64,11 +74,11 @@ const ShopContactInfoModal: React.FC<IShopContactInfoModalProps> = ({
<RoundedDiv className="h-9 w-full gap-2">
<span>{contactInfo[key as keyof IShopContactInfo]}</span>
<Image
width={24}
height={24}
width={22}
height={22}
alt={icon}
src={`/images/icons/${icon}`}
className="dark:invert"
unoptimized
/>
</RoundedDiv>
</a>

View File

@@ -85,7 +85,7 @@ export default function ShopFavoritesTab() {
{discountPercent != null && (
<DiscountBadge
percent={discountPercent}
className="absolute right-2 top-2"
className="absolute left-2 top-2"
/>
)}
<button

View File

@@ -1,15 +1,20 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import VerificationBadge, {
resolveShopVerificationTier,
} from "@/components/main/VerificationBadge";
type ShopProductAuthorProps = {
shopName: string;
shopLogo?: string | null;
shopVerifyBadge?: string | null;
};
export default function ShopProductAuthor({
shopName,
shopLogo,
shopVerifyBadge,
}: ShopProductAuthorProps) {
return (
<span className="flex min-w-0 items-center gap-1.5">
@@ -23,8 +28,12 @@ export default function ShopProductAuthor({
) : (
<span className="h-8 w-8 shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
)}
<span className="truncate text-xs font-medium text-neutral-600 dark:text-neutral-300">
<span className="flex min-w-0 items-center gap-1 truncate text-xs font-medium text-neutral-600 dark:text-neutral-300">
{shopName}
<VerificationBadge
verifyBadge={resolveShopVerificationTier(shopVerifyBadge)}
size={12}
/>
</span>
</span>
);

View File

@@ -114,8 +114,8 @@ export default function ShopProductCommentsModal({
src={IMAGE_BASE_URL + c.creator.profile_image}
alt=""
fill
sizes="36px"
className="object-cover"
unoptimized
/>
) : null}
</div>

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