Skip to content
Open
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
20 changes: 18 additions & 2 deletions src/components/PaymentPlanComparison.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export default function PaymentPlanComparison({
}: PaymentPlanComparisonProps) {
const [hoveredPlanIndex, setHoveredPlanIndex] = useState<number | null>(null);

const cheapestPlanIndex = plans.reduce(
(cheapestIndex, plan, index) =>
plan.effectiveCost < plans[cheapestIndex].effectiveCost ? index : cheapestIndex,
0
);

return (
<div className="w-full overflow-x-auto">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 min-w-max md:min-w-full">
Expand Down Expand Up @@ -92,10 +98,20 @@ export default function PaymentPlanComparison({
</div>

{/* Total Cost */}
<div className="border-t border-gray-200 pt-4">
<div
className={`border-t pt-4 ${
index === cheapestPlanIndex
? 'border-green-500 bg-green-50 -mx-6 px-6 pb-2 rounded-b-lg'
: 'border-gray-200'
}`}
>
<div className="flex justify-between items-center">
<span className="text-sm font-semibold text-gray-700">Total Cost:</span>
<span className="text-xl font-bold text-gray-900">
<span
className={`text-xl font-bold ${
index === cheapestPlanIndex ? 'text-green-700' : 'text-gray-900'
}`}
>
{formatted.total} USDC
</span>
</div>
Expand Down
148 changes: 82 additions & 66 deletions src/components/SubscriptionCalendarPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useMemo } from "react";
import { useMemo, useState } from "react";
import { computeUpcomingDates } from "@/lib/subscriptions";

interface Props {
Expand All @@ -25,81 +25,97 @@ export default function SubscriptionCalendarPreview({
[intervalDays, count, fromDate]
);

const monthGroups = useMemo(() => {
const groups: Record<string, { year: number; month: number; days: Set<number> }> = {};
const initialView = useMemo(() => {
const first = upcomingDates[0] ?? fromDate ?? new Date();
return { year: first.getFullYear(), month: first.getMonth() };
}, [upcomingDates, fromDate]);

const [viewOffset, setViewOffset] = useState(0);

const viewDate = useMemo(() => {
return new Date(initialView.year, initialView.month + viewOffset, 1);
}, [initialView, viewOffset]);

const viewYear = viewDate.getFullYear();
const viewMonth = viewDate.getMonth();

const invoiceDaysInView = useMemo(() => {
const days = new Set<number>();
for (const date of upcomingDates) {
const key = `${date.getFullYear()}-${date.getMonth()}`;
if (!groups[key]) {
groups[key] = {
year: date.getFullYear(),
month: date.getMonth(),
days: new Set(),
};
if (date.getFullYear() === viewYear && date.getMonth() === viewMonth) {
days.add(date.getDate());
}
groups[key].days.add(date.getDate());
}
return Object.values(groups);
}, [upcomingDates]);
return days;
}, [upcomingDates, viewYear, viewMonth]);

const monthName = viewDate.toLocaleString("default", {
month: "long",
year: "numeric",
});
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
const firstDayOfWeek = new Date(viewYear, viewMonth, 1).getDay();

const cells: (number | null)[] = [];
for (let i = 0; i < firstDayOfWeek; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);

return (
<div className="bg-gray-900 rounded-lg p-4 border border-gray-800">
<h3 className="text-sm font-semibold text-gray-300 mb-3">
Upcoming Invoice Dates
</h3>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-300">
Upcoming Invoice Dates
</h3>
<div className="flex items-center gap-2">
<button
type="button"
aria-label="Previous month"
onClick={() => setViewOffset((o) => o - 1)}
className="text-gray-400 hover:text-white px-1"
>
</button>
<span className="text-xs font-medium text-gray-300 min-w-[7rem] text-center">
{monthName}
</span>
<button
type="button"
aria-label="Next month"
onClick={() => setViewOffset((o) => o + 1)}
className="text-gray-400 hover:text-white px-1"
>
</button>
</div>
</div>

{monthGroups.length === 0 ? (
{upcomingDates.length === 0 ? (
<p className="text-sm text-gray-500">No upcoming dates.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{monthGroups.map((group) => {
const monthName = new Date(group.year, group.month).toLocaleString(
"default",
{ month: "long", year: "numeric" }
);
const daysInMonth = getDaysInMonth(group.year, group.month);
const firstDayOfWeek = new Date(
group.year,
group.month,
1
).getDay();

const cells: (number | null)[] = [];
for (let i = 0; i < firstDayOfWeek; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);

<div className="grid grid-cols-7 gap-0.5">
{DAY_LABELS.map((label) => (
<div
key={label}
className="text-center text-[10px] text-gray-600 pb-0.5"
>
{label.charAt(0)}
</div>
))}
{cells.map((day, i) => {
if (day === null) {
return <div key={`empty-${i}`} />;
}
const isInvoiceDay = invoiceDaysInView.has(day);
return (
<div key={`${group.year}-${group.month}`}>
<p className="text-xs font-medium text-gray-400 mb-2">
{monthName}
</p>
<div className="grid grid-cols-7 gap-0.5">
{DAY_LABELS.map((label) => (
<div
key={label}
className="text-center text-[10px] text-gray-600 pb-0.5"
>
{label.charAt(0)}
</div>
))}
{cells.map((day, i) => {
if (day === null) {
return <div key={`empty-${i}`} />;
}
const isInvoiceDay = group.days.has(day);
return (
<div
key={day}
className={`text-center text-xs py-1 rounded ${
isInvoiceDay
? "bg-indigo-600 text-white font-bold"
: "text-gray-500"
}`}
>
{day}
</div>
);
})}
</div>
<div
key={day}
className={`text-center text-xs py-1 rounded ${
isInvoiceDay
? "bg-indigo-600 text-white font-bold"
: "text-gray-500"
}`}
>
{day}
</div>
);
})}
Expand Down
16 changes: 14 additions & 2 deletions src/hooks/useInvoiceCostEstimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface CostBreakdown {
reserveTopUpXlm: number;
platformFeeXlm: number;
addOnCostsXlm: number;
pathSpreadXlm: number;
totalXlm: number;
}

Expand All @@ -22,6 +23,7 @@ interface UseInvoiceCostEstimateParams {
creatorAddress: string;
enabledAddOns?: FeatureAddOn[];
feeTierMultiplier?: number;
pathPaymentSpreadXlm?: number;
}

const BASE_NETWORK_FEE_XLM = 0.00001; // 100 stroops
Expand All @@ -32,6 +34,7 @@ export function useInvoiceCostEstimate({
creatorAddress,
enabledAddOns = [],
feeTierMultiplier = 1,
pathPaymentSpreadXlm = 0,
}: UseInvoiceCostEstimateParams) {
const [existingTrustlines, setExistingTrustlines] = useState<Set<string>>(new Set());
const [creatorBalance, setCreatorBalance] = useState<number | null>(null);
Expand Down Expand Up @@ -84,17 +87,26 @@ export function useInvoiceCostEstimate({
const reserveTopUpXlm = calculateReserveTopUp(estimatedNewTrustlines);

const addOnCostsXlm = getAddOnCosts(enabledAddOns);
const pathSpreadXlm = pathPaymentSpreadXlm;

const totalXlm = networkFeeXlm + platformFeeXlm + reserveTopUpXlm + addOnCostsXlm;
const totalXlm =
networkFeeXlm + platformFeeXlm + reserveTopUpXlm + addOnCostsXlm + pathSpreadXlm;

return {
networkFeeXlm,
reserveTopUpXlm,
platformFeeXlm,
addOnCostsXlm,
pathSpreadXlm,
totalXlm,
};
}, [invoiceAmountXlm, recipientAddresses.length, feeTierMultiplier, enabledAddOns]);
}, [
invoiceAmountXlm,
recipientAddresses.length,
feeTierMultiplier,
enabledAddOns,
pathPaymentSpreadXlm,
]);

const isBalanceSufficient = useMemo(() => {
if (creatorBalance === null) return null;
Expand Down
25 changes: 23 additions & 2 deletions src/hooks/usePathPayment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';

export interface PathPaymentResult {
path: string[];
Expand All @@ -12,6 +12,12 @@ export interface PathPaymentResult {
slippage: number; // percentage
}

export interface SelectedPath {
hops: string[];
effectiveRate: number;
slippageEstimate: number;
}

interface UsePathPaymentOptions {
sourceAsset?: string;
destinationAsset: string;
Expand All @@ -32,6 +38,7 @@ const MOCK_EXCHANGE_RATES: Record<string, Record<string, number>> = {

export function usePathPayment(options: UsePathPaymentOptions): {
paths: PathPaymentResult[];
selectedPath: SelectedPath | null;
loading: boolean;
error: string | null;
} {
Expand Down Expand Up @@ -99,5 +106,19 @@ export function usePathPayment(options: UsePathPaymentOptions): {
fetchPaths();
}, [fetchPaths]);

return { paths, loading, error };
const selectedPath = useMemo((): SelectedPath | null => {
if (loading || paths.length === 0) return null;

const lowestSlippagePath = paths.reduce((lowest, current) =>
current.slippage < lowest.slippage ? current : lowest
);

return {
hops: lowestSlippagePath.path,
effectiveRate: lowestSlippagePath.exchangeRate,
slippageEstimate: lowestSlippagePath.slippage,
};
}, [paths, loading]);

return { paths, selectedPath, loading, error };
}