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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ https://zapplanner.albylabs.com/confirm?amount=21&recipient=hello@getalby.com&ti
- `comment` and `payerdata` will only be sent if the recipient lightning address supports it
- `returnUrl` encoded URL to show as link on confirmation page
- `nwcUrl` a url-encoded NWC connection secret
- `maxPayments` (optional) maximum number of payments before stopping e.g. `12`
- `endDateTime` (optional) ISO 8601 date string when payments should stop e.g. `2025-12-31T23:59:59.000Z`

## API

Expand Down
6 changes: 6 additions & 0 deletions app/api/subscriptions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ export async function POST(request: Request) {
sleepDuration,
sleepDurationMs,
cronExpression,
maxPayments: createSubscriptionRequest.maxPayments
? parseInt(createSubscriptionRequest.maxPayments)
: null,
endDateTime: createSubscriptionRequest.endDateTime
? new Date(createSubscriptionRequest.endDateTime)
: null,
},
});

Expand Down
6 changes: 6 additions & 0 deletions app/confirm/components/ConfirmSubscriptionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ export function ConfirmSubscriptionForm({
nextCronExecution,
message: unconfirmedSubscription.message,
payerData: unconfirmedSubscription.payerData,
maxPayments: unconfirmedSubscription.maxPayments
? parseInt(unconfirmedSubscription.maxPayments)
: undefined,
endDateTime: unconfirmedSubscription.endDateTime
? new Date(unconfirmedSubscription.endDateTime)
: undefined,
}}
showFirstPayment
/>
Expand Down
14 changes: 14 additions & 0 deletions app/confirm/components/SubscriptionSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type SubscriptionSummaryProps = {
numFailedPayments?: number;
retryCount?: number;
payerData?: string;
maxPayments?: number;
endDateTime?: Date;
};
showFirstPayment?: boolean;
};
Expand Down Expand Up @@ -120,6 +122,18 @@ export function SubscriptionSummary({
}
/>
)}
{values.maxPayments && (
<SubscriptionSummaryItem
left="Maximum payments"
right={`${values.maxPayments} payments`}
/>
)}
{values.endDateTime && (
<SubscriptionSummaryItem
left="Auto-stop date"
right={new Date(values.endDateTime).toLocaleDateString()}
/>
)}
<SubscriptionSummaryItem
left="Message"
right={values.message || "(no message provided)"}
Expand Down
6 changes: 6 additions & 0 deletions app/confirm/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type ConfirmSubscriptionPageProps = {
returnUrl?: string;
nwcUrl?: string;
currency?: string;
maxPayments?: string;
endDateTime?: string;
}>;
};

Expand All @@ -34,6 +36,8 @@ export default async function ConfirmSubscriptionPage({
returnUrl,
nwcUrl,
currency,
maxPayments,
endDateTime,
} = await searchParams;

if (!amount || !recipient || (!timeframe && !cron)) {
Expand All @@ -48,6 +52,8 @@ export default async function ConfirmSubscriptionPage({
message: comment ? decodeURIComponent(comment) : undefined,
payerData: payerdata ? decodeURIComponent(payerdata) : undefined,
currency,
maxPayments: maxPayments,
endDateTime: endDateTime,
};

return (
Expand Down
168 changes: 168 additions & 0 deletions app/create/components/AdvancedOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"use client";

import { UseFormRegister, FieldErrors } from "react-hook-form";
import { useState } from "react";

type AdvancedOptionsProps = {
register: UseFormRegister<any>;
errors: FieldErrors;
useCron: boolean;
};

export function AdvancedOptions({
register,
errors,
useCron,
}: AdvancedOptionsProps) {
const [isOpen, setIsOpen] = useState(false);

return (
<>
<div className="mt-6">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 text-primary font-medium hover:underline focus:outline-none"
>
<span>{isOpen ? "▼" : "▶"}</span>
<span>Advanced options</span>
</button>
</div>

{isOpen && (
<>
{/* Cron Expression Toggle */}
<div className="flex items-center gap-2 mt-4">
<input
type="checkbox"
id="useCron"
{...register("useCron")}
className="checkbox"
/>
<label className="label-text" htmlFor="useCron">
Use cron expression
</label>
</div>

{useCron && (
<div className="space-y-2">
<label className="zp-label">Cron Expression</label>
<input
key="cronExpression"
{...register("cronExpression", {
validate: (value) => {
if (!value) return "Cron expression is required";
const parts = value.split(" ");
if (parts.length !== 5) {
return "Cron expression must have 5 parts (minute hour day month weekday)";
}
if (
process.env.NEXT_PUBLIC_ALLOW_SHORT_TIMEFRAMES !==
"true" &&
!/^[0-5]?[0-9] /.test(value)
) {
return "Cron expression must repeat only once per hour";
}
},
})}
className="zp-input"
placeholder="0 10 * * 0 (Every Sunday at 10:00 AM UTC)"
/>
{errors.cronExpression && (
<p className="zp-form-error">
{errors.cronExpression.message as string}
</p>
)}
<div className="text-sm text-gray-600">
<p>Examples:</p>
<ul className="list-disc list-inside space-y-1">
<li>
<code>0 10 * * 0</code> - Every Sunday at 10:00 AM UTC
</li>
<li>
<code>0 23 * * 0</code> - Every Sunday at 11:00 PM UTC
</li>
<li>
<code>0 9 * * 1</code> - Every Monday at 9:00 AM UTC
</li>
<li>
<code>0 12 * * *</code> - Every day at 12:00 PM UTC
</li>
<li>
<code>0 0 1 * *</code> - First day of every month at
midnight UTC
</li>
<li>
<code>0 0 * * 1#1</code> - First Monday of every month at
midnight UTC
</li>
</ul>
<p className="mt-2">
<a
href="https://crontab.guru/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
📅 Use crontab.guru for help
</a>
</p>
</div>
</div>
)}

{/* Max Payments */}
<label className="zp-label">
Maximum number of payments (Optional)
</label>
<input
{...register("maxPayments", {
validate: (value) => {
if (!value) return undefined;
if (!Number.isInteger(Number(value))) {
return "Please enter a whole number";
}
if (Number(value) <= 0) {
return "Please enter a positive number";
}
return undefined;
},
})}
type="number"
min="1"
className="zp-input"
placeholder="e.g., 12 (leave empty for unlimited)"
/>
{errors.maxPayments && (
<p className="zp-form-error">
{errors.maxPayments.message as string}
</p>
)}

{/* End DateTime */}
<label className="zp-label">End date and time (Optional)</label>
<input
{...register("endDateTime", {
validate: (value) => {
if (!value) return undefined;
if (new Date(value) <= new Date()) {
return "End date must be in the future";
}
return undefined;
},
})}
type="datetime-local"
className="zp-input"
min={new Date().toISOString().slice(0, 16)}
placeholder="Leave empty for unlimited payments"
/>
{errors.endDateTime && (
<p className="zp-form-error">
{errors.endDateTime.message as string}
</p>
)}
</>
)}
</>
);
}
Loading