diff --git a/docs/README.md b/docs/README.md index e0cf1965..f58d51ba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1127,6 +1127,40 @@ donationRate = 10 ``` +## fields + +> **The 'fields' parameter specifies custom input fields to collect additional information from users before payment.** + +?> This parameter is optional. Default value is empty. Possible values are an array of field objects or a JSON string. Each field object can have the following properties: `name` (required, unique identifier), `text` (required, label displayed to user), `type` (input type like 'text', 'email', 'number'), `value` (default value), and `required` (boolean). + +**Example:** + + +#### ** HTML ** + +```html +fields='[{"name": "email", "text": "Email Address", "type": "email", "required": true}, {"name": "note", "text": "Note", "type": "text"}]' +``` + +#### ** JavaScript ** + +```javascript +fields: [ + { name: 'email', text: 'Email Address', type: 'email', required: true }, + { name: 'note', text: 'Note', type: 'text' } +] +``` + +#### ** React ** + +```react +fields = {[ + { name: 'email', text: 'Email Address', type: 'email', required: true }, + { name: 'note', text: 'Note', type: 'text' } +]} +``` + + # Contribute PayButton is a community-driven open-source initiative. Contributions from the community are _crucial_ to the success of the project. diff --git a/docs/zh-cn/README.md b/docs/zh-cn/README.md index 2e5addaa..ca3afd36 100644 --- a/docs/zh-cn/README.md +++ b/docs/zh-cn/README.md @@ -1126,6 +1126,40 @@ donationRate = 10 ``` +## fields + +> **「fields」参数用于指定自定义输入字段,以便在付款前收集用户的额外信息。** + +?> 此参数是可选的。默认值为空。可接受的值是字段对象数组或 JSON 字符串。每个字段对象可以包含以下属性:`name`(必填,唯一标识符)、`text`(必填,显示给用户的标签)、`type`(输入类型,如 'text'、'email'、'number')、`value`(默认值)和 `required`(布尔值)。 + +**Example:** + + +#### ** HTML ** + +```html +fields='[{"name": "email", "text": "电子邮件地址", "type": "email", "required": true}, {"name": "note", "text": "备注", "type": "text"}]' +``` + +#### ** JavaScript ** + +```javascript +fields: [ + { name: 'email', text: '电子邮件地址', type: 'email', required: true }, + { name: 'note', text: '备注', type: 'text' } +] +``` + +#### ** React ** + +```react +fields = {[ + { name: 'email', text: '电子邮件地址', type: 'email', required: true }, + { name: 'note', text: '备注', type: 'text' } +]} +``` + + # 贡献 diff --git a/docs/zh-tw/README.md b/docs/zh-tw/README.md index c93271f1..32790e32 100644 --- a/docs/zh-tw/README.md +++ b/docs/zh-tw/README.md @@ -1122,6 +1122,77 @@ donationRate = 10 ``` +## fields + +> **「fields」參數用於指定自訂輸入欄位,以便在付款前收集用戶的額外資訊。** + +?> 此參數是可選的。預設值為空。可接受的值是欄位物件陣列或 JSON 字串。每個欄位物件可以包含以下屬性:`name`(必填,唯一識別碼)、`text`(必填,顯示給用戶的標籤)、`type`(輸入類型,如 'text'、'email'、'number')、`value`(預設值)和 `required`(布林值)。 + +**Example:** + + +#### ** HTML ** + +```html +fields='[{"name": "email", "text": "電子郵件地址", "type": "email", "required": true}, {"name": "note", "text": "備註", "type": "text"}]' +``` + +#### ** JavaScript ** + +```javascript +fields: [ + { name: 'email', text: '電子郵件地址', type: 'email', required: true }, + { name: 'note', text: '備註', type: 'text' } +] +``` + +#### ** React ** + +```react +fields = {[ + { name: 'email', text: '電子郵件地址', type: 'email', required: true }, + { name: 'note', text: '備註', type: 'text' } +]} +``` + + +## on-fields-submit + +> **「on-fields-submit」參數用於指定當用戶提交自訂欄位時執行的回呼函數。** + +?> 此參數是可選的。預設值為空。可接受的值是任何已定義的函數。 + +#### *callback* 參數 + +- **fieldValues** (`object`): 包含以欄位名稱為鍵的欄位值物件 + +**Example:** + + +#### ** HTML ** + +```html +on-fields-submit="fieldsSubmitCallback" +``` + +#### ** JavaScript ** + +```javascript +onFieldsSubmit: (fieldValues) => { + console.log('Fields submitted:', fieldValues); + // fieldValues 示例: { email: 'user@example.com', note: '我的付款備註' } +} +``` + +#### ** React ** + +```react +onFieldsSubmit = {(fieldValues) => { + console.log('Fields submitted:', fieldValues); +}} +``` + + # 貢獻 diff --git a/paybutton/dev/demo/index.html b/paybutton/dev/demo/index.html index 3ee3f628..6e06499a 100644 --- a/paybutton/dev/demo/index.html +++ b/paybutton/dev/demo/index.html @@ -30,6 +30,14 @@
+
+
diff --git a/paybutton/src/index.tsx b/paybutton/src/index.tsx index d6d911bc..0766f88f 100644 --- a/paybutton/src/index.tsx +++ b/paybutton/src/index.tsx @@ -106,6 +106,7 @@ const allowedProps = [ 'transactionText', 'size', 'donationRate', + 'fields' ]; const requiredProps = [ diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index f46588d1..f853cbcd 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -20,7 +20,8 @@ import { CryptoCurrency, ButtonSize, DEFAULT_DONATION_RATE, - createPayment + createPayment, + Field } from '../../util'; import { PaymentDialog } from '../PaymentDialog'; import { AltpaymentCoin, AltpaymentError, AltpaymentPair, AltpaymentShift } from '../../altpayment'; @@ -59,6 +60,7 @@ export interface PayButtonProps extends ButtonProps { sizeScaleAlreadyApplied?: boolean; donationAddress?: string; donationRate?: number; + fields?: Field[]; } export const PayButton = ({ @@ -86,6 +88,7 @@ export const PayButton = ({ apiBaseUrl, transactionText, disableSound, + fields, autoClose = false, disableAltpayment, contributionOffset, @@ -464,6 +467,7 @@ export const PayButton = ({ donationRate={donationRate} convertedCurrencyObj={convertedCurrencyObj} setConvertedCurrencyObj={setConvertedCurrencyObj} + fields={fields} /> {errorMsg && (

{ const [success, setSuccess] = useState(false); const [internalDisabled, setInternalDisabled] = useState(false); @@ -259,6 +261,7 @@ export const PaymentDialog = ({ donationRate={donationRate} convertedCurrencyObj={convertedCurrencyObj} setConvertedCurrencyObj={setConvertedCurrencyObj} + fields={fields} foot={success && ( ) => void; } interface StyleProps { @@ -176,7 +179,30 @@ export const Widget: React.FunctionComponent = props => { donationRate = DEFAULT_DONATION_RATE, setConvertedCurrencyObj = () => {}, setPaymentId, + fields: fieldsProp, + onFieldsSubmit } = props; + + // State to track field values + const [fieldValues, setFieldValues] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [fieldsSubmitted, setFieldsSubmitted] = useState(false); + + // Parse fields if it's a JSON string + const fields = useMemo(() => { + if (!fieldsProp) return undefined; + if (Array.isArray(fieldsProp)) return fieldsProp; + if (typeof fieldsProp === 'string') { + try { + const parsed = JSON.parse(fieldsProp); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } + } + return undefined; + }, [fieldsProp]); + const [loading, setLoading] = useState(true); const [draftAmount, setDraftAmount] = useState("") const inputRef = React.useRef(null) @@ -638,6 +664,7 @@ export const Widget: React.FunctionComponent = props => { to, apiBaseUrl, ); + console.log("entrou aqui carai") setPaymentId(responsePaymentId); } catch (error) { console.error('Error creating payment ID:', error); @@ -839,7 +866,7 @@ export const Widget: React.FunctionComponent = props => { } setUrl(nextUrl ?? '') } - }, [to, thisCurrencyObject, price, thisAmount, opReturn, hasPrice, isCashtabAvailable, userDonationRate, donationEnabled, disabled, donationAddress, currency, randomSatoshis, thisAddressType, shouldApplyDonation]) + }, [to, thisCurrencyObject, price, thisAmount, opReturn, hasPrice, isCashtabAvailable, userDonationRate, donationEnabled, disabled, donationAddress, currency, randomSatoshis, thisAddressType, shouldApplyDonation, paymentId]) useEffect(() => { try { @@ -957,9 +984,62 @@ export const Widget: React.FunctionComponent = props => { const handleQrCodeClick = useCallback((): void => { if (disabled || to === undefined || qrLoading) return if (!url || !copyToClipboard(url)) return + console.log({ + url, + paymentId + }) setCopied(true) setRecentlyCopied(true) - }, [disabled, to, url, setCopied, setRecentlyCopied, qrLoading]) + }, [disabled, to, url, setCopied, setRecentlyCopied, qrLoading, paymentId]) + + const handleFieldsSubmit = useCallback(async (): Promise => { + if (!fields || success || isSubmitting) return; + + const missingFields = fields.filter( + (field) => field.required && !fieldValues[field.name]?.trim() + ); + + if (missingFields.length > 0) { + console.warn('Missing required fields:', missingFields.map(f => f.name)); + return; + } + + const fieldsWithValues = fields.map((field) => ({ + ...field, + value: fieldValues[field.name] || '' + })); + + setIsSubmitting(true); + + try { + if (!disablePaymentId && to) { + const effectiveAmount = convertedCryptoAmount ?? thisCurrencyObject?.float; + const newPaymentId = await createPayment(effectiveAmount, to, apiBaseUrl, fieldsWithValues); + if (setPaymentId && newPaymentId) { + setOpReturn( + encodeOpReturnProps({ + opReturn: props.opReturn, + paymentId: newPaymentId, + disablePaymentId: disablePaymentId ?? false, + }), + ) + setPaymentId(newPaymentId); + } + } + + if (onFieldsSubmit) { + onFieldsSubmit(fieldValues); + } + setFieldsSubmitted(true); + setTimeout(() => { + setFieldsSubmitted(false); + }, 2000); + } catch (error) { + console.error('Error submitting fields:', error); + } finally { + setIsSubmitting(false); + } + }, [fields, fieldValues, success, isSubmitting, onFieldsSubmit, disablePaymentId, to, convertedCryptoAmount, thisCurrencyObject, apiBaseUrl, setPaymentId, paymentId, props.opReturn]); const resolveUrl = useCallback((currency: string, amount?: number) => { if (disabled || !to) return; @@ -1274,7 +1354,6 @@ export const Widget: React.FunctionComponent = props => { {success ? null : ( { - // Use createElement to avoid JSX element-type incompatibility from duplicate React types React.createElement(ButtonEl, { text: widgetButtonText, hoverText, @@ -1319,6 +1398,98 @@ export const Widget: React.FunctionComponent = props => { ) : null} + {fields && Array.isArray(fields) && fields.length > 0 ? ( + + {fields.map((field, index) => ( + { + setFieldValues(prev => ({ + ...prev, + [field.name]: e.target.value + })); + }} + sx={{ + '& .MuiOutlinedInput-root': { + backgroundColor: isDarkMode ? '#1a1a1a' : '#fff', + '& fieldset': { + borderColor: isDarkMode ? '#333' : '#ddd', + }, + '&:hover fieldset': { + borderColor: theme.palette.primary, + }, + '&.Mui-focused fieldset': { + borderColor: theme.palette.primary, + }, + }, + '& .MuiInputLabel-root': { + color: isDarkMode ? '#b0b0b0' : '#666', + }, + '& .MuiInputBase-input': { + color: isDarkMode ? '#e0e0e0' : '#333', + }, + }} + /> + ))} + + {isSubmitting ? ( + <> + + Submitting... + + ) : fieldsSubmitted ? ( + 'Confirmed ✓' + ) : ( + 'Confirm' + )} + + + ) : null} + Powered by PayButton.org diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index e0836d23..21436043 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -19,6 +19,7 @@ import { shouldTriggerOnSuccess, isPropsTrue, DEFAULT_DONATION_RATE, + Field, } from '../../util'; import Widget, { WidgetProps } from './Widget'; @@ -54,6 +55,7 @@ export interface WidgetContainerProps donationAddress?: string donationRate?: number convertedCurrencyObj?: CurrencyObject; + fields?: Field[]; } const snackbarOptionsSuccess: OptionsObject = { @@ -138,6 +140,7 @@ export const WidgetContainer: React.FunctionComponent = donationRate, convertedCurrencyObj, setConvertedCurrencyObj, + fields, ...widgetProps } = props; const [internalCurrencyObj, setInternalCurrencyObj] = useState(); @@ -327,6 +330,7 @@ export const WidgetContainer: React.FunctionComponent = convertedCurrencyObj={convertedCurrencyObj} setConvertedCurrencyObj={setConvertedCurrencyObj} setPaymentId={setThisPaymentId} + fields={fields} /> ); diff --git a/react/lib/util/api-client.ts b/react/lib/util/api-client.ts index ab271df0..819cab85 100644 --- a/react/lib/util/api-client.ts +++ b/react/lib/util/api-client.ts @@ -8,6 +8,7 @@ import { PriceData, TransactionDetails, Currency, + Field, } from './types'; import { isFiat } from './currency'; import { CURRENCY_TYPES_MAP, DECIMALS } from './constants'; @@ -94,6 +95,7 @@ export const createPayment = async ( amount: string | number | undefined, address: string, rootUrl = config.apiBaseUrl, + fields?: Field[], ): Promise => { const prefix = getAddressPrefix(address) const decimals = DECIMALS[CURRENCY_TYPES_MAP[prefix]] @@ -102,7 +104,7 @@ export const createPayment = async ( : undefined const { data, status } = await axios.post( `${rootUrl}/api/payments/paymentId`, - { amount: safeAmount, address } + { amount: safeAmount, address, fields } ); if (status === 200) { diff --git a/react/lib/util/types.ts b/react/lib/util/types.ts index 9ca41d6c..5483cec6 100644 --- a/react/lib/util/types.ts +++ b/react/lib/util/types.ts @@ -157,3 +157,12 @@ export interface CheckSuccessInfo { currencyObj?: CurrencyObject, donationRate?: number } + +export type Field = { + name: string + text: string + type: string + value?: string + required?: boolean +} +