Compare commits

...

33 Commits

Author SHA1 Message Date
payacom
551260a836 reels 2026-10-09 15:44:12 +03:30
payacom
f364f989ed pay 2026-10-06 17:07:08 +03:30
payacom
d6386b779f e2ee 2026-10-02 18:12:58 +03:30
payacom
9fb454b0a7 reels 2026-10-01 21:11:37 +03:30
payacom
6e76e367f3 4.2.1 2026-09-25 13:35:34 +03:30
payacom
200b387504 full app 2026-09-24 20:56:23 +03:30
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
286 changed files with 10845 additions and 4844 deletions

View File

@@ -1,11 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "modstagram-next-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}

View File

@@ -1,24 +0,0 @@
"use client";
import { useState } from "react";
import Step1 from "./Step1";
import Step2 from "./Step2";
import Step3 from "./Step3";
import Step4 from "./Step4";
import Step5 from "./Step5";
const MultiStepForm = () => {
const [step, setStep] = useState(1);
return (
<div className="p-4 flex flex-col items-center w-full">
{step === 1 && <Step1 nextStep={() => setStep(2)} />}
{step === 2 && <Step2 nextStep={() => setStep(3)} />}
{step === 3 && <Step3 nextStep={() => setStep(4)} />}
{step === 4 && <Step4 nextStep={() => setStep(5)} />}
{step === 5 && <Step5 />}
</div>
);
};
export default MultiStepForm;

View File

@@ -1,151 +0,0 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import LocationSelector from "./step1/LocationSelector";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import SelectBox from "@/components/elements/SelectBox";
import { IAdvertisingCategory } from "@/types/types";
import RoundedInput from "@/components/elements/RoundedInput";
import ImageUploader from "./step1/ImageUploader";
import { useTranslation } from "react-i18next";
const Step1 = ({ nextStep }: { nextStep: () => void }) => {
const { t } = useTranslation("common");
const { formData, updateForm } = useBillboardForm();
const [categories, setCategories] = useState<IAdvertisingCategory[]>([]);
const { request } = useAxios();
const validationSchema = useMemo(
() =>
Yup.object({
stateId: Yup.string().required(t("billboards.form.validation.provinceRequired")),
cityId: Yup.string().required(t("billboards.form.validation.cityRequired")),
categoryId: Yup.string().required(t("billboards.form.validation.categoryRequired")),
adsTitle: Yup.string().required(t("billboards.form.validation.titleRequired")),
description: Yup.string().required(t("billboards.form.validation.descriptionRequired")),
neighbourhood: Yup.string().required(t("billboards.form.validation.neighbourhoodRequired")),
address: Yup.string().required(t("billboards.form.validation.addressRequired")),
images: Yup.array()
.min(1, t("billboards.form.validation.minImages"))
.max(5, t("billboards.form.validation.maxImages"))
.required(t("billboards.form.validation.imagesRequired")),
markerCoordinate: Yup.array()
.of(Yup.number().required())
.length(2, t("billboards.form.validation.mapLocationRequired"))
.required(t("billboards.form.validation.mapLocationRequired")),
}),
[t]
);
useEffect(() => {
const fetchData = async () => {
try {
const response = await request<{ categories: IAdvertisingCategory[] }>(
"GET",
"/advertising/categories"
);
setCategories(response?.categories);
} catch (err) {
console.log(err);
}
};
fetchData();
}, [request]);
const formik = useFormik({
initialValues: {
cityId: formData.cityId || "",
stateId: formData.stateId || "",
adsTitle: formData.adsTitle || "",
categoryId: formData.categoryId || "",
images: formData.images || [],
address: formData.address || "",
neighbourhood: formData.neighbourhood || "",
description: formData.description || "",
markerCoordinate: formData.markerCoordinate || null,
},
validationSchema,
onSubmit: (values) => {
updateForm(values);
nextStep();
},
});
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
<SelectBox
className={`max-w-full bg-secondary-light dark:bg-secondary-dark ${
formik.touched.categoryId && formik.errors.categoryId
? "border-red-500 dark:border-red-500"
: ""
}`}
value={formik.values.categoryId}
onChange={(e) => formik.setFieldValue("categoryId", e.target.value)}
>
<option className="bg-secondary-light dark:bg-secondary-dark" disabled value="">
{t("billboards.category")}
</option>
{categories?.map((item: IAdvertisingCategory) => (
<option
className="bg-secondary-light dark:bg-secondary-dark"
key={item._id}
value={item.title}
>
{item?.title}
</option>
))}
</SelectBox>
{formik.touched.categoryId && formik.errors.categoryId && (
<p className="text-red-500 text-xs">{formik.errors.categoryId}</p>
)}
<hr />
<RoundedInput
className={`max-w-full ${
formik.touched.stateId && formik.errors.stateId
? "border-red-500 dark:border-red-500"
: ""
}`}
type="text"
placeholder={t("billboards.title")}
{...formik.getFieldProps("adsTitle")}
/>
{formik.touched.adsTitle && formik.errors.adsTitle && (
<p className="text-red-500 text-xs">{formik.errors.adsTitle}</p>
)}
<textarea
className={`w-full p-4 mt-2 h-24 rounded-3xl border
${
formik.touched.description && formik.errors.description
? "border-red-500 dark:border-red-500"
: ""
}
border-neutral-950 text-neutral-900 dark:text-neutral-50 dark:border-neutral-400 border font-medium`}
placeholder={t("billboards.description")}
{...formik.getFieldProps("description")}
/>
{formik.touched.description && formik.errors.description && (
<p className="text-red-500 text-xs">{formik.errors.description}</p>
)}
<hr className="w-full my-2" />
<LocationSelector formik={formik} />
<ImageUploader formik={formik} />
{formik.touched.images && formik.errors.images && (
<p className="text-red-500 text-xs">{formik.errors.images}</p>
)}
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("billboards.submitContinue")}
</RoundedButton>
</form>
);
};
export default Step1;

View File

@@ -1,89 +0,0 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { useMemo, useState } from "react";
import { Service } from "@/types/types";
import ServiceItem from "@/components/main/Services/ServiceItem";
import AddServiceModal from "@/components/main/Services/AddServiceModal";
import { useTranslation } from "react-i18next";
const Step2 = ({ nextStep }: { nextStep: () => void }) => {
const { t } = useTranslation("common");
const { formData, updateForm } = useBillboardForm();
const [isModalOpen, setModalOpen] = useState(false);
const validationSchema = useMemo(
() =>
Yup.object({
services: Yup.array().min(1, t("billboards.form.validation.minServices")),
}),
[t]
);
const formik = useFormik({
initialValues: {
services: formData.services || [],
},
validationSchema,
onSubmit: (values) => {
updateForm(values);
nextStep();
},
});
const services = formik.values.services;
const handleAddService = (newService: Service) => {
formik.setFieldValue("services", [...services, newService]);
};
const handleDeleteService = (id: string) => {
formik.setFieldValue(
"services",
services.filter((service) => service.id !== id)
);
};
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm pb-28"
>
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
<div className="gap-4 min-h-96 w-full">
{services.map((service) => (
<ServiceItem
onDelete={handleDeleteService}
key={service.id}
service={service}
/>
))}
</div>
<hr className="w-full" />
<div className="sticky bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-20 flex w-full justify-center gap-4 bg-background/95 py-3 backdrop-blur-sm">
<RoundedButton type="submit" variant="primary" className="h-9 w-32">
{t("billboards.submitContinue")}
</RoundedButton>
<RoundedButton
onClick={() => setModalOpen(true)}
className="!text-[#0066FF] !border-[#0066FF] w-32 h-9"
type="button"
>
{t("billboards.add")}
</RoundedButton>
</div>
{isModalOpen && (
<AddServiceModal
onClose={() => setModalOpen(false)}
onAdd={handleAddService}
isModalOpen={isModalOpen}
/>
)}
</form>
);
};
export default Step2;

View File

@@ -1,176 +0,0 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import useAxios from "@/hooks/useAxios";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
export interface IFeatureFetch {
title?: string;
value: boolean;
_id: string;
}
const Step3 = ({ nextStep }: { nextStep: () => void }) => {
const { t } = useTranslation("common");
const { formData, updateForm } = useBillboardForm();
const { request } = useAxios();
const [features, setFeatures] = useState<IFeatureFetch[]>([]);
const validationSchema = useMemo(
() =>
Yup.object({
selectedFeatures: Yup.array().min(1, t("billboards.form.validation.minFeatures")),
}),
[t]
);
useEffect(() => {
const fetchData = async () => {
try {
const response = await request<{ features: IFeatureFetch[] }>(
"GET",
"/advertising/features"
);
setFeatures(response.features);
} catch (err) {
console.log(err);
}
};
fetchData();
}, [request]);
const formik = useFormik({
initialValues: {
selectedFeatures: formData.selectedFeatures || [],
},
validationSchema,
onSubmit: (values) => {
updateForm(values);
nextStep();
},
enableReinitialize: true,
});
const handleSelect = (
featureId: string,
value: boolean,
title: string | undefined
) => {
formik.setFieldValue("selectedFeatures", [
...formik.values.selectedFeatures.filter(
(feature) => feature._id !== featureId
),
{ _id: featureId, value, title: title },
]);
};
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-4 w-full items-center text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
<div className="flex flex-col w-full gap-3">
{features.map((feature: IFeatureFetch) => {
const isSelected = formik.values.selectedFeatures.some(
(featureItem) =>
featureItem._id === feature._id && featureItem.value === true
);
const isDeselected = formik.values.selectedFeatures.some(
(featureItem) =>
featureItem._id === feature._id && featureItem.value === false
);
return (
<div
key={feature._id}
className="flex justify-between items-center w-full p-4 border-b border-gray-300"
>
<span className="text-lg">{feature.title}</span>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`feature-${feature._id}`}
value="true"
checked={isSelected}
onChange={() =>
handleSelect(feature._id, true, feature?.title)
}
className="hidden"
/>
<span>{t("billboards.form.has")}</span>
<div
className={`w-6 h-6 border-2 rounded-full flex items-center justify-center border-green-500`}
>
{isSelected && (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#01A168"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
)}
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`feature-${feature._id}`}
value="false"
checked={isDeselected}
onChange={() =>
handleSelect(feature._id, false, feature?.title)
}
className="hidden"
/>
<span>{t("billboards.form.hasNot")}</span>
<div
className={`w-6 h-6 border-2 rounded-full flex items-center justify-center border-red-500`}
>
{isDeselected && (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#FF0000"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
)}
</div>
</label>
</div>
</div>
);
})}
</div>
{formik.touched.selectedFeatures &&
formik.errors.selectedFeatures &&
typeof formik.errors.selectedFeatures === "string" && (
<p className="text-red-500">{formik.errors.selectedFeatures}</p>
)}
<RoundedButton type="submit" variant="primary" className="mt-4 h-9 w-32">
{t("billboards.submitContinue")}
</RoundedButton>
</form>
);
};
export default Step3;

View File

@@ -1,193 +0,0 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { useEffect, useState } from "react";
import useAxios from "@/hooks/useAxios";
import RoundedInput from "@/components/elements/RoundedInput";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
const validationSchema = Yup.object({
landline: Yup.string(),
mobile: Yup.string(),
telegram: Yup.string(),
whatsapp: Yup.string(),
instagram: Yup.string(),
saveData: Yup.boolean(),
});
interface ContactInfo {
landline?: string;
mobile?: string;
telegram?: string;
whatsapp?: string;
instagram?: string;
saveData?: boolean;
}
interface ApiResponse {
data: {
contactInfo: ContactInfo;
};
}
const Step4 = ({ nextStep }: { nextStep: () => void }) => {
const { t } = useTranslation("common");
const { formData, updateForm } = useBillboardForm();
const { request } = useAxios();
const [isCheckedOne, setIsCheckedOne] = useState<boolean>(false);
const formik = useFormik({
initialValues: {
landline: formData.landline || "",
mobile: formData.mobile || "",
telegram: formData.telegram || "",
whatsapp: formData.whatsapp || "",
instagram: formData.instagram || "",
saveData: formData.saveData || false,
},
validationSchema,
onSubmit: (values) => {
if (
!formik?.values?.landline &&
!formik?.values?.mobile &&
!formik?.values?.telegram &&
!formik?.values?.whatsapp &&
!formik?.values?.instagram
) {
toast.error(t("billboards.form.contactRequired"));
} else {
updateForm(values);
nextStep();
}
},
});
useEffect(() => {
const fetchData = async () => {
try {
const response = await request<ApiResponse>(
"GET",
"/users/contact-info"
);
const contactInfo = response?.data?.contactInfo;
if (contactInfo) {
formik.setFieldValue("landline", contactInfo.landline || "");
formik.setFieldValue("mobile", contactInfo.mobile || "");
formik.setFieldValue("telegram", contactInfo.telegram || "");
formik.setFieldValue("whatsapp", contactInfo.whatsapp || "");
formik.setFieldValue("instagram", contactInfo.instagram || "");
formik.setFieldValue("saveData", contactInfo.saveData || false);
}
} catch (err) {
console.error("Error fetching contact info:", err);
}
};
fetchData();
}, [request]);
const toggleCheckBox = () => {
formik.setFieldValue("saveData", !isCheckedOne);
setIsCheckedOne(!isCheckedOne);
};
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
<RoundedInput
className={`max-w-full mt-2 ${
formik.touched.landline && formik.errors.landline
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
maxLength={11}
placeholder={t("billboards.form.landline")}
{...formik.getFieldProps("landline")}
/>
{formik.touched.landline && formik.errors.landline && (
<p className="text-red-500 text-xs">{formik.errors.landline}</p>
)}
<RoundedInput
className={`max-w-full mt-2 ${
formik.touched.mobile && formik.errors.mobile
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
maxLength={11}
placeholder={t("billboards.form.mobile")}
{...formik.getFieldProps("mobile")}
/>
{formik.touched.mobile && formik.errors.mobile && (
<p className="text-red-500 text-xs">{formik.errors.mobile}</p>
)}
<RoundedInput
className={`max-w-full mt-2 ${
formik.touched.telegram && formik.errors.telegram
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
placeholder={t("billboards.form.telegram")}
{...formik.getFieldProps("telegram")}
/>
{formik.touched.telegram && formik.errors.telegram && (
<p className="text-red-500 text-xs">{formik.errors.telegram}</p>
)}
<RoundedInput
className={`max-w-full mt-2 ${
formik.touched.whatsapp && formik.errors.whatsapp
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
placeholder={t("billboards.form.whatsapp")}
maxLength={11}
{...formik.getFieldProps("whatsapp")}
/>
{formik.touched.whatsapp && formik.errors.whatsapp && (
<p className="text-red-500 text-xs">{formik.errors.whatsapp}</p>
)}
<RoundedInput
className={`max-w-full mt-2 ${
formik.touched.instagram && formik.errors.instagram
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
type="text"
placeholder={t("billboards.form.instagram")}
{...formik.getFieldProps("instagram")}
/>
{formik.touched.instagram && formik.errors.instagram && (
<p className="text-red-500 text-xs">{formik.errors.instagram}</p>
)}
<label className="flex items-center gap-2 justify-start w-full pr-4 mt-4">
<input
{...formik.getFieldProps("stateId")}
className="scale-125"
type="checkbox"
onChange={toggleCheckBox}
/>
<span>{t("billboards.form.saveContactInfo")}</span>
</label>
<hr />
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("billboards.submitContinue")}
</RoundedButton>
</form>
);
};
export default Step4;

View File

@@ -1,268 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useEffect, useMemo, useState } from "react";
import useAxios from "@/hooks/useAxios";
import { IAdvertisingType } from "@/types/types";
import { sampleAds, sampleAdsHighlight, sampleAdsSpecial } from "@/constants";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { useRouter } from "next/navigation";
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import { dataURLtoBlob } from "@/helpers/helpers";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { getBillboardDisplayTypeOptionLabel } from "@/lib/billboards/displayTypeLabel";
import { useTranslation } from "react-i18next";
interface CreateAdvertisingResponse {
success: boolean;
message: string;
data: {
id: string;
};
}
const Step5 = () => {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const { formData, updateForm, isEditing } = useBillboardForm();
const [selectedType, setSelectedType] = useState("normal");
const [showDiscount, setShowDiscount] = useState<boolean>(false);
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
const validationSchema = useMemo(
() =>
Yup.object({
selectedType: Yup.string().required(
t("billboards.form.validation.displayTypeRequired")
),
}),
[t]
);
const {
adsTitle,
categoryId,
description,
stateId,
cityId,
neighbourhood,
address,
markerCoordinate,
services,
selectedFeatures,
landline,
mobile,
telegram,
whatsapp,
instagram,
saveData,
images,
} = formData;
const toggleCheckBox = () => {
setShowDiscount(!showDiscount);
};
const fetchStates = async () => {
try {
const response = await request<{ advertisingTypes: IAdvertisingType[] }>(
"GET",
"/advertising/types"
);
setTypeList(response?.advertisingTypes || null);
} catch (err) {
console.log(err);
}
};
useEffect(() => {
fetchStates();
}, []);
const formik = useFormik({
initialValues: {
selectedType: formData.selectedType || "",
},
validationSchema,
onSubmit: async (values) => {
updateForm(values);
const data = new FormData();
data.append("title", adsTitle);
data.append("category", categoryId);
if (description) {
data.append("description", description);
}
data.append("province", stateId ? stateId : "");
data.append("city", cityId ? cityId : "");
if (neighbourhood) {
data.append("neighbourhood", neighbourhood);
}
if (address) {
data.append("address", address);
}
if (markerCoordinate) {
data.append("lat", markerCoordinate[0]);
data.append("lng", markerCoordinate[1]);
}
data.append("services", JSON.stringify(services));
data.append("features", JSON.stringify(selectedFeatures));
if (landline) {
data.append("contactInfo[phone]", landline);
}
if (mobile) {
data.append("contactInfo[mobile]", mobile);
}
if (telegram) {
data.append("contactInfo[telegramLink]", telegram);
}
if (whatsapp) {
data.append("contactInfo[whatsappNumber]", whatsapp);
}
if (instagram) {
data.append("contactInfo[instagramLink]", instagram);
}
if (saveData !== null && typeof saveData === "boolean") {
data.append("contactInfo[saveInfoForNextAds]", String(saveData));
}
if (showDiscount !== null && typeof showDiscount === "boolean") {
data.append("showDiscount", String(showDiscount));
}
data.append("type", selectedType);
const mostDiscountPercentage =
services.length > 0
? services.reduce<number>((max, service) => {
const discount =
typeof service.discountPercentage === "number"
? service.discountPercentage
: 0;
return discount > max ? discount : max;
}, 0)
: 0;
if (mostDiscountPercentage) {
data.append("mostDiscountPercentage", String(mostDiscountPercentage));
}
images?.forEach((image) => {
if (typeof image === "string" && image.startsWith("/advertising/")) {
data.append("existingImages[]", image);
} else {
const blob = dataURLtoBlob(image);
data.append("images", blob);
}
});
services.forEach((service) => {
if (
typeof service.image === "string" &&
service.image.startsWith("/services/")
) {
data.append(`existingServiceImages[${service.id}]`, service.image);
} else if (service.image) {
const blob = dataURLtoBlob(service.image);
data.append(
`serviceImages[${service.id}]`,
blob,
`service-${service.id}.jpg`
);
}
});
try {
if (isEditing) {
await request<{ id: string; message: string }>(
"POST",
`/advertising/edit/${formData?._id}`,
data
);
localStorage.removeItem("billboardForm");
router.push(`/settings/my-billboards`);
} else {
const response = await request<CreateAdvertisingResponse>(
"POST",
"/advertising/create",
data
);
localStorage.removeItem("billboardForm");
router.push(`/billboards/new/${response.data.id}`);
}
} catch (err: any) {
console.log("Unhandled error:", err?.message);
}
},
});
useEffect(() => {
if (isEditing && formik.values.selectedType) {
formik.submitForm();
}
}, [isEditing, formik.values.selectedType]);
return (
<form
onSubmit={formik.handleSubmit}
className="flex flex-col gap-2 w-full items-center text-sm"
>
<h6 className="font-bold text-xl mb-2">{t("billboards.displayTypeTitle")}</h6>
<p>{t("billboards.displayTypeHint")}</p>
<div className="flex items-center gap-2 mt-10">
<input
className="scale-125"
type="checkbox"
onChange={toggleCheckBox}
/>
<span className="text-xs font-bold">{t("billboards.showDiscount")}</span>
</div>
{typeList?.map((item: IAdvertisingType) => {
return (
<div
onClick={() => {
setSelectedType(item?.name);
formik.setFieldValue("selectedType", item?.name);
}}
className="w-full flex flex-col items-center"
key={item?._id}
>
<MainBillboardCard
adStatus=""
billboard={
item?.name == "special"
? sampleAdsSpecial
: item?.name == "highlight"
? sampleAdsHighlight
: sampleAds
}
/>
<RoundedDiv
className={selectionCardClass(selectedType == item?.name)}
>
{getBillboardDisplayTypeOptionLabel(t, item.name)}:
{item.price !== 0
? !showDiscount
? Number(item.price).toLocaleString() + t("settings.toman")
: (Number(item.price) + 20000).toLocaleString() +
t("settings.toman")
: ` ${t("billboards.free")}`}
</RoundedDiv>
</div>
);
})}
{formik.touched.selectedType && formik.errors.selectedType && (
<p className="text-red-500">{formik.errors.selectedType}</p>
)}
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{t("billboards.submitRequest")}
</RoundedButton>
</form>
);
};
export default Step5;

View File

@@ -1,90 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { useState } from "react";
import { useTranslation } from "react-i18next";
type ImageUploaderProps = {
formik: any;
};
const ImageUploader: React.FC<ImageUploaderProps> = ({ formik }) => {
const { t } = useTranslation("common");
const [images, setImages] = useState<string[]>(formik.values.images || []);
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
const files = Array.from(event.target.files);
if (images.length + files.length > 5) {
alert(t("billboards.form.maxImagesAlert"));
return;
}
const newImages: string[] = [];
files.forEach((file) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
if (typeof reader.result === "string") {
newImages.push(reader.result);
if (newImages.length === files.length) {
const updatedImages = [...images, ...newImages];
setImages(updatedImages);
formik.setFieldValue("images", updatedImages);
}
}
};
});
};
const handleRemoveImage = (index: number) => {
const newImages = images.filter((_, i) => i !== index);
setImages(newImages);
formik.setFieldValue("images", newImages);
};
return (
<div className="flex flex-wrap gap-2 w-full mt-5">
{images.map((image, index) => (
<div
key={crypto.randomUUID()}
className="relative w-24 h-24 rounded-lg overflow-hidden border"
>
<img
src={
images[index].slice(0, 12) === "/advertising"
? IMAGE_BASE_URL + image
: image
}
alt="uploaded"
className="w-full h-full object-cover"
/>
<button
type="button"
className="absolute top-1 right-1 bg-black bg-opacity-50 p-1 px-2.5 rounded-full text-white"
onClick={() => handleRemoveImage(index)}
>
x
</button>
</div>
))}
{images.length < 5 && (
<label className="w-24 h-24 flex items-center justify-center border-dashed border-2 cursor-pointer rounded-lg">
<input
type="file"
multiple
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
+
</label>
)}
</div>
);
};
export default ImageUploader;

View File

@@ -1,202 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { ICity, IProvince } from "@/types/types";
import SelectBox from "@/components/elements/SelectBox";
import { useState, useEffect } from "react";
import useAxios from "@/hooks/useAxios";
import RoundedInput from "@/components/elements/RoundedInput";
import Map, { GeolocateControl, Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Image from "next/image";
import { useBillboardForm } from "@/contexts/BillboardFormContext";
import { useTranslation } from "react-i18next";
interface LocationSelectorProps {
formik: any;
}
const LocationSelector: React.FC<LocationSelectorProps> = ({ formik }) => {
const { t } = useTranslation("common");
const { formData } = useBillboardForm();
const { request } = useAxios();
const [allStates, setAllStates] = useState<IProvince[] | null>(null);
const [cities, setCities] = useState<ICity[] | null>(null);
const [selectedLocation, setSelectedLocation] = useState<{
lat: number;
lng: number;
} | null>(null);
const fetchStates = async () => {
try {
const response = await request<{ provinces: IProvince[] }>(
"GET",
"/provinces"
);
setAllStates(response?.provinces || null);
} catch (err) {
console.log(err);
}
};
const fetchCities = async (provinceId: string) => {
try {
const response = await request<{ cities: ICity[] }>(
"GET",
`/cities/${provinceId}`
);
setCities(response?.cities || []);
} catch (err) {
console.log(err);
}
};
useEffect(() => {
fetchStates();
}, []);
useEffect(() => {
if (formik.values.stateId) {
fetchCities(formik.values.stateId);
}
if (formData?.markerCoordinate && formData?.markerCoordinate[1]) {
setSelectedLocation({
lat: Number(formData?.markerCoordinate[1]),
lng: Number(formData?.markerCoordinate[0]),
});
}
}, [formik?.values?.markerCoordinate]);
const handleProvinceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedProvinceId = e.target.value;
formik.setFieldValue("stateId", selectedProvinceId);
fetchCities(selectedProvinceId);
};
const handleMapClick = (event: any) => {
const { lngLat } = event;
setSelectedLocation({
lat: lngLat.lat,
lng: lngLat.lng,
});
formik.setFieldValue("markerCoordinate", [lngLat.lng, lngLat.lat]);
};
return (
<>
<SelectBox
className={`max-w-full ${
formik.touched.stateId && formik.errors.stateId
? "border-red-500 dark:border-red-500"
: ""
}`}
value={formik.values.stateId}
onChange={handleProvinceChange}
>
<option disabled value="">
{t("billboards.province")}
</option>
{allStates?.map((item: IProvince) => (
<option key={item.id} value={item.id}>
{item?.name}
</option>
))}
</SelectBox>
{formik.touched.stateId && formik.errors.stateId && (
<small className="text-red-500 block text-center">
{formik.errors.stateId}
</small>
)}
<SelectBox
className={`max-w-full ${
formik.touched.cityId && formik.errors.cityId
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.cityId}
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
>
<option disabled value="">
{t("billboards.city")}
</option>
{cities?.map((city: ICity) => (
<option key={city.id} value={city.id}>
{city.name}
</option>
))}
</SelectBox>
{formik.touched.cityId && formik.errors.cityId && (
<small className="text-red-500 block text-center">
{formik.errors.cityId}
</small>
)}
<RoundedInput
className={`max-w-full ${
formik.touched.neighbourhood && formik.errors.neighbourhood
? "border-red-500 dark:border-red-500"
: ""
}`}
type="text"
placeholder={t("billboards.neighbourhood")}
{...formik.getFieldProps("neighbourhood")}
/>
{formik.touched.neighbourhood && formik.errors.neighbourhood && (
<p className="text-red-500">{formik.errors.neighbourhood}</p>
)}
<textarea
className={`w-full p-4 mt-2 h-24 rounded-3xl border
${
formik.touched.address && formik.errors.address
? "border-red-500 dark:border-red-500"
: "border-border-primary-light dark:border-border-primary-dark"
}
border-neutral-950 text-neutral-900 dark:text-neutral-50 dark:border-neutral-400 font-medium`}
placeholder={t("billboards.address")}
{...formik.getFieldProps("address")}
></textarea>
{formik.touched.address && formik.errors.address && (
<p className="text-red-500">{formik.errors.address}</p>
)}
<div className="w-full rounded-2xl overflow-hidden mt-2">
<Map
style={{ height: "240px" }}
initialViewState={{
longitude: 51.375433528216654,
latitude: 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={handleMapClick}
>
<GeolocateControl />
{selectedLocation && (
<Marker
latitude={selectedLocation.lat}
longitude={selectedLocation.lng}
>
<Image
alt="location icon"
className="-mt-5"
width={25}
height={25}
src={"/images/icons/location.svg"}
/>
</Marker>
)}
</Map>
{formik.touched.markerCoordinate && formik.errors.markerCoordinate && (
<p className="text-red-500 text-xs">
{formik.errors.markerCoordinate as string}
</p>
)}
</div>
</>
);
};
export default LocationSelector;

View File

@@ -66,25 +66,31 @@ const nextConfig = {
minimumCacheTTL: 31536000,
},
// ۳. ریدایرکت دائمی دامنه قدیمی .ir به دامنه اصلی .com
// ۳. ریدایرکت دائمی دامنه قدیمی .ir و نسخه بدون www به دامنه اصلی www.modstagram.com
async redirects() {
return [
{
source: '/:path*',
has: [{ type: 'host', value: 'modstagram.ir' }],
destination: 'https://modstagram.com/:path*',
destination: 'https://www.modstagram.com/:path*',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'www.modstagram.ir' }],
destination: 'https://modstagram.com/:path*',
destination: 'https://www.modstagram.com/:path*',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'app.modstagram.ir' }],
destination: 'https://modstagram.com/:path*',
destination: 'https://www.modstagram.com/:path*',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'modstagram.com' }],
destination: 'https://www.modstagram.com/:path*',
permanent: true,
},
];

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "modstagram-next",
"version": "0.1.0",
"version": "4.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modstagram-next",
"version": "0.1.0",
"version": "4.2.0",
"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.4.0",
"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

BIN
public/images/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

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

@@ -27,6 +27,9 @@ Disallow: /*/payment/
Disallow: /projects/payment/
Disallow: /billboards/payment/
Disallow: /academy/payment/
Disallow: /shops/new
Disallow: /shops/checkout
Disallow: /shops/orders
Host: https://modstagram.com
Sitemap: https://modstagram.com/sitemap.xml
Host: https://www.modstagram.com
Sitemap: https://www.modstagram.com/sitemap.xml

View File

@@ -22,10 +22,18 @@ const messaging = firebase.messaging.isSupported() ? firebase.messaging() : null
if (messaging) {
messaging.onBackgroundMessage((payload) => {
const title = payload.notification?.title || "modstagram";
// عکسِ فرستنده (یا بنرِ دستیِ ادمین برای admin_broadcast) — قبلاً همیشه لوگوی ثابتِ
// اپ بود، صرف‌نظر از اینکه نوتیف مربوط به کدوم کاربره؛ notification.image رو
// utils/pushNotification.js بک‌اند از قبل می‌فرسته (فقط این‌جا نادیده گرفته می‌شد)
const avatarOrBanner = payload.notification?.image;
self.registration.showNotification(title, {
body: payload.notification?.body,
icon: "/images/icons/192x192.png",
icon: avatarOrBanner || "/images/icons/192x192.png",
badge: "/images/icons/192x192.png",
// بنرِ بزرگِ زیرِ متن (برای admin_broadcast که واقعاً قراره «بنر» باشه)، غیر از
// آیکونِ کوچیکِ گردِ کنارِ عنوان — مرورگرهایی که image رو ساپورت نکنن سرشون این رو
// نادیده می‌گیرن، پس بی‌خطره
image: avatarOrBanner || undefined,
data: payload.data || {},
});
});
@@ -33,7 +41,10 @@ if (messaging) {
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = "/settings/notifications";
// بک‌اند وقتی خودش دقیقاً می‌دونه مقصد کجاست (پیام چت، تگِ پست و...) یه مسیرِ نسبیِ
// آماده می‌فرسته (نگاه کن به utils/notifyUser.js/chatHelpers.js: data.link) — وگرنه
// مثلِ قبل می‌ریم صفحه‌ی عمومیِ اعلان‌ها
const url = event.notification.data?.link || "/settings/notifications";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsArr) => {
const existing = clientsArr.find((c) => c.url.includes(url));
@@ -45,6 +56,17 @@ self.addEventListener("notificationclick", (event) => {
const CACHE_NAME = "pwa-cache-v12";
const MAX_CACHED_IMAGES = 200;
async function trimCache(name, maxEntries) {
const cache = await caches.open(name);
const keys = await cache.keys();
const extra = keys.length - maxEntries;
if (extra > 0) {
await Promise.all(keys.slice(0, extra).map((key) => cache.delete(key)));
}
}
const PRECACHE_URLS = [
"/",
"/offline.html",
@@ -124,7 +146,8 @@ self.addEventListener("fetch", (event) => {
const clone = res.clone();
caches
.open(CACHE_NAME)
.then((cache) => cache.put(event.request, clone));
.then((cache) => cache.put(event.request, clone))
.then(() => trimCache(CACHE_NAME, MAX_CACHED_IMAGES));
}
return res;
});

View File

@@ -15,7 +15,7 @@ export async function fetchPostById(
try {
const res = await fetch(primaryUrl, {
cache: "no-store",
next: { revalidate: 45 },
headers,
});
if (res.ok) {
@@ -35,7 +35,7 @@ export async function fetchPostById(
for (const url of fallbackUrls) {
try {
const res = await fetch(url, {
cache: "no-store",
next: { revalidate: 45 },
headers,
});
if (!res.ok) continue;

View File

@@ -20,8 +20,6 @@ export async function fetchPosts(
seedPostId?: string;
sort?: "latest";
reelsTab?: "for_you" | "following" | "saved" | "near_me";
lat?: string;
lng?: string;
heightMin?: string;
heightMax?: string;
weightMin?: string;

View File

@@ -22,6 +22,7 @@ import toast from "react-hot-toast";
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { establishIdentityAtLogin } from "@/lib/e2ee";
function LoginWithUsername() {
const { t, i18n } = useTranslation("common");
@@ -66,6 +67,12 @@ function LoginWithUsername() {
{ noToast: true }
)) as IVerifyOtp;
// قبل از این‌که password از scope خارج بشه، کلیدِ E2EE رو زیرِ همین رمز
// بک‌آپ/بازیابی کن — best-effort، نباید جلویِ خودِ لاگین رو بگیره
if (response.id) {
// [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(response.id, values.password);
}
const loggedIn = await completeLogin(router, response, {
redirectTo: redirectPath,
});

View File

@@ -0,0 +1,177 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { IProfileData } from "@/types/types";
import Image from "next/image";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import Cropper from "react-easy-crop";
import getCroppedImg from "@/helpers/cropImage";
import { Area } from "react-easy-crop";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
// عین verify/avatar/page.tsx (ویزارد تایید هویت)، با این تفاوت که این مرحله
// زودتر آورده شده: همین بعد از نام/نام‌خانوادگی (register/fullname) و قبل از
// صفحه‌ی انتخابِ «ادامه ثبت‌نام / ورود به برنامه» (register/complete) — چون عکس
// همین‌جا آپلود می‌شه، دیگه لازم نیست ویزارد تایید هویت دوباره همین قدم رو بپرسه
// (verify/avatar/page.tsx دیگه از fullname/complete لینک نمی‌شه)
function RegisterAvatarPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const [avatar, setAvatar] = useState<string | null>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [loadingUpload, setLoadingUpload] = useState(false);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const fetchData = async () => {
try {
const response = await request<{ user: IProfileData }>("GET", "/profile");
setAvatar(response?.user?.profile_image || null);
} catch (err) {
console.log(err);
}
};
useEffect(() => {
fetchData();
}, []);
const selectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = (e) => {
const imageDataUrl = e.target?.result as string;
setAvatar(imageDataUrl);
};
reader.readAsDataURL(file);
}
};
const onCropComplete = useCallback(
(_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels);
},
[]
);
const createCroppedImage = async () => {
if (!avatar || !croppedAreaPixels) return null;
const croppedImage = await getCroppedImg(avatar, croppedAreaPixels);
return croppedImage;
};
const goNext = () => router.push("/register/complete");
const uploadImage = async () => {
if (!avatar) {
toast.error(t("auth.selectProfileImage"));
return;
}
setLoadingUpload(true);
const croppedBlob = await createCroppedImage();
if (!croppedBlob) {
toast.error(t("auth.cropError"));
setLoadingUpload(false);
return;
}
const formData = new FormData();
formData.append("profile_image", croppedBlob, "avatar.jpg");
formData.append("mobile", mobile as string);
try {
await request("POST", "/verify/profile_image", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
toast.success(t("auth.imageSavedSuccess"));
goNext();
} catch (err) {
console.log("Upload error:", err);
toast.error(t("auth.imageUploadError"));
setLoadingUpload(false);
}
};
return (
<LocalePageShell>
<AuthPageLayout>
<AuthPageContent>
<span className="text-xl font-bold mb-4">
{t("auth.profileImageTitle")}
</span>
<div className="relative aspect-square w-[250px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
{avatar ? (
<Cropper
image={
avatar.startsWith("data:") ? avatar : IMAGE_BASE_URL + avatar
}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
cropShape="rect"
showGrid={false}
objectFit="contain"
/>
) : (
<label
htmlFor="fileInput"
className="cursor-pointer w-full h-full flex items-center justify-center"
>
<Image
src="/images/icons/gallery-add.svg"
width={120}
height={120}
alt="add profile icon"
/>
</label>
)}
</div>
<input
id="fileInput"
type="file"
accept="image/*"
className="hidden"
onChange={selectImage}
/>
<AuthNextButton className="mt-4">
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
{t("auth.selectEditImage")}
</label>
</AuthNextButton>
</AuthPageContent>
<AuthFormFooter onSkip={goNext} skipDisabled={loadingUpload}>
<AuthNextButton
onClick={uploadImage}
loading={loadingUpload}
disabled={loadingUpload}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</AuthPageLayout>
</LocalePageShell>
);
}
export default RegisterAvatarPage;

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import AuthHead from "@/components/auth/AuthHead";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
@@ -14,12 +14,40 @@ import toast from "react-hot-toast";
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { IProfileData } from "@/types/types";
function RegisterCompletePage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request, loading } = useAxios();
const [action, setAction] = useState<"app" | "continue" | null>(null);
const [user, setUser] = useState<IProfileData | null>(null);
useEffect(() => {
request<{ user: IProfileData }>("GET", "/profile")
.then((res) => setUser(res?.user ?? null))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ");
// مطابق درخواست: عکس پروفایل حالا زودتر، توی خودِ صفحه‌ی register/avatar گرفته
// می‌شه (قبل از این صفحه) — پس دیگه لازم نیست ویزارد تایید هویت با verify/avatar
// شروع بشه؛ مستقیم می‌ره سراغ قدم بعدی (عین navigateHandler توی verify/avatar/page.tsx)
const goToVerifyWizard = async () => {
try {
const response = await request<{ user: IProfileData }>("GET", "/profile");
if (response?.user?.user_type === "user") {
router.push("/verify/expertise");
return;
}
} catch (err) {
console.log(err);
}
router.push("/verify/gender");
};
const finishRegistration = async (next: "app" | "continue") => {
setAction(next);
@@ -30,9 +58,12 @@ function RegisterCompletePage() {
localStorage.setItem("usertype", "user");
if (next === "continue") {
router.push("/verify/avatar");
await goToVerifyWizard();
} else {
router.push(getSafeRedirectPath());
// navigation سخت — همون دلیلِ Logout.tsx: router.push ممکنه نسخه‌ی
// کش‌شده‌ی فید رو از زمانی که هنوز مهمان بودیم (قبل از تکمیلِ ثبت‌نام،
// ریدایرکت‌شده به ورود) دوباره نشون بده، بدون اجرای دوباره‌ی میدل‌ور
window.location.href = getSafeRedirectPath();
}
} catch (err: any) {
toast.error(
@@ -48,32 +79,53 @@ function RegisterCompletePage() {
<AuthPageLayout>
<div className="flex w-full flex-1 flex-col items-center">
<AuthPageContent>
<AuthHead title={t("auth.registerCompleteTitle")} />
<p className="text-sm text-gray-500 text-center max-w-sm mt-2">
<div className="flex flex-col items-center">
<Image
src={
user?.profile_image
? IMAGE_BASE_URL + user.profile_image
: "/images/fake-avatar.png"
}
alt=""
width={72}
height={72}
unoptimized
className="mb-2 h-[72px] w-[72px] rounded-full object-cover bg-gray-100"
/>
{name ? (
<span className="text-base font-bold text-foreground">
{name}
</span>
) : null}
{user?.user_name ? (
<span className="text-sm text-gray-500">{user.user_name}</span>
) : null}
</div>
<p className="text-sm text-gray-500 text-center max-w-sm mt-6">
{t("auth.registerCompleteDesc")}
</p>
</AuthPageContent>
<AuthFormFooter>
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className=" w-full sm:w-auto"
disabled={loading}
loading={loading && action === "continue"}
>
{t("auth.continueRegistration")}
</AuthNextButton>
<div className="flex flex-row gap-3 w-full max-w-sm justify-center items-center">
<AuthNextButton
type="button"
onClick={() => finishRegistration("app")}
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
className="!border-[#16a34a] !text-[#16a34a] flex-1"
disabled={loading}
loading={loading && action === "app"}
>
{t("auth.enterApp")}
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className="!border-[#FF0000] !text-[#FF0000] flex-1"
disabled={loading}
loading={loading && action === "continue"}
>
{t("auth.continueRegistration")}
</AuthNextButton>
</div>
</AuthFormFooter>
</div>

View File

@@ -14,7 +14,6 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import Image from "next/image";
@@ -32,9 +31,6 @@ function FullNamePage() {
const router = useRouter();
const { request, loading } = useAxios();
const [nameSaved, setNameSaved] = useState(false);
const [choiceLoading, setChoiceLoading] = useState<"app" | "continue" | null>(
null
);
const [avatar, setAvatar] = useState<string | null>(null);
const isInstagramProvider =
@@ -68,25 +64,18 @@ function FullNamePage() {
[t]
);
const finishRegistration = async (action: "app" | "continue") => {
setChoiceLoading(action);
// فقط برای ثبت‌نامِ اینستاگرام صدا زده می‌شه (پایین‌تر) — چون اون کاربرها از قبل
// عکس دارن، همون ویزارد تایید هویت (verify/avatar) رو می‌بینن، نه صفحه‌ی جدیدِ
// register/avatar. بقیه‌ی کاربرها بعد از ثبت نام مستقیم به register/avatar می‌رن
const finishRegistrationInstagram = async () => {
try {
await request("POST", "/register/user_type", {
user_type: "user",
});
await request("POST", "/register/user_type", { user_type: "user" });
localStorage.setItem("usertype", "user");
if (action === "continue") {
router.push("/verify/avatar");
} else {
router.push(getSafeRedirectPath());
}
router.push("/verify/avatar");
} catch (err: any) {
toast.error(
err?.response?.data?.message || t("auth.registrationFinishFailed")
);
} finally {
setChoiceLoading(null);
}
};
@@ -117,7 +106,11 @@ function FullNamePage() {
// no manual "enter app or continue" choice, matching the phone/Google flow's
// choice screen being skipped only for this provider.
if (isInstagramProvider) {
await finishRegistration("continue");
await finishRegistrationInstagram();
} else {
// بقیه‌ی کاربرها: به‌جای دکمه‌های «ادامه/ورود به برنامه» زیر همین فرم،
// اول مرحله‌ی آپلود عکس پروفایل میاد
router.push("/register/avatar");
}
} catch (err: any) {
toast.error(err?.response?.data?.message || t("auth.saveNameFailed"));
@@ -196,48 +189,17 @@ function FullNamePage() {
{formik.errors.family}
</small>
)}
{nameSaved && (
<p className="text-sm text-green-600 text-center mt-4">
{isInstagramProvider
? t("auth.instagramContinuingToProfile")
: t("auth.nameSavedChoose")}
</p>
)}
</div>
</AuthPageContent>
<AuthFormFooter>
{!nameSaved ? (
<AuthNextButton
type="submit"
disabled={loading || !formik.values.name.trim()}
loading={loading}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
) : !isInstagramProvider ? (
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className=" w-full sm:w-auto"
disabled={Boolean(choiceLoading)}
loading={choiceLoading === "continue"}
>
{t("auth.continueRegistration")}
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => finishRegistration("app")}
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
disabled={Boolean(choiceLoading)}
loading={choiceLoading === "app"}
>
{t("auth.enterApp")}
</AuthNextButton>
</div>
) : null}
<AuthNextButton
type="submit"
disabled={nameSaved || loading || !formik.values.name.trim()}
loading={loading}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
</AuthFormFooter>
</form>
</AuthPageLayout>

View File

@@ -46,12 +46,17 @@ function Register() {
validationSchema: schema,
onSubmit: async (values) => {
const mobile = normalizeMobileInput(values.mobile);
// سیستم «معرفی به دوستان»: اگه کاربر قبل از این از یه لینکِ معرفی
// (/users/username?ref=CODE) اومده، ReferralCapture.tsx همین‌جا توی
// localStorage نگهش داشته — بک‌اند (registerController.js) این کد رو به
// referred_by وصل می‌کنه، پاداش بعد از تکمیلِ کاملِ ثبت‌نام پرداخت می‌شه
const referralCode = localStorage.getItem("referral_code");
try {
await request(
"POST",
"/register",
{ mobile },
{ mobile, ...(referralCode ? { referral_code: referralCode } : {}) },
{ noToast: true }
);
localStorage.setItem("mobile", mobile);

View File

@@ -17,6 +17,8 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { getStoredUserId } from "@/lib/auth/session";
import { establishIdentityAtLogin } from "@/lib/e2ee";
function PasswordPage() {
const { t } = useTranslation("common");
@@ -53,6 +55,9 @@ function PasswordPage() {
(await request("POST", "/register/password", {
new_password: values.password,
})) as IVerifyOtp;
// اولین رمزِ عبورِ این کاربره — همین الان کلیدِ E2EE رو زیرش بک‌آپ بگیر
const userId = getStoredUserId();
// [E2EE] موقتاً غیرفعال — if (userId) void establishIdentityAtLogin(userId, values.password);
router.push("/register/fullname");
} catch (err: any) {
const message =

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,10 +98,10 @@ 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"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
>
@@ -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

@@ -26,10 +26,11 @@ import { Comments } from "@/components/academy/CommentsModal";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { usePathname } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Cookies from "js-cookie";
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
import { AcademyCourseDetailSkeleton } from "@/components/academy/AcademySkeletons";
import { useTranslation } from "react-i18next";
import BackButton from "@/components/ui/BackButton";
@@ -85,26 +86,49 @@ interface CourseDetailProps {
initialIsPurchased?: boolean;
}
interface MediaFile {
id: string;
title: string;
file: File | null;
preview?: string;
/** شکلِ واقعیِ ویدیوهایی که از /academy/academy/course/content میان و توی
* لیستِ سرفصل‌ها + پلیر رندر می‌شن (نه فرم‌آپلود — MOCK_VIDEOS[0] هم همین شکله) */
interface CourseVideo {
_id: string;
file_name: string;
course_video: string;
type: "image" | "video";
isFree?: boolean;
is_free?: boolean;
is_free: boolean;
type: string;
duration: string;
}
/** این اندپوینت‌ها ساختارهای مختلفی برمی‌گردونن (بعضاً حتی خودِ کد چند شکلِ
* احتمالی رو با || پشتِ‌سرِ‌هم چک می‌کنه) — به‌جای برگردوندنِ generic هرکدوم به
* یه interface دقیقِ جدا (که نیاز به rewrite بک‌اند برای یکدست‌کردن داشت)، یه
* تایپِ عمومیِ صریح که همون فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده */
type ApiResponse = {
success?: boolean;
message?: string;
data?: any;
[key: string]: any;
};
export default function CourseDetail({
initialIsPurchased = false,
}: CourseDetailProps) {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const paymentResult = searchParams.get("payment");
useEffect(() => {
if (!paymentResult) return;
if (paymentResult === "success") toast.success(t("shops.paymentSuccessTitle"));
else toast.error(t("shops.paymentFailedTitle"));
router.replace(window.location.pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paymentResult]);
const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]);
const [isPurchased, setIsPurchased] = useState(initialIsPurchased);
const [purchasing, setPurchasing] = useState(false);
const [showContactInfoModal, setShowContactInfoModal] = useState(false);
const [course] = useState(MOCK_COURSE);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
const pathname = usePathname();
const match = pathname.match(/\/academy\/([^\/]+)/);
@@ -116,6 +140,13 @@ export default function CourseDetail({
const _id = courses[0]?._id;
useEffect(() => {
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [comments, setComments] = useState<Comments[]>([]);
const [totalComments, setTotalComments] = useState(0);
const [averageRate, setAverageRate] = useState(0);
@@ -126,7 +157,7 @@ export default function CourseDetail({
const [sortBy, setSortBy] = useState<string>("createdAt");
const [sortOrder, setSortOrder] = useState<string>("desc");
const [usersCache, setUsersCache] = useState<Record<string, any>>({});
const [videoFields, setVideoFields] = useState<MediaFile[]>([]);
const [videoFields, setVideoFields] = useState<CourseVideo[]>([]);
const [rating, setRating] = useState<number>(0);
@@ -138,11 +169,11 @@ export default function CourseDetail({
try {
const getAcademyCourses = (await request(
const getAcademyCourses = await request<ApiResponse>(
"GET",
`/academy/academy/course/content?courseId=${id}&page=1&limit=20`,
{}
)) as MediaFile;
);
setVideoFields(getAcademyCourses.data.courses);
setSelectedVideo(getAcademyCourses.data.courses[0])
@@ -158,7 +189,7 @@ export default function CourseDetail({
useEffect(() => {
const getAcademyCourses = async () => {
try {
const getCoursespayment = await request(
const getCoursespayment = await request<ApiResponse>(
"GET", // تغییر به GET
`/academy/course/checkCoursePurchase/${id}` // ارسال ID در مسیر
);
@@ -187,7 +218,7 @@ export default function CourseDetail({
}
try {
const response = await request("GET", `/profile/${userId}`);
const response = await request<ApiResponse>("GET", `/profile/${userId}`);
if (response?.user) {
const userData = response.user;
setUsersCache((prev) => ({ ...prev, [userId]: userData }));
@@ -204,7 +235,7 @@ export default function CourseDetail({
if (!_id) return;
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/${_id}/comments/count`
);
@@ -223,15 +254,15 @@ export default function CourseDetail({
setLoading(true);
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}`
);
let sum = 0;
let count = 0;
response.data.stats.rateDistribution.forEach(element => {
response.data.stats.rateDistribution.forEach((element: number) => {
sum += Number(element);
count++;
});
@@ -242,7 +273,7 @@ export default function CourseDetail({
setRating(roundedRating);
if (response?.success) {
let newComments = response.data.comments;
let newComments: any[] = response.data.comments;
const pagination = response.data.pagination;
if (newComments && newComments.length > 0) {
@@ -340,7 +371,7 @@ export default function CourseDetail({
const getAcademyCourses = async () => {
setCourseLoading(true);
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`
);
@@ -387,46 +418,7 @@ export default function CourseDetail({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courses[0]?._id]);
// خرید دوره
// const handlePurchase = async () => {
// setPurchasing(true);
// try {
// const response = await request(
// "POST",
// "/academy/academy/course/payment-web",
// {
// courseId: id, // یا _id که از courses[0]?._id می‌آید
// }
// );
// console.log("پاسخ کامل سرور:", response);
// // بررسی موفقیت آمیز بودن درخواست
// if (response?.success && response?.paymentUrl) {
// // هدایت کاربر به درگاه پرداخت زرین‌پال
// window.location.href = response.paymentUrl;
// } else {
// // نمایش خطا به کاربر
// toast.error(response?.message || "خطا در اتصال به درگاه پرداخت");
// setPurchasing(false);
// }
// } catch (error) {
// console.error("خطا در خرید:", error);
// toast.error("خطا در ارتباط با سرور");
// setPurchasing(false);
// }
// };
const handleBuyCourse = async () => {
const token = Cookies.get("token");
if (!token) {
toast.error(t("academy.course.loginToBuy"));
return;
}
const startPayment = async (useWallet: boolean) => {
setPurchasing(true);
const loadingToastId = toast.loading(
courses[0]?.is_free
@@ -435,8 +427,9 @@ export default function CourseDetail({
);
try {
const response = await request("POST", "/academy/academy/course/payment-web", {
const response = await request<ApiResponse>("POST", "/academy/academy/course/payment-web", {
courseId: id,
useWallet,
});
toast.dismiss(loadingToastId);
@@ -447,6 +440,12 @@ export default function CourseDetail({
return;
}
if (response?.paidFromWallet) {
setIsPurchased(true);
toast.success(t("wallet.paidFromWalletSuccess"));
return;
}
const paymentUrl =
response?.paymentUrl ||
response?.data?.paymentUrl ||
@@ -475,96 +474,23 @@ export default function CourseDetail({
}
};
const handlePurchase = async (plan: {
duration: string;
label: string;
price: number;
discount?: number;
}) => {
let loadingToastId = null;
try {
// عین صفحه‌ی درخواست همکاری/سفارش فروشگاه: قبل پرداخت، اگه موجودی کیف پول داره
// بپرسه استفاده کنه یا نه
const handleBuyCourse = async () => {
const token = Cookies.get("token");
const token = Cookies.get("token");
if (!token) {
toast.error(t("academy.course.loginToBuy"));
return
}
// نمایش لودینگ
loadingToastId = toast.loading(t("academy.course.connectingPayment"));
const response = await request(
"POST",
"/academy/academy/course/payment-web",
{
courseId: id, // یا _id که از courses[0]?._id می‌آید
}
);
// بستن لودینگ
toast.dismiss(loadingToastId);
// پردازش پاسخ (ساختارهای مختلف احتمالی)
let authority = null;
let paymentUrl = null;
if (response?.data?.authority) {
authority = response.data.authority;
paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
} else if (response?.authority) {
authority = response.authority;
paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
} else if (response?.data?.paymentUrl) {
paymentUrl = response.data.paymentUrl;
} else if (response?.paymentUrl) {
paymentUrl = response.paymentUrl;
}
if (paymentUrl) {
window.open(paymentUrl, "_blank", "noopener,noreferrer");
toast.success(t("academy.course.redirectedToPayment"));
} else {
console.error("ساختار پاسخ نامعتبر:", response);
toast.error(
response?.data?.message ||
response?.message ||
t("academy.course.paymentStartError")
);
}
} catch (err: any) {
// بستن لودینگ در صورت خطا
if (loadingToastId) {
toast.dismiss(loadingToastId);
}
console.error("خطا در شروع پرداخت:", err);
// استخراج پیام خطا
let errorMessage = t("academy.course.paymentStartError");
if (err?.response?.data?.message) {
errorMessage = err.response.data.message;
} else if (err?.response?.data?.error) {
errorMessage = err.response.data.error;
} else if (err?.message) {
errorMessage = err.message;
}
if (errorMessage.includes("amount") || errorMessage.includes("مبلغ")) {
errorMessage = t("academy.course.invalidAmount");
} else if (errorMessage.includes("merchant")) {
errorMessage = t("academy.course.gatewayConfigError");
}
toast.error(errorMessage);
if (!token) {
toast.error(t("academy.course.loginToBuy"));
return;
}
};
if (!courses[0]?.is_free && walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
await startPayment(false);
};
// پخش ویدیو
const handlePlayVideo = (video: (typeof MOCK_VIDEOS)[0]) => {
@@ -589,7 +515,7 @@ export default function CourseDetail({
const finalPrice = courses[0]?.is_free
? 0
: courses[0]?.offerNumber > 0
? courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
? Number(courses[0]?.price) * (1 - courses[0]?.offerNumber / 100)
: course.price;
const courseContactInfo = courses[0]?.contactInfo;
@@ -754,8 +680,8 @@ useEffect(() => {
{/* آواتار کاربر */}
<div className="shrink-0">
<ProfileAvatar
src={item?.user_id?.profile_image}
alt={item?.user_id?.user_name || t("common.user")}
src={typeof item?.user_id === "object" ? item.user_id?.profile_image : undefined}
alt={(typeof item?.user_id === "object" ? item.user_id?.user_name : undefined) || t("common.user")}
size="chat"
rounded="full"
fallback="/images/default-avatar.png"
@@ -768,8 +694,8 @@ useEffect(() => {
{/* اطلاعات کاربر و امتیاز */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<span className="font-semibold text-sm dark:text-white inline-flex items-center gap-1">
{item?.user_id?.user_name || t("common.user")}
<VerificationBadge verifyBadge={item?.user_id?.verify_badge} />
{(typeof item?.user_id === "object" ? item.user_id?.user_name : undefined) || t("common.user")}
<VerificationBadge verifyBadge={typeof item?.user_id === "object" ? item.user_id?.verify_badge : undefined} />
</span>
{item?.rate > 0 && (
@@ -853,12 +779,12 @@ useEffect(() => {
<div className="flex items-center gap-3 flex-wrap">
<span className="text-3xl font-bold text-[#FF107D]">
{Math.round(
courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
Number(courses[0]?.price) * (1 - courses[0]?.offerNumber / 100)
).toLocaleString()}{" "}
{t("settings.toman")}
</span>
<span className="text-sm line-through text-gray-400">
{Math.round(courses[0]?.price).toLocaleString()}{" "}
{Math.round(Number(courses[0]?.price)).toLocaleString()}{" "}
{t("settings.toman")}
</span>
<span className="bg-red-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
@@ -871,7 +797,7 @@ useEffect(() => {
</div>
) : (
<span className="text-3xl font-bold text-[#FF107D]">
{Math.round(courses[0]?.price || course.price).toLocaleString()}{" "}
{Math.round(Number(courses[0]?.price) || course.price).toLocaleString()}{" "}
{t("settings.toman")}
</span>
)}
@@ -1044,6 +970,16 @@ useEffect(() => {
}}
/>
)}
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={Math.round(finalPrice) || 0}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void startPayment(useWallet);
}}
/>
</div>
);
}

View File

@@ -1,8 +1,11 @@
import { Metadata } from "next";
import { cache } from "react";
import { notFound } from "next/navigation";
import CourseDetail from "./CourseClient";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { buildCourseDetailSeo } from "@/lib/buildCourseDetailSeo";
import { buildCourseDetailSeo, buildCourseJsonLd } from "@/lib/buildCourseDetailSeo";
import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
@@ -10,20 +13,33 @@ type Props = {
params: Promise<{ id: string; title: string }>;
};
// Deduped per request: generateMetadata() and the page body's JSON-LD both need
// this course — cache() avoids firing the fetch to the backend twice.
const loadCourse = cache(async (id: string) => {
try {
const res = await fetch(
`${getApiBaseUrl()}/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`,
{ next: { revalidate: 45 } }
);
const response = await res.json();
return (
response?.data?.courses?.[0] ||
response?.courses?.[0] ||
response?.[0] ||
null
);
} catch (error) {
console.error("خطا در دریافت اطلاعات دوره:", error);
return null;
}
});
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id, title: slugTitle } = await params;
try {
const res = await fetch(
`${getApiBaseUrl()}/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`,
{ cache: "no-store" }
);
const response = await res.json();
const course =
response?.data?.courses?.[0] ||
response?.courses?.[0] ||
response?.[0];
const course = await loadCourse(id);
if (course) {
const lang = await getServerLanguage();
@@ -53,5 +69,22 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function CoursePage({ params }: Props) {
const { id } = await params;
return <CourseDetail courseId={id} />;
const lang = await getServerLanguage();
const course = await loadCourse(id);
if (!course) notFound();
return (
<>
{course && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: safeJsonLdString(buildCourseJsonLd(course, id, lang)),
}}
/>
)}
<CourseDetail courseId={id} />
</>
);
}

View File

@@ -23,6 +23,7 @@ function FailedProject() {
const [project, setProject] = useState<Project>();
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
const [price, setPrice] = useState("");
const [paying, setPaying] = useState(false);
const getDisplayTypeLabel = (projectType?: string) => {
if (projectType === "normal") {
@@ -68,6 +69,9 @@ function FailedProject() {
}, [project, typeList]);
const payHandler = async () => {
// قبلاً هیچ گاردی نداشت — دابل‌کلیک دو درخواستِ initiate-payment-web هم‌زمان می‌فرستاد
if (paying) return;
setPaying(true);
try {
const response = await request<{ authority?: string; type?: string }>(
"POST",
@@ -82,10 +86,13 @@ 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);
setPaying(false);
}
};
@@ -106,7 +113,7 @@ function FailedProject() {
</div>
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
{project && <MainProjectCard project={project} />}
<div className="flex flex-col items-center gap-4 mt-4">
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<RoundedDiv className="p-2 w-full">
{getDisplayTypeLabel(project?.project_type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
@@ -120,8 +127,9 @@ function FailedProject() {
onClick={() => {
payHandler();
}}
disabled={paying}
variant="primary"
className="w-32 h-9"
className="flex-1 h-9"
>
{t("academy.payment.retry")}
</RoundedButton>

View File

@@ -39,6 +39,15 @@ interface AcademyOwner {
user_score?: string | number;
}
/** این اندپوینت‌ها ساختارهای مختلفی برمی‌گردونن — یه تایپِ عمومیِ صریح که همون
* فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده (بجای {} ضمنی) */
type ApiResponse = {
academy?: any;
courses?: any[];
data?: any;
[key: string]: any;
};
interface AcademyData {
_id: string;
userId?: string;
@@ -138,7 +147,7 @@ export default function AcademyProfileClient() {
setIsLoading(true);
try {
const response = await request("POST", "/academy/findAcademyById", {
const response = await request<ApiResponse>("POST", "/academy/findAcademyById", {
_id: academyId,
});
const academyData =
@@ -159,7 +168,7 @@ export default function AcademyProfileClient() {
if (!academyId) return;
setIsLoadingCourses(true);
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/academy/get/getAcademyCourse/${academyId}?page=1&limit=50&status=accept`
);

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

@@ -1,8 +1,9 @@
import { Metadata } from "next";
import { Suspense } from "react";
import { Suspense, cache } from "react";
import { cookies } from "next/headers";
import ProjectDetailClient from "./ProjectDetailClient";
import PageLoader from "@/components/ui/PageLoader";
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { fetchProject } from "@/api/fetchProject";
import {
@@ -15,13 +16,16 @@ type ProjectDetailPageProps = {
params: Promise<{ id: string; title: string }>;
};
// Deduped per request — see loadUser in users/[username]/page.tsx for rationale.
const loadProject = cache((id: string, token: string) => fetchProject(id, token));
export async function generateMetadata({
params,
}: ProjectDetailPageProps): Promise<Metadata> {
const { id, title } = await params;
const token = (await cookies()).get("token")?.value || "";
const lang = await getServerLanguage();
const project = await fetchProject(id, token);
const project = await loadProject(id, token);
if (project) {
const seo = buildProjectDetailSeo(project, lang);
@@ -50,11 +54,21 @@ export async function generateMetadata({
export default async function ProjectDetailPage({
params,
}: ProjectDetailPageProps) {
const { id } = await params;
const { id, title } = await params;
const token = (await cookies()).get("token")?.value || "";
const lang = await getServerLanguage();
const project = await loadProject(id, token);
const h1 = project
? buildProjectDetailSeo(project, lang).title
: buildProjectDetailFallbackSeo(decodeURIComponent(title || ""), id, lang).title;
return (
<Suspense fallback={<PageLoader className="min-h-[50vh]" />}>
<ProjectDetailClient id={id} />
</Suspense>
<>
<h1 className="sr-only">{h1}</h1>
<Suspense fallback={<PageLoader className="min-h-[50vh]" />}>
<ProjectDetailClient id={id} />
</Suspense>
<GuestAuthPromptModal />
</>
);
}

View File

@@ -0,0 +1,15 @@
import IOSSpinner from "@/components/ui/IOSSpinner";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
export default async function ProjectsLoading() {
const lang = await getServerLanguage();
const label = translateCommon(lang, "common.loading");
return (
<div className="mx-auto flex min-h-[50vh] w-full max-w-xl flex-col items-center justify-center gap-3 px-4">
<IOSSpinner size={28} color="#ff107d" />
<span className="text-sm text-neutral-500">{label}</span>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } from "react";
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import MainProjectCard from "@/components/projects/MainProjectCard";
import { useTranslation } from "react-i18next";
@@ -18,11 +18,13 @@ function FailedProject() {
const { t } = useTranslation("common");
const { request } = useAxios();
const searchParams = useSearchParams();
const router = useRouter();
const projectId = searchParams.get("projectId");
const [project, setProject] = useState<Project>();
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
const [price, setPrice] = useState("");
const [paying, setPaying] = useState(false);
const getDisplayTypeLabel = (projectType?: string) => {
if (projectType === "normal" || projectType === "free") {
@@ -68,6 +70,9 @@ function FailedProject() {
}, [project, typeList]);
const payHandler = async () => {
// قبلاً هیچ گاردی نداشت — دابل‌کلیک دو درخواستِ initiate-payment-web هم‌زمان می‌فرستاد
if (paying) return;
setPaying(true);
try {
const response = await request<{
authority?: string;
@@ -88,12 +93,14 @@ function FailedProject() {
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
} else {
toast.error(response?.message || t("projects.payment.initFailed"));
setPaying(false);
}
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { message?: string } } };
toast.error(
axiosErr?.response?.data?.message || t("projects.payment.initFailed")
);
setPaying(false);
}
};
@@ -114,7 +121,7 @@ function FailedProject() {
</div>
<div className="mt-10 w-full px-4 text-sm font-semibold md:text-base">
{project && <MainProjectCard project={project} />}
<div className="mt-4 flex flex-col items-center gap-4">
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<RoundedDiv className="w-full p-2">
{getDisplayTypeLabel(project?.project_type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
@@ -126,11 +133,18 @@ function FailedProject() {
</RoundedDiv>
<RoundedButton
onClick={payHandler}
disabled={paying}
variant="primary"
className="h-9 w-32"
className="h-9 flex-1"
>
{t("projects.payment.retry")}
</RoundedButton>
<RoundedButton
onClick={() => router.push("/settings/workroom?filter=unpaid")}
className="h-9 flex-1"
>
{t("common.back")}
</RoundedButton>
</div>
</div>
</Container>

View File

@@ -8,7 +8,6 @@ import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import MainProjectCard from "@/components/projects/MainProjectCard";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -124,11 +123,13 @@ function SuccessProject() {
</RoundedDiv>
)}
<p className="my-5">{t("projects.payment.reviewNotice")}</p>
<Link href={"/settings/workroom"}>
<RoundedButton variant="primary" className="h-9 w-32">
{t("projects.payment.workroom")}
</RoundedButton>
</Link>
<RoundedButton
variant="primary"
className="h-9 w-32"
onClick={() => (window.location.href = projectId ? `/projects/${projectId}` : "/settings/workroom")}
>
{t("settings.confirm")}
</RoundedButton>
</div>
</div>
</Container>

View File

@@ -12,7 +12,7 @@ import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import SpecialistCarousel from "@/components/projects/SpecialistCarousel";
import useAxios from "@/hooks/useAxios";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
@@ -70,9 +70,6 @@ function SpecialistCard({
const media = user.previewPost
? getPostMedia(user.previewPost as Post)
: null;
const profileUrl = user.profile_image
? buildStorageUrl(user.profile_image)
: null;
const isMain = variant === "main";
const profileHref = user.user_name ? `/users/${user.user_name}` : "#";
@@ -97,7 +94,6 @@ function SpecialistCard({
fill
className="object-cover"
sizes={isMain ? "280px" : "120px"}
unoptimized
/>
)
) : (
@@ -131,26 +127,13 @@ function SpecialistCard({
isMain ? "px-4 pb-5" : "px-2 pb-3"
)}
>
<div
className={cn(
"relative mb-2 overflow-hidden rounded-full ring-2 ring-white/90",
isMain ? "h-16 w-16" : "h-9 w-9"
)}
>
{profileUrl ? (
<Image
src={profileUrl}
alt=""
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">
{displayName.charAt(0)}
</div>
)}
<div className="mb-2 overflow-hidden rounded-full ring-2 ring-white/90">
<ProfileAvatar
src={user.profile_image}
userId={user._id}
sizePx={isMain ? 64 : 36}
rounded="full"
/>
</div>
{isMain ? (

View File

@@ -1,13 +1,16 @@
import { BASE_URL } from "@/components/main/BaseUrl";
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import BillboardDetailContent from "@/components/billboards/BillboardDetailContent";
import BillboardNotFound from "@/components/billboards/BillboardNotFound";
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
import { IAdvertising, IRate } from "@/types/types";
import { cookies } from "next/headers";
import { cache } from "react";
import { notFound } from "next/navigation";
import { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getLocaleBundle } from "@/lib/i18n/resources";
import { getServerLanguage } from "@/lib/i18n/server";
import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
import { SITE_URL } from "@/config/site";
interface IBillboardProps {
params: Promise<{ id: string; title: string }>;
@@ -78,6 +81,39 @@ export async function generateMetadata({
});
}
function buildBillboardJsonLd(billboard: IAdvertising, id: string) {
const address = [
billboard.province?.name,
billboard.city?.name,
billboard.neighbourhood,
]
.filter(Boolean)
.join(" - ");
const url = `${SITE_URL}/billboards/${id}`;
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": url,
url,
name: billboard.title,
description: billboard.description || billboard.title,
image: billboard.images?.map((path) => `${IMAGE_BASE_URL}${path}`),
address: address
? { "@type": "PostalAddress", addressLocality: address, addressCountry: "IR" }
: undefined,
...(billboard.averageRating && billboard.ratingsCount
? {
aggregateRating: {
"@type": "AggregateRating",
ratingValue: billboard.averageRating,
reviewCount: billboard.ratingsCount,
},
}
: {}),
};
}
async function BillboardPage({ params }: IBillboardProps) {
const { id } = await params;
const token = (await cookies()).get("token")?.value || "";
@@ -85,13 +121,24 @@ async function BillboardPage({ params }: IBillboardProps) {
const data = await getBillboardData(id, token);
if (!data || !data.advertising) {
return <BillboardNotFound />;
notFound();
}
const billboard = data.advertising as IAdvertising;
const rate = data.rate as IRate;
return <BillboardDetailContent billboard={billboard} rate={rate} />;
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: safeJsonLdString(buildBillboardJsonLd(billboard, id)),
}}
/>
<BillboardDetailContent billboard={billboard} rate={rate} />
<GuestAuthPromptModal />
</>
);
}
export default BillboardPage;

View File

@@ -26,6 +26,7 @@ function BillboardPayment({ params }: IBillboardProps) {
const [advertising, setAdvertising] = useState<IAdvertising>();
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
const [price, setPrice] = useState("");
const [paying, setPaying] = useState(false);
useEffect(() => {
const fetchAd = async () => {
@@ -50,6 +51,9 @@ function BillboardPayment({ params }: IBillboardProps) {
}, [id, request]);
const payHandler = async () => {
// قبلاً هیچ گاردی نداشت — دابل‌کلیک دو درخواستِ initiate-payment-web هم‌زمان می‌فرستاد
if (paying) return;
setPaying(true);
try {
const response = await request<{ authority?: string }>(
"POST",
@@ -66,10 +70,13 @@ 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);
setPaying(false);
}
};
@@ -101,6 +108,7 @@ function BillboardPayment({ params }: IBillboardProps) {
<RoundedButton
variant="primary"
onClick={payHandler}
disabled={paying}
className="h-9 w-32"
>
{t("billboards.payment.title")}

View File

@@ -27,6 +27,7 @@ function FailedBillboard() {
const price = searchParams.get("price") || "0";
const [advertising, setAdvertising] = useState<IAdvertising>();
const [paying, setPaying] = useState(false);
useEffect(() => {
const fetchAd = async () => {
@@ -39,7 +40,11 @@ function FailedBillboard() {
fetchAd();
}, [projectId, request]);
// قبلاً هیچ‌کدومِ این دو تابع گاردِ submitting نداشتن — دابل‌کلیکِ «تلاشِ دوباره»
// دو درخواستِ پرداختِ هم‌زمان می‌فرستاد
const payHandler = async () => {
if (paying) return;
setPaying(true);
try {
const response = await request<{ authority?: string }>(
"POST",
@@ -56,14 +61,19 @@ 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);
setPaying(false);
}
};
const republishHandler = async () => {
if (paying) return;
setPaying(true);
try {
const response = await request<{ authority?: string }>(
"POST",
@@ -80,10 +90,13 @@ 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);
setPaying(false);
}
};
@@ -104,7 +117,7 @@ function FailedBillboard() {
</div>
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
{advertising && <MainBillboardCard billboard={advertising} />}
<div className="flex flex-col items-center gap-4 mt-4">
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<RoundedDiv className="p-2 w-full">
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
@@ -122,11 +135,18 @@ function FailedBillboard() {
payHandler();
}
}}
disabled={paying}
variant="primary"
className="w-32 h-9"
className="flex-1 h-9"
>
{t("billboards.payment.retry")}
</RoundedButton>
<RoundedButton
onClick={() => router.push("/settings/my-billboards?filter=unpaid")}
className="flex-1 h-9"
>
{t("common.back")}
</RoundedButton>
</div>
</div>
</Container>

View File

@@ -12,7 +12,6 @@ import PageTitle from "@/components/settings/PageTitle";
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
function SuccessBillboard() {
@@ -59,11 +58,13 @@ function SuccessBillboard() {
{Number(price).toLocaleString()} {t("settings.toman")}
</RoundedDiv>
<p className="my-5">{t("billboards.payment.registeredMessage")}</p>
<Link href={"/settings/my-billboards"}>
<RoundedButton variant="primary" className="w-32 h-9">
{t("billboards.payment.myBillboards")}
</RoundedButton>
</Link>
<RoundedButton
variant="primary"
className="w-32 h-9"
onClick={() => (window.location.href = projectId ? `/settings/my-billboards/${projectId}/billboard` : "/settings/my-billboards")}
>
{t("settings.confirm")}
</RoundedButton>
</div>
</div>
</Container>

View File

@@ -1,11 +1,12 @@
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import AdvertisingProfileContent from "@/components/billboards/AdvertisingProfileContent";
import BillboardAdNotFound from "@/components/billboards/BillboardAdNotFound";
import { IAdvertisingProfile } from "@/types/types";
import { Metadata } from "next";
import { cache } from "react";
import { notFound } from "next/navigation";
import { getLocaleBundle } from "@/lib/i18n/resources";
import { getServerLanguage } from "@/lib/i18n/server";
import { SITE_URL } from "@/config/site";
interface IUserProps {
params: Promise<{ id: string[] }>;
@@ -57,14 +58,14 @@ export async function generateMetadata({ params }: IUserProps): Promise<Metadata
openGraph: {
title: finalTitle,
description,
url: `https://modstagram.com/billboards/${id}`,
url: `${SITE_URL}/billboards/${id}`,
siteName: "Modstagram",
images: [{ url: image }],
locale: lang === "fa" ? "fa_IR" : "en_US",
type: "article",
},
alternates: {
canonical: `https://modstagram.com/billboards/${id}`,
canonical: `${SITE_URL}/billboards/${id}`,
},
};
}
@@ -74,7 +75,7 @@ export default async function AdvertisingProfilePage({ params }: IUserProps) {
const id = idSegments?.[0] || "";
const profile = await getProfile(id);
if (!profile) return <BillboardAdNotFound />;
if (!profile) notFound();
return <AdvertisingProfileContent profile={profile} id={id} />;
}

View File

@@ -1,11 +1,12 @@
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import AdvertisingProfileContent from "@/components/billboards/AdvertisingProfileContent";
import BillboardAdNotFound from "@/components/billboards/BillboardAdNotFound";
import { IAdvertisingProfile } from "@/types/types";
import { Metadata } from "next";
import { cache } from "react";
import { notFound } from "next/navigation";
import { getLocaleBundle } from "@/lib/i18n/resources";
import { getServerLanguage } from "@/lib/i18n/server";
import { SITE_URL } from "@/config/site";
interface IUserProps {
params: Promise<{ id: string }>;
@@ -56,14 +57,14 @@ export async function generateMetadata({ params }: IUserProps): Promise<Metadata
openGraph: {
title: finalTitle,
description,
url: `https://modstagram.com/billboards/${id}`,
url: `${SITE_URL}/billboards/${id}`,
siteName: "Modstagram",
images: [{ url: image }],
locale: lang === "fa" ? "fa_IR" : "en_US",
type: "article",
},
alternates: {
canonical: `https://modstagram.com/billboards/${id}`,
canonical: `${SITE_URL}/billboards/${id}`,
},
};
}
@@ -72,7 +73,7 @@ export default async function AdvertisingProfilePage({ params }: IUserProps) {
const { id } = await params;
const profile = await getProfile(id);
if (!profile) return <BillboardAdNotFound />;
if (!profile) notFound();
return <AdvertisingProfileContent profile={profile} id={id} />;
}

View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next";
// این صفحه یه فرم رزرو شخصی‌شده برای هر کاربره — محتوای یکتا و قابل‌رتبه‌بندی
// نداره (خودِ پروفایل کاربر جای درستیه که ایندکس بشه)، پس noindex می‌کنیم تا
// ایندکس گوگل با صدها صفحه‌ی تقریباً یکسان پر نشه.
export const metadata: Metadata = {
robots: { index: false, follow: true },
};
export default function BookingUserLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -8,6 +8,7 @@ import {
buildPostSeoTitle,
} from "@/lib/postSlug";
import ExploreReelClient from "./ExploreReelClient";
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
@@ -44,5 +45,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function ExploreReelPage({ params }: Props) {
const { id } = await params;
return <ExploreReelClient id={id} />;
return (
<>
<ExploreReelClient id={id} />
<GuestAuthPromptModal />
</>
);
}

View File

@@ -9,9 +9,9 @@ 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";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
const PRESET_AMOUNTS = [2000, 5000, 10000, 15000, 20000, 50000, 100000];
const CUSTOM_MIN = 5000;
@@ -25,7 +25,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>(
@@ -35,6 +34,8 @@ function GiftPage({ params }: GiftPageProps) {
const [isCustom, setIsCustom] = useState(false);
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
useEffect(() => {
const fetchUser = async () => {
@@ -45,6 +46,10 @@ function GiftPage({ params }: GiftPageProps) {
setUserDetail(response?.user);
};
void fetchUser();
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, request]);
const finalAmount = useMemo(() => {
@@ -58,19 +63,30 @@ function GiftPage({ params }: GiftPageProps) {
const canSubmit = finalAmount > 0 && isCustomValid && !submitting;
const handleSubmit = async () => {
const handleSubmit = async (useWallet: boolean) => {
if (!canSubmit) return;
setSubmitting(true);
try {
const response = await request<{ authority?: string; paymentUrl?: string }>(
"POST",
"/gifts/initiate",
{ receiverId: userId, amount: finalAmount, message }
);
const response = await request<{
authority?: string;
paymentUrl?: string;
paidFromWallet?: boolean;
}>("POST", "/gifts/initiate", {
receiverId: userId,
amount: finalAmount,
message,
useWallet,
});
if (response?.paidFromWallet) {
toast.success(t("wallet.paidFromWalletSuccess"));
return;
}
// لینک خارجی — 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"));
}
@@ -84,6 +100,15 @@ function GiftPage({ params }: GiftPageProps) {
}
};
const handlePayClick = () => {
if (!canSubmit) return;
if (walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
void handleSubmit(false);
};
return (
<Container>
<div className="mx-3 mb-10">
@@ -167,11 +192,21 @@ function GiftPage({ params }: GiftPageProps) {
variant="primary"
className="px-8 py-2"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
onClick={handlePayClick}
>
{t("giftPage.payButton")}
</RoundedButton>
</div>
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={finalAmount}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void handleSubmit(useWallet);
}}
/>
</div>
</Container>
);

View File

@@ -1,6 +1,13 @@
import type { Metadata } from "next";
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
// فرم ارسال هدیه به یه کاربر خاص — محتوای یکتای قابل‌رتبه‌بندی نداره (خودِ
// پروفایل کاربر جای درستیه که ایندکس بشه)، پس noindex می‌کنیم.
export const metadata: Metadata = {
robots: { index: false, follow: true },
};
export default function GiftLayout({
children,
}: {

View File

@@ -4,13 +4,14 @@ import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
function GiftPaymentFailed() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const receiverId = searchParams.get("receiverId");
@@ -28,13 +29,18 @@ function GiftPaymentFailed() {
/>
<span>{t("giftPage.paymentFailed.message")}</span>
</div>
{receiverId ? (
<Link className="mt-8 flex flex-col items-center" href={`/gift/${receiverId}`}>
<RoundedButton variant="primary" className="h-9 w-32">
{t("giftPage.paymentFailed.retry")}
</RoundedButton>
</Link>
) : null}
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
{receiverId ? (
<Link href={`/gift/${receiverId}`} className="flex-1">
<RoundedButton variant="primary" className="h-9 w-full">
{t("giftPage.paymentFailed.retry")}
</RoundedButton>
</Link>
) : null}
<RoundedButton variant="default" className="h-9 flex-1" onClick={() => router.push(receiverId ? `/gift/${receiverId}` : "/")}>
{t("giftPage.paymentFailed.back")}
</RoundedButton>
</div>
</Container>
</LocalePageShell>
);

View File

@@ -32,16 +32,15 @@ function GiftPaymentSuccess() {
</p>
</div>
<div className="mt-6 flex justify-center gap-4">
{receiverId && receiverUsername ? (
<Link href={`/settings/chats/${receiverUsername}/${receiverId}`}>
<RoundedButton variant="primary" className="h-9 w-36">
{t("giftPage.paymentSuccess.sendMessage")}
</RoundedButton>
</Link>
) : null}
<Link href="/">
<RoundedButton variant="primary" className="h-9 w-36">
{t("giftPage.paymentSuccess.later")}
<Link
href={
receiverId && receiverUsername
? `/settings/chats/${receiverUsername}/${receiverId}`
: "/"
}
>
<RoundedButton variant="primary" className="h-9 w-40">
{t("giftPage.paymentSuccess.backToChat")}
</RoundedButton>
</Link>
</div>

View File

@@ -182,6 +182,14 @@
html {
scroll-behavior: smooth;
/* بدون این، Chrome روی گوشی‌هایی که «Force dark برای محتوای وب» روشنه (پیش‌فرض بعضی
بیلدهای شیائومی/سامسونگ) فکر می‌کنه این سایت از تم تیره پشتیبانی نمی‌کنه و خودش
رنگ‌های input/text رو الگوریتمی معکوس می‌کنه — دقیقاً همون چیزی که باعث می‌شد نام/
نام‌خانوادگی توی تم تیره ناخوانا بشه. اعلامِ صریحِ color-scheme این رفتار رو خاموش می‌کنه */
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body {
direction: rtl;
@@ -641,11 +649,17 @@ select {
border-color: rgba(255, 255, 255, 0.15);
}
/* env(safe-area-inset-bottom) روی اندروید (برخلافِ iOS) تقریباً همیشه 0px
برمی‌گرده، حتی وقتی نوار جست‌وجوی سیستم/نشانگرِ ژستِ اندروید همون چند پیکسلِ
پایینِ صفحه رو اشغال کرده — یعنی قبلاً تکیه‌ی تنها به env() باعث می‌شد دکمه‌ی
ارسال/ویس روی خیلی از گوشی‌های اندروید دقیقاً روی لبه‌ی پایینیِ صفحه بشینه و
زیرِ نوارِ سیستم بره. max() یه حداقلِ ثابت (که این نوارها رو پوشش می‌ده) رو
تضمین می‌کنه، مستقل از این‌که env() چی برگردونه */
.chat-input-area {
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
padding-bottom: max(1.5rem, calc(0.75rem + env(safe-area-inset-bottom, 0px)));
}
.chat-input-area--thread {
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
padding-bottom: max(1.5rem, calc(1rem + env(safe-area-inset-bottom, 0px)));
}
.ig-dm-send-btn {
@@ -712,6 +726,10 @@ select {
.story-ring-live {
animation: story-ring-spin 3s linear infinite;
}
/* حلقه‌ی «در حالِ آپلودِ استوری» — چرخشِ سریع‌تر، عینِ لودینگِ ثبتِ استوری اینستاگرام */
.story-ring-uploading {
animation: story-ring-spin 0.9s linear infinite;
}
/* Message enter — handled by framer-motion in ChatMessageCard */
@@ -749,22 +767,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,33 +802,33 @@ 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 {
background: rgba(8, 8, 10, 0.52);
backdrop-filter: blur(20px) saturate(120%);
-webkit-backdrop-filter: blur(20px) saturate(120%);
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.dark .glass-modal-overlay {
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(22px) saturate(110%);
-webkit-backdrop-filter: blur(22px) saturate(110%);
background: transparent;
}
.glass-modal-overlay--dark {
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(24px) saturate(110%);
-webkit-backdrop-filter: blur(24px) saturate(110%);
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.dark .glass-modal-overlay--dark {
background: rgba(0, 0, 0, 0.82);
background: transparent;
}
.glass-modal-panel {

View File

@@ -1,121 +1,127 @@
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import { defaultSEOConfig } from "@/config/seoConfig";
import { getSiteSeoMeta, getSeoPage } from "@/lib/i18n/seo";
import { getLanguageDefinition } from "@/lib/i18n/registry";
import { getServerLanguage } from "@/lib/i18n/server";
import SiteJsonLd from "@/components/seo/SiteJsonLd";
import Script from 'next/script';
import "./globals.css";
import Layout from "@/components/Layout";
import RegisterSW from "@/components/RegisterSW";
import PushNotificationListener from "@/components/PushNotificationListener";
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
const iranSansFont = localFont({
src: "../../public/fonts/IRANSansX-Regular.woff",
display: "swap",
fallback: ["Tahoma", "Arial", "sans-serif"],
});
export async function generateMetadata(): Promise<Metadata> {
const lang = await getServerLanguage();
const site = getSiteSeoMeta(lang);
const home = getSeoPage("home", lang);
return {
title: {
default: home.title || site.name,
template: `%s | ${site.titleSuffix}`,
},
description: home.description,
metadataBase: new URL("https://modstagram.com"),
keywords: site.keywords,
authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
creator: "Modstagram",
manifest: "/manifest.webmanifest",
applicationName: site.name,
icons: {
icon: [
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
{ url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
],
shortcut: ["/favicon.png"],
apple: [
{ url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
{ url: "/images/icons/192x192.png", sizes: "192x192" },
],
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: site.name,
},
openGraph: {
siteName: site.name,
locale: lang === "fa" ? "fa_IR" : "en_US",
type: "website",
images: defaultSEOConfig.openGraph?.images || [],
},
robots: "index, follow",
twitter: {
card: "summary_large_image",
creator: "@modstagram",
site: "@modstagram",
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
},
verification: {
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
},
other: {
"msapplication-TileColor": "#FF107D",
samandehi: "522190795",
},
};
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const lang = await getServerLanguage();
const def = getLanguageDefinition(lang);
return (
<html lang={def.htmlLang} dir={def.direction} suppressHydrationWarning>
<body className={`${iranSansFont.className} antialiased`}>
<SiteJsonLd />
<RegisterSW />
<PushNotificationListener />
<ClientErrorBoundary>
<Layout initialLanguage={lang}>{children}</Layout>
</ClientErrorBoundary>
{/* ====================== گوگل آنالیتیکس ====================== */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-GT0QQX6F97"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-GT0QQX6F97');
`}
</Script>
{/* ======================================================== */} </body>
</html>
);
}
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import { defaultSEOConfig } from "@/config/seoConfig";
import { SITE_URL } from "@/config/site";
import { getSiteSeoMeta, getSeoPage, buildHreflangAlternates } from "@/lib/i18n/seo";
import { getLanguageDefinition } from "@/lib/i18n/registry";
import { getServerLanguage } from "@/lib/i18n/server";
import SiteJsonLd from "@/components/seo/SiteJsonLd";
import { toPwaLang } from "@/lib/i18n/pwaLang";
import { PWA_VERSION } from "@/lib/pwaConfig";
import Script from 'next/script';
import "./globals.css";
import Layout from "@/components/Layout";
import RegisterSW from "@/components/RegisterSW";
import PushNotificationListener from "@/components/PushNotificationListener";
import VpnWarningGate from "@/components/VpnWarningGate";
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
const iranSansFont = localFont({
src: "../../public/fonts/IRANSansX-Regular.woff",
display: "swap",
fallback: ["Tahoma", "Arial", "sans-serif"],
});
export async function generateMetadata(): Promise<Metadata> {
const lang = await getServerLanguage();
const site = getSiteSeoMeta(lang);
const home = getSeoPage("home", lang);
return {
title: {
default: home.title || site.name,
template: `%s | ${site.titleSuffix}`,
},
description: home.description,
metadataBase: new URL(SITE_URL),
keywords: site.keywords,
authors: [{ name: "Modstagram", url: SITE_URL }],
creator: "Modstagram",
alternates: buildHreflangAlternates(home.path || "/", lang),
manifest: `/manifest.webmanifest?lang=${toPwaLang(lang)}&v=${PWA_VERSION}`,
applicationName: site.name,
icons: {
icon: [
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
{ url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
],
shortcut: ["/favicon.png"],
apple: [
{ url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
{ url: "/images/icons/192x192.png", sizes: "192x192" },
],
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: site.name,
},
openGraph: {
siteName: site.name,
locale: lang === "fa" ? "fa_IR" : "en_US",
type: "website",
images: defaultSEOConfig.openGraph?.images || [],
},
robots: "index, follow",
twitter: {
card: "summary_large_image",
creator: "@modstagram",
site: "@modstagram",
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
},
verification: {
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
},
other: {
"msapplication-TileColor": "#FF107D",
samandehi: "522190795",
},
};
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const lang = await getServerLanguage();
const def = getLanguageDefinition(lang);
return (
<html lang={def.htmlLang} dir={def.direction} suppressHydrationWarning>
<body className={`${iranSansFont.className} antialiased`}>
<SiteJsonLd />
<RegisterSW />
<PushNotificationListener />
<VpnWarningGate />
<ClientErrorBoundary>
<Layout initialLanguage={lang}>{children}</Layout>
</ClientErrorBoundary>
{/* ====================== گوگل آنالیتیکس ====================== */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-GT0QQX6F97"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-GT0QQX6F97');
`}
</Script>
{/* ======================================================== */} </body>
</html>
);
}

View File

@@ -5,6 +5,8 @@
import useAxios from "@/hooks/useAxios";
import { optimizeImageToWebP, optimizeVideoToMp4, isImageAspectRatioAllowed } from "@/lib/media";
import { createStoryBase64, StoryOverlay } from "@/api/fetchStories";
import { useQueryClient } from "@tanstack/react-query";
import { setPendingStoryUpload } from "@/lib/stories/pendingStoryUpload";
import StoryEditor from "@/components/stories/StoryEditor";
import StoryCameraCapture from "@/components/stories/StoryCameraCapture";
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
@@ -62,6 +64,7 @@ export default function NewPostPage() {
const { t, i18n } = useTranslation("common");
const captionLang = i18n.language === "en" ? "en" : "fa";
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const { request, loading } = useAxios();
const user = useUser();
@@ -303,39 +306,49 @@ export default function NewPostPage() {
toast.error(t("posts.storyMediaRequired"));
return;
}
try {
toast.loading(t("posts.publishingStory"), { id: "story-upload" });
let optimized: File;
if (file.type.startsWith("video/")) {
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال می‌شود
try {
optimized = await optimizeVideoToMp4(file);
} catch {
optimized = file;
}
} else {
try {
optimized = await optimizeImageToWebP(file);
} catch {
optimized = file;
// عین اینستاگرام: بلافاصله به فید برمی‌گردیم و آپلود پس‌زمینه ادامه پیدا می‌کنه —
// StoriesBar با خوندنِ همین پرچمِ pending، حلقه‌ی چرخانِ «در حال آپلود» رو نشون
// می‌ده تا وقتی createStoryBase64 واقعاً تموم بشه، بدون این‌که کاربر منتظر بمونه
const pendingFile = file;
const pendingOverlays = storyOverlays;
setPendingStoryUpload(queryClient, true);
router.push("/");
void (async () => {
try {
let optimized: File;
if (pendingFile.type.startsWith("video/")) {
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال می‌شود
try {
optimized = await optimizeVideoToMp4(pendingFile);
} catch {
optimized = pendingFile;
}
} else {
try {
optimized = await optimizeImageToWebP(pendingFile);
} catch {
optimized = pendingFile;
}
}
await createStoryBase64(
{
name: optimized.name,
type: optimized.type,
data: await fileToBase64(optimized),
},
token,
pendingOverlays
);
toast.success(t("posts.storyPublished"));
void queryClient.invalidateQueries({ queryKey: ["stories-feed"] });
} catch (err) {
toast.error(err instanceof Error ? err.message : t("posts.storyPublishError"));
} finally {
setPendingStoryUpload(queryClient, false);
}
await createStoryBase64(
{
name: optimized.name,
type: optimized.type,
data: await fileToBase64(optimized),
},
token,
storyOverlays
);
toast.success(t("posts.storyPublished"), { id: "story-upload" });
router.push("/");
} catch (err) {
toast.error(err instanceof Error ? err.message : t("posts.storyPublishError"), {
id: "story-upload",
});
}
})();
return;
}
@@ -733,6 +746,7 @@ export default function NewPostPage() {
onShopsChange={setSelectedShops}
onShopProductsChange={setSelectedShopProducts}
onBookingChange={setSelectedBooking}
taggedUserIds={taggedUsers.map((u) => u._id)}
/>
<div className="mt-4">

25
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import Container from "@/components/elements/Container";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
export default async function NotFound() {
const lang = await getServerLanguage();
return (
<Container className="flex min-h-[70vh] flex-col items-center justify-center gap-4 py-20 text-center">
<h1 className="text-2xl font-bold text-foreground">
{translateCommon(lang, "notFoundPage.title")}
</h1>
<p className="max-w-sm text-sm text-neutral-500 dark:text-neutral-400">
{translateCommon(lang, "notFoundPage.description")}
</p>
<Link
href="/"
className="mt-2 rounded-full bg-primary px-6 py-2 text-sm font-medium text-white"
>
{translateCommon(lang, "notFoundPage.backHome")}
</Link>
</Container>
);
}

View File

@@ -1,279 +1,343 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { BASE_URL } from "@/components/main/BaseUrl";
import UserInfo from "@/components/main/UserInfo";
import ScoreRateStats from "@/components/main/ScoreRateStats";
import PageTitle from "@/components/settings/PageTitle";
import useAxios from "@/hooks/useAxios";
import { IOfferType, User } from "@/types/types";
import axios from "axios";
import { useFormik } from "formik";
import { useRouter } from "next/navigation";
import React, { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import Cookies from "js-cookie";
import * as Yup from "yup";
import { useTranslation } from "react-i18next";
import { useUser } from "@/hooks/useUser";
type OfferTypeRow = IOfferType & {
disabled?: boolean;
unavailableReason?: string | null;
};
interface ITicketChatProps {
params: Promise<{ id: string; title: string }>;
}
const FALLBACK_TYPES: OfferTypeRow[] = [
{ _id: "normal", name: "normal", price: 50000 },
{ _id: "free", name: "free", price: 0 },
];
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
const order = ["normal", "special", "highlight", "free"];
return [...list].sort((a, b) => {
const ai = order.indexOf(a.name);
const bi = order.indexOf(b.name);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
function TicketChat({ params }: ITicketChatProps) {
const { t } = useTranslation("common");
const resolvedParams = React.use(params);
const { request } = useAxios();
const { id } = resolvedParams;
const router = useRouter();
const me = useUser();
const validationSchema = Yup.object({
selectedType: Yup.string().required(t("offerPage.typeRequired")),
});
const [userDetail, setUserDetail] = useState<User>();
const [selectedType, setSelectedType] = useState("normal");
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
useEffect(() => {
const fetchUser = async () => {
const response = await request<{ user: User }>(
"GET",
`/users/get?user_id=${id}`
);
setUserDetail(response?.user);
};
void fetchUser();
}, [id, request]);
useEffect(() => {
const monthly = Number(me?.monthly_free_offer ?? 1);
setHasUsedFreeOffer(monthly <= 0);
}, [me]);
const fetchOfferTypes = async () => {
try {
const response = await request<{
offerTypes: OfferTypeRow[];
monthly_free_offer?: number;
}>("GET", "/offers/types");
const list = sortOfferTypes(
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
);
setTypeList(list);
if (typeof response?.monthly_free_offer === "number") {
setHasUsedFreeOffer(response.monthly_free_offer <= 0);
}
// همیشه پیش‌فرض روی پولی (normal)
const paid =
list.find((item) => item.name === "normal" && !item.disabled) ||
list.find((item) => item.name !== "free" && !item.disabled);
setSelectedType(paid?.name || "normal");
} catch (err) {
console.log(err);
setTypeList(FALLBACK_TYPES);
setSelectedType("normal");
}
};
useEffect(() => {
void fetchOfferTypes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const formik = useFormik({
initialValues: {
selectedType: "normal",
},
validationSchema,
enableReinitialize: true,
onSubmit: async (values, { setSubmitting }) => {
const chosen = typeList?.find((t) => t.name === values.selectedType);
if (
chosen?.disabled ||
(values.selectedType === "free" && hasUsedFreeOffer)
) {
toast.error(
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
);
return;
}
const getToken = (): string => {
if (typeof window !== "undefined") {
const token = Cookies.get("token");
return token ? `Bearer ${token}` : "";
}
return "";
};
try {
const response = await axios.post(
`${BASE_URL}/offers/initiate-payment-web`,
{
offerType: values.selectedType,
receiverId: id,
},
{
headers: {
Authorization: getToken(),
"Content-Type": "application/json",
},
}
);
const data: {
authority?: string;
paymentUrl?: string;
type?: string;
} = response.data;
if (values.selectedType === "free" || data.type === "free") {
toast.success(t("offerPage.freeSuccess"));
setHasUsedFreeOffer(true);
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paymentUrl) {
window.open(data.paymentUrl, "_blank", "noopener,noreferrer");
} else if (data.authority) {
window.open(
`https://www.zarinpal.com/pg/StartPay/${data.authority}`,
"_blank",
"noopener,noreferrer"
);
} else {
toast.error(t("offerPage.paymentInfoError"));
}
} catch (err: any) {
console.error("Payment initiation error:", err);
toast.error(
err?.response?.data?.error ||
err?.response?.data?.message ||
t("offerPage.paymentStartError")
);
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
void formik.setFieldValue("selectedType", selectedType);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedType]);
const displayTypes = useMemo(
() => sortOfferTypes(typeList || FALLBACK_TYPES),
[typeList]
);
const typeLabel = (name: string) => {
if (name === "normal") return t("offerPage.types.normal");
if (name === "free") return t("offerPage.types.free");
if (name === "special") return t("offerPage.types.special");
return t("offerPage.types.highlight");
};
const userHeader = userDetail ? (
<div className="flex w-full items-end justify-between">
<UserInfo
first_name={userDetail?.first_name}
is_verified={userDetail?.verify_badge}
userId={userDetail?._id}
profile_image={userDetail?.profile_image}
user_level={userDetail?.user_level}
last_name={userDetail?.last_name}
user_name={userDetail?.user_name}
noLink
trailingContent={
<ScoreRateStats score={userDetail?.user_score} rate={userDetail?.rate} />
}
/>
</div>
) : null;
return (
<Container>
<div className="mx-3 mb-10">
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
<form
className="flex w-full flex-col items-center gap-2 text-sm"
onSubmit={formik.handleSubmit}
>
{displayTypes.map((item) => {
const isFree = item.name === "free";
const isDisabled =
Boolean(item.disabled) || (isFree && hasUsedFreeOffer);
return (
<div
key={item?._id || item.name}
className="mt-8 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
onClick={() => {
if (isDisabled) return;
setSelectedType(item.name);
void formik.setFieldValue("selectedType", item.name);
}}
>
{userHeader}
<RoundedDiv
className={`${selectionCardClass(selectedType === item?.name)} ${
isDisabled ? "cursor-not-allowed opacity-50" : ""
}`}
>
{typeLabel(item.name)}
{Number(item.price) !== 0
? ": " +
Number(item.price).toLocaleString() +
t("offerPage.currencySuffix")
: ""}
</RoundedDiv>
{isDisabled && item.unavailableReason ? (
<p className="text-center text-[11px] text-red-500">
{item.unavailableReason}
</p>
) : null}
</div>
);
})}
<RoundedButton
type="submit"
variant="primary"
className="mt-4 px-8 py-2"
disabled={formik.isSubmitting}
>
{t("offerPage.submitRequest")}
</RoundedButton>
</form>
</div>
</Container>
);
}
export default TicketChat;
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import { BASE_URL } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import UserInfoClient from "@/components/main/UserInfoClient";
import ScoreRateStats from "@/components/main/ScoreRateStats";
import { formatFullName } from "@/lib/formatFullName";
import PageTitle from "@/components/settings/PageTitle";
import useAxios from "@/hooks/useAxios";
import { IOfferType, User } from "@/types/types";
import axios from "axios";
import { useFormik } from "formik";
import { useRouter } from "next/navigation";
import React, { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import Cookies from "js-cookie";
import * as Yup from "yup";
import { useTranslation } from "react-i18next";
import { useUser } from "@/hooks/useUser";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
type OfferTypeRow = IOfferType & {
disabled?: boolean;
unavailableReason?: string | null;
};
interface ITicketChatProps {
params: Promise<{ id: string; title: string }>;
}
const FALLBACK_TYPES: OfferTypeRow[] = [
{ _id: "normal", name: "normal", price: 50000 },
{ _id: "free", name: "free", price: 0 },
];
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
const order = ["normal", "special", "highlight", "free"];
return [...list].sort((a, b) => {
const ai = order.indexOf(a.name);
const bi = order.indexOf(b.name);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
function TicketChat({ params }: ITicketChatProps) {
const { t } = useTranslation("common");
const resolvedParams = React.use(params);
const { request } = useAxios();
const { id } = resolvedParams;
const router = useRouter();
const me = useUser();
const validationSchema = Yup.object({
selectedType: Yup.string().required(t("offerPage.typeRequired")),
});
const [userDetail, setUserDetail] = useState<User>();
const [selectedType, setSelectedType] = useState("normal");
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
const pendingPaymentRetryRef = React.useRef<(() => void) | null>(null);
useEffect(() => {
const fetchUser = async () => {
const response = await request<{ user: User }>(
"GET",
`/users/get?user_id=${id}`
);
setUserDetail(response?.user);
};
void fetchUser();
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, request]);
const fetchOfferTypes = async () => {
try {
const response = await request<{
offerTypes: OfferTypeRow[];
}>("GET", `/offers/types?userId=${id}`);
const list = sortOfferTypes(
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
);
setTypeList(list);
// همیشه پیش‌فرض روی پولی (normal)
const paid =
list.find((item) => item.name === "normal" && !item.disabled) ||
list.find((item) => item.name !== "free" && !item.disabled);
setSelectedType(paid?.name || "normal");
} catch (err) {
console.log(err);
setTypeList(FALLBACK_TYPES);
setSelectedType("normal");
}
};
useEffect(() => {
void fetchOfferTypes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getToken = (): string => {
if (typeof window !== "undefined") {
const token = Cookies.get("token");
return token ? `Bearer ${token}` : "";
}
return "";
};
const startPayment = async (offerType: string, useWallet: boolean) => {
try {
const response = await axios.post(
`${BASE_URL}/offers/initiate-payment-web`,
{
offerType,
receiverId: id,
useWallet,
},
{
headers: {
Authorization: getToken(),
"Content-Type": "application/json",
},
}
);
const data: {
authority?: string;
paymentUrl?: string;
type?: string;
paidFromWallet?: boolean;
} = response.data;
if (offerType === "free" || data.type === "free") {
toast.success(t("offerPage.freeSuccess"));
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paidFromWallet) {
toast.success(t("wallet.paidFromWalletSuccess"));
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paymentUrl) {
// 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.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
} else {
toast.error(t("offerPage.paymentInfoError"));
}
} catch (err: any) {
if (err?.response?.data?.type === "needs_username_password") {
pendingPaymentRetryRef.current = () => {
void startPayment(offerType, useWallet);
};
setSocialSignupOpen(true);
return;
}
console.error("Payment initiation error:", err);
toast.error(
err?.response?.data?.error ||
err?.response?.data?.message ||
t("offerPage.paymentStartError")
);
}
};
const formik = useFormik({
initialValues: {
selectedType: "normal",
},
validationSchema,
enableReinitialize: true,
onSubmit: async (values, { setSubmitting }) => {
const chosen = typeList?.find((t) => t.name === values.selectedType);
if (chosen?.disabled) {
toast.error(
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
);
return;
}
try {
if (values.selectedType !== "free" && walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
await startPayment(values.selectedType, false);
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
void formik.setFieldValue("selectedType", selectedType);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedType]);
const displayTypes = useMemo(
() => sortOfferTypes(typeList || FALLBACK_TYPES),
[typeList]
);
const typeLabel = (name: string) => {
if (name === "normal") return t("offerPage.types.normal");
if (name === "free") return t("offerPage.types.free");
if (name === "special") return t("offerPage.types.special");
return t("offerPage.types.highlight");
};
// چیدمان این صفحه با UserInfo مشترک (استفاده‌شده توی نتایج جستجو/هدیه/...) فرق
// داره — عین اندروید (OfferScreen.tsx): ستون نام+یوزرنیم کنار آواتار، ستون
// سطح+ریت/امتیاز روبروش؛ به‌جای تغییر کامپوننت مشترک، چیدمان مخصوص همین صفحه‌ست
const userHeader = userDetail ? (
<div className="flex w-full items-center gap-3">
<ProfileAvatar
src={userDetail?.profile_image}
alt={userDetail?.user_name || ""}
size="md"
rounded="2xl"
userId={userDetail?._id}
verifyBadge={userDetail?.verify_badge}
/>
<div className="flex flex-1 items-start justify-between gap-2 text-xs max-sm:text-[10px] md:text-sm font-semibold">
<div className="flex flex-col gap-1">
<div className="font-semibold">
{formatFullName(userDetail?.first_name, userDetail?.last_name)}
</div>
<span className="inline-flex items-center gap-1">
{userDetail?.user_name}
<VerificationBadge verifyBadge={userDetail?.verify_badge} />
</span>
</div>
<div className="flex flex-col items-end gap-1">
<UserInfoClient user_level={userDetail?.user_level} />
<ScoreRateStats score={userDetail?.user_score} rate={userDetail?.rate} />
</div>
</div>
</div>
) : null;
return (
<Container>
<div className="mx-3 mb-10">
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
<form
className="flex w-full flex-col items-center gap-2 text-sm"
onSubmit={formik.handleSubmit}
>
{displayTypes.map((item) => {
const isDisabled = Boolean(item.disabled);
return (
<div
key={item?._id || item.name}
className="mt-4 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
onClick={() => {
if (isDisabled) return;
setSelectedType(item.name);
void formik.setFieldValue("selectedType", item.name);
}}
>
{userHeader ? <div className="w-full">{userHeader}</div> : null}
<RoundedButton
type="button"
variant={selectedType === item?.name ? "primary" : "default"}
disabled={isDisabled}
className="w-full h-10"
>
{typeLabel(item.name)}
{Number(item.price) !== 0
? ": " +
Number(item.price).toLocaleString() +
t("offerPage.currencySuffix")
: ""}
</RoundedButton>
{isDisabled && item.unavailableReason ? (
<p className="text-center text-[11px] text-red-500">
{item.unavailableReason}
</p>
) : null}
</div>
);
})}
<div className="pointer-events-none fixed bottom-28 left-0 right-0 z-[54] flex justify-center px-4">
<RoundedButton
type="submit"
variant="primary"
className="pointer-events-auto h-10 w-[160px]"
disabled={formik.isSubmitting}
>
{t("offerPage.submitRequest")}
</RoundedButton>
</div>
</form>
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={
Number(
displayTypes.find((item) => item.name === selectedType)?.price
) || 0
}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void startPayment(selectedType, useWallet);
}}
/>
<CompleteSocialSignupModal
open={socialSignupOpen}
hasUsername={Boolean(me?.user_name)}
onClose={() => {
setSocialSignupOpen(false);
pendingPaymentRetryRef.current = null;
}}
onSuccess={() => {
setSocialSignupOpen(false);
pendingPaymentRetryRef.current?.();
pendingPaymentRetryRef.current = null;
}}
/>
</div>
</Container>
);
}
export default TicketChat;

View File

@@ -5,13 +5,14 @@ import React from "react";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
function FailedBillboard() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const userId = searchParams.get("userId");
@@ -29,14 +30,20 @@ function FailedBillboard() {
/>
<span>{t("offerPage.paymentFailed.message")}</span>
</div>
<Link
className="flex items-center flex-col mt-8"
href={`/offer/${userId}`}
>
<RoundedButton variant="primary" className="w-32 h-9">
{t("offerPage.paymentFailed.retry")}
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<Link href={`/offer/${userId}`} className="flex-1">
<RoundedButton variant="primary" className="h-9 w-full">
{t("offerPage.paymentFailed.retry")}
</RoundedButton>
</Link>
<RoundedButton
variant="default"
className="h-9 flex-1"
onClick={() => router.push(`/offer/${userId}`)}
>
{t("offerPage.paymentFailed.back")}
</RoundedButton>
</Link>
</div>
</Container>
</LocalePageShell>
);

View File

@@ -7,7 +7,7 @@ import React, { useEffect, useState } from "react";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
function SuccessBillboard() {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const searchParams = useSearchParams();
const userId = searchParams.get("userId");
@@ -50,20 +51,23 @@ function SuccessBillboard() {
username: userDetail?.user_name ?? "",
})}
</p>
<div className="grid grid-cols-2 gap-4">
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`}>
<RoundedButton variant="primary" className="w-32 h-9">
{t("offerPage.paymentSuccess.sendMessage")}
</RoundedButton>
</Link>
<Link href={"/"}>
<RoundedButton variant="primary" className="w-32 h-9">
{t("offerPage.paymentSuccess.later")}
</RoundedButton>
</Link>
</div>
</div>
</div>
{/* دکمه‌های پایین صفحه: ارسال پیام (به گفتگو) و بازگشت (به صفحه‌ی درخواست) */}
<div className="fixed bottom-28 left-0 right-0 z-[54] flex justify-center gap-3 px-4">
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`} className="flex-1 max-w-[9rem]">
<RoundedButton variant="primary" className="h-10 w-full">
{t("offerPage.paymentSuccess.sendMessage")}
</RoundedButton>
</Link>
<RoundedButton
variant="default"
className="h-10 flex-1 max-w-[9rem]"
onClick={() => router.push(`/offer/${userId}`)}
>
{t("offerPage.paymentFailed.back")}
</RoundedButton>
</div>
</Container>
</LocalePageShell>
);

View File

@@ -1,17 +1,25 @@
import { Metadata } from "next";
import { cache } from "react";
import { fetchPostById } from "@/api/fetchPostById";
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
buildPostPath,
buildPostJsonLd,
buildPostSeoDescription,
buildPostSeoTitle,
} from "@/lib/postSlug";
import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
import { getSiteSeoMeta } from "@/lib/i18n/seo";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
// Deduped per request: generateMetadata() and the page body's JSON-LD both need
// this post — cache() avoids firing the fetch to the backend twice.
const loadPost = cache((id: string) => fetchPostById(id));
type Props = {
params: Promise<{ id: string; slug?: string[] }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
@@ -50,7 +58,7 @@ function toFeedSearchParams(
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const lang = await getServerLanguage();
const post = await fetchPostById(id);
const post = await loadPost(id);
const title = post
? buildPostSeoTitle(post, lang)
: translateCommon(lang, "postsMeta.defaultTitle");
@@ -84,6 +92,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
].filter(Boolean),
publishedTime: post?.createdAt,
modifiedTime: post?.updatedAt,
// پست‌های private/followers-only بدون توکن null برمی‌گردن ولی برای کاربر
// واقعی توی PostFeedView (کلاینتی) درست لود می‌شن — پس نباید notFound() کنیم،
// فقط نباید این حالت رو به گوگل به‌عنوان محتوای معتبر ایندکس بدیم
index: Boolean(post),
lang,
});
}
@@ -93,14 +105,28 @@ export default async function PostPage({ params, searchParams }: Props) {
const { id } = await params;
const sp = await searchParams;
const feedSearchParams = toFeedSearchParams(sp);
const lang = await getServerLanguage();
const post = await loadPost(id);
return (
<main className="min-h-[100dvh]">
{post && (
<>
<h1 className="sr-only">{buildPostSeoTitle(post, lang)}</h1>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: safeJsonLdString(buildPostJsonLd(post, id, lang)),
}}
/>
</>
)}
<PostFeedView
initialPostId={id}
showClose
searchParams={feedSearchParams}
/>
<GuestAuthPromptModal />
</main>
);
}

View File

@@ -34,6 +34,9 @@ export default function robots(): MetadataRoute.Robots {
"/projects/payment/",
"/billboards/payment/",
"/academy/payment/",
"/shops/new",
"/shops/checkout",
"/shops/orders",
],
},
],

View File

@@ -1,4 +1,4 @@
import Title from "@/Components/ui/Title";
import Title from "@/components/ui/Title";
import React, { ReactNode } from "react";
interface BoxProps {

View File

@@ -1,5 +1,5 @@
"use client";
import { Separator } from "@/Components/ui/separator";
import { Separator } from "@/components/ui/separator";
import React from "react";
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";

View File

@@ -14,6 +14,14 @@ import { useTranslation } from "react-i18next";
import BackButton from "@/components/ui/BackButton";
import Link from "next/link";
/** این اندپوینت ساختارهای مختلفی برمی‌گردونه — یه تایپِ عمومیِ صریح که همون
* فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده (بجای {} ضمنی) */
type ApiResponse = {
success?: boolean;
data?: any;
[key: string]: any;
};
interface PaginationData {
page: number;
limit: number;
@@ -41,7 +49,7 @@ export default function PurchasedCoursesPage() {
const fetchCourses = async (page: number) => {
setIsLoading(true);
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/getUserPurchasedCoursesWithPopulate?page=${page}&limit=6`
);

View File

@@ -59,6 +59,13 @@ interface Payment {
};
}
/** این اندپوینت ساختارهای مختلفی برمی‌گردونه — یه تایپِ عمومیِ صریح که همون
* فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده (بجای {} ضمنی) */
type ApiResponse = {
data?: { payments?: Payment[]; [key: string]: any };
[key: string]: any;
};
interface Transaction {
id: string;
type: "deposit" | "withdraw" | "expense";
@@ -86,7 +93,7 @@ const WalletDashboard = () => {
// دریافت پرداخت‌های موفق (فروش‌های انجام شده)
const fetchSuccessfulPayments = async () => {
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/getAllAcademyPayments?status=success`
);
@@ -123,7 +130,7 @@ const WalletDashboard = () => {
// دریافت پرداخت‌های تسویه شده
const fetchSettledPayments = async () => {
try {
const response = await request(
const response = await request<ApiResponse>(
"GET",
`/academy/course/getAllAcademyPayments?status=settled`
);

View File

@@ -0,0 +1,135 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useTranslation } from "react-i18next";
type SponsoredContent = { title: string; image?: string | null };
type AdDetail = {
_id: string;
ad_type: "first_post" | "first_story" | "notification" | "project" | "product" | "shop" | "billboard";
effective_status: "pending_payment" | "active" | "expired" | "failed";
price: number;
views_count: number;
clicks_count: number;
notification_title?: string;
notification_body?: string;
target_gender: "female" | "male" | "any";
target_expertise?: string | null;
target_sub_expertise?: string[];
started_at?: string;
expires_at?: string;
};
const STATUS_COLOR: Record<string, string> = {
pending_payment: "text-amber-600",
active: "text-green-600",
expired: "text-neutral-400",
failed: "text-red-500",
};
export default function AdDetailPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const params = useParams<{ id: string }>();
const [ad, setAd] = useState<AdDetail | null>(null);
const [content, setContent] = useState<SponsoredContent | null>(null);
useEffect(() => {
if (!params?.id) return;
request<{ ad?: AdDetail; content?: SponsoredContent }>("GET", `/ads/${params.id}`, null, {
noToast: true,
})
.then((res) => {
setAd(res?.ad ?? null);
setContent(res?.content ?? null);
})
.catch(() => setAd(null));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params?.id]);
const isLinkedContentType = ad
? (["project", "product", "shop", "billboard"] as const).includes(
ad.ad_type as "project" | "product" | "shop" | "billboard"
)
: false;
const ctr =
ad && ad.views_count > 0 ? Math.round((ad.clicks_count / ad.views_count) * 1000) / 10 : 0;
return (
<LocalePageShell>
<Container>
<PageTitle>{t("ads.detailTitle")}</PageTitle>
{!ad ? (
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
) : (
<div className="mt-6 space-y-4 text-xs md:text-sm">
<div className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<span className="font-semibold">{t(`ads.type.${ad.ad_type}`)}</span>
<span className={`font-semibold ${STATUS_COLOR[ad.effective_status]}`}>
{t(`ads.status.${ad.effective_status}`)}
</span>
</div>
{ad.ad_type === "notification" && (
<div className="rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<p className="font-semibold">{ad.notification_title}</p>
<p className="mt-1 text-neutral-500">{ad.notification_body}</p>
</div>
)}
{isLinkedContentType && content && (
<div className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
{content.image && (
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={IMAGE_BASE_URL + content.image} alt="" className="h-full w-full object-cover" />
</div>
)}
<span className="font-semibold">{content.title}</span>
</div>
)}
<div className="grid grid-cols-3 gap-3">
<div className="rounded-2xl border border-neutral-200 px-3 py-4 text-center dark:border-neutral-800">
<p className="text-lg font-bold">{ad.views_count}</p>
<p className="text-neutral-500">{t("ads.viewsLabel")}</p>
</div>
<div className="rounded-2xl border border-neutral-200 px-3 py-4 text-center dark:border-neutral-800">
<p className="text-lg font-bold">{ad.clicks_count}</p>
<p className="text-neutral-500">{t("ads.clicksLabel")}</p>
</div>
<div className="rounded-2xl border border-neutral-200 px-3 py-4 text-center dark:border-neutral-800">
<p className="text-lg font-bold">{ctr}%</p>
<p className="text-neutral-500">{t("ads.ctrLabel")}</p>
</div>
</div>
<div className="rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800 space-y-1">
<p className="font-semibold">{t("ads.targetingTitle")}</p>
<p className="text-neutral-500">
{t("ads.targetingGender")}:{" "}
{ad.target_gender === "any" ? t("ads.genderAny") : t(`ads.gender.${ad.target_gender}`)}
</p>
<p className="text-neutral-500">
{t("ads.targetingExpertise")}: {ad.target_expertise || t("ads.genderAny")}
</p>
</div>
<p className="text-neutral-400">
{Number(ad.price).toLocaleString()} {t("common.toman")}
</p>
</div>
)}
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,765 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import CountryProvinceCitySelect, {
type GeoSelection,
} from "@/components/ui/CountryProvinceCitySelect";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
type AdType =
| "first_post"
| "first_story"
| "notification"
| "project"
| "product"
| "shop"
| "billboard";
type MyPost = {
_id: string;
post_images?: string[];
post_image?: string;
post_video?: string;
type?: "image" | "video";
files?: { path: string; type: "image" | "video" }[];
caption?: string;
};
type MyStory = {
_id: string;
media_path: string;
media_type?: string;
};
type Expertise = {
_id: string;
expertise: string;
sub_expertise?: { _id: string; name: string }[];
};
type MyProject = { _id: string; title?: string };
type MyShop = { _id: string; name: string; logo?: string | null };
type MyShopProduct = { _id: string; title: string; images?: string[]; primaryImageIndex?: number };
type MyBillboard = { _id: string; title?: string; images?: string[] };
const STEPS = ["type", "content", "location", "expertise", "gender", "review"] as const;
type Step = (typeof STEPS)[number];
/** تصویر بندانگشتی پست — post_images قدیمی + files جدید (و ویدیو) */
function postThumb(post: MyPost): { src: string; isVideo: boolean } | null {
// ۱) تصاویر قدیمی
if (post.post_images?.[0]) {
return { src: buildStorageUrl(post.post_images[0]), isVideo: false };
}
// ۲) فایل‌های جدید — اول تصویر، بعد هر فایلی
const imageFile = post.files?.find((f) => f.type === "image" && f.path);
if (imageFile?.path) {
return { src: buildStorageUrl(imageFile.path), isVideo: false };
}
const anyFile = post.files?.find((f) => f.path);
if (anyFile?.path) {
return {
src: buildStorageUrl(anyFile.path),
isVideo: anyFile.type === "video",
};
}
// ۳) فیلدهای قدیمی تکی
if (post.post_image) {
return { src: buildStorageUrl(post.post_image), isVideo: false };
}
if (post.post_video) {
return { src: buildStorageUrl(post.post_video), isVideo: true };
}
return null;
}
export default function NewAdPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const user = useUser();
const searchParams = useSearchParams();
// از منوی ۳نقطه‌ی ریلز («این پست رو آگهی کن») — نوع و پست از قبل معلومه، مستقیم
// می‌ره سراغ مرحله‌ی لوکیشن (index دوم توی STEPS)
const presetPostId = searchParams.get("postId") || "";
const presetAdType = searchParams.get("type") === "first_post" ? "first_post" : "";
const [stepIndex, setStepIndex] = useState(presetPostId ? 2 : 0);
const step: Step = STEPS[stepIndex];
const [adType, setAdType] = useState<AdType | "">(presetAdType as AdType | "");
const [selectedPostId, setSelectedPostId] = useState(presetPostId);
const [selectedStoryId, setSelectedStoryId] = useState("");
const [notifTitle, setNotifTitle] = useState("");
const [notifBody, setNotifBody] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState("");
const [selectedShopId, setSelectedShopId] = useState("");
const [selectedBillboardId, setSelectedBillboardId] = useState("");
const [productShopId, setProductShopId] = useState("");
const [selectedShopProductId, setSelectedShopProductId] = useState("");
const [geo, setGeo] = useState<GeoSelection>({ countryId: null, provinceId: null, cityId: null });
const [expertise, setExpertise] = useState("");
const [subExpertise, setSubExpertise] = useState<string[]>([]);
const [gender, setGender] = useState<"female" | "male" | "any">("any");
const [myPosts, setMyPosts] = useState<MyPost[] | null>(null);
const [myStories, setMyStories] = useState<MyStory[] | null>(null);
const [expertiseList, setExpertiseList] = useState<Expertise[]>([]);
const [myProjects, setMyProjects] = useState<MyProject[] | null>(null);
const [myShops, setMyShops] = useState<MyShop[] | null>(null);
const [myBillboards, setMyBillboards] = useState<MyBillboard[] | null>(null);
const [shopProducts, setShopProducts] = useState<MyShopProduct[] | null>(null);
const [price, setPrice] = useState<number | null>(null);
const [priceLoading, setPriceLoading] = useState(false);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
request<{ charged?: { available?: number } }>("GET", "/wallet/summary", null, { noToast: true })
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (adType === "first_post" && user?._id && myPosts === null) {
request<{ posts?: MyPost[] }>("GET", `/posts/user-posts/web?user_id=${user._id}&page=1&limit=30`, null, {
noToast: true,
})
.then((res) => setMyPosts(res?.posts ?? []))
.catch(() => setMyPosts([]));
}
if (adType === "first_story" && myStories === null) {
request<{ my_story?: { stories?: MyStory[] } }>("GET", "/stories/feed", null, { noToast: true })
.then((res) => setMyStories(res?.my_story?.stories ?? []))
.catch(() => setMyStories([]));
}
if (adType === "project" && user?._id && myProjects === null) {
request<{ data?: { projects?: MyProject[] }; projects?: MyProject[] }>(
"GET",
`/projects/user-projects/web?user_id=${user._id}&page=1&limit=50&linkable=1`,
null,
{ noToast: true }
)
.then((res) => setMyProjects(res?.data?.projects ?? res?.projects ?? []))
.catch(() => setMyProjects([]));
}
if ((adType === "shop" || adType === "product") && myShops === null) {
request<{ shops?: MyShop[] }>("GET", "/shops/mine", null, { noToast: true })
.then((res) => setMyShops(res?.shops ?? []))
.catch(() => setMyShops([]));
}
if (adType === "billboard" && myBillboards === null) {
request<{ advertisings?: MyBillboard[]; data?: { advertisings?: MyBillboard[] } }>(
"GET",
"/advertising/user-advertising?page=1&limit=50",
null,
{ noToast: true }
)
.then((res) => setMyBillboards(res?.advertisings ?? res?.data?.advertisings ?? []))
.catch(() => setMyBillboards([]));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adType, user?._id]);
useEffect(() => {
if (!productShopId) return;
setShopProducts(null);
request<{ docs?: MyShopProduct[] }>("GET", `/shop-products?shopId=${productShopId}&limit=100`, null, {
noToast: true,
})
.then((res) => setShopProducts(res?.docs ?? []))
.catch(() => setShopProducts([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productShopId]);
useEffect(() => {
request<{ expertises?: Expertise[] }>("GET", "/expertise", null, { noToast: true })
.then((res) => setExpertiseList(res?.expertises ?? []))
.catch(() => setExpertiseList([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// قیمتِ زنده — با هر تغییری توی نوع/هدف‌گذاری دوباره از سرور می‌گیره (utils/adPricing.js)
useEffect(() => {
if (!adType) return;
setPriceLoading(true);
const params = new URLSearchParams({ ad_type: adType });
if (geo.provinceId != null) params.set("target_province_id", String(geo.provinceId));
if (gender !== "any") params.set("target_gender", gender);
if (expertise) params.set("target_expertise", expertise);
if (subExpertise.length) params.set("target_sub_expertise", subExpertise.join(","));
request<{ price: number | null }>("GET", `/ads/pricing?${params.toString()}`, null, { noToast: true })
.then((res) => setPrice(res?.price ?? null))
.catch(() => setPrice(null))
.finally(() => setPriceLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adType, geo.provinceId, gender, expertise, subExpertise]);
const canGoNext = useMemo(() => {
if (step === "type") return Boolean(adType);
if (step === "content") {
if (adType === "first_post") return Boolean(selectedPostId);
if (adType === "first_story") return Boolean(selectedStoryId);
if (adType === "notification") return notifTitle.trim().length > 0 && notifBody.trim().length > 0;
if (adType === "project") return Boolean(selectedProjectId);
if (adType === "shop") return Boolean(selectedShopId);
if (adType === "product") return Boolean(selectedShopProductId);
if (adType === "billboard") return Boolean(selectedBillboardId);
return false;
}
return true;
}, [
step,
adType,
selectedPostId,
selectedStoryId,
notifTitle,
notifBody,
selectedProjectId,
selectedShopId,
selectedShopProductId,
selectedBillboardId,
]);
const goNext = () => setStepIndex((i) => Math.min(i + 1, STEPS.length - 1));
const goBack = () => setStepIndex((i) => Math.max(i - 1, 0));
const buildPayload = (useWallet: boolean) => ({
ad_type: adType,
content_post_id: adType === "first_post" ? selectedPostId : undefined,
content_story_id: adType === "first_story" ? selectedStoryId : undefined,
notification_title: adType === "notification" ? notifTitle.trim() : undefined,
notification_body: adType === "notification" ? notifBody.trim() : undefined,
content_project_id: adType === "project" ? selectedProjectId : undefined,
content_shop_id: adType === "shop" ? selectedShopId : undefined,
content_shop_product_id: adType === "product" ? selectedShopProductId : undefined,
content_billboard_id: adType === "billboard" ? selectedBillboardId : undefined,
target_country_id: geo.countryId ?? undefined,
target_province_id: geo.provinceId ?? undefined,
target_city_id: geo.cityId ?? undefined,
target_expertise: expertise || undefined,
target_sub_expertise: subExpertise,
target_gender: gender,
useWallet,
});
const submit = async (useWallet: boolean) => {
setSubmitting(true);
try {
const res = await request<{
paidFromWallet?: boolean;
paymentUrl?: string;
authority?: string;
}>("POST", "/ads", buildPayload(useWallet));
if (res?.paidFromWallet) {
toast.success(t("wallet.paidFromWalletSuccess"));
router.push("/settings/ads");
return;
}
const payUrl = res?.paymentUrl || (res?.authority ? `https://www.zarinpal.com/pg/StartPay/${res.authority}` : null);
if (payUrl) {
window.location.href = payUrl;
} else {
toast.error(t("settings.paymentLinkNotReceived"));
}
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
t("common.error");
toast.error(msg);
} finally {
setSubmitting(false);
}
};
const handleSubmitClick = () => {
if (!price) {
toast.error(t("ads.priceUnavailable"));
return;
}
if (walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
void submit(false);
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("ads.newTitle")}</PageTitle>
<div className="mt-4 space-y-6 text-xs md:text-sm">
{step === "type" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseTypeTitle")}</p>
<div className="grid grid-cols-3 gap-2">
{(
// "shop" فعلاً کامنت شده — طبق خواسته‌ی کاربر، صفحه‌ی عمومیِ گشت‌وگذارِ
// فروشگاه‌ها وجود نداره، پس فعلاً فقط "product" (کالا) قابل‌انتخابه
[
"first_post",
"first_story",
"notification",
"project",
"product",
// "shop",
"billboard",
] as AdType[]
).map((type) => (
<button
key={type}
type="button"
onClick={() => setAdType(type)}
className={toggleBtnClass(
adType === type,
"min-h-10 p-2 text-xs leading-tight text-center"
)}
>
{t(`ads.type.${type}`)}
</button>
))}
</div>
</div>
)}
{step === "content" && adType === "first_post" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.choosePostTitle")}</p>
{myPosts === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : myPosts.length === 0 ? (
<p className="text-neutral-500">{t("ads.noPostsYet")}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{myPosts.map((post) => {
const thumb = postThumb(post);
return (
<button
key={post._id}
type="button"
onClick={() => setSelectedPostId(post._id)}
className={cn(
"relative aspect-square overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
selectedPostId === post._id ? "border-pink-500" : "border-transparent"
)}
>
{thumb?.isVideo ? (
<video
src={thumb.src}
className="h-full w-full object-cover"
muted
playsInline
preload="metadata"
/>
) : thumb?.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={thumb.src}
alt=""
className="h-full w-full object-cover"
/>
) : null}
</button>
);
})}
</div>
)}
</div>
)}
{step === "content" && adType === "first_story" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseStoryTitle")}</p>
{myStories === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : myStories.length === 0 ? (
<p className="text-neutral-500">{t("ads.noStoriesYet")}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{myStories.map((story) => (
<button
key={story._id}
type="button"
onClick={() => setSelectedStoryId(story._id)}
className={cn(
"aspect-[9/16] overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
selectedStoryId === story._id ? "border-pink-500" : "border-transparent"
)}
>
{story.media_type !== "video" ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={
story.media_path.startsWith("http")
? story.media_path
: `https://api.modstagram.com${story.media_path}`
}
alt=""
className="h-full w-full object-cover"
/>
) : null}
</button>
))}
</div>
)}
</div>
)}
{step === "content" && adType === "notification" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.notificationContentTitle")}</p>
<RoundedInput
value={notifTitle}
onChange={(e) => setNotifTitle(e.target.value)}
placeholder={t("ads.notificationTitlePlaceholder")}
/>
<textarea
value={notifBody}
onChange={(e) => setNotifBody(e.target.value)}
placeholder={t("ads.notificationBodyPlaceholder")}
rows={3}
className="w-full resize-none rounded-2xl border border-neutral-200 bg-transparent px-4 py-3 outline-none dark:border-neutral-700"
/>
</div>
)}
{step === "content" && adType === "project" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseProjectTitle")}</p>
{myProjects === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : myProjects.length === 0 ? (
<p className="text-neutral-500">{t("ads.noProjectsYet")}</p>
) : (
<div className="space-y-2">
{myProjects.map((p) => (
<button
key={p._id}
type="button"
onClick={() => setSelectedProjectId(p._id)}
className={cn(
"w-full rounded-2xl border px-4 py-3 text-right",
selectedProjectId === p._id
? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
: "border-neutral-200 dark:border-neutral-700"
)}
>
{p.title || t("ads.untitled")}
</button>
))}
</div>
)}
</div>
)}
{step === "content" && adType === "shop" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseShopTitle")}</p>
{myShops === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : myShops.length === 0 ? (
<p className="text-neutral-500">{t("ads.noShopsYet")}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{myShops.map((shop) => (
<button
key={shop._id}
type="button"
onClick={() => setSelectedShopId(shop._id)}
className={cn(
"flex flex-col items-center gap-1 rounded-xl border-2 p-2",
selectedShopId === shop._id ? "border-pink-500" : "border-transparent"
)}
>
<div className="h-16 w-16 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="" className="h-full w-full object-cover" />
) : null}
</div>
<span className="line-clamp-1">{shop.name}</span>
</button>
))}
</div>
)}
</div>
)}
{step === "content" && adType === "product" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseShopTitle")}</p>
{myShops === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : (
<div className="flex flex-wrap gap-2">
{myShops.map((shop) => (
<button
key={shop._id}
type="button"
onClick={() => {
setProductShopId(shop._id);
setSelectedShopProductId("");
}}
className={cn(
"rounded-full border px-3 py-1.5",
productShopId === shop._id
? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
: "border-neutral-200 dark:border-neutral-700"
)}
>
{shop.name}
</button>
))}
</div>
)}
{productShopId && (
<>
<p className="font-semibold">{t("ads.chooseProductTitle")}</p>
{shopProducts === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : shopProducts.length === 0 ? (
<p className="text-neutral-500">{t("ads.noProductsYet")}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{shopProducts.map((product) => {
const img = product.images?.[product.primaryImageIndex ?? 0] ?? product.images?.[0];
return (
<button
key={product._id}
type="button"
onClick={() => setSelectedShopProductId(product._id)}
className={cn(
"aspect-square overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
selectedShopProductId === product._id ? "border-pink-500" : "border-transparent"
)}
>
{img ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={IMAGE_BASE_URL + img} alt="" className="h-full w-full object-cover" />
) : null}
</button>
);
})}
</div>
)}
</>
)}
</div>
)}
{step === "content" && adType === "billboard" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseBillboardTitle")}</p>
{myBillboards === null ? (
<p className="text-neutral-500">{t("common.loading")}</p>
) : myBillboards.length === 0 ? (
<p className="text-neutral-500">{t("ads.noBillboardsYet")}</p>
) : (
<div className="space-y-2">
{myBillboards.map((b) => (
<button
key={b._id}
type="button"
onClick={() => setSelectedBillboardId(b._id)}
className={cn(
"w-full rounded-2xl border px-4 py-3 text-right",
selectedBillboardId === b._id
? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
: "border-neutral-200 dark:border-neutral-700"
)}
>
{b.title || t("ads.untitled")}
</button>
))}
</div>
)}
</div>
)}
{step === "location" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseLocationTitle")}</p>
<CountryProvinceCitySelect value={geo} onChange={setGeo} />
</div>
)}
{step === "expertise" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseExpertiseTitle")}</p>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={() => {
setExpertise("");
setSubExpertise([]);
}}
className={toggleBtnClass(
!expertise,
"min-h-10 p-2 text-xs leading-tight text-center"
)}
>
{t("ads.genderAny")}
</button>
{expertiseList.map((exp) => (
<button
key={exp._id}
type="button"
onClick={() => {
setExpertise(exp.expertise);
setSubExpertise([]);
}}
className={toggleBtnClass(
expertise === exp.expertise,
"min-h-10 p-2 text-xs leading-tight text-center"
)}
>
{exp.expertise}
</button>
))}
</div>
{expertise &&
(expertiseList.find((e) => e.expertise === expertise)?.sub_expertise?.length ?? 0) > 0 && (
<div className="space-y-2">
<p className="text-neutral-500">{t("ads.chooseSubExpertiseTitle")}</p>
<div className="grid grid-cols-3 gap-2">
{(expertiseList.find((e) => e.expertise === expertise)?.sub_expertise ?? []).map(
(sub) => (
<button
key={sub._id}
type="button"
onClick={() =>
setSubExpertise((prev) =>
prev.includes(sub.name)
? prev.filter((s) => s !== sub.name)
: [...prev, sub.name]
)
}
className={toggleBtnClass(
subExpertise.includes(sub.name),
"min-h-10 p-2 text-xs leading-tight text-center"
)}
>
{sub.name}
</button>
)
)}
</div>
</div>
)}
</div>
)}
{step === "gender" && (
<div className="space-y-3">
<p className="font-semibold">{t("ads.chooseGenderTitle")}</p>
<div className="grid grid-cols-3 gap-2">
{(["female", "male", "any"] as const).map((g) => (
<button
key={g}
type="button"
onClick={() => setGender(g)}
className={toggleBtnClass(
gender === g,
"min-h-10 p-2 text-xs leading-tight text-center"
)}
>
{g === "any" ? t("ads.genderAny") : t(`ads.gender.${g}`)}
</button>
))}
</div>
</div>
)}
{step === "review" && (
<div className="space-y-4">
<p className="font-semibold">{t("ads.reviewTitle")}</p>
<div className="rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
<p>{t(`ads.type.${adType}`)}</p>
</div>
<div className="rounded-2xl border border-neutral-200 px-4 py-3 text-center dark:border-neutral-700">
<p className="text-neutral-500">{t("ads.priceLabel")}</p>
<p className="text-xl font-bold">
{priceLoading
? "..."
: price != null
? `${price.toLocaleString()} ${t("common.toman")}`
: t("ads.priceUnavailable")}
</p>
</div>
{/* dir=ltr تا در صفحه RTL هم قبلی سمت چپ فیزیکی و پرداخت سمت راست بماند */}
<div className="flex items-center justify-between gap-3 pt-2" dir="ltr">
<RoundedButton
className="h-11 w-28 shrink-0"
disabled={stepIndex === 0}
onClick={goBack}
>
{t("ads.backAction")}
</RoundedButton>
<RoundedButton
variant="primary"
className="h-11 min-w-0 flex-1"
disabled={submitting || !price}
onClick={handleSubmitClick}
>
{submitting ? t("common.loading") : t("ads.payAndPublish")}
</RoundedButton>
</div>
</div>
)}
{step !== "review" && (
<div className="flex justify-between pt-4">
<RoundedButton
variant="primary"
className="h-10 w-28"
disabled={!canGoNext}
onClick={goNext}
>
{t("ads.nextAction")}
</RoundedButton>
<RoundedButton
className="h-10 w-28"
disabled={stepIndex === 0}
onClick={goBack}
>
{t("ads.backAction")}
</RoundedButton>
</div>
)}
</div>
</Container>
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={price ?? 0}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void submit(useWallet);
}}
/>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import EmptyState from "@/components/elements/EmptyState";
import { staticIconUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
type AdRow = {
_id: string;
ad_type: "first_post" | "first_story" | "notification" | "project" | "product" | "shop" | "billboard";
effective_status: "pending_payment" | "active" | "expired" | "failed";
price: number;
views_count: number;
clicks_count: number;
createdAt: string;
};
const STATUS_COLOR: Record<string, string> = {
pending_payment: "text-amber-600",
active: "text-green-600",
expired: "text-neutral-400",
failed: "text-red-500",
};
export default function AdsSettingsPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [ads, setAds] = useState<AdRow[] | null>(null);
useEffect(() => {
request<{ ads?: AdRow[] }>("GET", "/ads/mine", null, { noToast: true })
.then((res) => setAds(res?.ads ?? []))
.catch(() => setAds([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<LocalePageShell>
<Container>
<PageTitle
action={
<Link href="/settings/ads/new" aria-label={t("ads.addNew")} className="p-2">
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
}
>
{t("settings.nav.ads")}
</PageTitle>
<div className="mt-6 space-y-3 text-xs md:text-sm">
{ads === null ? (
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
) : ads.length === 0 ? (
<EmptyState
icon="chart-2"
title={t("ads.emptyTitle")}
subtitle={t("ads.emptyHint")}
/>
) : (
ads.map((ad) => (
<Link
key={ad._id}
href={`/settings/ads/${ad._id}`}
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<div>
<p className="font-semibold">{t(`ads.type.${ad.ad_type}`)}</p>
<p className="text-neutral-500">
{t("ads.viewsClicks", { views: ad.views_count, clicks: ad.clicks_count })}
</p>
</div>
<div className="text-left">
<p className={`font-semibold ${STATUS_COLOR[ad.effective_status]}`}>
{t(`ads.status.${ad.effective_status}`)}
</p>
<p className="text-neutral-400">{Number(ad.price).toLocaleString()} {t("common.toman")}</p>
</div>
</Link>
))
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import AutoDirectList from "@/components/settings/AutoDirectList";
import { staticIconUrl } from "@/components/main/BaseUrl";
import Image from "next/image";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import AutoDirectFormModal from "@/components/settings/AutoDirectFormModal";
function AutoDirectSettingsPage() {
const { t } = useTranslation("common");
const [showCreate, setShowCreate] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
return (
<LocalePageShell>
<Container>
<PageTitle
action={
<button
type="button"
onClick={() => setShowCreate(true)}
aria-label={t("autoDirect.addNew")}
className="p-2"
>
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:brightness-0 dark:invert"
/>
</button>
}
>
{t("settings.nav.autoDirect")}
</PageTitle>
<div className="text-xs md:text-sm">
<AutoDirectList refreshKey={refreshKey} />
</div>
</Container>
{showCreate && (
<AutoDirectFormModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
rule={null}
onSaved={() => setRefreshKey((k) => k + 1)}
/>
)}
</LocalePageShell>
);
}
export default AutoDirectSettingsPage;

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -58,9 +59,11 @@ export default function BookingListPage() {
{t("common.loading")}
</p>
) : filteredConfigs.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("booking.emptyList")}
</p>
<EmptyState
icon="calendar"
title={t("booking.emptyList")}
subtitle={t("booking.emptyListHint")}
/>
) : (
filteredConfigs.map((config) => (
<BookingListCard

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -264,10 +265,12 @@ export default function FindPartnerPage() {
</p>
{users.length === 0 ? (
<div className="py-6 text-center">
<p className="text-sm text-neutral-500">{t("findPartner.empty")}</p>
<p className="mt-2 text-xs text-neutral-400">{t("findPartner.emptyHint")}</p>
</div>
<EmptyState
icon="emoji-happy"
title={t("findPartner.empty")}
subtitle={t("findPartner.emptyHint")}
align="right"
/>
) : (
users.map((item) => {
const age = ageFromBirthday(item.birthday);

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import Map, { Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -479,9 +480,11 @@ export default function NearbyFriendsPage() {
) : null}
{users.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-500">
{t("nearbyFriends.empty")}
</p>
<EmptyState
icon="location"
title={t("nearbyFriends.empty")}
subtitle={t("nearbyFriends.emptyHint")}
/>
) : (
users.map((item) => (
<Link

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedInput from "@/components/elements/RoundedInput";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
@@ -199,9 +200,11 @@ function Chats() {
</Link>
{isEmpty ? (
<p className="py-8 text-center text-neutral-500">
{t("chats.empty")}
</p>
<EmptyState
icon="message"
title={t("chats.empty")}
subtitle={t("chats.emptyHint")}
/>
) : (
data?.pages.map((page) =>
page.filteredUsersData?.map((item: IMessage) => {
@@ -297,9 +300,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

@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import BackButton from "@/components/ui/BackButton";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
@@ -14,8 +15,7 @@ import {
joinGroupRoom,
joinUserRoom,
leaveGroupRoom,
releaseChatSocket,
} from "@/lib/chat/socketClient";
releaseChatSocket, isChatSocketConnected, } from "@/lib/chat/socketClient";
import MessageInput from "@/components/chat/MessageInput";
import ChatActionBar from "@/components/chat/ChatActionBar";
import AddChatRoomMembersModal from "@/components/chat/AddChatRoomMembersModal";
@@ -42,12 +42,14 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import { optimizeMediaFile } from "@/lib/media";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import {
E2EE_LOCKED_PREVIEW,
decryptRoomContent,
decryptRoomMessageList,
encryptRoomContent,
ensureIdentityKeys,
ensureRoomAesKey,
} from "@/lib/e2ee";
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
type RoomMember = {
_id: string;
@@ -163,6 +165,12 @@ function RoomMessageRow({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { onDragStart: _, ...restDragProps } = dragProps;
// طبقِ خواسته‌ی کاربر، E2EE باید کاملاً نامرئی باشه — پیامِ متنیِ خالصی که رمزگشایی
// نشده (هویتِ قدیمی ازدست‌رفته) اصلاً نمایش داده نمی‌شه، نه یه بابلِ خالی/قفل‌شده
const bubbleContent =
msg.content === E2EE_LOCKED_PREVIEW ? "" : msg.content;
if (!bubbleContent && !msg.file) return null;
return (
<div
ref={(el) => {
@@ -202,7 +210,11 @@ function RoomMessageRow({
<span className="font-semibold text-[#0095f6]">
{msg.replyTo.senderName}
</span>
<p className="truncate">{msg.replyTo.content}</p>
<p className="truncate">
{msg.replyTo.content === E2EE_LOCKED_PREVIEW
? ""
: msg.replyTo.content}
</p>
</button>
) : null}
<div className="relative w-full overflow-hidden">
@@ -287,9 +299,9 @@ function RoomMessageRow({
<span>File</span>
</a>
) : null}
{msg.content ? (
{bubbleContent ? (
<p className="chat-message-text select-text whitespace-pre-wrap break-words">
{msg.content}
{bubbleContent}
</p>
) : null}
<span className="mt-1 block text-[10px] opacity-70">
@@ -327,6 +339,8 @@ export default function ChatRoomThreadPage({
const { request } = useAxios();
const router = useRouter();
const user = useUser();
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
const pendingSendRetryRef = useRef<(() => void) | null>(null);
const [room, setRoom] = useState<RoomDetail | null>(null);
const [messages, setMessages] = useState<RoomMessage[]>([]);
// این صفحه با تغییرِ فقط پارامترِ roomId دوباره mount نمی‌شه (رفتارِ App Router)،
@@ -534,7 +548,12 @@ export default function ChatRoomThreadPage({
useEffect(() => {
if (loading) return;
// وقتی سوکت وصله، پیام‌های گروه real-time (newGroupMessage) می‌رسن؛ polling فقط
// همگام‌سازیِ ایمنی با فاصله‌ی بیشتره، وگرنه همون ۸ ثانیه‌ی قبلی
let tick = 0;
const poll = window.setInterval(() => {
tick += 1;
if (isChatSocketConnected() && tick % 8 !== 0) return;
void loadMessages();
void loadRoom();
}, 8000);
@@ -547,9 +566,10 @@ export default function ChatRoomThreadPage({
const processSendMessage = async (
fileToUpload: File | null = null,
fileType?: RoomMessage["fileType"]
fileType?: RoomMessage["fileType"],
contentOverride?: string
) => {
const text = newMessage.trim();
const text = (contentOverride ?? newMessage).trim();
if ((!text && !fileToUpload) || sending) return;
const senderId = String(user?._id ?? getStoredUserId());
@@ -606,14 +626,17 @@ export default function ChatRoomThreadPage({
}
try {
let sendContent = text;
try {
await ensureIdentityKeys(senderId);
const encrypted = await encryptRoomContent(senderId, roomId, text);
if (encrypted) sendContent = encrypted;
} catch {
/* fallback plaintext */
}
// ارسالِ فایل/عکس/ویس/ویدیو توی روم هم فعلاً بدونِ e2ee می‌ره — عینِ دلیلِ همین
// تغییر توی چتِ مستقیم (chats/[username]/[id]/page.tsx): اگه encryptRoomContent
// به هر دلیلی null برگردونه، قبلاً کلِ آپلودِ فایل هم با همین خطا شکست می‌خورد
const sendContent = fileForUpload
? text
: await (async () => {
await ensureIdentityKeys(senderId);
const encrypted = await encryptRoomContent(senderId, roomId, text);
if (encrypted === null) throw new Error("E2EE encryption failed");
return encrypted;
})();
let res: { message?: RoomMessage } | null = null;
if (fileForUpload) {
@@ -662,6 +685,17 @@ export default function ChatRoomThreadPage({
if (replyPayload && savedReply) {
setReplyingTo(savedReply);
}
const errType = (err as { response?: { data?: { type?: string } } })
?.response?.data?.type;
if (errType === "needs_username_password") {
pendingSendRetryRef.current = () => {
void processSendMessage(fileToUpload, fileType, text);
};
setSocialSignupOpen(true);
return;
}
const message =
(err as { response?: { data?: { error?: string; message?: string } } })
?.response?.data?.error ||
@@ -856,9 +890,7 @@ export default function ChatRoomThreadPage({
<IOSSpinner size={28} />
</div>
) : messages.length === 0 ? (
<p className="py-16 text-center text-sm text-neutral-500">
{t("chats.threadEmpty")}
</p>
<EmptyState icon="message" title={t("chats.threadEmpty")} />
) : (
<div className="flex flex-col gap-2 pb-2">
{messages.map((msg) => {
@@ -958,6 +990,20 @@ export default function ChatRoomThreadPage({
existingMemberIds={room?.members?.map((member) => member._id) ?? []}
onAdded={() => void loadRoom()}
/>
<CompleteSocialSignupModal
open={socialSignupOpen}
hasUsername={Boolean(user?.user_name)}
onClose={() => {
setSocialSignupOpen(false);
pendingSendRetryRef.current = null;
}}
onSuccess={() => {
setSocialSignupOpen(false);
pendingSendRetryRef.current?.();
pendingSendRetryRef.current = null;
}}
/>
</Container>
</LocalePageShell>
);

View File

@@ -2,6 +2,7 @@
import Container from "@/components/elements/Container";
import CreateChatRoomModal from "@/components/chat/CreateChatRoomModal";
import EmptyState from "@/components/elements/EmptyState";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
@@ -106,9 +107,7 @@ export default function ChatRoomsPage() {
<IOSSpinner size={28} />
</div>
) : rooms.length === 0 ? (
<p className="py-12 text-center text-neutral-500">
{t("chats.roomsEmpty")}
</p>
<EmptyState icon="message" title={t("chats.roomsEmpty")} />
) : (
<div className="mt-2 flex flex-col gap-1">
{rooms.map((room) => (

View File

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

View File

@@ -6,8 +6,7 @@ import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import Image from "next/image";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import Link from "next/link";
import { useTranslation } from "react-i18next";
@@ -17,6 +16,7 @@ type BlockedUser = {
first_name?: string;
last_name?: string;
profile_image?: string;
verify_badge?: string;
};
function BlockedUsersPage() {
@@ -57,19 +57,13 @@ function BlockedUsersPage() {
href={`/users/${u.user_name}`}
className="flex items-center gap-3 rounded-2xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-neutral-200">
<Image
src={
u.profile_image
? buildStorageUrl(u.profile_image)
: "/images/fake-avatar.png"
}
alt=""
fill
className="object-cover"
unoptimized
/>
</div>
<ProfileAvatar
src={u.profile_image}
userId={u._id}
verifyBadge={u.verify_badge}
sizePx={44}
rounded="full"
/>
<div className="min-w-0">
<p className="truncate text-sm font-bold">{name}</p>
<p className="truncate text-xs text-neutral-500">

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

@@ -16,6 +16,7 @@ const EDIT_NAV_KEY: Record<string, string> = {
"/username": "username",
"/password": "password",
"/google-account": "googleAccount",
"/register-mobile": "registerMobile",
"/two-factor": "twoFactor",
"/link-device": "linkDevice",
"/avatar": "avatar",
@@ -51,7 +52,8 @@ function EditPage() {
{editUserNavLinks?.map((item) => (
<Link
className={`flex items-center gap-2 ${
item.href === "/License" && isRegister === "false"
(item.href === "/License" && isRegister === "false") ||
(item.href === "/register-mobile" && Boolean(user?.mobile))
? "hidden"
: ""
}`}

View File

@@ -16,6 +16,7 @@ import BackButton from "@/components/ui/BackButton";
import { useUser } from "@/hooks/useUser";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { establishIdentityAtLogin } from "@/lib/e2ee";
function PasswordPage() {
const { t } = useTranslation("common");
@@ -60,6 +61,11 @@ function PasswordPage() {
...(user?.mobile ? { mobile: user.mobile } : {}),
new_password: values.password,
})) as IVerifyOtp;
// رمزِ عبور عوض شد (یا اولین‌بار تنظیم شد) — کلیدِ E2EEِ همین مرورگر
// (دست‌نخورده توی IndexedDB) رو زیرِ رمزِ جدید دوباره بک‌آپ بگیر
if (user?._id) {
// [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(user._id, values.password);
}
toast.success(
isGoogleUser
? t("settings.edit.password.setSuccess")

View File

@@ -0,0 +1,132 @@
"use client";
import AuthOtpInput from "@/components/auth/AuthOtpInput";
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { markOtpSent } from "@/lib/auth/otpTimer";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
export default function RegisterMobilePage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [step, setStep] = useState<"mobile" | "otp">("mobile");
const [mobile, setMobile] = useState("");
const [otp, setOtp] = useState("");
const [otpError, setOtpError] = useState(false);
const [otpExpired, setOtpExpired] = useState(false);
const [sending, setSending] = useState(false);
const [verifying, setVerifying] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const sendOtp = async () => {
const cleaned = mobile.replace(/\D/g, "");
if (!/^09[0-9]{9}$/.test(cleaned)) {
toast.error(t("auth.invalidMobile"));
return;
}
setSending(true);
try {
await request("POST", "/account/mobile/request", { mobile: cleaned });
markOtpSent();
setStep("otp");
} catch {
// toast از interceptor
} finally {
setSending(false);
}
};
const verify = async () => {
if (otp.length !== 6) return;
setVerifying(true);
try {
await request("POST", "/account/mobile/verify", { otp });
toast.success(t("settings.edit.registerMobile.success"));
// نه router.push — صفحه‌ی edit با useUser کش‌شده هنوز mobile رو خالی نشون
// می‌داد؛ یه ناوبریِ کامل تضمین می‌کنه که آیتم از لیست درست حذف بشه
window.location.href = "/settings/edit";
} catch {
setOtpError(true);
} finally {
setVerifying(false);
}
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.nav.registerMobile")}</PageTitle>
<div className="mt-6 flex flex-col items-center gap-4 text-sm">
{step === "mobile" ? (
<>
<p className="text-center text-neutral-500">
{t("settings.edit.registerMobile.hint")}
</p>
<RoundedInput
value={mobile}
onChange={(e) => setMobile(e.target.value)}
placeholder="09xxxxxxxxx"
dir="ltr"
className="text-center"
maxLength={11}
/>
<RoundedButton
variant="primary"
className="h-11 w-full"
disabled={sending}
onClick={sendOtp}
>
{sending ? t("common.loading") : t("auth.sendCode")}
</RoundedButton>
</>
) : (
<>
<AuthOtpStatus
mobile={mobile.replace(/\D/g, "")}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
setResendLoading(true);
try {
await request("POST", "/account/mobile/request", {
mobile: mobile.replace(/\D/g, ""),
});
setOtp("");
setOtpError(false);
} finally {
setResendLoading(false);
}
}}
/>
<AuthOtpInput
value={otp}
onChange={(v) => {
setOtp(v);
setOtpError(false);
}}
onComplete={() => setTimeout(() => void verify(), 400)}
error={otpError}
disabled={verifying || otpExpired}
/>
<RoundedButton
variant="primary"
className="mt-3 h-11 w-full"
disabled={verifying || otp.length !== 6}
onClick={() => void verify()}
>
{verifying ? t("common.loading") : t("auth.verifyCode")}
</RoundedButton>
</>
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -78,10 +78,10 @@ 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"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => setSelectedButton(1)}
>
@@ -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

@@ -29,8 +29,12 @@ export default function Financial() {
limit: 15,
});
// قبلاً flatMap روی خودِ page.financial (یه آبجکت {docs, totalPages}، نه آرایه) اجرا
// می‌شد، پس هر صفحه فقط به یه آیتم تبدیل می‌شد و allTransactions[0]?.docs همیشه فقط
// صفحه‌ی اول رو می‌داد — اسکرول به پایین صفحه‌های بعدی رو دانلود می‌کرد ولی هیچ‌وقت
// نمایش نمی‌داد
const allTransactions =
data?.pages.flatMap((page) => page.financial || []) ?? [];
data?.pages.flatMap((page) => page.financial?.docs ?? []) ?? [];
const getTransactionType = (type: string) => {
const known = [
@@ -98,38 +102,38 @@ export default function Financial() {
ref={containerRef}
className="space-y-4 max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-thumb-neutral-300 dark:scrollbar-thumb-neutral-600"
>
{allTransactions[0]?.docs?.map((item: IFinancial) => (
{allTransactions.map((item: IFinancial) => (
<div
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

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import GiftItem from "@/components/settings/gifts/GiftItem";
import GiftReplyModal from "@/components/settings/gifts/GiftReplyModal";
@@ -59,9 +60,11 @@ function GiftsPage() {
<div className="mt-10 w-full">
{data?.pages.length === 0 ||
(data?.pages[0]?.gifts?.length === 0 && !isFetchingNextPage) ? (
<p className="text-center text-gray-500">
{t("settings.gifts.empty")}
</p>
<EmptyState
icon="gift"
title={t("settings.gifts.empty")}
subtitle={t("settings.gifts.emptyHint")}
/>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>

View File

@@ -1,7 +1,10 @@
"use client";
import { useSearchParams } from "next/navigation";
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedInput from "@/components/elements/RoundedInput";
import FilterModal from "@/components/settings/my-billboards/FilterModal";
import PageTitle from "@/components/settings/PageTitle";
@@ -30,6 +33,8 @@ function MyBillboard() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const unpaidOnly = searchParams.get("filter") === "unpaid";
const [searchText, setSearchText] = useState("");
const [search, setSearch] = useState("");
const [showFilterModal, setShowFilterModal] = useState<boolean>(false);
@@ -97,13 +102,17 @@ function MyBillboard() {
{data?.pages.length === 0 ||
(data?.pages[0]?.advertisings?.length === 0 &&
!isFetchingNextPage) ? (
<p className="text-center text-gray-500">
{t("settings.billboards.empty")}
</p>
<EmptyState
icon="gallery"
title={t("settings.billboards.empty")}
subtitle={t("settings.billboards.emptyHint")}
/>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>
{page?.advertisings?.map((item: IAdvertising) => (
{page?.advertisings
?.filter((item: IAdvertising) => !unpaidOnly || (item as { effective_status?: string })?.effective_status === "pending_payment")
.map((item: IAdvertising) => (
<div
className="cursor-pointer"
onClick={() =>

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
import NotificationRow from "@/components/settings/NotificationRow";
@@ -229,9 +230,11 @@ function Notifications() {
<div className="mt-5 font-semibold">
{filteredItems.length === 0 && !isFetchingNextPage ? (
<p className="text-center text-gray-500">
{t("settings.notifications.empty")}
</p>
<EmptyState
icon="notification"
title={t("settings.notifications.empty")}
subtitle={t("settings.notifications.emptyHint")}
/>
) : (
groupedItems.map((entry) =>
entry.type === "date" ? (

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import CommentModal from "@/components/settings/offers/CommentModal";
import ConfirmModal from "@/components/settings/offers/ConfirmModal";
@@ -63,9 +64,11 @@ function Offers() {
<div className="w-full mt-10">
{data?.pages.length === 0 ||
(data?.pages[0]?.offer?.length === 0 && !isFetchingNextPage) ? (
<p className="text-center text-gray-500">
{t("settings.offers.empty")}
</p>
<EmptyState
icon="briefcase"
title={t("settings.offers.empty")}
subtitle={t("settings.offers.emptyHint")}
/>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>

View File

@@ -13,6 +13,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import { isAppLanguage, type AppLanguage } from "@/lib/i18n/constants";
import { isSubUserSession } from "@/lib/auth/session";
// زبان‌هایی که آیتم‌های آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما فقط توی اونا نمایش داده می‌شن
const SETTINGS_ITEMS_VISIBLE_LANGUAGES: AppLanguage[] = ["fa", "ar", "en"];
@@ -215,6 +216,17 @@ export default function UserSettingsPage() {
<span className="text-neutral-400">›</span>
</Link>
{/* «مدیریت کاربران» فقط مالِ خودِ صاحبِ اکانته، نه کاربرِ زیرمجموعه */}
{!isSubUserSession() && (
<Link
href="/settings/sub-users"
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.nav.subUsers")}</span>
<span className="text-neutral-400">›</span>
</Link>
)}
<Link
href="/settings/booking"
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
@@ -223,6 +235,30 @@ export default function UserSettingsPage() {
<span className="text-neutral-400">›</span>
</Link>
<Link
href="/settings/auto-direct"
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.nav.autoDirect")}</span>
<span className="text-neutral-400">›</span>
</Link>
<Link
href="/settings/shareable-links"
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.nav.shareableLinks")}</span>
<span className="text-neutral-400">›</span>
</Link>
<Link
href="/settings/ads"
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.nav.ads")}</span>
<span className="text-neutral-400">›</span>
</Link>
{/* آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما و محتوای این آیتم‌ها فقط توی
فارسی/عربی/انگلیسی نمایش داده می‌شن */}
{SETTINGS_ITEMS_VISIBLE_LANGUAGES.includes(language) ? (

View File

@@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from "react";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import PageTitle from "@/components/settings/PageTitle";
import VisitorRow, { type ProfileVisitor } from "@/components/settings/VisitorRow";
import ListDateSeparator from "@/components/settings/ListDateSeparator";
@@ -60,9 +61,11 @@ export default function ProfileVisitorsPage() {
) : error ? (
<p className="py-8 text-center text-sm text-red-500">{error}</p>
) : visitors.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("settings.visitor.empty")}
</p>
<EmptyState
icon="eye"
title={t("settings.visitor.empty")}
subtitle={t("settings.visitor.emptyHint")}
/>
) : (
<div className="mt-2 font-semibold">
{groupedVisitors.map((entry) =>

View File

@@ -0,0 +1,24 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import ShareableLinksManager from "@/components/settings/ShareableLinksManager";
import { useTranslation } from "react-i18next";
function ShareableLinksSettingsPage() {
const { t } = useTranslation("common");
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.nav.shareableLinks")}</PageTitle>
<div className="text-xs md:text-sm">
<ShareableLinksManager />
</div>
</Container>
</LocalePageShell>
);
}
export default ShareableLinksSettingsPage;

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

@@ -0,0 +1,291 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { SUB_USER_PERMISSION_KEYS } from "@/constants/subUserPermissions";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type SubUser = {
_id: string;
first_name?: string;
last_name?: string;
user_name: string;
profile_image?: string | null;
sub_user_active: boolean;
sub_user_permissions: string[];
};
export default function SubUserDetailPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const params = useParams<{ id: string }>();
const id = params?.id;
// نه router.back()/Link — چون توی این صفحه یه غیرفعال/فعال‌سازی یا ویرایش ممکنه
// انجام شده باشه و لیستِ صفحه‌ی قبل (که فقط با useEffect یه‌بار دیتاش رو می‌گیره)
// بدونِ یه ناوبریِ کامل (نه soft) وضعیتِ تازه رو نشون نمی‌داد
const backToList = () => {
window.location.href = "/settings/sub-users";
};
const [subUser, setSubUser] = useState<SubUser | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [toggling, setToggling] = useState(false);
// فرمِ ویرایش
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [userName, setUserName] = useState("");
const [password, setPassword] = useState("");
const [avatar, setAvatar] = useState<string | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [permissions, setPermissions] = useState<string[]>([]);
const load = () => {
if (!id) return;
request<{ subUser?: SubUser }>("GET", `/sub-users/${id}`, null, { noToast: true })
.then((res) => setSubUser(res?.subUser ?? null))
.catch(() => setSubUser(null));
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const startEditing = () => {
if (!subUser) return;
setFirstName(subUser.first_name || "");
setLastName(subUser.last_name || "");
setUserName(subUser.user_name || "");
setPassword("");
setAvatar(subUser.profile_image ? buildStorageUrl(subUser.profile_image) : null);
setAvatarFile(null);
setPermissions(subUser.sub_user_permissions || []);
setEditing(true);
};
const togglePermission = (key: string) => {
setPermissions((prev) =>
prev.includes(key) ? prev.filter((p) => p !== key) : [...prev, key]
);
};
const selectImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAvatarFile(file);
const reader = new FileReader();
reader.onload = () => setAvatar(reader.result as string);
reader.readAsDataURL(file);
};
const save = async () => {
if (!id || !firstName.trim() || !userName.trim()) {
toast.error(t("auth.usernamePasswordRequired"));
return;
}
setSaving(true);
try {
const formData = new FormData();
formData.append("first_name", firstName.trim());
formData.append("last_name", lastName.trim());
formData.append("user_name", userName.trim());
if (password) formData.append("password", password);
formData.append("permissions", JSON.stringify(permissions));
if (avatarFile) formData.append("profile_image", avatarFile);
await request("PATCH", `/sub-users/${id}`, formData);
toast.success(t("subUsers.updateSuccess"));
setEditing(false);
load();
} catch {
// toast از interceptor
} finally {
setSaving(false);
}
};
const toggleActive = async () => {
if (!id || !subUser) return;
setToggling(true);
try {
await request("PATCH", `/sub-users/${id}/active`, {
active: !subUser.sub_user_active,
});
toast.success(
t(subUser.sub_user_active ? "subUsers.disableSuccess" : "subUsers.enableSuccess")
);
load();
} catch {
// toast از interceptor
} finally {
setToggling(false);
}
};
if (!subUser) {
return (
<LocalePageShell>
<Container>
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
</Container>
</LocalePageShell>
);
}
const name = [subUser.first_name, subUser.last_name].filter(Boolean).join(" ") || subUser.user_name;
if (editing) {
return (
<LocalePageShell>
<Container>
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
<div className="mt-6 flex flex-col items-center gap-5 text-xs md:text-sm">
<div className="flex flex-col items-center gap-3">
<div className="relative flex h-[110px] w-[110px] items-center justify-center overflow-hidden rounded-full border border-[#676767]">
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={avatar} alt="" className="h-full w-full object-cover" />
) : (
<label htmlFor="subUserAvatarEditInput" className="flex h-full w-full cursor-pointer items-center justify-center">
<Image src="/images/icons/gallery-add.svg" width={40} height={40} alt="" />
</label>
)}
</div>
<input
id="subUserAvatarEditInput"
type="file"
accept="image/*"
className="hidden"
onChange={selectImage}
/>
<label htmlFor="subUserAvatarEditInput" className="cursor-pointer text-neutral-500 underline">
{t("settings.edit.avatar.editImage")}
</label>
</div>
<div className="w-full space-y-3">
<div>
<p className="mb-2 font-semibold">{t("subUsers.nameLabel")}</p>
<RoundedInput value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.lastNameLabel")}</p>
<RoundedInput value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.usernameLabel")}</p>
<RoundedInput value={userName} onChange={(e) => setUserName(e.target.value)} dir="ltr" />
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.passwordLabel")}</p>
<RoundedInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("subUsers.passwordEditHint")}
type="password"
dir="ltr"
/>
</div>
</div>
<div className="w-full">
<p className="mb-3 font-semibold">{t("subUsers.permissionsLabel")}</p>
<div className="space-y-2">
{SUB_USER_PERMISSION_KEYS.map((key) => (
<label
key={key}
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<input
type="checkbox"
checked={permissions.includes(key)}
onChange={() => togglePermission(key)}
className="h-4 w-4"
/>
<span>{t(`subUsers.permissions.${key}`)}</span>
</label>
))}
</div>
</div>
<div className="flex w-full gap-3">
<RoundedButton className="h-11 flex-1" onClick={() => setEditing(false)} disabled={saving}>
{t("ads.backAction")}
</RoundedButton>
<RoundedButton variant="primary" className="h-11 flex-1" onClick={save} disabled={saving}>
{saving ? t("subUsers.saving") : t("subUsers.save")}
</RoundedButton>
</div>
</div>
</Container>
</LocalePageShell>
);
}
return (
<LocalePageShell>
<Container>
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
<div className="mt-6 flex flex-col items-center gap-4 text-xs md:text-sm">
<ProfileAvatar src={subUser.profile_image} size="lg" rounded="full" />
<div className="text-center">
<p className="text-lg font-bold">{name}</p>
<p className="text-neutral-500">@{subUser.user_name}</p>
</div>
<div className="w-full">
<p className="mb-3 font-semibold">{t("subUsers.permissionsTitle")}</p>
<div className="space-y-3">
{SUB_USER_PERMISSION_KEYS.map((key) => {
const granted = subUser.sub_user_permissions?.includes(key);
return (
<div key={key}>
<p className="mb-2 text-sm font-semibold">{t(`subUsers.permissions.${key}`)}</p>
<div
className={
"flex min-h-[42px] w-full items-center rounded-2xl border px-4 py-2.5 text-sm " +
(granted
? "border-green-300 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400"
: "border-[#C3C3C3] bg-white/80 text-neutral-500 dark:border-neutral-600 dark:bg-neutral-900/80")
}
>
{t(granted ? "settings.yes" : "settings.no")}
</div>
</div>
);
})}
</div>
</div>
<div className="flex w-full gap-3 pt-2">
<RoundedButton className="h-11 flex-1" onClick={startEditing}>
{t("subUsers.edit")}
</RoundedButton>
<RoundedButton
variant={subUser.sub_user_active ? "default" : "primary"}
className="h-11 flex-1"
onClick={toggleActive}
disabled={toggling}
>
{t(subUser.sub_user_active ? "subUsers.disable" : "subUsers.enable")}
</RoundedButton>
</div>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import { SUB_USER_PERMISSION_KEYS } from "@/constants/subUserPermissions";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
export default function NewSubUserPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [userName, setUserName] = useState("");
const [password, setPassword] = useState("");
const [avatar, setAvatar] = useState<string | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [permissions, setPermissions] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const togglePermission = (key: string) => {
setPermissions((prev) =>
prev.includes(key) ? prev.filter((p) => p !== key) : [...prev, key]
);
};
const selectImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAvatarFile(file);
const reader = new FileReader();
reader.onload = () => setAvatar(reader.result as string);
reader.readAsDataURL(file);
};
const submit = async () => {
if (!firstName.trim() || !userName.trim() || !password) {
toast.error(t("auth.usernamePasswordRequired"));
return;
}
setSubmitting(true);
try {
const formData = new FormData();
formData.append("first_name", firstName.trim());
formData.append("last_name", lastName.trim());
formData.append("user_name", userName.trim());
formData.append("password", password);
formData.append("permissions", JSON.stringify(permissions));
if (avatarFile) formData.append("profile_image", avatarFile);
await request("POST", "/sub-users", formData);
toast.success(t("subUsers.createSuccess"));
// router.push ساده کافی نیست — صفحه‌ی لیست دیتاش رو فقط توی useEffect (یه بار
// موقع mount) می‌گیره؛ چون Next.js موقعِ برگشت این صفحه رو از کشِ خودش (نه
// واقعاً remount) نشون می‌ده، useEffect دوباره اجرا نمی‌شه و کاربرِ تازه‌ساخته
// دیده نمی‌شه تا یه رفرشِ دستی. navigation کامل (نه soft) این رو تضمین می‌کنه.
window.location.href = "/settings/sub-users";
} catch {
// خطا با toast interceptor خودِ useAxios نمایش داده می‌شه
} finally {
setSubmitting(false);
}
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("subUsers.createTitle")}</PageTitle>
<div className="mt-6 flex flex-col items-center gap-5 text-xs md:text-sm">
<div className="flex flex-col items-center gap-3">
<div className="relative flex h-[110px] w-[110px] items-center justify-center overflow-hidden rounded-full border border-[#676767]">
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={avatar} alt="" className="h-full w-full object-cover" />
) : (
<label htmlFor="subUserAvatarInput" className="flex h-full w-full cursor-pointer items-center justify-center">
<Image src="/images/icons/gallery-add.svg" width={40} height={40} alt="" />
</label>
)}
</div>
<input
id="subUserAvatarInput"
type="file"
accept="image/*"
className="hidden"
onChange={selectImage}
/>
<label htmlFor="subUserAvatarInput" className="cursor-pointer text-neutral-500 underline">
{t("settings.edit.avatar.editImage")}
</label>
</div>
<div className="w-full space-y-3">
<div>
<p className="mb-2 font-semibold">{t("subUsers.nameLabel")}</p>
<RoundedInput
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder={t("subUsers.namePlaceholder")}
/>
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.lastNameLabel")}</p>
<RoundedInput
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder={t("subUsers.lastNamePlaceholder")}
/>
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.usernameLabel")}</p>
<RoundedInput
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder={t("subUsers.usernamePlaceholder")}
autoCapitalize="none"
dir="ltr"
/>
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.passwordLabel")}</p>
<RoundedInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("subUsers.passwordPlaceholder")}
type="password"
dir="ltr"
/>
</div>
</div>
<div className="w-full">
<p className="mb-3 font-semibold">{t("subUsers.permissionsLabel")}</p>
<div className="space-y-2">
{SUB_USER_PERMISSION_KEYS.map((key) => (
<label
key={key}
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<input
type="checkbox"
checked={permissions.includes(key)}
onChange={() => togglePermission(key)}
className="h-4 w-4"
/>
<span>{t(`subUsers.permissions.${key}`)}</span>
</label>
))}
</div>
</div>
<RoundedButton
variant="primary"
className="mt-2 h-11 w-full"
disabled={submitting}
onClick={submit}
>
{submitting ? t("subUsers.saving") : t("subUsers.save")}
</RoundedButton>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import EmptyState from "@/components/elements/EmptyState";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { staticIconUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
type SubUserRow = {
_id: string;
first_name?: string;
last_name?: string;
user_name: string;
profile_image?: string | null;
sub_user_active: boolean;
};
export default function SubUsersListPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [subUsers, setSubUsers] = useState<SubUserRow[] | null>(null);
useEffect(() => {
request<{ subUsers?: SubUserRow[] }>("GET", "/sub-users", null, { noToast: true })
.then((res) => setSubUsers(res?.subUsers ?? []))
.catch(() => setSubUsers([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<LocalePageShell>
<Container>
<PageTitle
action={
<Link href="/settings/sub-users/new" aria-label={t("subUsers.createTitle")} className="p-2">
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
}
>
{t("subUsers.listTitle")}
</PageTitle>
<div className="mt-6 space-y-3 text-xs md:text-sm">
{subUsers === null ? (
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
) : subUsers.length === 0 ? (
<EmptyState
icon="profile-2user"
title={t("subUsers.emptyTitle")}
subtitle={t("subUsers.emptyHint")}
/>
) : (
subUsers.map((su) => {
const name = [su.first_name, su.last_name].filter(Boolean).join(" ") || su.user_name;
return (
<Link
key={su._id}
href={`/settings/sub-users/${su._id}`}
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<ProfileAvatar src={su.profile_image} size="sm" rounded="full" />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold">{name}</p>
<p className="truncate text-neutral-500">@{su.user_name}</p>
</div>
<span
className={
su.sub_user_active
? "shrink-0 text-green-600"
: "shrink-0 text-neutral-400"
}
>
{t(su.sub_user_active ? "subUsers.active" : "subUsers.inactive")}
</span>
</Link>
);
})
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import PageTitle from "@/components/settings/PageTitle";
@@ -57,9 +58,11 @@ function Tickets() {
<div className="w-full mt-5">
{data?.pages.length === 0 ||
(data?.pages[0]?.tickets?.length === 0 && !isFetchingNextPage) ? (
<p className="text-center text-gray-500">
{t("settings.tickets.empty")}
</p>
<EmptyState
icon="message-question"
title={t("settings.tickets.empty")}
subtitle={t("settings.tickets.emptyHint")}
/>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>

View File

@@ -8,7 +8,6 @@ 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 { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -31,8 +30,6 @@ type WalletSummary = {
export default function WalletSettingsPage() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const { request, loading } = useAxios();
const [bucket, setBucket] = useState<string>(BUCKET_SHOP);
@@ -48,9 +45,17 @@ export default function WalletSettingsPage() {
useEffect(() => {
loadSummary();
const charge = searchParams.get("charge");
if (charge === "success") toast.success(t("shops.paymentSuccessTitle"));
if (charge === "failed") toast.error(t("shops.paymentFailedTitle"));
const onVisible = () => {
if (document.visibilityState === "visible") loadSummary();
};
window.addEventListener("pageshow", loadSummary);
window.addEventListener("focus", loadSummary);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.removeEventListener("pageshow", loadSummary);
window.removeEventListener("focus", loadSummary);
document.removeEventListener("visibilitychange", onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -82,14 +87,19 @@ export default function WalletSettingsPage() {
const amount = Number(chargeAmount);
if (!amount || amount <= 0) return;
try {
const response = await request<{ authority?: string }>(
const response = await request<{ authority?: string; paymentUrl?: string }>(
"POST",
"/wallet/charge/initiate",
{ amount }
);
if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
}
const target =
response?.paymentUrl ||
(response?.authority
? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
: null);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
if (target) window.location.href = target;
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
@@ -98,9 +108,21 @@ export default function WalletSettingsPage() {
}
};
const cta =
bucket === BUCKET_CHARGED
? { label: t("shops.chargeWallet"), onClick: handleCharge, disabled: loading }
: {
label: t("shops.requestWithdrawal"),
onClick: handleWithdraw,
disabled:
loading ||
(bucket === BUCKET_GIFT &&
(!summary || summary.gift.available < summary.gift.minWithdrawal)),
};
return (
<LocalePageShell>
<Container>
<Container className="pb-28">
<PageTitle>{t("settings.nav.wallet")}</PageTitle>
<div className="mx-auto mt-4 max-w-md text-center">
@@ -172,14 +194,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
@@ -205,14 +219,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading || summary.gift.available < summary.gift.minWithdrawal}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
@@ -233,14 +239,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
@@ -261,14 +259,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
@@ -289,14 +279,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
@@ -318,19 +300,24 @@ export default function WalletSettingsPage() {
value={chargeAmount}
onChange={(e) => setChargeAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading}
onClick={handleCharge}
>
{t("shops.chargeWallet")}
</RoundedButton>
</div>
</>
)}
</div>
</Container>
{summary ? (
<div className="pointer-events-none fixed bottom-28 left-0 right-0 z-[54] flex justify-center px-4">
<RoundedButton
variant="primary"
className="pointer-events-auto h-10 w-full max-w-[200px]"
disabled={cta.disabled}
onClick={cta.onClick}
>
{cta.label}
</RoundedButton>
</div>
) : null}
</LocalePageShell>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedDiv from "@/components/elements/RoundedDiv";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
export default function WalletPaymentFailedPage() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const { request } = useAxios();
const [paying, setPaying] = useState(false);
const amount = Number(searchParams.get("amount") || 0);
const retryPayment = async () => {
if (!amount || amount <= 0 || paying) return;
setPaying(true);
try {
const response = await request<{ authority?: string; paymentUrl?: string }>(
"POST",
"/wallet/charge/initiate",
{ amount },
{ noToast: true }
);
const paymentUrl =
response.paymentUrl ||
(response.authority
? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
: null);
if (!paymentUrl) {
toast.error(t("settings.paymentStartError"));
setPaying(false);
return;
}
window.location.href = paymentUrl;
} catch (error: unknown) {
const message =
(error as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("settings.paymentStartError");
toast.error(message);
setPaying(false);
}
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("shops.paymentFailedTitle")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}
height={50}
alt=""
src="/images/icons/failed.svg"
className="mb-5 pb-1"
/>
<span>{t("shops.paymentFailedHint")}</span>
</div>
<div className="mt-8 w-full px-4">
{amount > 0 && (
<RoundedDiv className="w-full p-3 text-center">
{amount.toLocaleString()} {t("settings.toman")}
</RoundedDiv>
)}
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<RoundedButton
onClick={retryPayment}
disabled={paying || !amount}
variant="primary"
className="h-9 flex-1"
>
{paying ? "..." : t("offerPage.paymentFailed.retry")}
</RoundedButton>
<RoundedButton
onClick={() => router.push("/settings/wallet")}
className="h-9 flex-1"
>
{t("common.back")}
</RoundedButton>
</div>
</div>
</Container>
</LocalePageShell>
);
}

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