Skip to content

Commit 62910d4

Browse files
committed
feat: update components
1 parent b161194 commit 62910d4

File tree

14 files changed

+596
-18
lines changed

14 files changed

+596
-18
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export const Icons = {
2+
Check: (props: any) => (
3+
<svg
4+
xmlns="http://www.w3.org/2000/svg"
5+
width="22"
6+
height="22"
7+
viewBox="0 0 22 22"
8+
fill="none"
9+
{...props}
10+
>
11+
<path
12+
fillRule="evenodd"
13+
clipRule="evenodd"
14+
d="M1.83398 11.0004C1.83398 5.9378 5.93804 1.83374 11.0007 1.83374C16.0633 1.83374 20.1673 5.9378 20.1673 11.0004C20.1673 16.063 16.0633 20.1671 11.0007 20.1671C5.93804 20.1671 1.83398 16.063 1.83398 11.0004ZM14.3988 8.51853C14.0409 8.16055 13.4605 8.16055 13.1025 8.51853L10.084 11.5363L8.89883 10.3519L8.81248 10.2756C8.45288 9.99601 7.93291 10.0214 7.60247 10.3519C7.24449 10.7098 7.24449 11.2902 7.60247 11.6482L9.4358 13.4816L9.52216 13.5578C9.88176 13.8374 10.4017 13.812 10.7322 13.4816L14.3988 9.8149L14.4751 9.72854C14.7547 9.36894 14.7293 8.84898 14.3988 8.51853Z"
15+
fill="#18181B"
16+
/>
17+
</svg>
18+
),
19+
};
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import React, { useState, useCallback, ReactNode, useEffect } from "react";
2+
import { Button } from "./ui/Button";
3+
import { ChevronRight, CircleCheck } from "lucide-react";
4+
import { cn } from "@/lib/utils";
5+
import { Icons } from "./Icons";
6+
7+
interface StepProps {
8+
children: ReactNode;
9+
}
10+
11+
export interface StepperRenderProps {
12+
updateValidity: (isValid: boolean) => void;
13+
isValid: boolean;
14+
stepIndex: number;
15+
}
16+
17+
export interface StepData {
18+
id: string;
19+
label: string;
20+
description?: string;
21+
isValid?: boolean;
22+
isCompleted?: boolean;
23+
nextLabel?: string;
24+
disabled?: boolean;
25+
render: (props: StepperRenderProps) => ReactNode;
26+
}
27+
28+
interface StepperProps {
29+
steps: StepData[];
30+
initialStep?: number;
31+
className?: string;
32+
stepClassName?: string;
33+
completeLabel?: string;
34+
showArrow?: boolean;
35+
onComplete?: () => void;
36+
}
37+
38+
export const Step = ({ children }: StepProps) => {
39+
return <>{children}</>;
40+
};
41+
42+
const StepRenderer = React.memo(
43+
({
44+
step,
45+
stepIndex,
46+
updateValidity,
47+
}: {
48+
step: StepData;
49+
stepIndex: number;
50+
updateValidity: (isValid: boolean) => void;
51+
}) => {
52+
useEffect(() => {
53+
if (step.isValid) {
54+
updateValidity(true);
55+
}
56+
}, [step.id, step.isValid]);
57+
58+
return (
59+
<>
60+
{step?.render({
61+
updateValidity,
62+
isValid: !!step.isValid,
63+
stepIndex,
64+
})}
65+
</>
66+
);
67+
},
68+
);
69+
70+
StepRenderer.displayName = "StepRenderer";
71+
72+
const safeArrayLength = (arr: any[] | null | undefined = []): number => {
73+
return Array.isArray(arr) ? arr.length : 0;
74+
};
75+
76+
const StepperBase = ({
77+
steps: initialSteps,
78+
initialStep = 0,
79+
className = "",
80+
stepClassName = "",
81+
completeLabel = "Complete",
82+
showArrow = true,
83+
onComplete,
84+
}: StepperProps) => {
85+
const [currentStep, setCurrentStep] = useState(initialStep);
86+
const [steps, setSteps] = useState<StepData[]>(() => {
87+
const validSteps = Array.isArray(initialSteps) ? initialSteps : [];
88+
return validSteps.map((step) => ({
89+
...step,
90+
isValid: step.isValid || false,
91+
isCompleted: step.isCompleted || false,
92+
}));
93+
});
94+
95+
useEffect(() => {
96+
const validSteps = Array.isArray(initialSteps) ? initialSteps : [];
97+
setSteps(validSteps.map((newStep, index) => {
98+
const currentStep = steps[index];
99+
return {
100+
...newStep,
101+
isValid: newStep.isValid !== undefined ? newStep.isValid : (currentStep?.isValid || false),
102+
isCompleted: newStep.isCompleted !== undefined ? newStep.isCompleted : (currentStep?.isCompleted || false),
103+
};
104+
}));
105+
}, [initialSteps]);
106+
107+
const getCurrentStep = useCallback((): StepData | null => {
108+
const stepsLength = safeArrayLength(steps);
109+
if (stepsLength === 0) return null;
110+
return currentStep >= 0 && currentStep < stepsLength
111+
? steps[currentStep]
112+
: null;
113+
}, [steps, currentStep]);
114+
115+
const updateStepValidity = useCallback(
116+
(stepIndex: number, isValid: boolean): void => {
117+
const stepsLength = safeArrayLength(steps);
118+
if (!Array.isArray(steps) || stepIndex < 0 || stepIndex >= stepsLength)
119+
return;
120+
121+
setSteps((prev) => {
122+
const safeArray = Array.isArray(prev) ? prev : [];
123+
return safeArray.map((step, idx) =>
124+
idx === stepIndex ? { ...step, isValid } : step,
125+
);
126+
});
127+
},
128+
[],
129+
);
130+
131+
const completeStep = useCallback(
132+
(stepIndex: number): void => {
133+
const stepsLength = safeArrayLength(steps);
134+
if (!Array.isArray(steps) || stepIndex < 0 || stepIndex >= stepsLength)
135+
return;
136+
137+
setSteps((prev) => {
138+
const safeArray = Array.isArray(prev) ? prev : [];
139+
return safeArray.map((step, idx) =>
140+
idx === stepIndex ? { ...step, isCompleted: true } : step,
141+
);
142+
});
143+
},
144+
[],
145+
);
146+
147+
const nextStep = useCallback((): boolean => {
148+
const stepsLength = safeArrayLength(steps);
149+
if (stepsLength === 0 || currentStep >= stepsLength - 1) return false;
150+
151+
const currentStepData = steps?.[currentStep];
152+
if (!currentStepData?.isValid) return false;
153+
154+
completeStep(currentStep);
155+
setCurrentStep((prev) => prev + 1);
156+
return true;
157+
}, [currentStep, steps, completeStep]);
158+
159+
const handleStepClick = useCallback(
160+
(stepIndex: number): boolean => {
161+
const stepsLength = safeArrayLength(steps);
162+
if (stepsLength === 0 || stepIndex < 0 || stepIndex >= stepsLength)
163+
return false;
164+
165+
const safeSteps = Array.isArray(steps) ? steps : [];
166+
const isAccessible =
167+
stepIndex <= currentStep + 1 &&
168+
(stepIndex === 0 ||
169+
safeSteps.slice(0, stepIndex).every((step) => step.isCompleted));
170+
171+
if (isAccessible) {
172+
setCurrentStep(stepIndex);
173+
return true;
174+
}
175+
176+
return false;
177+
},
178+
[currentStep, steps],
179+
);
180+
181+
const handleNext = () => {
182+
const stepsLength = safeArrayLength(steps);
183+
const isLastStep = stepsLength > 0 && currentStep === stepsLength - 1;
184+
185+
if (isLastStep) {
186+
if (onComplete && typeof onComplete === "function") {
187+
onComplete();
188+
}
189+
} else {
190+
nextStep();
191+
}
192+
};
193+
194+
const currentStepData = getCurrentStep();
195+
const stepsLength = safeArrayLength(steps);
196+
const isLastStep = stepsLength > 0 && currentStep === stepsLength - 1;
197+
198+
if (stepsLength === 0) {
199+
return null;
200+
}
201+
202+
const safeSteps = Array.isArray(steps) ? steps : [];
203+
204+
return (
205+
<div className={cn("w-full flex flex-col gap-10", className)}>
206+
<div className="flex items-center gap-2 pb-3 border-b border-b-sidebar-border">
207+
{safeSteps.map((step, index) => {
208+
const isActive = index === currentStep;
209+
const isCompleted = !!step.isCompleted;
210+
const isClickable = index <= currentStep || isCompleted;
211+
const isLastStep = index === safeSteps.length - 1;
212+
213+
return (
214+
<React.Fragment key={step.id || index}>
215+
<div
216+
className={`flex flex-col items-center ${isClickable ? "cursor-pointer" : "cursor-not-allowed opacity-60"} ${stepClassName}`}
217+
onClick={() => isClickable && handleStepClick(index)}
218+
>
219+
<div className="flex items-center gap-1.5">
220+
{isCompleted ? (
221+
<Icons.Check />
222+
) : (
223+
<CircleCheck
224+
className={cn(
225+
"h-5 w-5 text-black",
226+
!isActive && "opacity-50",
227+
)}
228+
/>
229+
)}
230+
231+
<span
232+
className={`text-sm ${isActive || isCompleted ? "font-medium" : "font-normal"}`}
233+
>
234+
{step.label}
235+
</span>
236+
</div>
237+
</div>
238+
{!isLastStep && (
239+
<ChevronRight className="text-base-muted-foreground h-4 w-4" />
240+
)}
241+
</React.Fragment>
242+
);
243+
})}
244+
</div>
245+
246+
<div className="flex flex-col gap-3.5">
247+
{currentStepData?.description && (
248+
<h3 className="text-sm text-base-foreground font-semibold font-inter">
249+
{currentStepData?.description}
250+
</h3>
251+
)}
252+
{currentStepData && (
253+
<StepRenderer
254+
step={currentStepData}
255+
stepIndex={currentStep}
256+
updateValidity={(isValid: boolean) =>
257+
updateStepValidity(currentStep, isValid)
258+
}
259+
/>
260+
)}
261+
</div>
262+
263+
<div className="flex justify-end mt-6">
264+
{!isLastStep ? (
265+
<Button
266+
onClick={handleNext}
267+
className="min-w-[200px]"
268+
disabled={!currentStepData?.isValid}
269+
>
270+
<div className="flex items-center">
271+
{currentStepData?.nextLabel || "Next"}
272+
{showArrow && <ChevronRight />}
273+
</div>
274+
</Button>
275+
) : (
276+
<button
277+
onClick={handleNext}
278+
disabled={!currentStepData?.isValid}
279+
className="bg-gray-400 hover:bg-gray-500 text-white font-medium px-6 py-2 rounded-md disabled:opacity-50 enabled:bg-gray-600"
280+
>
281+
{completeLabel}
282+
</button>
283+
)}
284+
</div>
285+
</div>
286+
);
287+
};
288+
289+
const Stepper = {
290+
displayName: "Stepper",
291+
Base: StepperBase,
292+
Step,
293+
};
294+
295+
export default Stepper;

apps/client/src/components/inputs/Input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const InputWrapper = ({
7171
};
7272

7373
export const InputBase = classed.input(
74-
"flex min-h-9 w-full rounded-md text-base-foreground bg-base-background py-1 text-sm shadow-input transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-base-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
74+
"flex min-h-9 w-full rounded-md text-base-foreground bg-base-background py-1 text-sm shadow-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-base-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
7575
{
7676
variants: {
7777
withIcon: {

apps/client/src/components/inputs/Select.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const SelectTrigger = forwardRef<
2424
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
2525
field?: FieldApi<any, any, any, any>;
2626
}
27-
>(({ className, children, field, ...props }, ref) => (
27+
>(({ className, children, field, disabled = false, ...props }, ref) => (
2828
<SelectPrimitive.Trigger
2929
ref={ref}
3030
className={cn(
@@ -35,6 +35,7 @@ const SelectTrigger = forwardRef<
3535
: "border-base-input",
3636
)}
3737
{...props}
38+
disabled={disabled}
3839
>
3940
{children}
4041
<SelectPrimitive.Icon asChild>

apps/client/src/components/ui/Button.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ButtonHTMLAttributes, forwardRef } from "react";
55
import { LoaderCircle as Loader } from "lucide-react";
66

77
const ButtonComponent = classed.button(
8+
"duration-300",
89
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap font-inter rounded-md text-sm font-normal 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
910
"focus:outline-none focus:ring-0 outline-none focus:ring-offset-0 focus:outline-none",
1011
{
@@ -18,18 +19,32 @@ const ButtonComponent = classed.button(
1819
"border border-input bg-base-background hover:bg-accent text-base-foreground hover:text-accent-foreground",
1920
secondary:
2021
"bg-base-secondary text-secondary-foreground hover:bg-base-secondary/80",
22+
checkbox:
23+
"bg-base-background border border-base-border hover:bg-sidebar-background/80 disabled:bg-sidebar-background",
2124
ghost: "hover:bg-accent hover:text-accent-foreground",
2225
link: "text-primary underline-offset-4 hover:underline",
2326
error:
2427
"bg-[#FEE2E2] text-[#DC2626] hover:bg-[#FEE2E2] border border-[#DC2626]",
2528
},
2629
size: {
27-
default: "h-10 px-4 py-2",
2830
sm: "h-9 rounded-md px-3 text-sm leading-5",
31+
md: "h-10 px-4 py-2",
2932
lg: "h-11 rounded-md px-8",
33+
xl: "rounded-md py-5 px-10",
3034
icon: "h-10 w-10",
3135
},
36+
active: {
37+
true: "",
38+
},
3239
},
40+
compoundVariants: [
41+
{
42+
variant: "checkbox",
43+
active: true,
44+
className:
45+
"border-[1.5px] border-black bg-sidebar-background font-semibold",
46+
},
47+
],
3348
defaultVariants: {
3449
variant: "black",
3550
size: "sm",

apps/client/src/hooks/usePosts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const useGetPostById = (postId: string | number) => {
2626
staleTime: 0,
2727
queryKey: ["getPostById", postId],
2828
queryFn: () => {
29-
const postById = fetch(`http://localhost:3001/api/posts/${postId}`).then(
29+
const postById = fetch(`${API_URL}/api/posts/${postId}`).then(
3030
(res) => res.json(),
3131
)
3232
if (!postById) {

0 commit comments

Comments
 (0)