diff --git a/README.md b/README.md index 5cd2558..90cd16e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/subscriptions/route.ts b/app/api/subscriptions/route.ts index d725ccc..9ff651e 100644 --- a/app/api/subscriptions/route.ts +++ b/app/api/subscriptions/route.ts @@ -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, }, }); diff --git a/app/confirm/components/ConfirmSubscriptionForm.tsx b/app/confirm/components/ConfirmSubscriptionForm.tsx index 1b70099..df57aab 100644 --- a/app/confirm/components/ConfirmSubscriptionForm.tsx +++ b/app/confirm/components/ConfirmSubscriptionForm.tsx @@ -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 /> diff --git a/app/confirm/components/SubscriptionSummary.tsx b/app/confirm/components/SubscriptionSummary.tsx index 3b9c706..f9374c4 100644 --- a/app/confirm/components/SubscriptionSummary.tsx +++ b/app/confirm/components/SubscriptionSummary.tsx @@ -19,6 +19,8 @@ type SubscriptionSummaryProps = { numFailedPayments?: number; retryCount?: number; payerData?: string; + maxPayments?: number; + endDateTime?: Date; }; showFirstPayment?: boolean; }; @@ -120,6 +122,18 @@ export function SubscriptionSummary({ } /> )} + {values.maxPayments && ( + + )} + {values.endDateTime && ( + + )} ; }; @@ -34,6 +36,8 @@ export default async function ConfirmSubscriptionPage({ returnUrl, nwcUrl, currency, + maxPayments, + endDateTime, } = await searchParams; if (!amount || !recipient || (!timeframe && !cron)) { @@ -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 ( diff --git a/app/create/components/AdvancedOptions.tsx b/app/create/components/AdvancedOptions.tsx new file mode 100644 index 0000000..8508f81 --- /dev/null +++ b/app/create/components/AdvancedOptions.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { UseFormRegister, FieldErrors } from "react-hook-form"; +import { useState } from "react"; + +type AdvancedOptionsProps = { + register: UseFormRegister; + errors: FieldErrors; + useCron: boolean; +}; + +export function AdvancedOptions({ + register, + errors, + useCron, +}: AdvancedOptionsProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> +
+ +
+ + {isOpen && ( + <> + {/* Cron Expression Toggle */} +
+ + +
+ + {useCron && ( +
+ + { + 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 && ( +

+ {errors.cronExpression.message as string} +

+ )} +
+

Examples:

+
    +
  • + 0 10 * * 0 - Every Sunday at 10:00 AM UTC +
  • +
  • + 0 23 * * 0 - Every Sunday at 11:00 PM UTC +
  • +
  • + 0 9 * * 1 - Every Monday at 9:00 AM UTC +
  • +
  • + 0 12 * * * - Every day at 12:00 PM UTC +
  • +
  • + 0 0 1 * * - First day of every month at + midnight UTC +
  • +
  • + 0 0 * * 1#1 - First Monday of every month at + midnight UTC +
  • +
+

+ + 📅 Use crontab.guru for help + +

+
+
+ )} + + {/* Max Payments */} + + { + 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 && ( +

+ {errors.maxPayments.message as string} +

+ )} + + {/* End DateTime */} + + { + 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 && ( +

+ {errors.endDateTime.message as string} +

+ )} + + )} + + ); +} diff --git a/app/create/components/CreateSubscriptionForm.tsx b/app/create/components/CreateSubscriptionForm.tsx index 468995a..27a2767 100644 --- a/app/create/components/CreateSubscriptionForm.tsx +++ b/app/create/components/CreateSubscriptionForm.tsx @@ -15,6 +15,8 @@ import { fiat, } from "@getalby/lightning-tools"; import { SATS_CURRENCY } from "lib/constants"; +import { FrequencySelector } from "./FrequencySelector"; +import { AdvancedOptions } from "./AdvancedOptions"; type CreateSubscriptionFormData = Omit< CreateSubscriptionRequest, @@ -119,6 +121,12 @@ export function CreateSubscriptionForm() { if (encodedPayerData) { searchParams.append("payerdata", encodeURIComponent(encodedPayerData)); } + if (data.maxPayments) { + searchParams.append("maxPayments", data.maxPayments); + } + if (data.endDateTime) { + searchParams.append("endDateTime", data.endDateTime); + } setNavigating(true); push(`/confirm?${searchParams.toString()}`); }); @@ -261,128 +269,14 @@ export function CreateSubscriptionForm() { {errors.amount && (

{errors.amount.message}

)} - -
- - -
- - {watch("useCron") ? ( -
- - { - 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 0 1 * * (Every month on the 1st at midnight UTC)" - /> - {errors.cronExpression && ( -

{errors.cronExpression.message}

- )} -
-

Examples:

-
    -
  • - 0 0 1 * * - First day of every month at - midnight UTC -
  • -
  • - 0 10 * * 0 - Every Sunday at 10:00 AM UTC -
  • -
  • - 0 23 * * 0 - Every Sunday at 11:00 PM UTC -
  • -
  • - 0 9 * * 1 - Every Monday at 9:00 AM UTC -
  • -
  • - 0 12 * * * - Every day at 12:00 PM UTC -
  • -
  • - 0 0 * * 1#1 - First Monday of every month at - midnight UTC -
  • -
-

- - 📅 Use crontab.guru for help - -

-
-
- ) : ( - <> -
-

Repeat payment every

- { - if (!isValidPositiveValue(parseInt(value))) { - return "Please enter a positive value"; - } - - // Multiple months cannot easily be done with cron - if ( - watchedTimeframe === "months" && - parseInt(value) > 1 - ) { - return "Multiple months not supported. Please use weeks instead (e.g., 8 weeks for 2 months)"; - } - - return undefined; - }, - })} - className={`zp-input w-full`} - /> - -
- {errors.timeframeValue && ( -

{errors.timeframeValue.message}

- )} - )} {lightningAddress && @@ -405,6 +299,12 @@ export function CreateSubscriptionForm() { )} + +