chore(frontend): commit remaining workspace updates

This commit is contained in:
2026-10-01 12:24:50 +03:30
parent 0f5ec429d0
commit 57c8da1326
8 changed files with 56 additions and 22 deletions

4
next-env.d.ts vendored
View File

@@ -1,7 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
import "./.next/types/root-params.d.ts"; import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -22,7 +22,10 @@ type Props = {
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
basePrice: number; // مبلغ اولیه رویداد basePrice: number; // مبلغ اولیه رویداد
onVerifyCouponRaw: (code: string) => Promise<RawVerifyResult>; onVerifyCouponRaw: (code: string) => Promise<RawVerifyResult>;
onContinue: (coupon?: string, finalPrice?: number) => void; // ادامه‌ی جریان ثبت‌نام/پرداخت onContinue: (coupon?: string, finalPrice?: number) => void | Promise<void>; // ادامه‌ی جریان ثبت‌نام/پرداخت
continueError?: string | null;
isSubmitting?: boolean;
onContinueErrorClear?: () => void;
}; };
export default function CouponDialogFa({ export default function CouponDialogFa({
@@ -31,6 +34,9 @@ export default function CouponDialogFa({
basePrice, basePrice,
onVerifyCouponRaw, onVerifyCouponRaw,
onContinue, onContinue,
continueError,
isSubmitting = false,
onContinueErrorClear,
}: Props) { }: Props) {
const [code, setCode] = React.useState(""); const [code, setCode] = React.useState("");
const [verifying, setVerifying] = React.useState(false); const [verifying, setVerifying] = React.useState(false);
@@ -80,11 +86,15 @@ export default function CouponDialogFa({
<Input <Input
id="coupon" id="coupon"
value={code} value={code}
onChange={(e) => setCode(e.target.value)} onChange={(e) => {
setCode(e.target.value);
setRes(null);
onContinueErrorClear?.();
}}
placeholder="مثلاً OFF20" placeholder="مثلاً OFF20"
className="text-right" className="text-right"
/> />
<Button variant="secondary" disabled={!code || verifying} onClick={handleVerify}> <Button type="button" variant="secondary" disabled={!code.trim() || verifying || isSubmitting} onClick={handleVerify}>
{verifying ? "در حال بررسی..." : "بررسی کد"} {verifying ? "در حال بررسی..." : "بررسی کد"}
</Button> </Button>
</div> </div>
@@ -117,16 +127,22 @@ export default function CouponDialogFa({
<span className="font-semibold">{formatNumberPersian(finalPrice)} تومان</span> <span className="font-semibold">{formatNumberPersian(finalPrice)} تومان</span>
</div> </div>
</div> </div>
{continueError ? (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm leading-6 text-foreground">
{continueError}
</p>
) : null}
</div> </div>
<DialogFooter className="sm:justify-start"> <DialogFooter className="sm:justify-start">
<div className="flex w-full items-center justify-end gap-2"> <div className="flex w-full items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>انصراف</Button> <Button type="button" variant="outline" disabled={isSubmitting} onClick={() => onOpenChange(false)}>انصراف</Button>
<Button <Button
onClick={() => onContinue(code || undefined, finalPrice)} type="button"
disabled={verifying /* در حال بررسی که هستیم، ادامه غیرفعال باشد */} onClick={() => void onContinue(code.trim() || undefined, finalPrice)}
disabled={verifying || isSubmitting || Boolean(code.trim() && !res?.valid)}
> >
ادامه و پرداخت {isSubmitting ? "در حال آماده‌سازی..." : finalPrice === 0 ? "ثبت‌نام رایگان با کد تخفیف" : "ادامه و پرداخت"}
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>

View File

@@ -23,6 +23,10 @@ type MarkdownProps = {
size?: MarkdownSize; size?: MarkdownSize;
}; };
type MarkdownNodeWithPosition = {
position?: { start?: { line?: number } };
};
function CodeBlock({ function CodeBlock({
className, className,
children, children,
@@ -92,7 +96,8 @@ export default function Markdown({
}: MarkdownProps) { }: MarkdownProps) {
const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined; const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined;
const headings = extractMarkdownHeadings(content); const headings = extractMarkdownHeadings(content);
let headingIndex = 0; const headingIdsByLine = new Map<number, string>();
headings.forEach(({ line, id }) => headingIdsByLine.set(line, id));
const baseSizeClass = const baseSizeClass =
size === "sm" ? "text-sm" : size === "lg" ? "text-lg" : "text-base"; size === "sm" ? "text-sm" : size === "lg" ? "text-lg" : "text-base";
@@ -108,13 +113,9 @@ export default function Markdown({
? { textAlign: "justify", textJustify: "inter-word" } ? { textAlign: "justify", textJustify: "inter-word" }
: undefined; : undefined;
const nextHeadingId = (level: 1 | 2 | 3) => { const headingId = (node?: MarkdownNodeWithPosition) => {
while (headingIndex < headings.length) { const line = node?.position?.start?.line;
const heading = headings[headingIndex]; return line ? headingIdsByLine.get(line) : undefined;
headingIndex += 1;
if (heading.level === level) return heading.id;
}
return undefined;
}; };
return ( return (
@@ -127,9 +128,9 @@ export default function Markdown({
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
components={{ components={{
h1: (p) => <h1 {...p} id={nextHeadingId(1)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h1)} style={{ ...p.style, textAlign: "right" }} />, h1: (p) => <h1 {...p} id={headingId(p.node)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h1)} style={{ ...p.style, textAlign: "right" }} />,
h2: (p) => <h2 {...p} id={nextHeadingId(2)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h2)} style={{ ...p.style, textAlign: "right" }} />, h2: (p) => <h2 {...p} id={headingId(p.node)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h2)} style={{ ...p.style, textAlign: "right" }} />,
h3: (p) => <h3 {...p} id={nextHeadingId(3)} className={cn("scroll-mt-28 pt-2 text-right font-semibold", hScale.h3)} style={{ ...p.style, textAlign: "right" }} />, h3: (p) => <h3 {...p} id={headingId(p.node)} className={cn("scroll-mt-28 pt-2 text-right font-semibold", hScale.h3)} style={{ ...p.style, textAlign: "right" }} />,
h4: (p) => <h4 {...p} className={cn("mt-4 text-right font-semibold", hScale.h4)} style={{ ...p.style, textAlign: "right" }} />, h4: (p) => <h4 {...p} className={cn("mt-4 text-right font-semibold", hScale.h4)} style={{ ...p.style, textAlign: "right" }} />,
p: (p) => <p className="my-4" {...p} />, p: (p) => <p className="my-4" {...p} />,
a: (p) => <a className="break-all underline decoration-primary hover:opacity-90" target="_blank" rel="noopener noreferrer" {...p} />, a: (p) => <a className="break-all underline decoration-primary hover:opacity-90" target="_blank" rel="noopener noreferrer" {...p} />,

View File

@@ -16,6 +16,7 @@ const Slider = React.forwardRef<
<SliderPrimitive.Range className="absolute h-full bg-primary" /> <SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)); ));
Slider.displayName = SliderPrimitive.Root.displayName; Slider.displayName = SliderPrimitive.Root.displayName;

View File

@@ -2,6 +2,7 @@ export type MarkdownHeading = {
id: string; id: string;
level: 1 | 2 | 3; level: 1 | 2 | 3;
text: string; text: string;
line: number;
}; };
function plainHeadingText(value: string) { function plainHeadingText(value: string) {
@@ -32,7 +33,7 @@ export function extractMarkdownHeadings(content?: string): MarkdownHeading[] {
return (content || "") return (content || "")
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => { .map((line, index) => {
const match = /^(#{1,3})\s+(.+?)\s*#*$/.exec(line.trim()); const match = /^(#{1,3})\s+(.+?)\s*#*$/.exec(line.trim());
if (!match) return null; if (!match) return null;
@@ -46,6 +47,7 @@ export function extractMarkdownHeadings(content?: string): MarkdownHeading[] {
id: nextCount === 1 ? base : `${base}-${nextCount}`, id: nextCount === 1 ? base : `${base}-${nextCount}`,
level, level,
text, text,
line: index + 1,
}; };
}) })
.filter(Boolean) as MarkdownHeading[]; .filter(Boolean) as MarkdownHeading[];

View File

@@ -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 },
],
);
});