@brainerce/mcp-server 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/http.js +468 -41
- package/dist/bin/stdio.js +468 -41
- package/dist/index.d.mts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +468 -41
- package/dist/index.mjs +468 -41
- package/package.json +54 -53
package/dist/index.mjs
CHANGED
|
@@ -403,6 +403,234 @@ function PayPalPaymentForm({ clientId, orderId, checkoutId }: { clientId: string
|
|
|
403
403
|
}
|
|
404
404
|
\`\`\``;
|
|
405
405
|
}
|
|
406
|
+
function getCheckoutCustomFieldsSection() {
|
|
407
|
+
return `## Checkout Custom Fields (Optional Step Between Shipping & Payment)
|
|
408
|
+
|
|
409
|
+
Some stores configure **custom fields** at checkout \u2014 e.g., gift wrapping, floor number for delivery, installation date, custom engraving. Each field can add a **surcharge** to the order total. The server filters by visibility (always / shipping-only / pickup-only / cart-products) and calculates surcharges automatically.
|
|
410
|
+
|
|
411
|
+
### When to Build This
|
|
412
|
+
|
|
413
|
+
Check capabilities first:
|
|
414
|
+
\`\`\`typescript
|
|
415
|
+
const caps = await fetch(\`/api/vc/\${connectionId}/capabilities\`).then(r => r.json());
|
|
416
|
+
if (caps.features.hasCheckoutCustomFields) {
|
|
417
|
+
// Build the step described below
|
|
418
|
+
}
|
|
419
|
+
\`\`\`
|
|
420
|
+
|
|
421
|
+
If \`hasCheckoutCustomFields\` is true, you MUST build this step or customers will be charged surcharges they never saw.
|
|
422
|
+
|
|
423
|
+
### Where to Place It
|
|
424
|
+
|
|
425
|
+
After \`selectShippingMethod()\` (or pickup confirmation), BEFORE \`createPaymentIntent()\`. Pseudocode:
|
|
426
|
+
|
|
427
|
+
\`\`\`
|
|
428
|
+
address \u2192 shipping/pickup \u2192 custom-fields (NEW) \u2192 payment
|
|
429
|
+
\`\`\`
|
|
430
|
+
|
|
431
|
+
If the loaded fields array is empty for this checkout (visibility filters can hide all fields), **skip the step entirely** \u2014 go straight to payment with no UI flicker.
|
|
432
|
+
|
|
433
|
+
### Loading Field Definitions
|
|
434
|
+
|
|
435
|
+
\`\`\`typescript
|
|
436
|
+
import type { CheckoutCustomFieldDefinition } from 'brainerce';
|
|
437
|
+
|
|
438
|
+
const fields = await client.getCheckoutCustomFields(checkoutId);
|
|
439
|
+
if (fields.length === 0) {
|
|
440
|
+
setStep('payment'); // Nothing to collect
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
\`\`\`
|
|
444
|
+
|
|
445
|
+
Each \`CheckoutCustomFieldDefinition\` has:
|
|
446
|
+
- \`key\` \u2014 the form field name to use in your state
|
|
447
|
+
- \`name\` \u2014 display label
|
|
448
|
+
- \`description\` \u2014 helper text (render below the input)
|
|
449
|
+
- \`type\` \u2014 \`'TEXT' | 'TEXTAREA' | 'NUMBER' | 'BOOLEAN' | 'SELECT' | 'DATE'\`
|
|
450
|
+
- \`required\` \u2014 boolean
|
|
451
|
+
- \`minLength\` / \`maxLength\` \u2014 for TEXT/TEXTAREA
|
|
452
|
+
- \`minValue\` / \`maxValue\` \u2014 for NUMBER
|
|
453
|
+
- \`options\` \u2014 \`Array<{value, label}>\` for SELECT
|
|
454
|
+
- \`pricing\` \u2014 never render this; the server uses it to compute surcharges
|
|
455
|
+
|
|
456
|
+
### Rendering All 6 Field Types
|
|
457
|
+
|
|
458
|
+
\`\`\`typescript
|
|
459
|
+
function CustomFieldsStep({ fields, values, onChange, onApply, loading }: {
|
|
460
|
+
fields: CheckoutCustomFieldDefinition[];
|
|
461
|
+
values: Record<string, unknown>;
|
|
462
|
+
onChange: (key: string, value: unknown) => void;
|
|
463
|
+
onApply: () => void;
|
|
464
|
+
loading?: boolean;
|
|
465
|
+
}) {
|
|
466
|
+
return (
|
|
467
|
+
<div className="space-y-4">
|
|
468
|
+
{fields.map((field) => {
|
|
469
|
+
const value = values[field.key] ?? '';
|
|
470
|
+
const label = (
|
|
471
|
+
<label className="block text-sm font-medium">
|
|
472
|
+
{field.name}{field.required && <span className="text-red-500"> *</span>}
|
|
473
|
+
</label>
|
|
474
|
+
);
|
|
475
|
+
const help = field.description && (
|
|
476
|
+
<p className="text-xs text-gray-500 mt-1">{field.description}</p>
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
switch (field.type) {
|
|
480
|
+
case 'TEXT':
|
|
481
|
+
return (
|
|
482
|
+
<div key={field.key}>
|
|
483
|
+
{label}
|
|
484
|
+
<input
|
|
485
|
+
type="text"
|
|
486
|
+
value={value as string}
|
|
487
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
488
|
+
required={field.required}
|
|
489
|
+
minLength={field.minLength ?? undefined}
|
|
490
|
+
maxLength={field.maxLength ?? undefined}
|
|
491
|
+
className="w-full border rounded px-3 py-2"
|
|
492
|
+
/>
|
|
493
|
+
{help}
|
|
494
|
+
</div>
|
|
495
|
+
);
|
|
496
|
+
case 'TEXTAREA':
|
|
497
|
+
return (
|
|
498
|
+
<div key={field.key}>
|
|
499
|
+
{label}
|
|
500
|
+
<textarea
|
|
501
|
+
value={value as string}
|
|
502
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
503
|
+
required={field.required}
|
|
504
|
+
minLength={field.minLength ?? undefined}
|
|
505
|
+
maxLength={field.maxLength ?? undefined}
|
|
506
|
+
rows={3}
|
|
507
|
+
className="w-full border rounded px-3 py-2"
|
|
508
|
+
/>
|
|
509
|
+
{help}
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
512
|
+
case 'NUMBER':
|
|
513
|
+
return (
|
|
514
|
+
<div key={field.key}>
|
|
515
|
+
{label}
|
|
516
|
+
<input
|
|
517
|
+
type="number"
|
|
518
|
+
value={value as number}
|
|
519
|
+
onChange={(e) => onChange(field.key, e.target.valueAsNumber)}
|
|
520
|
+
required={field.required}
|
|
521
|
+
min={field.minValue ?? undefined}
|
|
522
|
+
max={field.maxValue ?? undefined}
|
|
523
|
+
className="w-full border rounded px-3 py-2"
|
|
524
|
+
/>
|
|
525
|
+
{help}
|
|
526
|
+
</div>
|
|
527
|
+
);
|
|
528
|
+
case 'BOOLEAN':
|
|
529
|
+
return (
|
|
530
|
+
<div key={field.key} className="flex items-start gap-2">
|
|
531
|
+
<input
|
|
532
|
+
type="checkbox"
|
|
533
|
+
checked={value === true}
|
|
534
|
+
onChange={(e) => onChange(field.key, e.target.checked)}
|
|
535
|
+
className="mt-1"
|
|
536
|
+
/>
|
|
537
|
+
<div>{label}{help}</div>
|
|
538
|
+
</div>
|
|
539
|
+
);
|
|
540
|
+
case 'SELECT':
|
|
541
|
+
return (
|
|
542
|
+
<div key={field.key}>
|
|
543
|
+
{label}
|
|
544
|
+
<select
|
|
545
|
+
value={value as string}
|
|
546
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
547
|
+
required={field.required}
|
|
548
|
+
className="w-full border rounded px-3 py-2"
|
|
549
|
+
>
|
|
550
|
+
<option value="">\u2014 Select \u2014</option>
|
|
551
|
+
{field.options?.map((opt) => (
|
|
552
|
+
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
553
|
+
))}
|
|
554
|
+
</select>
|
|
555
|
+
{help}
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
case 'DATE':
|
|
559
|
+
return (
|
|
560
|
+
<div key={field.key}>
|
|
561
|
+
{label}
|
|
562
|
+
<input
|
|
563
|
+
type="date"
|
|
564
|
+
value={value as string}
|
|
565
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
566
|
+
required={field.required}
|
|
567
|
+
className="w-full border rounded px-3 py-2"
|
|
568
|
+
/>
|
|
569
|
+
{help}
|
|
570
|
+
</div>
|
|
571
|
+
);
|
|
572
|
+
default:
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
})}
|
|
576
|
+
|
|
577
|
+
<button
|
|
578
|
+
type="button"
|
|
579
|
+
onClick={onApply}
|
|
580
|
+
disabled={loading}
|
|
581
|
+
className="w-full bg-black text-white py-3 rounded"
|
|
582
|
+
>
|
|
583
|
+
{loading ? 'Updating\u2026' : 'Continue to Payment'}
|
|
584
|
+
</button>
|
|
585
|
+
</div>
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
\`\`\`
|
|
589
|
+
|
|
590
|
+
### Submitting Values & Recalculating Surcharges
|
|
591
|
+
|
|
592
|
+
\`\`\`typescript
|
|
593
|
+
async function applyCustomFields() {
|
|
594
|
+
setLoading(true);
|
|
595
|
+
try {
|
|
596
|
+
// Server validates required + min/max + coerces types + recalculates totals
|
|
597
|
+
const updated = await client.setCheckoutCustomFields(checkoutId, customFieldValues);
|
|
598
|
+
setCheckout(updated); // updated.surchargeAmount and updated.appliedSurcharges are now fresh
|
|
599
|
+
setStep('payment');
|
|
600
|
+
} catch (err) {
|
|
601
|
+
setError(err instanceof Error ? err.message : 'Failed to save selections');
|
|
602
|
+
} finally {
|
|
603
|
+
setLoading(false);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
\`\`\`
|
|
607
|
+
|
|
608
|
+
### Order Summary \u2014 Show the Surcharges
|
|
609
|
+
|
|
610
|
+
**Critical:** if you don't render \`appliedSurcharges\`, customers will see a higher total with no explanation. Add this to your order summary above the total row:
|
|
611
|
+
|
|
612
|
+
\`\`\`typescript
|
|
613
|
+
{checkout.appliedSurcharges?.length > 0 && (
|
|
614
|
+
<>
|
|
615
|
+
{checkout.appliedSurcharges.map((s) => (
|
|
616
|
+
<div key={s.key} className="flex justify-between text-sm text-gray-600">
|
|
617
|
+
<span>{s.name}</span>
|
|
618
|
+
<span>{formatPrice(s.amount, { currency })}</span>
|
|
619
|
+
</div>
|
|
620
|
+
))}
|
|
621
|
+
</>
|
|
622
|
+
)}
|
|
623
|
+
\`\`\`
|
|
624
|
+
|
|
625
|
+
\`checkout.surchargeAmount\` is a string (use \`parseFloat()\`); \`checkout.totalAmount\` already includes it.
|
|
626
|
+
|
|
627
|
+
### Notes
|
|
628
|
+
|
|
629
|
+
- The server filters fields by \`visibility\` (\`always\` / \`shipping\` / \`pickup\` / \`products\`) \u2014 your client just renders what \`getCheckoutCustomFields()\` returns.
|
|
630
|
+
- The server validates \`required\`, \`minLength\`/\`maxLength\`, \`minValue\`/\`maxValue\`, and SELECT options. Don't rely on client validation alone \u2014 surface server errors with a toast.
|
|
631
|
+
- If \`getCheckoutCustomFields()\` returns 0 fields, skip the step entirely. Don't render an empty UI.
|
|
632
|
+
`;
|
|
633
|
+
}
|
|
406
634
|
function getPaymentProvidersSection() {
|
|
407
635
|
return `## Payment Provider Detection (DO THIS FIRST on checkout page!)
|
|
408
636
|
|
|
@@ -1252,6 +1480,10 @@ ${getCheckoutFlowSection(currency)}
|
|
|
1252
1480
|
|
|
1253
1481
|
---
|
|
1254
1482
|
|
|
1483
|
+
${getCheckoutCustomFieldsSection()}
|
|
1484
|
+
|
|
1485
|
+
---
|
|
1486
|
+
|
|
1255
1487
|
${getPaymentProvidersSection()}
|
|
1256
1488
|
|
|
1257
1489
|
---
|
|
@@ -1310,6 +1542,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1310
1542
|
return getCartSection(cur);
|
|
1311
1543
|
case "checkout":
|
|
1312
1544
|
return getCheckoutFlowSection(cur);
|
|
1545
|
+
case "checkout-custom-fields":
|
|
1546
|
+
return getCheckoutCustomFieldsSection();
|
|
1313
1547
|
case "payment":
|
|
1314
1548
|
return getPaymentProvidersSection();
|
|
1315
1549
|
case "auth":
|
|
@@ -1338,7 +1572,7 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1338
1572
|
storeType: "E-commerce store"
|
|
1339
1573
|
});
|
|
1340
1574
|
default:
|
|
1341
|
-
return `Unknown topic: "${topic}". Available topics: setup, products, cart, checkout, payment, auth, order-confirmation, inventory, discounts, recommendations, tax, critical-rules, type-reference, admin, all`;
|
|
1575
|
+
return `Unknown topic: "${topic}". Available topics: setup, products, cart, checkout, checkout-custom-fields, payment, auth, order-confirmation, inventory, discounts, recommendations, tax, critical-rules, type-reference, admin, all`;
|
|
1342
1576
|
}
|
|
1343
1577
|
}
|
|
1344
1578
|
|
|
@@ -1351,6 +1585,7 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
1351
1585
|
"products",
|
|
1352
1586
|
"cart",
|
|
1353
1587
|
"checkout",
|
|
1588
|
+
"checkout-custom-fields",
|
|
1354
1589
|
"payment",
|
|
1355
1590
|
"auth",
|
|
1356
1591
|
"order-confirmation",
|
|
@@ -3372,6 +3607,7 @@ import type {
|
|
|
3372
3607
|
ShippingDestinations,
|
|
3373
3608
|
PickupLocation,
|
|
3374
3609
|
CheckoutBumpsResponse,
|
|
3610
|
+
CheckoutCustomFieldDefinition,
|
|
3375
3611
|
} from 'brainerce';
|
|
3376
3612
|
import { formatPrice } from 'brainerce';
|
|
3377
3613
|
import { getClient } from '@/lib/brainerce';
|
|
@@ -3381,6 +3617,7 @@ import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
|
3381
3617
|
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
3382
3618
|
import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
|
|
3383
3619
|
import { PickupStep } from '@/components/checkout/pickup-step';
|
|
3620
|
+
import { CustomFieldsStep } from '@/components/checkout/custom-fields-step';
|
|
3384
3621
|
import { TaxDisplay } from '@/components/checkout/tax-display';
|
|
3385
3622
|
import { OrderBumpCard } from '@/components/checkout/order-bump-card';
|
|
3386
3623
|
import { CouponInput } from '@/components/cart/coupon-input';
|
|
@@ -3389,7 +3626,7 @@ import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
|
3389
3626
|
import { useTranslations } from '@/lib/translations';
|
|
3390
3627
|
import { cn } from '@/lib/utils';
|
|
3391
3628
|
|
|
3392
|
-
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'payment';
|
|
3629
|
+
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'custom-fields' | 'payment';
|
|
3393
3630
|
|
|
3394
3631
|
function CheckoutContent() {
|
|
3395
3632
|
const searchParams = useSearchParams();
|
|
@@ -3422,6 +3659,9 @@ function CheckoutContent() {
|
|
|
3422
3659
|
const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
|
|
3423
3660
|
const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
|
|
3424
3661
|
const [bumpLoading, setBumpLoading] = useState<string | null>(null);
|
|
3662
|
+
const [customFields, setCustomFields] = useState<CheckoutCustomFieldDefinition[]>([]);
|
|
3663
|
+
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
|
|
3664
|
+
const [customFieldsLoading, setCustomFieldsLoading] = useState(false);
|
|
3425
3665
|
|
|
3426
3666
|
// Check for returning from canceled payment
|
|
3427
3667
|
const canceled = searchParams.get('canceled') === 'true';
|
|
@@ -3473,6 +3713,23 @@ function CheckoutContent() {
|
|
|
3473
3713
|
const existing = await client.getCheckout(existingCheckoutId);
|
|
3474
3714
|
setCheckout(existing);
|
|
3475
3715
|
|
|
3716
|
+
// Preload custom field definitions and any existing values so the
|
|
3717
|
+
// step indicator and "change options" affordance work on resume.
|
|
3718
|
+
client
|
|
3719
|
+
.getCheckoutCustomFields(existing.id)
|
|
3720
|
+
.then((fields) => {
|
|
3721
|
+
setCustomFields(fields);
|
|
3722
|
+
const existingValues = (
|
|
3723
|
+
existing as unknown as {
|
|
3724
|
+
customFieldValues?: Record<string, unknown> | null;
|
|
3725
|
+
}
|
|
3726
|
+
).customFieldValues;
|
|
3727
|
+
if (existingValues) setCustomFieldValues(existingValues);
|
|
3728
|
+
})
|
|
3729
|
+
.catch(() => {
|
|
3730
|
+
setCustomFields([]);
|
|
3731
|
+
});
|
|
3732
|
+
|
|
3476
3733
|
// Determine step based on checkout state
|
|
3477
3734
|
const allDigital = existing.lineItems.every(
|
|
3478
3735
|
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
@@ -3643,6 +3900,21 @@ function CheckoutContent() {
|
|
|
3643
3900
|
}
|
|
3644
3901
|
}
|
|
3645
3902
|
|
|
3903
|
+
// After shipping/pickup is set, decide whether to show the custom-fields step
|
|
3904
|
+
// or jump straight to payment. Returns the next step.
|
|
3905
|
+
async function loadCustomFieldsOrSkip(checkoutId: string): Promise<CheckoutStep> {
|
|
3906
|
+
try {
|
|
3907
|
+
const fields = await getClient().getCheckoutCustomFields(checkoutId);
|
|
3908
|
+
setCustomFields(fields);
|
|
3909
|
+
return fields.length > 0 ? 'custom-fields' : 'payment';
|
|
3910
|
+
} catch {
|
|
3911
|
+
// If the endpoint isn't available or fails, fall through to payment
|
|
3912
|
+
// rather than blocking the customer.
|
|
3913
|
+
setCustomFields([]);
|
|
3914
|
+
return 'payment';
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3646
3918
|
// Handle shipping method selection
|
|
3647
3919
|
async function handleShippingSelect(rateId: string) {
|
|
3648
3920
|
if (!checkout) return;
|
|
@@ -3655,7 +3927,7 @@ function CheckoutContent() {
|
|
|
3655
3927
|
|
|
3656
3928
|
const updated = await client.selectShippingMethod(checkout.id, rateId);
|
|
3657
3929
|
setCheckout(updated);
|
|
3658
|
-
setStep(
|
|
3930
|
+
setStep(await loadCustomFieldsOrSkip(updated.id));
|
|
3659
3931
|
} catch (err) {
|
|
3660
3932
|
const message = err instanceof Error ? err.message : t('failedToSelectShipping');
|
|
3661
3933
|
setError(message);
|
|
@@ -3664,6 +3936,23 @@ function CheckoutContent() {
|
|
|
3664
3936
|
}
|
|
3665
3937
|
}
|
|
3666
3938
|
|
|
3939
|
+
// Submit custom fields
|
|
3940
|
+
async function handleCustomFieldsApply() {
|
|
3941
|
+
if (!checkout) return;
|
|
3942
|
+
try {
|
|
3943
|
+
setCustomFieldsLoading(true);
|
|
3944
|
+
setError(null);
|
|
3945
|
+
const updated = await getClient().setCheckoutCustomFields(checkout.id, customFieldValues);
|
|
3946
|
+
setCheckout(updated);
|
|
3947
|
+
setStep('payment');
|
|
3948
|
+
} catch (err) {
|
|
3949
|
+
const message = err instanceof Error ? err.message : t('customFieldsFailed');
|
|
3950
|
+
setError(message);
|
|
3951
|
+
} finally {
|
|
3952
|
+
setCustomFieldsLoading(false);
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3667
3956
|
// Handle delivery method selection
|
|
3668
3957
|
async function handleDeliveryTypeSelect(method: 'shipping' | 'pickup') {
|
|
3669
3958
|
if (!checkout) return;
|
|
@@ -3709,7 +3998,7 @@ function CheckoutContent() {
|
|
|
3709
3998
|
phone: customerInfo.phone,
|
|
3710
3999
|
});
|
|
3711
4000
|
setCheckout(updated);
|
|
3712
|
-
setStep(
|
|
4001
|
+
setStep(await loadCustomFieldsOrSkip(updated.id));
|
|
3713
4002
|
} catch (err) {
|
|
3714
4003
|
const message = err instanceof Error ? err.message : t('failedToSelectPickup');
|
|
3715
4004
|
setError(message);
|
|
@@ -3771,9 +4060,15 @@ function CheckoutContent() {
|
|
|
3771
4060
|
);
|
|
3772
4061
|
}
|
|
3773
4062
|
|
|
4063
|
+
const customFieldsStep =
|
|
4064
|
+
customFields.length > 0
|
|
4065
|
+
? [{ key: 'custom-fields' as CheckoutStep, label: t('stepCustomFields') }]
|
|
4066
|
+
: [];
|
|
4067
|
+
|
|
3774
4068
|
const steps: { key: CheckoutStep; label: string }[] = isAllDigital
|
|
3775
4069
|
? [
|
|
3776
4070
|
{ key: 'address', label: t('stepContactInfo') },
|
|
4071
|
+
...customFieldsStep,
|
|
3777
4072
|
{ key: 'payment', label: t('stepPayment') },
|
|
3778
4073
|
]
|
|
3779
4074
|
: pickupLocations.length > 0
|
|
@@ -3781,17 +4076,20 @@ function CheckoutContent() {
|
|
|
3781
4076
|
? [
|
|
3782
4077
|
{ key: 'method', label: t('stepMethod') },
|
|
3783
4078
|
{ key: 'pickup', label: t('stepPickup') },
|
|
4079
|
+
...customFieldsStep,
|
|
3784
4080
|
{ key: 'payment', label: t('stepPayment') },
|
|
3785
4081
|
]
|
|
3786
4082
|
: [
|
|
3787
4083
|
{ key: 'method', label: t('stepMethod') },
|
|
3788
4084
|
{ key: 'address', label: t('stepAddress') },
|
|
3789
4085
|
{ key: 'shipping', label: t('stepShipping') },
|
|
4086
|
+
...customFieldsStep,
|
|
3790
4087
|
{ key: 'payment', label: t('stepPayment') },
|
|
3791
4088
|
]
|
|
3792
4089
|
: [
|
|
3793
4090
|
{ key: 'address', label: t('stepAddress') },
|
|
3794
4091
|
{ key: 'shipping', label: t('stepShipping') },
|
|
4092
|
+
...customFieldsStep,
|
|
3795
4093
|
{ key: 'payment', label: t('stepPayment') },
|
|
3796
4094
|
];
|
|
3797
4095
|
|
|
@@ -3990,19 +4288,54 @@ function CheckoutContent() {
|
|
|
3990
4288
|
</div>
|
|
3991
4289
|
)}
|
|
3992
4290
|
|
|
4291
|
+
{/* Custom Fields (optional, between shipping/pickup and payment) */}
|
|
4292
|
+
{step === 'custom-fields' && checkout && (
|
|
4293
|
+
<div>
|
|
4294
|
+
<div className="mb-4 flex items-center justify-between">
|
|
4295
|
+
<h2 className="text-foreground text-lg font-semibold">{t('customFieldsTitle')}</h2>
|
|
4296
|
+
<button
|
|
4297
|
+
type="button"
|
|
4298
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
4299
|
+
className="text-primary text-sm hover:underline"
|
|
4300
|
+
>
|
|
4301
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
4302
|
+
</button>
|
|
4303
|
+
</div>
|
|
4304
|
+
<CustomFieldsStep
|
|
4305
|
+
fields={customFields}
|
|
4306
|
+
values={customFieldValues}
|
|
4307
|
+
onChange={(key, value) =>
|
|
4308
|
+
setCustomFieldValues((prev) => ({ ...prev, [key]: value }))
|
|
4309
|
+
}
|
|
4310
|
+
onApply={handleCustomFieldsApply}
|
|
4311
|
+
loading={customFieldsLoading}
|
|
4312
|
+
/>
|
|
4313
|
+
</div>
|
|
4314
|
+
)}
|
|
4315
|
+
|
|
3993
4316
|
{/* Payment */}
|
|
3994
4317
|
{step === 'payment' && checkout && (
|
|
3995
4318
|
<div>
|
|
3996
4319
|
<div className="mb-4 flex items-center justify-between">
|
|
3997
4320
|
<h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
|
|
3998
|
-
{
|
|
4321
|
+
{customFields.length > 0 ? (
|
|
3999
4322
|
<button
|
|
4000
4323
|
type="button"
|
|
4001
|
-
onClick={() => setStep(
|
|
4324
|
+
onClick={() => setStep('custom-fields')}
|
|
4002
4325
|
className="text-primary text-sm hover:underline"
|
|
4003
4326
|
>
|
|
4004
|
-
{
|
|
4327
|
+
{t('changeOptions')}
|
|
4005
4328
|
</button>
|
|
4329
|
+
) : (
|
|
4330
|
+
!isAllDigital && (
|
|
4331
|
+
<button
|
|
4332
|
+
type="button"
|
|
4333
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
4334
|
+
className="text-primary text-sm hover:underline"
|
|
4335
|
+
>
|
|
4336
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
4337
|
+
</button>
|
|
4338
|
+
)
|
|
4006
4339
|
)}
|
|
4007
4340
|
</div>
|
|
4008
4341
|
|
|
@@ -4189,6 +4522,20 @@ function CheckoutContent() {
|
|
|
4189
4522
|
taxBreakdown={checkout.taxBreakdown}
|
|
4190
4523
|
/>
|
|
4191
4524
|
|
|
4525
|
+
{/* Custom field surcharges (one line per applied surcharge) */}
|
|
4526
|
+
{checkout.appliedSurcharges && checkout.appliedSurcharges.length > 0 && (
|
|
4527
|
+
<>
|
|
4528
|
+
{checkout.appliedSurcharges.map((s) => (
|
|
4529
|
+
<div key={s.key} className="flex items-center justify-between">
|
|
4530
|
+
<span className="text-muted-foreground">{s.name}</span>
|
|
4531
|
+
<span className="text-foreground">
|
|
4532
|
+
{formatPrice(Number(s.amount), { currency }) as string}
|
|
4533
|
+
</span>
|
|
4534
|
+
</div>
|
|
4535
|
+
))}
|
|
4536
|
+
</>
|
|
4537
|
+
)}
|
|
4538
|
+
|
|
4192
4539
|
<div className="border-border mt-2 border-t pt-2">
|
|
4193
4540
|
<div className="flex items-center justify-between">
|
|
4194
4541
|
<span className="text-foreground font-semibold">{tc('total')}</span>
|
|
@@ -11207,7 +11554,17 @@ async function handleGetRequiredPages(args) {
|
|
|
11207
11554
|
import { z as z5 } from "zod";
|
|
11208
11555
|
|
|
11209
11556
|
// src/utils/fetch-store-info.ts
|
|
11210
|
-
|
|
11557
|
+
var KNOWN_API_URLS = {
|
|
11558
|
+
production: "https://api.brainerce.com",
|
|
11559
|
+
staging: "https://api-staging.brainerce.com"
|
|
11560
|
+
};
|
|
11561
|
+
var ConnectionNotFoundError = class extends Error {
|
|
11562
|
+
constructor(connectionId, baseUrl) {
|
|
11563
|
+
super(`Connection "${connectionId}" not found at ${baseUrl}`);
|
|
11564
|
+
this.code = "NOT_FOUND";
|
|
11565
|
+
}
|
|
11566
|
+
};
|
|
11567
|
+
async function fetchStoreInfo(connectionId, baseUrl = KNOWN_API_URLS.production) {
|
|
11211
11568
|
const url = `${baseUrl}/api/vc/${connectionId}/info`;
|
|
11212
11569
|
const controller = new AbortController();
|
|
11213
11570
|
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
@@ -11216,55 +11573,90 @@ async function fetchStoreInfo(connectionId, baseUrl = "https://api.brainerce.com
|
|
|
11216
11573
|
res = await fetch(url, { signal: controller.signal });
|
|
11217
11574
|
} catch (err) {
|
|
11218
11575
|
if (err.name === "AbortError") {
|
|
11219
|
-
throw new Error(
|
|
11576
|
+
throw new Error(`Request to ${baseUrl} timed out`);
|
|
11220
11577
|
}
|
|
11221
|
-
throw new Error(`Failed to connect to
|
|
11578
|
+
throw new Error(`Failed to connect to ${baseUrl}: ${err.message}`);
|
|
11222
11579
|
} finally {
|
|
11223
11580
|
clearTimeout(timeout);
|
|
11224
11581
|
}
|
|
11225
11582
|
if (res.status === 404) {
|
|
11226
|
-
throw new
|
|
11583
|
+
throw new ConnectionNotFoundError(connectionId, baseUrl);
|
|
11227
11584
|
}
|
|
11228
11585
|
if (!res.ok) {
|
|
11229
|
-
throw new Error(
|
|
11586
|
+
throw new Error(`${baseUrl} returned status ${res.status}`);
|
|
11230
11587
|
}
|
|
11231
11588
|
let json;
|
|
11232
11589
|
try {
|
|
11233
11590
|
json = await res.json();
|
|
11234
11591
|
} catch {
|
|
11235
|
-
throw new Error(
|
|
11592
|
+
throw new Error(`Invalid response from ${baseUrl}`);
|
|
11236
11593
|
}
|
|
11594
|
+
const storeName = json.storeName || json.name || "My Store";
|
|
11595
|
+
const displayName = json.channelName || storeName;
|
|
11237
11596
|
return {
|
|
11238
|
-
name:
|
|
11597
|
+
name: displayName,
|
|
11598
|
+
storeName,
|
|
11239
11599
|
currency: json.currency || "USD",
|
|
11240
11600
|
language: json.language || "en",
|
|
11241
11601
|
...json.i18n ? { i18n: json.i18n } : {}
|
|
11242
11602
|
};
|
|
11243
11603
|
}
|
|
11604
|
+
async function resolveStoreInfo(connectionId, candidateUrls) {
|
|
11605
|
+
const urls = candidateUrls.filter((u, i) => u && candidateUrls.indexOf(u) === i);
|
|
11606
|
+
if (urls.length === 0) {
|
|
11607
|
+
throw new Error("No API URLs to try");
|
|
11608
|
+
}
|
|
11609
|
+
let lastError;
|
|
11610
|
+
let allNotFound = true;
|
|
11611
|
+
for (let i = 0; i < urls.length; i++) {
|
|
11612
|
+
const baseUrl = urls[i];
|
|
11613
|
+
try {
|
|
11614
|
+
const info = await fetchStoreInfo(connectionId, baseUrl);
|
|
11615
|
+
return { info, apiBaseUrl: baseUrl, fellBack: i > 0 };
|
|
11616
|
+
} catch (err) {
|
|
11617
|
+
lastError = err;
|
|
11618
|
+
const isNotFound = err.code === "NOT_FOUND";
|
|
11619
|
+
if (!isNotFound) {
|
|
11620
|
+
allNotFound = false;
|
|
11621
|
+
}
|
|
11622
|
+
}
|
|
11623
|
+
}
|
|
11624
|
+
if (allNotFound) {
|
|
11625
|
+
throw new Error(
|
|
11626
|
+
`Connection "${connectionId}" not found in any known environment (${urls.join(", ")}).`
|
|
11627
|
+
);
|
|
11628
|
+
}
|
|
11629
|
+
throw lastError || new Error("Failed to resolve store info");
|
|
11630
|
+
}
|
|
11631
|
+
function getCandidateApiUrls() {
|
|
11632
|
+
const explicit = (process.env.BRAINERCE_API_URL || "").replace(/\/$/, "");
|
|
11633
|
+
if (explicit) return [explicit];
|
|
11634
|
+
return [KNOWN_API_URLS.production, KNOWN_API_URLS.staging];
|
|
11635
|
+
}
|
|
11244
11636
|
|
|
11245
11637
|
// src/tools/get-store-info.ts
|
|
11246
11638
|
var GET_STORE_INFO_NAME = "get-store-info";
|
|
11247
|
-
var GET_STORE_INFO_DESCRIPTION = "Fetch live store information from the Brainerce API using a connection ID. Returns the store name, currency, and language. Use this to personalize the store being built.";
|
|
11639
|
+
var GET_STORE_INFO_DESCRIPTION = "Fetch live store information from the Brainerce API using a connection ID. Returns the channel display name (what the user sees in their dashboard), parent store name, currency, and language. Use this to personalize the store being built \u2014 prefer the channel name for user-facing text since a single Brainerce store can have multiple sales channels.";
|
|
11248
11640
|
var GET_STORE_INFO_SCHEMA = {
|
|
11249
11641
|
connectionId: z5.string().describe("Vibe-coded connection ID (starts with vc_)")
|
|
11250
11642
|
};
|
|
11251
11643
|
async function handleGetStoreInfo(args) {
|
|
11252
11644
|
try {
|
|
11253
|
-
const
|
|
11254
|
-
/\/$/,
|
|
11255
|
-
""
|
|
11256
|
-
);
|
|
11257
|
-
const info = await fetchStoreInfo(args.connectionId, baseUrl);
|
|
11645
|
+
const resolved = await resolveStoreInfo(args.connectionId, getCandidateApiUrls());
|
|
11258
11646
|
return {
|
|
11259
11647
|
content: [
|
|
11260
11648
|
{
|
|
11261
11649
|
type: "text",
|
|
11262
11650
|
text: JSON.stringify(
|
|
11263
11651
|
{
|
|
11264
|
-
name
|
|
11265
|
-
|
|
11266
|
-
|
|
11267
|
-
|
|
11652
|
+
// `name` is the channel display name (from the dashboard sales-channel card);
|
|
11653
|
+
// falls back to storeName when a channel doesn't have its own name.
|
|
11654
|
+
name: resolved.info.name,
|
|
11655
|
+
storeName: resolved.info.storeName,
|
|
11656
|
+
currency: resolved.info.currency,
|
|
11657
|
+
language: resolved.info.language,
|
|
11658
|
+
connectionId: args.connectionId,
|
|
11659
|
+
apiBaseUrl: resolved.apiBaseUrl
|
|
11268
11660
|
},
|
|
11269
11661
|
null,
|
|
11270
11662
|
2
|
|
@@ -11285,6 +11677,12 @@ async function handleGetStoreInfo(args) {
|
|
|
11285
11677
|
import { z as z6 } from "zod";
|
|
11286
11678
|
|
|
11287
11679
|
// src/utils/fetch-store-capabilities.ts
|
|
11680
|
+
var ConnectionNotFoundError2 = class extends Error {
|
|
11681
|
+
constructor(connectionId, baseUrl) {
|
|
11682
|
+
super(`Connection "${connectionId}" not found at ${baseUrl}`);
|
|
11683
|
+
this.code = "NOT_FOUND";
|
|
11684
|
+
}
|
|
11685
|
+
};
|
|
11288
11686
|
async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brainerce.com") {
|
|
11289
11687
|
const url = `${baseUrl}/api/vc/${connectionId}/capabilities`;
|
|
11290
11688
|
const controller = new AbortController();
|
|
@@ -11294,24 +11692,51 @@ async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brain
|
|
|
11294
11692
|
res = await fetch(url, { signal: controller.signal });
|
|
11295
11693
|
} catch (err) {
|
|
11296
11694
|
if (err.name === "AbortError") {
|
|
11297
|
-
throw new Error(
|
|
11695
|
+
throw new Error(`Request to ${baseUrl} timed out`);
|
|
11298
11696
|
}
|
|
11299
|
-
throw new Error(`Failed to connect to
|
|
11697
|
+
throw new Error(`Failed to connect to ${baseUrl}: ${err.message}`);
|
|
11300
11698
|
} finally {
|
|
11301
11699
|
clearTimeout(timeout);
|
|
11302
11700
|
}
|
|
11303
11701
|
if (res.status === 404) {
|
|
11304
|
-
throw new
|
|
11702
|
+
throw new ConnectionNotFoundError2(connectionId, baseUrl);
|
|
11305
11703
|
}
|
|
11306
11704
|
if (!res.ok) {
|
|
11307
|
-
throw new Error(
|
|
11705
|
+
throw new Error(`${baseUrl} returned status ${res.status}`);
|
|
11308
11706
|
}
|
|
11309
11707
|
try {
|
|
11310
11708
|
return await res.json();
|
|
11311
11709
|
} catch {
|
|
11312
|
-
throw new Error(
|
|
11710
|
+
throw new Error(`Invalid response from ${baseUrl}`);
|
|
11313
11711
|
}
|
|
11314
11712
|
}
|
|
11713
|
+
async function resolveStoreCapabilities(connectionId, candidateUrls) {
|
|
11714
|
+
const urls = candidateUrls.filter((u, i) => u && candidateUrls.indexOf(u) === i);
|
|
11715
|
+
if (urls.length === 0) {
|
|
11716
|
+
throw new Error("No API URLs to try");
|
|
11717
|
+
}
|
|
11718
|
+
let lastError;
|
|
11719
|
+
let allNotFound = true;
|
|
11720
|
+
for (let i = 0; i < urls.length; i++) {
|
|
11721
|
+
const baseUrl = urls[i];
|
|
11722
|
+
try {
|
|
11723
|
+
const capabilities = await fetchStoreCapabilities(connectionId, baseUrl);
|
|
11724
|
+
return { capabilities, apiBaseUrl: baseUrl, fellBack: i > 0 };
|
|
11725
|
+
} catch (err) {
|
|
11726
|
+
lastError = err;
|
|
11727
|
+
const isNotFound = err.code === "NOT_FOUND";
|
|
11728
|
+
if (!isNotFound) {
|
|
11729
|
+
allNotFound = false;
|
|
11730
|
+
}
|
|
11731
|
+
}
|
|
11732
|
+
}
|
|
11733
|
+
if (allNotFound) {
|
|
11734
|
+
throw new Error(
|
|
11735
|
+
`Connection "${connectionId}" not found in any known environment (${urls.join(", ")}).`
|
|
11736
|
+
);
|
|
11737
|
+
}
|
|
11738
|
+
throw lastError || new Error("Failed to resolve store capabilities");
|
|
11739
|
+
}
|
|
11315
11740
|
|
|
11316
11741
|
// src/tools/get-store-capabilities.ts
|
|
11317
11742
|
var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
|
|
@@ -11321,7 +11746,8 @@ var GET_STORE_CAPABILITIES_SCHEMA = {
|
|
|
11321
11746
|
};
|
|
11322
11747
|
function formatCapabilities(caps) {
|
|
11323
11748
|
const lines = [];
|
|
11324
|
-
|
|
11749
|
+
const channelSuffix = caps.store.channelName && caps.store.channelName !== caps.store.name ? ` \u2014 Channel: "${caps.store.channelName}"` : "";
|
|
11750
|
+
lines.push(`## Store: ${caps.store.name} (${caps.store.currency})${channelSuffix}`);
|
|
11325
11751
|
lines.push(`Language: ${caps.store.language}`);
|
|
11326
11752
|
if (caps.store.i18n?.enabled) {
|
|
11327
11753
|
lines.push(
|
|
@@ -11354,6 +11780,9 @@ function formatCapabilities(caps) {
|
|
|
11354
11780
|
caps.features.hasDownloadableProducts ? "\u2705 Downloadable products available" : "\u274C No downloadable products"
|
|
11355
11781
|
);
|
|
11356
11782
|
lines.push(caps.features.hasCoupons ? "\u2705 Coupons available" : "\u274C No coupons");
|
|
11783
|
+
lines.push(
|
|
11784
|
+
caps.features.hasCheckoutCustomFields ? "\u2705 Checkout custom fields configured (surcharges may apply)" : "\u274C No checkout custom fields"
|
|
11785
|
+
);
|
|
11357
11786
|
lines.push("");
|
|
11358
11787
|
lines.push("## Connection Settings");
|
|
11359
11788
|
lines.push(
|
|
@@ -11399,6 +11828,11 @@ function formatCapabilities(caps) {
|
|
|
11399
11828
|
if (caps.features.hasCoupons) {
|
|
11400
11829
|
suggestions.push('Add a coupon input to the cart. Use get-code-example("coupon-input").');
|
|
11401
11830
|
}
|
|
11831
|
+
if (caps.features.hasCheckoutCustomFields) {
|
|
11832
|
+
suggestions.push(
|
|
11833
|
+
`Checkout custom fields are configured. Build a step between shipping/pickup and payment using getCheckoutCustomFields() + setCheckoutCustomFields(). Render checkout.appliedSurcharges in the order summary so customers see what they're paying for. Use get-code-example("checkout-custom-fields").`
|
|
11834
|
+
);
|
|
11835
|
+
}
|
|
11402
11836
|
if (caps.connection.requireEmailVerification) {
|
|
11403
11837
|
suggestions.push(
|
|
11404
11838
|
'Email verification is required \u2014 ALWAYS build the verify-email page. Use get-code-example("verify-email").'
|
|
@@ -11429,13 +11863,9 @@ function formatCapabilities(caps) {
|
|
|
11429
11863
|
}
|
|
11430
11864
|
async function handleGetStoreCapabilities(args) {
|
|
11431
11865
|
try {
|
|
11432
|
-
const
|
|
11433
|
-
/\/$/,
|
|
11434
|
-
""
|
|
11435
|
-
);
|
|
11436
|
-
const caps = await fetchStoreCapabilities(args.connectionId, baseUrl);
|
|
11866
|
+
const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
|
|
11437
11867
|
return {
|
|
11438
|
-
content: [{ type: "text", text: formatCapabilities(
|
|
11868
|
+
content: [{ type: "text", text: formatCapabilities(resolved.capabilities) }]
|
|
11439
11869
|
};
|
|
11440
11870
|
} catch (error) {
|
|
11441
11871
|
const message = error instanceof Error ? error.message : "Failed to fetch store capabilities";
|
|
@@ -11698,13 +12128,10 @@ var BUILD_STORE_SCHEMA = {
|
|
|
11698
12128
|
storeStyle: z7.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
|
|
11699
12129
|
};
|
|
11700
12130
|
async function handleBuildStore(args) {
|
|
11701
|
-
const baseUrl = (process.env.BRAINERCE_API_URL || "https://api.brainerce.com").replace(
|
|
11702
|
-
/\/$/,
|
|
11703
|
-
""
|
|
11704
|
-
);
|
|
11705
12131
|
let capabilities = null;
|
|
11706
12132
|
try {
|
|
11707
|
-
|
|
12133
|
+
const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
|
|
12134
|
+
capabilities = resolved.capabilities;
|
|
11708
12135
|
} catch {
|
|
11709
12136
|
}
|
|
11710
12137
|
const bundle = buildStoreBundle({
|