diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a..a419cbe 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/public/images/events-empty-state.png b/public/images/events-empty-state.png new file mode 100644 index 0000000..45b13f6 Binary files /dev/null and b/public/images/events-empty-state.png differ diff --git a/src/components/CouponDialogFa.tsx b/src/components/CouponDialogFa.tsx index 359b0ea..f417d7c 100644 --- a/src/components/CouponDialogFa.tsx +++ b/src/components/CouponDialogFa.tsx @@ -22,7 +22,10 @@ type Props = { onOpenChange: (v: boolean) => void; basePrice: number; // مبلغ اولیه رویداد onVerifyCouponRaw: (code: string) => Promise; - onContinue: (coupon?: string, finalPrice?: number) => void; // ادامه‌ی جریان ثبت‌نام/پرداخت + onContinue: (coupon?: string, finalPrice?: number) => void | Promise; // ادامه‌ی جریان ثبت‌نام/پرداخت + continueError?: string | null; + isSubmitting?: boolean; + onContinueErrorClear?: () => void; }; export default function CouponDialogFa({ @@ -31,6 +34,9 @@ export default function CouponDialogFa({ basePrice, onVerifyCouponRaw, onContinue, + continueError, + isSubmitting = false, + onContinueErrorClear, }: Props) { const [code, setCode] = React.useState(""); const [verifying, setVerifying] = React.useState(false); @@ -80,11 +86,15 @@ export default function CouponDialogFa({ setCode(e.target.value)} + onChange={(e) => { + setCode(e.target.value); + setRes(null); + onContinueErrorClear?.(); + }} placeholder="مثلاً OFF20" className="text-right" /> - @@ -117,16 +127,22 @@ export default function CouponDialogFa({ {formatNumberPersian(finalPrice)} تومان + {continueError ? ( +

+ {continueError} +

+ ) : null}
- +
diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index a920573..6522b12 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -23,6 +23,10 @@ type MarkdownProps = { size?: MarkdownSize; }; +type MarkdownNodeWithPosition = { + position?: { start?: { line?: number } }; +}; + function CodeBlock({ className, children, @@ -92,7 +96,8 @@ export default function Markdown({ }: MarkdownProps) { const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined; const headings = extractMarkdownHeadings(content); - let headingIndex = 0; + const headingIdsByLine = new Map(); + headings.forEach(({ line, id }) => headingIdsByLine.set(line, id)); const baseSizeClass = size === "sm" ? "text-sm" : size === "lg" ? "text-lg" : "text-base"; @@ -108,13 +113,9 @@ export default function Markdown({ ? { textAlign: "justify", textJustify: "inter-word" } : undefined; - const nextHeadingId = (level: 1 | 2 | 3) => { - while (headingIndex < headings.length) { - const heading = headings[headingIndex]; - headingIndex += 1; - if (heading.level === level) return heading.id; - } - return undefined; + const headingId = (node?: MarkdownNodeWithPosition) => { + const line = node?.position?.start?.line; + return line ? headingIdsByLine.get(line) : undefined; }; return ( @@ -127,9 +128,9 @@ export default function Markdown({ remarkPlugins={[remarkGfm]} rehypePlugins={rehypePlugins} components={{ - h1: (p) =>

, - h2: (p) =>

, - h3: (p) =>

, + h1: (p) =>

, + h2: (p) =>

, + h3: (p) =>

, h4: (p) =>

, p: (p) =>

, a: (p) => , diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx index 360d681..01f7717 100644 --- a/src/components/ui/slider.tsx +++ b/src/components/ui/slider.tsx @@ -16,6 +16,7 @@ const Slider = React.forwardRef< + )); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/src/lib/content-cms.ts b/src/lib/content-cms.ts index 8540634..d5fc1e7 100644 --- a/src/lib/content-cms.ts +++ b/src/lib/content-cms.ts @@ -254,7 +254,7 @@ const MOCK_PAGE_CONTENT: Record = { members_intro: "با اعضایی آشنا شوید که برای ساختن تجربه‌های بهتر در انجمن همکاری می‌کنند.", gallery_heading: "خاطرات انجمن", gallery_intro: "تصاویری از گردهمایی‌ها، برنامه‌ها و لحظه‌هایی که کنار هم ساخته‌ایم.", - past_events_heading: "رویدادهای برگزارشده", + past_events_heading: "رویدادهای برگزار شده", history_heading: "مسیر انجمن", audience_heading: "برای چه کسانی؟", year_events_heading: "لحظه‌های هر سال", diff --git a/src/lib/markdown-headings.ts b/src/lib/markdown-headings.ts index 25ed92a..3246064 100644 --- a/src/lib/markdown-headings.ts +++ b/src/lib/markdown-headings.ts @@ -2,6 +2,7 @@ export type MarkdownHeading = { id: string; level: 1 | 2 | 3; text: string; + line: number; }; function plainHeadingText(value: string) { @@ -32,7 +33,7 @@ export function extractMarkdownHeadings(content?: string): MarkdownHeading[] { return (content || "") .split(/\r?\n/) - .map((line) => { + .map((line, index) => { const match = /^(#{1,3})\s+(.+?)\s*#*$/.exec(line.trim()); if (!match) return null; @@ -46,6 +47,7 @@ export function extractMarkdownHeadings(content?: string): MarkdownHeading[] { id: nextCount === 1 ? base : `${base}-${nextCount}`, level, text, + line: index + 1, }; }) .filter(Boolean) as MarkdownHeading[]; diff --git a/tests/markdown-headings.test.mjs b/tests/markdown-headings.test.mjs new file mode 100644 index 0000000..2c659cf --- /dev/null +++ b/tests/markdown-headings.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { extractMarkdownHeadings } from "../src/lib/markdown-headings.ts"; + +test("keeps markdown heading IDs tied to their source lines", () => { + assert.deepEqual( + extractMarkdownHeadings("# مقدمه\n\n## موضوع\n## موضوع"), + [ + { id: "مقدمه", level: 1, text: "مقدمه", line: 1 }, + { id: "موضوع", level: 2, text: "موضوع", line: 3 }, + { id: "موضوع-2", level: 2, text: "موضوع", line: 4 }, + ], + ); +});