Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
497 changes: 487 additions & 10 deletions package-lock.json

Large diffs are not rendered by default.

194 changes: 135 additions & 59 deletions src/app/register/register-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ const categories = [
"Other",
];

const categoryOptions = categories.map((category) => ({
value: category,
label: category,
}));

const stepMeta = [
{
step: 1,
Expand Down Expand Up @@ -308,14 +313,23 @@ export function RegisterClient() {
))}
</div>

<form
onSubmit={handleSubmit}
className="rounded-lg border bg-card p-6 shadow-sm"
>
<div className="mb-6 flex items-center gap-3">
{activeStep ? (
<div className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<activeStep.icon className="size-5" />
<Card asChild>
<form onSubmit={handleSubmit}>
<div className="mb-6 flex items-center gap-3">
{activeStep ? (
<div className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<activeStep.icon className="size-5" />
</div>
) : null}
<div>
<p className="text-lg font-bold">
{step === 1 && "Personal details"}
{step === 2 && "Business details"}
{step === 3 && "Email verification"}
</p>
<p className="text-sm text-muted-foreground">
Step {step} of 3
</p>
</div>
) : null}
<div>
Expand Down Expand Up @@ -415,46 +429,96 @@ export function RegisterClient() {
/>
</label>
</div>
) : null}

{step === 3 ? (
<div className="grid gap-4">
<div className="rounded-lg border bg-secondary/60 p-4 text-sm text-muted-foreground">
A verification code was sent to{" "}
<span className="font-semibold text-foreground">
{values.email}
</span>
. Demo code:{" "}
<span className="font-mono font-bold text-primary">
{otpCode}
</span>
{step === 1 ? (
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium">
First name
<input
className="h-11 rounded-md border bg-background px-3 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
value={values.firstName}
onChange={(event) =>
updateField("firstName", event.target.value)
}
required
/>
</label>
<label className="grid gap-2 text-sm font-medium">
Last name
<input
className="h-11 rounded-md border bg-background px-3 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
value={values.lastName}
onChange={(event) =>
updateField("lastName", event.target.value)
}
required
/>
</label>
<label className="grid gap-2 text-sm font-medium sm:col-span-2">
Email address
<input
className="h-11 rounded-md border bg-background px-3 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
type="email"
inputMode="email"
autoComplete="email"
value={values.email}
onChange={(event) =>
updateField("email", event.target.value)
}
required
/>
</label>
</div>
<label className="grid gap-2 text-sm font-medium">
OTP code
<div className="grid w-fit max-w-full grid-cols-6 gap-2">
{Array.from({ length: 6 }).map((_, index) => (
<input
key={index}
ref={(element) => {
otpInputRefs.current[index] = element;
}}
aria-label={`OTP digit ${index + 1}`}
className="size-11 rounded-md border bg-background text-center font-mono text-lg font-semibold outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 sm:size-12"
inputMode="numeric"
maxLength={1}
value={otpInput[index] ?? ""}
onChange={(event) =>
updateOtpDigit(index, event.target.value)
}
onKeyDown={(event) => handleOtpKeyDown(index, event)}
onPaste={handleOtpPaste}
required
/>
))}
) : null}

{step === 2 ? (
<div className="grid gap-4">
<label className="grid gap-2 text-sm font-medium">
Business name
<input
className="h-11 rounded-md border bg-background px-3 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
value={values.businessName}
onChange={(event) =>
updateField("businessName", event.target.value)
}
required
/>
</label>
<label className="grid gap-2 text-sm font-medium">
Business logo
<input
className="rounded-md border bg-background px-3 py-2 text-sm outline-none file:mr-3 file:rounded-md file:border-0 file:bg-secondary file:px-3 file:py-2 file:text-sm file:font-semibold file:text-primary"
type="file"
accept="image/*"
onChange={handleLogoChange}
/>
</label>
<div className="grid gap-2 text-sm font-medium">
<label htmlFor="businessCategory">Business category</label>
<Select
id="businessCategory"
options={categoryOptions}
value={values.businessCategory}
onValueChange={(value) =>
updateField("businessCategory", value)
}
placeholder="Select category"
required
/>
</div>
</label>
</div>
) : null}
<label className="grid gap-2 text-sm font-medium">
Business description
<textarea
className="min-h-28 rounded-md border bg-background px-3 py-3 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
value={values.businessDescription}
onChange={(event) =>
updateField("businessDescription", event.target.value)
}
required
/>
</label>
</div>
) : null}

{error ? (
<Alert variant="destructive" className="mt-5">
Expand All @@ -473,24 +537,36 @@ export function RegisterClient() {
Back
</Button>

{step < 3 ? (
<div className="mt-7 flex flex-col gap-3 sm:flex-row sm:justify-between">
<Button
type="button"
onClick={goNext}
disabled={!canContinue || isSending}
variant="outline"
onClick={goBack}
disabled={step === 1 || isSending || isComplete}
>
{isSending ? <Loader2 className="animate-spin" /> : null}
Continue
<ChevronRight />
</Button>
) : (
<Button type="submit" disabled={!canContinue || isComplete}>
{isComplete ? <BadgeCheck /> : <MailCheck />}
Verify email
<ChevronLeft />
Back
</Button>
)}
</div>
</form>

{step < 3 ? (
<Button
type="button"
onClick={goNext}
disabled={!canContinue || isSending}
>
{isSending ? <Loader2 className="animate-spin" /> : null}
Continue
<ChevronRight />
</Button>
) : (
<Button type="submit" disabled={!canContinue || isComplete}>
{isComplete ? <BadgeCheck /> : <MailCheck />}
Verify email
</Button>
)}
</div>
</form>
</Card>
</section>
</main>
);
Expand Down
3 changes: 2 additions & 1 deletion src/app/sign-in/sign-in-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";

import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
getMerchantProfile,
MERCHANT_SESSION_KEY,
Expand Down Expand Up @@ -82,7 +83,7 @@ export function SignInClient() {
</p>
</div>

<div className="rounded-lg border bg-card p-6 shadow-sm">
<Card>
{error ? (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
Expand Down
6 changes: 4 additions & 2 deletions src/components/RevenueChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
ResponsiveContainer,
} from "recharts";

import { Card } from "@/components/ui/card";

export interface RevenueDataPoint {
date: string;
amount: number;
Expand Down Expand Up @@ -58,7 +60,7 @@ interface RevenueChartProps {

export default function RevenueChart({ data = MOCK_DATA }: RevenueChartProps) {
return (
<div className="rounded-lg border bg-card p-6 shadow-sm">
<Card>
<h2 className="mb-4 text-lg font-bold text-card-foreground">
Revenue Over Time
</h2>
Expand Down Expand Up @@ -106,6 +108,6 @@ export default function RevenueChart({ data = MOCK_DATA }: RevenueChartProps) {
/>
</AreaChart>
</ResponsiveContainer>
</div>
</Card>
);
}
46 changes: 46 additions & 0 deletions src/components/ui/badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";

import { Badge } from "./badge";

describe("Badge", () => {
it("renders arbitrary children with the pill base classes", () => {
render(<Badge>GDXX…7Q4A</Badge>);

const badge = screen.getByText("GDXX…7Q4A");

expect(badge).toHaveClass("inline-flex", "rounded-full", "border");
expect(badge).toHaveAttribute("data-slot", "badge");
});

it("defaults to the primary variant", () => {
render(<Badge>Paid</Badge>);

expect(screen.getByText("Paid")).toHaveClass(
"bg-primary",
"text-primary-foreground",
);
});

it.each([
["secondary", "bg-secondary"],
["destructive", "bg-destructive"],
["outline", "border-border"],
] as const)("applies the %s variant styling", (variant, expected) => {
render(<Badge variant={variant}>{variant}</Badge>);

expect(screen.getByText(variant)).toHaveClass(expected);
});

it("renders icon children alongside text", () => {
render(
<Badge>
<svg data-testid="badge-icon" />
Verified
</Badge>,
);

expect(screen.getByTestId("badge-icon")).toBeInTheDocument();
expect(screen.getByText(/Verified/)).toBeInTheDocument();
});
});
38 changes: 38 additions & 0 deletions src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";

const badgeVariants = cva(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none [&_svg]:size-3 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "border-border text-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);

const Badge = React.forwardRef<
HTMLSpanElement,
React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>
>(({ className, variant, ...props }, ref) => (
<span
ref={ref}
data-slot="badge"
className={cn(badgeVariants({ variant, className }))}
{...props}
/>
));

Badge.displayName = "Badge";

export { Badge, badgeVariants };
Loading
Loading