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
113 changes: 113 additions & 0 deletions components/stream/EndingSoonWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use client';

import { useMemo, useState } from 'react';
import Link from 'next/link';
import { AlertTriangle, ArrowRight } from 'lucide-react';
import { truncateAddress } from '@/lib/format';

export type EndingSoonRole = 'sender' | 'recipient';

export interface EndingSoonItem {
id: string;
role: EndingSoonRole;
counterparty: string;
token: string;
endTime: number;
}

interface EndingSoonWidgetProps {
streams: EndingSoonItem[];
}

const DAY_SECONDS = 24 * 60 * 60;

export function EndingSoonWidget({ streams }: EndingSoonWidgetProps) {
const [view, setView] = useState<EndingSoonRole>('sender');

const sorted = useMemo(
() =>
[...streams]
.filter((stream) => stream.role === view)
.sort((a, b) => a.endTime - b.endTime),
[streams, view],
);

const title = view === 'sender' ? 'Need top-ups' : 'Expiring payrolls';

if (sorted.length === 0) {
return (
<div className="card">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500">Ending soon</p>
<h3 className="mt-1 text-lg font-black text-black dark:text-white">{title}</h3>
</div>
</div>
<div className="rounded border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-500 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-400">
No streams are ending soon in this view.
</div>
</div>
);
}

return (
<div className="card">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-gray-400 dark:text-gray-500">Ending soon</p>
<h3 className="mt-1 text-lg font-black text-black dark:text-white">{title}</h3>
</div>
<div className="inline-flex rounded-full border border-gray-200 bg-gray-50 p-1 dark:border-gray-700 dark:bg-gray-950">
{(['sender', 'recipient'] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setView(option)}
className={[
'rounded-full px-3 py-1.5 text-xs font-semibold transition-colors',
view === option
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-500 hover:text-black dark:text-gray-400 dark:hover:text-white',
].join(' ')}
>
{option === 'sender' ? 'As Sender' : 'As Recipient'}
</button>
))}
</div>
</div>

<div className="space-y-3">
{sorted.slice(0, 5).map((stream) => {
const daysLeft = Math.max(0, Math.ceil((stream.endTime - Date.now() / 1000) / DAY_SECONDS));

return (
<Link
key={stream.id}
href={`/stream/${stream.id}`}
className="flex items-center justify-between gap-3 rounded border border-gray-200 bg-white p-3 transition-colors hover:border-gray-300 hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-950 dark:hover:border-gray-700 dark:hover:bg-gray-900"
>
<div className="flex min-w-0 items-center gap-3">
<div className="rounded-full bg-amber-100 p-2 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<AlertTriangle className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-black dark:text-white">
{truncateAddress(stream.counterparty)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{stream.token} • {daysLeft}d left
</p>
</div>
</div>

<span className="inline-flex items-center gap-1 text-xs font-semibold text-gray-500 dark:text-gray-400">
View
<ArrowRight className="h-3.5 w-3.5" />
</span>
</Link>
);
})}
</div>
</div>
);
}
77 changes: 62 additions & 15 deletions components/ui/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,77 @@
import { forwardRef, useId, type InputHTMLAttributes } from 'react';
import { forwardRef, useRef, type ChangeEvent, type InputHTMLAttributes } from 'react';
import { X } from 'lucide-react';

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
hint?: string;
error?: string;
label?: string;
hint?: string;
error?: string;
allowClear?: boolean;
}

export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, hint, error, id, className = '', ...props }, ref) => {
const reactId = useId();
const inputId = id ?? reactId;
({ label, hint, error, id, className = '', allowClear = false, value, onChange, ...props }, ref) => {
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, '-');
const inputRef = useRef<HTMLInputElement>(null);

const setRefs = (node: HTMLInputElement | null) => {
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
inputRef.current = node;
};

const handleClear = () => {
const el = inputRef.current;
if (!el) return;

el.value = '';

if (onChange) {
const event = {
target: el,
currentTarget: el,
} as ChangeEvent<HTMLInputElement>;
onChange(event);
}

el.focus();
};

const hasValue =
typeof value === 'string'
? value.length > 0
: typeof value === 'number'
? value > 0
: Boolean(inputRef.current?.value.length);

return (
<div className="space-y-1">
{label && (
<label htmlFor={inputId} className="block text-xs font-semibold text-black dark:text-white">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={`input ${error ? 'border-black dark:border-white border-2 focus:ring-black/10 dark:focus:ring-white/10' : ''} ${className}`}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
{...props}
/>
<div className="relative">
<input
{...props}
ref={setRefs}
id={inputId}
value={value}
onChange={onChange}
className={`input ${allowClear ? 'pr-10' : ''} ${error ? 'border-red-400 focus:border-red-500 focus:ring-red-500/10' : ''} ${className}`}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
/>
{allowClear && hasValue && (
<button
type="button"
onClick={handleClear}
aria-label="Clear input"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{hint && !error && (
<p id={`${inputId}-hint`} className="text-xs text-gray-400 dark:text-gray-500">{hint}</p>
)}
Expand Down
147 changes: 76 additions & 71 deletions components/ui/QrCodeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,87 +1,92 @@
"use client";
'use client';

import { useMemo } from "react";
import qrcode from "qrcode-generator";
import { Modal } from "@/components/ui/Modal";
import { useEffect, useMemo, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { Download } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';

export type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H';

interface QrCodeModalProps {
/** The address encoded into the QR code (legacy prop, kept for backwards compat). */
address?: string;
/**
* Generic payload encoded into the QR code (e.g. a stream claim URL).
* Takes precedence over `address` when both are provided (#688).
*/
value?: string;
/** Modal title. Defaults to "Scan address" for address codes. */
isOpen: boolean;
value: string;
title?: string;
onClose: () => void;
errorCorrectionLevel?: ErrorCorrectionLevel;
}

/** Cell size in SVG user units — kept small since the <svg> itself scales via CSS. */
const CELL_SIZE = 4;
export function QrCodeModal({
isOpen,
value,
title = 'QR code',
onClose,
errorCorrectionLevel = 'H',
}: QrCodeModalProps) {
const [qrDataUrl, setQrDataUrl] = useState('');
const canvasRef = useRef<HTMLCanvasElement | null>(null);

const levelLabel = useMemo(() => errorCorrectionLevel, [errorCorrectionLevel]);

useEffect(() => {
if (!isOpen || !value) return;

QRCode.toCanvas(
canvasRef.current,
value,
{
errorCorrectionLevel,
margin: 1,
width: 240,
color: {
dark: '#111827',
light: '#ffffff',
},
},
(error) => {
if (error) {
console.error('QR code render failed', error);
return;
}

if (!canvasRef.current) return;

/**
* Renders a scannable QR code for a Stellar address in a modal (#550).
*
* Builds the code with `qrcode-generator` (zero dependencies, ~16 KB) and
* draws it as plain inline SVG rects rather than using the library's
* `createSvgTag` HTML-string output, so it needs no `dangerouslySetInnerHTML`
* and can follow the app's light/dark theme.
*/
export function QrCodeModal({ address, value, title, onClose }: QrCodeModalProps) {
// #688 — generic share payload (stream claim URL) with legacy address fallback.
const payload = value ?? address ?? "";
const modalTitle = title ?? "Scan address";
const modules = useMemo(() => {
// Type number 0 = auto-select the smallest size that fits the data.
const qr = qrcode(0, "M");
qr.addData(payload);
qr.make();
const count = qr.getModuleCount();
const cells: boolean[][] = [];
for (let row = 0; row < count; row++) {
const line: boolean[] = [];
for (let col = 0; col < count; col++) {
line.push(qr.isDark(row, col));
}
cells.push(line);
}
return cells;
}, [payload]);
const dataUrl = canvasRef.current.toDataURL('image/png');
setQrDataUrl(dataUrl);
},
);
}, [isOpen, value, errorCorrectionLevel]);

const size = modules.length * CELL_SIZE;
if (!isOpen) return null;

const handleDownload = () => {
if (!qrDataUrl) return;
const link = document.createElement('a');
link.download = 'streamfi-qr.png';
link.href = qrDataUrl;
link.click();
};

return (
<Modal title={modalTitle} onClose={onClose}>
<div className="flex flex-col items-center gap-4">
<div className="p-3 bg-white rounded">
<svg
viewBox={`0 0 ${size} ${size}`}
width={size * 4}
height={size * 4}
role="img"
aria-label={`QR code for ${payload}`}
className="block"
<Modal title={title} onClose={onClose} size="max-w-sm">
<div className="space-y-4">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>Correction: {levelLabel}</span>
<button
type="button"
onClick={handleDownload}
className="inline-flex items-center gap-1 rounded border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
<rect x={0} y={0} width={size} height={size} fill="#fff" />
{modules.map((line, row) =>
line.map((dark, col) =>
dark ? (
<rect
key={`${row}-${col}`}
x={col * CELL_SIZE}
y={row * CELL_SIZE}
width={CELL_SIZE}
height={CELL_SIZE}
fill="#000"
/>
) : null,
),
)}
</svg>
<Download className="h-3.5 w-3.5" />
Download PNG
</button>
</div>

<div className="flex justify-center rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<canvas ref={canvasRef} className="h-56 w-56 rounded-lg bg-white" aria-label="QR code" />
</div>
<p className="font-mono text-xs text-center break-all text-gray-500 dark:text-gray-400">
{payload}

<p className="break-all text-center text-[11px] text-gray-500 dark:text-gray-400">
{value}
</p>
</div>
</Modal>
Expand Down
15 changes: 10 additions & 5 deletions components/ui/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,19 @@ export function Tooltip({ content, children, side = 'top' }: TooltipProps) {
const [show, setShow] = useState(false);
const tooltipId = useId();

const handleShow = () => setShow(true);
const handleHide = () => setShow(false);

return (
<span
className="relative inline-flex"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onFocus={() => setShow(true)}
onBlur={() => setShow(false)}
aria-describedby={show ? tooltipId : undefined}
onMouseEnter={handleShow}
onMouseLeave={handleHide}
onFocus={handleShow}
onBlur={handleHide}
onTouchStart={handleShow}
onTouchEnd={handleHide}
onTouchCancel={handleHide}
>
{children}
{show && (
Expand Down
Loading
Loading