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
398 changes: 398 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.16",
"@stellar/freighter-api": "^5.0.0",
"@stellar/stellar-sdk": "^14.1.1",
"buffer": "^6.0.3",
Expand Down
7 changes: 6 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Providers } from "@/components/providers";

const geistSans = Geist({
Expand Down Expand Up @@ -28,7 +30,10 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Providers>{children}</Providers>
<ThemeProvider>
<TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
<Toaster position="top-right" richColors closeButton />
</body>
</html>
);
Expand Down
7 changes: 4 additions & 3 deletions src/app/register/register-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
UserRound,
} from "lucide-react";

import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
Expand Down Expand Up @@ -456,9 +457,9 @@ export function RegisterClient() {
) : null}

{error ? (
<div className="mt-5 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
<Alert variant="destructive" className="mt-5">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}

<div className="mt-7 flex flex-col gap-3 sm:flex-row sm:justify-between">
Expand Down
53 changes: 50 additions & 3 deletions src/app/sign-in/sign-in-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,53 @@
import { BadgeCheck, WalletCards } from "lucide-react";
import { useRouter } from "next/navigation";

import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
getMerchantProfile,
MERCHANT_SESSION_KEY,
} from "@/lib/merchant-storage";

type AuthStatus = "idle" | "connecting" | "signing" | "verified" | "error";

type WalletSession = {
address: string;
challenge: string;
signature: string;
signedAt: string;
};

function createChallenge(address: string) {
const issuedAt = new Date().toISOString();
const nonce =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;

return [
"Shade Merchant Sign In",
"",
"Sign this message to prove you control this Stellar wallet.",
"This request will not move funds or create a transaction.",
"",
`Wallet: ${address}`,
`Nonce: ${nonce}`,
`Issued At: ${issuedAt}`,
].join("\n");
}

// async function verifySignedMessage(
// challenge: string,
// signedMessage: string,
// signerAddress: string,
// ) {
// const { Keypair } = await import("@stellar/stellar-sdk");
// const keypair = Keypair.fromPublicKey(signerAddress);
// const messageBytes = Buffer.from(challenge, "utf8");
// const signatureBytes = Buffer.from(signedMessage, "base64");
//
// return keypair.verify(messageBytes, signatureBytes);
// }
import { WalletConnectButton } from "@/components/wallet-connect-button";
import { useWalletConnect } from "@/hooks/use-wallet-connect";
import { getMerchantProfile } from "@/lib/merchant-storage";
Expand Down Expand Up @@ -37,9 +84,9 @@ export function SignInClient() {

<div className="rounded-lg border bg-card p-6 shadow-sm">
{error ? (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}

{session ? (
Expand Down
3 changes: 3 additions & 0 deletions src/components/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"use client";

export { ThemeProvider, useTheme } from "@/components/ThemeProvider";
49 changes: 49 additions & 0 deletions src/components/ui/alert.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";

describe("Alert", () => {
it("exposes an alert role and renders title and description", () => {
render(
<Alert>
<AlertTitle>Heads up</AlertTitle>
<AlertDescription>Something happened</AlertDescription>
</Alert>,
);

expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText("Heads up")).toBeInTheDocument();
expect(screen.getByText("Something happened")).toBeInTheDocument();
});

it("applies the destructive variant classes used by the auth error boxes", () => {
render(<Alert variant="destructive">Boom</Alert>);

const alert = screen.getByRole("alert");
expect(alert.className).toContain("border-destructive/30");
expect(alert.className).toContain("bg-destructive/10");
expect(alert.className).toContain("text-destructive");
expect(alert.className).toContain("rounded-lg");
expect(alert.className).toContain("p-3");
expect(alert.className).toContain("text-sm");
});

it("keeps caller classNames so layout spacing is preserved", () => {
render(
<Alert variant="destructive" className="mt-5">
Boom
</Alert>,
);

expect(screen.getByRole("alert").className).toContain("mt-5");
});

it("defaults to the neutral variant", () => {
render(<Alert>Neutral</Alert>);

const alert = screen.getByRole("alert");
expect(alert.className).toContain("bg-card");
expect(alert.className).not.toContain("bg-destructive/10");
});
});
61 changes: 61 additions & 0 deletions src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";

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

const alertVariants = cva("rounded-lg border p-3 text-sm", {
variants: {
variant: {
default: "border-border bg-card text-card-foreground",
destructive: "border-destructive/30 bg-destructive/10 text-destructive",
},
},
defaultVariants: {
variant: "default",
},
});

const Alert = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
data-slot="alert"
className={cn(alertVariants({ variant, className }))}
{...props}
/>
));

Alert.displayName = "Alert";

const AlertTitle = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="alert-title"
className={cn("font-semibold leading-6", className)}
{...props}
/>
));

AlertTitle.displayName = "AlertTitle";

const AlertDescription = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="alert-description"
className={className}
{...props}
/>
));

AlertDescription.displayName = "AlertDescription";

export { Alert, AlertTitle, AlertDescription, alertVariants };
105 changes: 105 additions & 0 deletions src/components/ui/dropdown-menu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

function Harness({ onSelect }: { onSelect?: () => void }) {
return (
<div>
<button type="button">outside</button>
<DropdownMenu>
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={onSelect}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

describe("DropdownMenu", () => {
it("opens on trigger click and renders items", async () => {
const user = userEvent.setup();
render(<Harness />);

expect(screen.queryByRole("menu")).not.toBeInTheDocument();

await user.click(screen.getByText("Actions"));

expect(await screen.findByRole("menu")).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: "Delete" }),
).toBeInTheDocument();
});

it("marks the trigger as expanded while open so the menu is anchored to it", async () => {
const user = userEvent.setup();
render(<Harness />);

const trigger = screen.getByText("Actions");
expect(trigger).toHaveAttribute("aria-expanded", "false");

await user.click(trigger);

expect(trigger).toHaveAttribute("aria-expanded", "true");
const menu = await screen.findByRole("menu");
expect(trigger).toHaveAttribute("aria-controls", menu.id);
});

it("closes on Escape", async () => {
const user = userEvent.setup();
render(<Harness />);

await user.click(screen.getByText("Actions"));
expect(await screen.findByRole("menu")).toBeInTheDocument();

await user.keyboard("{Escape}");

await waitFor(() =>
expect(screen.queryByRole("menu")).not.toBeInTheDocument(),
);
});

it("closes on outside click", async () => {
const user = userEvent.setup();
render(<Harness />);

await user.click(screen.getByText("Actions"));
expect(await screen.findByRole("menu")).toBeInTheDocument();

// Radix sets `pointer-events: none` on the body while an open menu traps
// interaction, which user-event refuses to click through. Dispatch the
// dismiss sequence Radix actually listens for on the outside element.
const outside = screen.getByText("outside");
fireEvent.pointerDown(outside, { pointerType: "mouse", button: 0 });
fireEvent.mouseDown(outside, { button: 0 });

await waitFor(() =>
expect(screen.queryByRole("menu")).not.toBeInTheDocument(),
);
});

it("fires onSelect and closes when an item is chosen", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<Harness onSelect={onSelect} />);

await user.click(screen.getByText("Actions"));
await user.click(await screen.findByRole("menuitem", { name: "Edit" }));

expect(onSelect).toHaveBeenCalledTimes(1);
await waitFor(() =>
expect(screen.queryByRole("menu")).not.toBeInTheDocument(),
);
});
});
Loading
Loading