Browse Source
feat: initialize application structure with new UI components, internationalization support, and core matchmaking pages
front-test-2
feat: initialize application structure with new UI components, internationalization support, and core matchmaking pages
front-test-2
65 changed files with 4424 additions and 2708 deletions
-
16next.config.ts
-
1src/app/[lang]/candidate-contact/page.tsx
-
20src/app/api/proxy/route.ts
-
25src/app/candidate-contact/page.tsx
-
118src/app/finding-match/page.tsx
-
43src/app/new-match/page.tsx
-
153src/app/new-match/profile/page.tsx
-
4src/app/page.tsx
-
2src/app/providers.tsx
-
20src/app/questions-list/[slug]/question-detail-client.tsx
-
2src/app/questions-list/page.tsx
-
416src/app/request-accepted/page.tsx
-
11src/app/request-sent/page.tsx
-
15src/components/Componentes/button.tsx
-
23src/components/Componentes/dev-click-to-component.tsx
-
15src/components/Componentes/dismiss-reason-sheet.tsx
-
388src/components/Componentes/female-outcome-sheet.tsx
-
38src/components/Componentes/flutter-locale-sync.tsx
-
18src/components/Componentes/loading-border-spinner.tsx
-
16src/components/Componentes/loading-icon-spinner.tsx
-
20src/components/Componentes/loading-pulse-text.tsx
-
7src/components/Componentes/loading-select-spinner.tsx
-
16src/components/Componentes/loading-three-dot.tsx
-
1src/components/Componentes/navigation-button.tsx
-
6src/components/Componentes/page-header.tsx
-
49src/components/Componentes/page-loading-skeleton.tsx
-
56src/components/Componentes/question-answer-storage.tsx
-
43src/components/Componentes/question-birthplace.tsx
-
47src/components/Componentes/question-file.tsx
-
7src/components/Componentes/question-phone.tsx
-
3src/components/Componentes/question-photo.tsx
-
10src/components/Componentes/question-slider.tsx
-
32src/components/Componentes/subscription-required-sheet.tsx
-
3src/components/Componentes/swipe-button.tsx
-
171src/components/Componentes/test-loading-screen.tsx
-
12src/data/questions/en.json
-
12src/data/questions/fa.json
-
8src/lib/get-submit-path.ts
-
36src/lib/http.ts
-
49src/lib/view-paddings.ts
-
10src/translations/locales/ar.json
-
10src/translations/locales/az.json
-
10src/translations/locales/bn.json
-
10src/translations/locales/da.json
-
10src/translations/locales/de.json
-
37src/translations/locales/en.json
-
10src/translations/locales/es.json
-
23src/translations/locales/fa.json
-
10src/translations/locales/fr.json
-
10src/translations/locales/gu.json
-
10src/translations/locales/ha.json
-
274src/translations/locales/he.json
-
1017src/translations/locales/hi.json
-
274src/translations/locales/id.json
-
274src/translations/locales/ks.json
-
274src/translations/locales/pt.json
-
274src/translations/locales/ru.json
-
274src/translations/locales/sw.json
-
274src/translations/locales/tg.json
-
274src/translations/locales/tr.json
-
274src/translations/locales/ul.json
-
274src/translations/locales/ur.json
-
274src/translations/locales/uz.json
-
1017src/translations/locales/zh.json
-
2src/types/window.d.ts
@ -1 +0,0 @@ |
|||||
export { default } from "@/app/candidate-contact/page"; |
|
||||
@ -0,0 +1,388 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { useEffect, useId, useMemo, useRef, useState } from "react"; |
||||
|
import { useI18n } from "@/translations/provider"; |
||||
|
import SwipeButton from "./swipe-button"; |
||||
|
|
||||
|
const EXIT_ANIMATION_MS = 220; |
||||
|
|
||||
|
export type FemaleOutcomeSheetProps = { |
||||
|
closeOnOutside?: boolean; |
||||
|
onClose?: () => void; |
||||
|
onSubmit?: (status: "success" | "failure", reason?: string) => void; |
||||
|
}; |
||||
|
|
||||
|
export function FemaleOutcomeSheet({ |
||||
|
closeOnOutside = true, |
||||
|
onClose, |
||||
|
onSubmit, |
||||
|
}: FemaleOutcomeSheetProps) { |
||||
|
const { dictionary: t } = useI18n(); |
||||
|
const groupId = useId(); |
||||
|
|
||||
|
const [isVisible, setIsVisible] = useState(true); |
||||
|
const [isEntering, setIsEntering] = useState(true); |
||||
|
const [isClosing, setIsClosing] = useState(false); |
||||
|
|
||||
|
// Outcome selection state: "ongoing" (Option A) or "canceled" (Option B)
|
||||
|
const [outcome, setOutcome] = useState<"ongoing" | "canceled">("ongoing"); |
||||
|
|
||||
|
// Rejection/Cancellation reason states
|
||||
|
const reasons = useMemo( |
||||
|
() => [ |
||||
|
t["Not a good personal fit"], |
||||
|
t["No mutual interest"], |
||||
|
t["Different expectations"], |
||||
|
t["No connection felt"], |
||||
|
t["Location not suitable"], |
||||
|
t["Other reasons"], |
||||
|
], |
||||
|
[t], |
||||
|
); |
||||
|
const [selectedReasons, setSelectedReasons] = useState<string[]>([]); |
||||
|
const [reasonText, setReasonText] = useState(""); |
||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const otherReason = reasons[reasons.length - 1]; |
||||
|
if (selectedReasons.includes(otherReason)) { |
||||
|
const timeoutId = setTimeout(() => { |
||||
|
textareaRef.current?.scrollIntoView({ |
||||
|
behavior: "smooth", |
||||
|
block: "nearest", |
||||
|
}); |
||||
|
}, 100); |
||||
|
return () => clearTimeout(timeoutId); |
||||
|
} |
||||
|
}, [selectedReasons, reasons]); |
||||
|
|
||||
|
const closeSheet = () => { |
||||
|
if (isClosing) { |
||||
|
return; |
||||
|
} |
||||
|
setIsClosing(true); |
||||
|
}; |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const frameId = window.requestAnimationFrame(() => { |
||||
|
setIsEntering(false); |
||||
|
}); |
||||
|
|
||||
|
return () => { |
||||
|
window.cancelAnimationFrame(frameId); |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (!isVisible) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const previousBodyOverflow = document.body.style.overflow; |
||||
|
const previousHtmlOverflow = document.documentElement.style.overflow; |
||||
|
|
||||
|
document.body.style.overflow = "hidden"; |
||||
|
document.documentElement.style.overflow = "hidden"; |
||||
|
|
||||
|
return () => { |
||||
|
document.body.style.overflow = previousBodyOverflow; |
||||
|
document.documentElement.style.overflow = previousHtmlOverflow; |
||||
|
}; |
||||
|
}, [isVisible]); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (!isClosing) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const timeoutId = window.setTimeout(() => { |
||||
|
setIsVisible(false); |
||||
|
onClose?.(); |
||||
|
}, EXIT_ANIMATION_MS); |
||||
|
|
||||
|
return () => { |
||||
|
window.clearTimeout(timeoutId); |
||||
|
}; |
||||
|
}, [isClosing, onClose]); |
||||
|
|
||||
|
if (!isVisible) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
const isCanceledSwipeDisabled = |
||||
|
selectedReasons.length === 0 || |
||||
|
(selectedReasons.includes(reasons[reasons.length - 1]) && |
||||
|
reasonText.trim() === ""); |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
className={[ |
||||
|
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms]", |
||||
|
isClosing || isEntering |
||||
|
? "bg-[#171717]/0 opacity-0" |
||||
|
: "bg-[#171717]/55 opacity-100", |
||||
|
] |
||||
|
.filter(Boolean) |
||||
|
.join(" ")} |
||||
|
role="dialog" |
||||
|
aria-modal="true" |
||||
|
aria-label={t["What was the outcome of your contact?"]} |
||||
|
tabIndex={-1} |
||||
|
onClick={(event) => { |
||||
|
if (closeOnOutside && event.target === event.currentTarget) { |
||||
|
closeSheet(); |
||||
|
} |
||||
|
}} |
||||
|
onKeyDown={(event) => { |
||||
|
if ( |
||||
|
closeOnOutside && |
||||
|
event.target === event.currentTarget && |
||||
|
(event.key === "Escape" || event.key === "Enter" || event.key === " ") |
||||
|
) { |
||||
|
event.preventDefault(); |
||||
|
closeSheet(); |
||||
|
} |
||||
|
}} |
||||
|
> |
||||
|
<section |
||||
|
className={[ |
||||
|
"flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-[34px] bg-[#F9F8F8] p-4 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform sm:max-w-[375px]", |
||||
|
isClosing || isEntering ? "translate-y-full" : "translate-y-0", |
||||
|
] |
||||
|
.filter(Boolean) |
||||
|
.join(" ")} |
||||
|
> |
||||
|
<div className="mx-auto flex h-full min-h-0 w-full flex-col items-center"> |
||||
|
<div className="flex min-h-0 w-full flex-1 flex-col items-center"> |
||||
|
<h2 className="text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]"> |
||||
|
{t["What was the outcome of your contact?"]} |
||||
|
</h2> |
||||
|
|
||||
|
<p className="mt-3.5 w-full text-start text-[14px] leading-[1.45] text-[#2C2C2C] dir-auto font-medium"> |
||||
|
{ |
||||
|
t[ |
||||
|
"We hope the acquaintance process is going well. Please let us know if you want to continue the acquaintance process or if the match has been canceled." |
||||
|
] |
||||
|
} |
||||
|
</p> |
||||
|
|
||||
|
<fieldset |
||||
|
className="mt-5 flex min-h-0 w-full flex-1 flex-col" |
||||
|
aria-labelledby={groupId} |
||||
|
> |
||||
|
<legend id={groupId} className="sr-only"> |
||||
|
{t["What was the outcome of your contact?"]} |
||||
|
</legend> |
||||
|
|
||||
|
<div className="flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto pr-1"> |
||||
|
{/* Option A: Ongoing acquaintance */} |
||||
|
<label |
||||
|
className={[ |
||||
|
"flex cursor-pointer items-center gap-3 rounded-[14px] px-[14px] py-[18px] text-left border transition-all", |
||||
|
outcome === "ongoing" |
||||
|
? "bg-[#E8F8F3] border-[#00AC78]/30" |
||||
|
: "bg-[#ECECEC] border-transparent", |
||||
|
].join(" ")} |
||||
|
> |
||||
|
<input |
||||
|
checked={outcome === "ongoing"} |
||||
|
className="sr-only" |
||||
|
name="outcome-status" |
||||
|
type="radio" |
||||
|
value="ongoing" |
||||
|
onChange={() => { |
||||
|
setOutcome("ongoing"); |
||||
|
}} |
||||
|
/> |
||||
|
<span |
||||
|
aria-hidden="true" |
||||
|
className={[ |
||||
|
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full border transition-colors", |
||||
|
outcome === "ongoing" |
||||
|
? "border-[#00AC78] bg-transparent" |
||||
|
: "border-[#9E9E9E] bg-transparent", |
||||
|
].join(" ")} |
||||
|
> |
||||
|
{outcome === "ongoing" ? ( |
||||
|
<span className="h-3 w-3 rounded-full bg-[#00AC78]" /> |
||||
|
) : null} |
||||
|
</span> |
||||
|
<span className="text-[15px] leading-snug font-bold text-[#262626] flex-1"> |
||||
|
{ |
||||
|
t[ |
||||
|
"We are in the acquaintance/proposal process and nothing is finalized yet" |
||||
|
] |
||||
|
} |
||||
|
</span> |
||||
|
</label> |
||||
|
|
||||
|
{/* Option B: Canceled */} |
||||
|
<label |
||||
|
className={[ |
||||
|
"flex cursor-pointer items-center gap-3 rounded-[14px] px-[14px] py-[18px] text-left border transition-all", |
||||
|
outcome === "canceled" |
||||
|
? "bg-[#FFECEF] border-[#F0445B]/30" |
||||
|
: "bg-[#ECECEC] border-transparent", |
||||
|
].join(" ")} |
||||
|
> |
||||
|
<input |
||||
|
checked={outcome === "canceled"} |
||||
|
className="sr-only" |
||||
|
name="outcome-status" |
||||
|
type="radio" |
||||
|
value="canceled" |
||||
|
onChange={() => { |
||||
|
setOutcome("canceled"); |
||||
|
}} |
||||
|
/> |
||||
|
<span |
||||
|
aria-hidden="true" |
||||
|
className={[ |
||||
|
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full border transition-colors", |
||||
|
outcome === "canceled" |
||||
|
? "border-[#F0445B] bg-transparent" |
||||
|
: "border-[#9E9E9E] bg-transparent", |
||||
|
].join(" ")} |
||||
|
> |
||||
|
{outcome === "canceled" ? ( |
||||
|
<span className="h-3 w-3 rounded-full bg-[#F0445B]" /> |
||||
|
) : null} |
||||
|
</span> |
||||
|
<span className="text-[15px] leading-snug font-bold text-[#262626] flex-1"> |
||||
|
{t.Canceled} |
||||
|
</span> |
||||
|
</label> |
||||
|
|
||||
|
{/* Reasons List (if Canceled is chosen) */} |
||||
|
{outcome === "canceled" ? ( |
||||
|
<div className="mt-3 flex flex-col gap-3 pl-2 text-start animate-fadeIn"> |
||||
|
<p className="text-[13px] font-bold text-[#555] mb-1"> |
||||
|
{t["Please select the reason for cancellation:"]} |
||||
|
</p> |
||||
|
<div className="flex flex-col gap-[10px]"> |
||||
|
{reasons.map((option) => { |
||||
|
const isReasonChecked = |
||||
|
selectedReasons.includes(option); |
||||
|
const isOtherReason = |
||||
|
option === reasons[reasons.length - 1]; |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
key={option} |
||||
|
className="w-full flex flex-col gap-2" |
||||
|
> |
||||
|
<button |
||||
|
type="button" |
||||
|
className={[ |
||||
|
"flex w-full items-center gap-3 bg-[#EAEAEA] px-4 py-[12px] text-start transition-all rounded-[12px]", |
||||
|
isReasonChecked |
||||
|
? "text-[#171717]" |
||||
|
: "text-[#7B7B7B]", |
||||
|
] |
||||
|
.filter(Boolean) |
||||
|
.join(" ")} |
||||
|
onClick={() => { |
||||
|
setSelectedReasons((prev) => |
||||
|
prev.includes(option) |
||||
|
? prev.filter((r) => r !== option) |
||||
|
: [...prev, option], |
||||
|
); |
||||
|
}} |
||||
|
> |
||||
|
<span |
||||
|
aria-hidden="true" |
||||
|
className={[ |
||||
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] border transition-colors", |
||||
|
isReasonChecked |
||||
|
? "border-[#F0445B] bg-[#F0445B]" |
||||
|
: "border-[#9E9E9E] bg-transparent", |
||||
|
] |
||||
|
.filter(Boolean) |
||||
|
.join(" ")} |
||||
|
> |
||||
|
{isReasonChecked ? ( |
||||
|
<svg |
||||
|
aria-hidden="true" |
||||
|
className="h-3.5 w-3.5 text-white" |
||||
|
fill="none" |
||||
|
viewBox="0 0 24 24" |
||||
|
stroke="currentColor" |
||||
|
strokeWidth={3.5} |
||||
|
> |
||||
|
<path |
||||
|
strokeLinecap="round" |
||||
|
strokeLinejoin="round" |
||||
|
d="M5 13l4 4L19 7" |
||||
|
/> |
||||
|
</svg> |
||||
|
) : null} |
||||
|
</span> |
||||
|
|
||||
|
<span className="min-w-0 flex-1"> |
||||
|
<span className="text-[14px] leading-none font-bold"> |
||||
|
{option} |
||||
|
</span> |
||||
|
</span> |
||||
|
</button> |
||||
|
|
||||
|
{isReasonChecked && isOtherReason ? ( |
||||
|
<textarea |
||||
|
ref={textareaRef} |
||||
|
className="mt-1 h-[90px] w-full resize-none rounded-[12px] border border-[#BFBFBF] bg-transparent px-3 py-2 text-[14px] text-[#171717] outline-none placeholder:text-[#AAAAAA]" |
||||
|
placeholder={ |
||||
|
t[ |
||||
|
"Feel free to briefly explain your decision..." |
||||
|
] |
||||
|
} |
||||
|
value={reasonText} |
||||
|
onChange={(event) => |
||||
|
setReasonText(event.target.value) |
||||
|
} |
||||
|
/> |
||||
|
) : null} |
||||
|
</div> |
||||
|
); |
||||
|
})} |
||||
|
</div> |
||||
|
</div> |
||||
|
) : null} |
||||
|
</div> |
||||
|
</fieldset> |
||||
|
</div> |
||||
|
|
||||
|
{/* SwipeButton Confirmation Area */} |
||||
|
<div className="mt-6 w-full shrink-0"> |
||||
|
{outcome === "ongoing" ? ( |
||||
|
<SwipeButton |
||||
|
theme="green" |
||||
|
text={t["Swipe to continue"]} |
||||
|
onSuccess={() => { |
||||
|
onSubmit?.("success"); |
||||
|
closeSheet(); |
||||
|
}} |
||||
|
/> |
||||
|
) : ( |
||||
|
<SwipeButton |
||||
|
theme="default" |
||||
|
disabled={isCanceledSwipeDisabled} |
||||
|
text={t["Swipe to confirm cancellation"]} |
||||
|
onSuccess={() => { |
||||
|
const activeReasons = selectedReasons.map((r) => { |
||||
|
if (r === reasons[reasons.length - 1]) { |
||||
|
return reasonText ? `${r}: ${reasonText}` : r; |
||||
|
} |
||||
|
return r; |
||||
|
}); |
||||
|
onSubmit?.("failure", activeReasons.join("\n")); |
||||
|
closeSheet(); |
||||
|
}} |
||||
|
/> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
</section> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export default FemaleOutcomeSheet; |
||||
@ -0,0 +1,38 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { usePathname, useRouter } from "next/navigation"; |
||||
|
import { useEffect } from "react"; |
||||
|
import { useFlutterConfig } from "@/hooks/use-view-paddings"; |
||||
|
import { setClientCookie } from "@/lib/cookies"; |
||||
|
import { isLocale, localizePath } from "@/translations/config"; |
||||
|
|
||||
|
const LANGUAGE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; |
||||
|
|
||||
|
function persistLocale(locale: string) { |
||||
|
const options = { maxAge: LANGUAGE_COOKIE_MAX_AGE }; |
||||
|
setClientCookie("HABIB_LANGUAGE", locale, options); |
||||
|
setClientCookie("habib_language", locale, options); |
||||
|
} |
||||
|
|
||||
|
export default function FlutterLocaleSync() { |
||||
|
const config = useFlutterConfig(); |
||||
|
const pathname = usePathname(); |
||||
|
const router = useRouter(); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const flutterLocale = config.locale?.languageCode; |
||||
|
if (!isLocale(flutterLocale)) return; |
||||
|
|
||||
|
persistLocale(flutterLocale); |
||||
|
|
||||
|
const currentLocale = pathname.split("/")[1]; |
||||
|
if (isLocale(currentLocale) && currentLocale === flutterLocale) return; |
||||
|
|
||||
|
const localizedPath = localizePath(pathname, flutterLocale); |
||||
|
router.replace(`${localizedPath}${window.location.search}`, { |
||||
|
scroll: false, |
||||
|
}); |
||||
|
}, [config.locale?.languageCode, pathname, router]); |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
import type { ComponentProps } from "react"; |
||||
|
|
||||
|
export function LoadingBorderSpinner({ className = "", ...props }: ComponentProps<"span">) { |
||||
|
const hasBorder = className.split(" ").some((c) => c.startsWith("border-")); |
||||
|
const borderClasses = hasBorder |
||||
|
? "" |
||||
|
: "border-2 border-rose-500/25 border-t-rose-500 dark:border-rose-400/20 dark:border-t-rose-400"; |
||||
|
|
||||
|
const hasSize = className.split(" ").some((c) => c.startsWith("size-") || c.startsWith("w-") || c.startsWith("h-")); |
||||
|
const sizeClasses = hasSize ? "" : "size-5"; |
||||
|
|
||||
|
return ( |
||||
|
<span |
||||
|
className={`animate-spin rounded-full inline-block animate-fade-in ${borderClasses} ${sizeClasses} ${className}`.trim()} |
||||
|
{...props} |
||||
|
/> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
import type { ComponentProps } from "react"; |
||||
|
|
||||
|
export function LoadingIconSpinner({ className = "", ...props }: ComponentProps<"svg">) { |
||||
|
return ( |
||||
|
<svg |
||||
|
className={`animate-spin transition-all duration-300 ease-out animate-fade-in ${className}`} |
||||
|
viewBox="0 0 24 24" |
||||
|
fill="none" |
||||
|
xmlns="http://www.w3.org/2000/svg" |
||||
|
{...props} |
||||
|
> |
||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> |
||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> |
||||
|
</svg> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
import { LoadingIconSpinner } from "./loading-icon-spinner"; |
||||
|
|
||||
|
interface LoadingPulseTextProps { |
||||
|
text?: string; |
||||
|
className?: string; |
||||
|
} |
||||
|
|
||||
|
export function LoadingPulseText({ |
||||
|
text = "در حال دریافت آخرین اطلاعات واقعی از سرور...", |
||||
|
className = "", |
||||
|
}: LoadingPulseTextProps) { |
||||
|
return ( |
||||
|
<div |
||||
|
className={`flex items-center justify-center gap-3 p-4 rounded-2xl border border-neutral-200/60 dark:border-neutral-800/60 bg-neutral-50/50 dark:bg-neutral-900/30 backdrop-blur-xs text-center text-xs font-medium text-muted-foreground animate-pulse shadow-xs ${className}`.trim()} |
||||
|
> |
||||
|
<LoadingIconSpinner className="size-4 text-rose-500/80 shrink-0" /> |
||||
|
<span>{text}</span> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
export function LoadingSelectSpinner({ className = "" }: { className?: string }) { |
||||
|
return ( |
||||
|
<span |
||||
|
className={`size-3 animate-spin rounded-full inline-block border border-neutral-200 dark:border-neutral-800 border-t-rose-500 dark:border-t-rose-400 animate-fade-in ${className}`.trim()} |
||||
|
/> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
import type { ComponentProps } from "react"; |
||||
|
|
||||
|
interface LoadingThreeDotProps extends ComponentProps<"span"> {} |
||||
|
|
||||
|
export function LoadingThreeDot({ className = "", ...props }: LoadingThreeDotProps) { |
||||
|
return ( |
||||
|
<span |
||||
|
className={`inline-flex items-center justify-center gap-1.5 py-0.5 ${className}`} |
||||
|
{...props} |
||||
|
> |
||||
|
<span className="size-2 rounded-full bg-current animate-dots-slide-1" /> |
||||
|
<span className="size-2 rounded-full bg-current animate-dots-slide-2" /> |
||||
|
<span className="size-2 rounded-full bg-current animate-dots-slide-3" /> |
||||
|
</span> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
import { LoadingSkeleton } from "./loading-skeleton"; |
||||
|
import { PageBackground } from "./page-background"; |
||||
|
|
||||
|
type PageLoadingSkeletonProps = { |
||||
|
compact?: boolean; |
||||
|
}; |
||||
|
|
||||
|
/** Shared full-page loading state for the mobile frontend. */ |
||||
|
export function PageLoadingSkeleton({ |
||||
|
compact = false, |
||||
|
}: PageLoadingSkeletonProps) { |
||||
|
return ( |
||||
|
<> |
||||
|
<PageBackground disabled /> |
||||
|
<main |
||||
|
aria-busy="true" |
||||
|
className="-mx-[17px] flex min-h-svh flex-col px-[23px] pt-[max(16px,calc(var(--safe-top)+8px))] pb-[calc(24px+var(--safe-bottom))]" |
||||
|
> |
||||
|
<header className="flex h-11 items-center justify-between"> |
||||
|
<LoadingSkeleton className="size-10 rounded-full" /> |
||||
|
<LoadingSkeleton className="h-5 w-28 rounded-lg" /> |
||||
|
<LoadingSkeleton className="size-10 rounded-full" /> |
||||
|
</header> |
||||
|
<section className="flex flex-1 flex-col items-center pt-16 text-center"> |
||||
|
<LoadingSkeleton className="size-28 rounded-full" /> |
||||
|
<LoadingSkeleton className="mt-8 h-6 w-48 rounded-lg" /> |
||||
|
<LoadingSkeleton className="mt-4 h-4 w-full max-w-[300px]" /> |
||||
|
<LoadingSkeleton className="mt-2 h-4 w-4/5 max-w-[250px]" /> |
||||
|
{!compact && ( |
||||
|
<div className="mt-12 w-full space-y-3 rounded-[18px] border border-neutral-200/70 p-4 dark:border-neutral-800/70"> |
||||
|
<LoadingSkeleton className="h-5 w-32" /> |
||||
|
<LoadingSkeleton className="h-3.5 w-full" /> |
||||
|
<LoadingSkeleton className="h-3.5 w-3/4" /> |
||||
|
<div className="flex items-center justify-between pt-3"> |
||||
|
<div className="flex -space-x-2"> |
||||
|
<LoadingSkeleton className="size-8 rounded-full" /> |
||||
|
<LoadingSkeleton className="size-8 rounded-full" /> |
||||
|
<LoadingSkeleton className="size-8 rounded-full" /> |
||||
|
</div> |
||||
|
<LoadingSkeleton className="h-10 w-28 rounded-xl" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</section> |
||||
|
<LoadingSkeleton className="mt-8 h-[52px] w-full rounded-xl" /> |
||||
|
</main> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
1017
src/translations/locales/hi.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
1017
src/translations/locales/zh.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue