chore(frontend): commit remaining workspace updates
This commit is contained in:
4
next-env.d.ts
vendored
4
next-env.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
BIN
public/images/events-empty-state.png
Normal file
BIN
public/images/events-empty-state.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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>
|
||||||
|
|||||||
@@ -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} />,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
14
tests/markdown-headings.test.mjs
Normal file
14
tests/markdown-headings.test.mjs
Normal 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 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user