@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.js
CHANGED
|
@@ -438,6 +438,234 @@ function PayPalPaymentForm({ clientId, orderId, checkoutId }: { clientId: string
|
|
|
438
438
|
}
|
|
439
439
|
\`\`\``;
|
|
440
440
|
}
|
|
441
|
+
function getCheckoutCustomFieldsSection() {
|
|
442
|
+
return `## Checkout Custom Fields (Optional Step Between Shipping & Payment)
|
|
443
|
+
|
|
444
|
+
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.
|
|
445
|
+
|
|
446
|
+
### When to Build This
|
|
447
|
+
|
|
448
|
+
Check capabilities first:
|
|
449
|
+
\`\`\`typescript
|
|
450
|
+
const caps = await fetch(\`/api/vc/\${connectionId}/capabilities\`).then(r => r.json());
|
|
451
|
+
if (caps.features.hasCheckoutCustomFields) {
|
|
452
|
+
// Build the step described below
|
|
453
|
+
}
|
|
454
|
+
\`\`\`
|
|
455
|
+
|
|
456
|
+
If \`hasCheckoutCustomFields\` is true, you MUST build this step or customers will be charged surcharges they never saw.
|
|
457
|
+
|
|
458
|
+
### Where to Place It
|
|
459
|
+
|
|
460
|
+
After \`selectShippingMethod()\` (or pickup confirmation), BEFORE \`createPaymentIntent()\`. Pseudocode:
|
|
461
|
+
|
|
462
|
+
\`\`\`
|
|
463
|
+
address \u2192 shipping/pickup \u2192 custom-fields (NEW) \u2192 payment
|
|
464
|
+
\`\`\`
|
|
465
|
+
|
|
466
|
+
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.
|
|
467
|
+
|
|
468
|
+
### Loading Field Definitions
|
|
469
|
+
|
|
470
|
+
\`\`\`typescript
|
|
471
|
+
import type { CheckoutCustomFieldDefinition } from 'brainerce';
|
|
472
|
+
|
|
473
|
+
const fields = await client.getCheckoutCustomFields(checkoutId);
|
|
474
|
+
if (fields.length === 0) {
|
|
475
|
+
setStep('payment'); // Nothing to collect
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
\`\`\`
|
|
479
|
+
|
|
480
|
+
Each \`CheckoutCustomFieldDefinition\` has:
|
|
481
|
+
- \`key\` \u2014 the form field name to use in your state
|
|
482
|
+
- \`name\` \u2014 display label
|
|
483
|
+
- \`description\` \u2014 helper text (render below the input)
|
|
484
|
+
- \`type\` \u2014 \`'TEXT' | 'TEXTAREA' | 'NUMBER' | 'BOOLEAN' | 'SELECT' | 'DATE'\`
|
|
485
|
+
- \`required\` \u2014 boolean
|
|
486
|
+
- \`minLength\` / \`maxLength\` \u2014 for TEXT/TEXTAREA
|
|
487
|
+
- \`minValue\` / \`maxValue\` \u2014 for NUMBER
|
|
488
|
+
- \`options\` \u2014 \`Array<{value, label}>\` for SELECT
|
|
489
|
+
- \`pricing\` \u2014 never render this; the server uses it to compute surcharges
|
|
490
|
+
|
|
491
|
+
### Rendering All 6 Field Types
|
|
492
|
+
|
|
493
|
+
\`\`\`typescript
|
|
494
|
+
function CustomFieldsStep({ fields, values, onChange, onApply, loading }: {
|
|
495
|
+
fields: CheckoutCustomFieldDefinition[];
|
|
496
|
+
values: Record<string, unknown>;
|
|
497
|
+
onChange: (key: string, value: unknown) => void;
|
|
498
|
+
onApply: () => void;
|
|
499
|
+
loading?: boolean;
|
|
500
|
+
}) {
|
|
501
|
+
return (
|
|
502
|
+
<div className="space-y-4">
|
|
503
|
+
{fields.map((field) => {
|
|
504
|
+
const value = values[field.key] ?? '';
|
|
505
|
+
const label = (
|
|
506
|
+
<label className="block text-sm font-medium">
|
|
507
|
+
{field.name}{field.required && <span className="text-red-500"> *</span>}
|
|
508
|
+
</label>
|
|
509
|
+
);
|
|
510
|
+
const help = field.description && (
|
|
511
|
+
<p className="text-xs text-gray-500 mt-1">{field.description}</p>
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
switch (field.type) {
|
|
515
|
+
case 'TEXT':
|
|
516
|
+
return (
|
|
517
|
+
<div key={field.key}>
|
|
518
|
+
{label}
|
|
519
|
+
<input
|
|
520
|
+
type="text"
|
|
521
|
+
value={value as string}
|
|
522
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
523
|
+
required={field.required}
|
|
524
|
+
minLength={field.minLength ?? undefined}
|
|
525
|
+
maxLength={field.maxLength ?? undefined}
|
|
526
|
+
className="w-full border rounded px-3 py-2"
|
|
527
|
+
/>
|
|
528
|
+
{help}
|
|
529
|
+
</div>
|
|
530
|
+
);
|
|
531
|
+
case 'TEXTAREA':
|
|
532
|
+
return (
|
|
533
|
+
<div key={field.key}>
|
|
534
|
+
{label}
|
|
535
|
+
<textarea
|
|
536
|
+
value={value as string}
|
|
537
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
538
|
+
required={field.required}
|
|
539
|
+
minLength={field.minLength ?? undefined}
|
|
540
|
+
maxLength={field.maxLength ?? undefined}
|
|
541
|
+
rows={3}
|
|
542
|
+
className="w-full border rounded px-3 py-2"
|
|
543
|
+
/>
|
|
544
|
+
{help}
|
|
545
|
+
</div>
|
|
546
|
+
);
|
|
547
|
+
case 'NUMBER':
|
|
548
|
+
return (
|
|
549
|
+
<div key={field.key}>
|
|
550
|
+
{label}
|
|
551
|
+
<input
|
|
552
|
+
type="number"
|
|
553
|
+
value={value as number}
|
|
554
|
+
onChange={(e) => onChange(field.key, e.target.valueAsNumber)}
|
|
555
|
+
required={field.required}
|
|
556
|
+
min={field.minValue ?? undefined}
|
|
557
|
+
max={field.maxValue ?? undefined}
|
|
558
|
+
className="w-full border rounded px-3 py-2"
|
|
559
|
+
/>
|
|
560
|
+
{help}
|
|
561
|
+
</div>
|
|
562
|
+
);
|
|
563
|
+
case 'BOOLEAN':
|
|
564
|
+
return (
|
|
565
|
+
<div key={field.key} className="flex items-start gap-2">
|
|
566
|
+
<input
|
|
567
|
+
type="checkbox"
|
|
568
|
+
checked={value === true}
|
|
569
|
+
onChange={(e) => onChange(field.key, e.target.checked)}
|
|
570
|
+
className="mt-1"
|
|
571
|
+
/>
|
|
572
|
+
<div>{label}{help}</div>
|
|
573
|
+
</div>
|
|
574
|
+
);
|
|
575
|
+
case 'SELECT':
|
|
576
|
+
return (
|
|
577
|
+
<div key={field.key}>
|
|
578
|
+
{label}
|
|
579
|
+
<select
|
|
580
|
+
value={value as string}
|
|
581
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
582
|
+
required={field.required}
|
|
583
|
+
className="w-full border rounded px-3 py-2"
|
|
584
|
+
>
|
|
585
|
+
<option value="">\u2014 Select \u2014</option>
|
|
586
|
+
{field.options?.map((opt) => (
|
|
587
|
+
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
588
|
+
))}
|
|
589
|
+
</select>
|
|
590
|
+
{help}
|
|
591
|
+
</div>
|
|
592
|
+
);
|
|
593
|
+
case 'DATE':
|
|
594
|
+
return (
|
|
595
|
+
<div key={field.key}>
|
|
596
|
+
{label}
|
|
597
|
+
<input
|
|
598
|
+
type="date"
|
|
599
|
+
value={value as string}
|
|
600
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
601
|
+
required={field.required}
|
|
602
|
+
className="w-full border rounded px-3 py-2"
|
|
603
|
+
/>
|
|
604
|
+
{help}
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
default:
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
})}
|
|
611
|
+
|
|
612
|
+
<button
|
|
613
|
+
type="button"
|
|
614
|
+
onClick={onApply}
|
|
615
|
+
disabled={loading}
|
|
616
|
+
className="w-full bg-black text-white py-3 rounded"
|
|
617
|
+
>
|
|
618
|
+
{loading ? 'Updating\u2026' : 'Continue to Payment'}
|
|
619
|
+
</button>
|
|
620
|
+
</div>
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
\`\`\`
|
|
624
|
+
|
|
625
|
+
### Submitting Values & Recalculating Surcharges
|
|
626
|
+
|
|
627
|
+
\`\`\`typescript
|
|
628
|
+
async function applyCustomFields() {
|
|
629
|
+
setLoading(true);
|
|
630
|
+
try {
|
|
631
|
+
// Server validates required + min/max + coerces types + recalculates totals
|
|
632
|
+
const updated = await client.setCheckoutCustomFields(checkoutId, customFieldValues);
|
|
633
|
+
setCheckout(updated); // updated.surchargeAmount and updated.appliedSurcharges are now fresh
|
|
634
|
+
setStep('payment');
|
|
635
|
+
} catch (err) {
|
|
636
|
+
setError(err instanceof Error ? err.message : 'Failed to save selections');
|
|
637
|
+
} finally {
|
|
638
|
+
setLoading(false);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
\`\`\`
|
|
642
|
+
|
|
643
|
+
### Order Summary \u2014 Show the Surcharges
|
|
644
|
+
|
|
645
|
+
**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:
|
|
646
|
+
|
|
647
|
+
\`\`\`typescript
|
|
648
|
+
{checkout.appliedSurcharges?.length > 0 && (
|
|
649
|
+
<>
|
|
650
|
+
{checkout.appliedSurcharges.map((s) => (
|
|
651
|
+
<div key={s.key} className="flex justify-between text-sm text-gray-600">
|
|
652
|
+
<span>{s.name}</span>
|
|
653
|
+
<span>{formatPrice(s.amount, { currency })}</span>
|
|
654
|
+
</div>
|
|
655
|
+
))}
|
|
656
|
+
</>
|
|
657
|
+
)}
|
|
658
|
+
\`\`\`
|
|
659
|
+
|
|
660
|
+
\`checkout.surchargeAmount\` is a string (use \`parseFloat()\`); \`checkout.totalAmount\` already includes it.
|
|
661
|
+
|
|
662
|
+
### Notes
|
|
663
|
+
|
|
664
|
+
- The server filters fields by \`visibility\` (\`always\` / \`shipping\` / \`pickup\` / \`products\`) \u2014 your client just renders what \`getCheckoutCustomFields()\` returns.
|
|
665
|
+
- 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.
|
|
666
|
+
- If \`getCheckoutCustomFields()\` returns 0 fields, skip the step entirely. Don't render an empty UI.
|
|
667
|
+
`;
|
|
668
|
+
}
|
|
441
669
|
function getPaymentProvidersSection() {
|
|
442
670
|
return `## Payment Provider Detection (DO THIS FIRST on checkout page!)
|
|
443
671
|
|
|
@@ -1287,6 +1515,10 @@ ${getCheckoutFlowSection(currency)}
|
|
|
1287
1515
|
|
|
1288
1516
|
---
|
|
1289
1517
|
|
|
1518
|
+
${getCheckoutCustomFieldsSection()}
|
|
1519
|
+
|
|
1520
|
+
---
|
|
1521
|
+
|
|
1290
1522
|
${getPaymentProvidersSection()}
|
|
1291
1523
|
|
|
1292
1524
|
---
|
|
@@ -1345,6 +1577,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1345
1577
|
return getCartSection(cur);
|
|
1346
1578
|
case "checkout":
|
|
1347
1579
|
return getCheckoutFlowSection(cur);
|
|
1580
|
+
case "checkout-custom-fields":
|
|
1581
|
+
return getCheckoutCustomFieldsSection();
|
|
1348
1582
|
case "payment":
|
|
1349
1583
|
return getPaymentProvidersSection();
|
|
1350
1584
|
case "auth":
|
|
@@ -1373,7 +1607,7 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1373
1607
|
storeType: "E-commerce store"
|
|
1374
1608
|
});
|
|
1375
1609
|
default:
|
|
1376
|
-
return `Unknown topic: "${topic}". Available topics: setup, products, cart, checkout, payment, auth, order-confirmation, inventory, discounts, recommendations, tax, critical-rules, type-reference, admin, all`;
|
|
1610
|
+
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`;
|
|
1377
1611
|
}
|
|
1378
1612
|
}
|
|
1379
1613
|
|
|
@@ -1386,6 +1620,7 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
1386
1620
|
"products",
|
|
1387
1621
|
"cart",
|
|
1388
1622
|
"checkout",
|
|
1623
|
+
"checkout-custom-fields",
|
|
1389
1624
|
"payment",
|
|
1390
1625
|
"auth",
|
|
1391
1626
|
"order-confirmation",
|
|
@@ -3407,6 +3642,7 @@ import type {
|
|
|
3407
3642
|
ShippingDestinations,
|
|
3408
3643
|
PickupLocation,
|
|
3409
3644
|
CheckoutBumpsResponse,
|
|
3645
|
+
CheckoutCustomFieldDefinition,
|
|
3410
3646
|
} from 'brainerce';
|
|
3411
3647
|
import { formatPrice } from 'brainerce';
|
|
3412
3648
|
import { getClient } from '@/lib/brainerce';
|
|
@@ -3416,6 +3652,7 @@ import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
|
3416
3652
|
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
3417
3653
|
import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
|
|
3418
3654
|
import { PickupStep } from '@/components/checkout/pickup-step';
|
|
3655
|
+
import { CustomFieldsStep } from '@/components/checkout/custom-fields-step';
|
|
3419
3656
|
import { TaxDisplay } from '@/components/checkout/tax-display';
|
|
3420
3657
|
import { OrderBumpCard } from '@/components/checkout/order-bump-card';
|
|
3421
3658
|
import { CouponInput } from '@/components/cart/coupon-input';
|
|
@@ -3424,7 +3661,7 @@ import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
|
3424
3661
|
import { useTranslations } from '@/lib/translations';
|
|
3425
3662
|
import { cn } from '@/lib/utils';
|
|
3426
3663
|
|
|
3427
|
-
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'payment';
|
|
3664
|
+
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'custom-fields' | 'payment';
|
|
3428
3665
|
|
|
3429
3666
|
function CheckoutContent() {
|
|
3430
3667
|
const searchParams = useSearchParams();
|
|
@@ -3457,6 +3694,9 @@ function CheckoutContent() {
|
|
|
3457
3694
|
const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
|
|
3458
3695
|
const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
|
|
3459
3696
|
const [bumpLoading, setBumpLoading] = useState<string | null>(null);
|
|
3697
|
+
const [customFields, setCustomFields] = useState<CheckoutCustomFieldDefinition[]>([]);
|
|
3698
|
+
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
|
|
3699
|
+
const [customFieldsLoading, setCustomFieldsLoading] = useState(false);
|
|
3460
3700
|
|
|
3461
3701
|
// Check for returning from canceled payment
|
|
3462
3702
|
const canceled = searchParams.get('canceled') === 'true';
|
|
@@ -3508,6 +3748,23 @@ function CheckoutContent() {
|
|
|
3508
3748
|
const existing = await client.getCheckout(existingCheckoutId);
|
|
3509
3749
|
setCheckout(existing);
|
|
3510
3750
|
|
|
3751
|
+
// Preload custom field definitions and any existing values so the
|
|
3752
|
+
// step indicator and "change options" affordance work on resume.
|
|
3753
|
+
client
|
|
3754
|
+
.getCheckoutCustomFields(existing.id)
|
|
3755
|
+
.then((fields) => {
|
|
3756
|
+
setCustomFields(fields);
|
|
3757
|
+
const existingValues = (
|
|
3758
|
+
existing as unknown as {
|
|
3759
|
+
customFieldValues?: Record<string, unknown> | null;
|
|
3760
|
+
}
|
|
3761
|
+
).customFieldValues;
|
|
3762
|
+
if (existingValues) setCustomFieldValues(existingValues);
|
|
3763
|
+
})
|
|
3764
|
+
.catch(() => {
|
|
3765
|
+
setCustomFields([]);
|
|
3766
|
+
});
|
|
3767
|
+
|
|
3511
3768
|
// Determine step based on checkout state
|
|
3512
3769
|
const allDigital = existing.lineItems.every(
|
|
3513
3770
|
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
@@ -3678,6 +3935,21 @@ function CheckoutContent() {
|
|
|
3678
3935
|
}
|
|
3679
3936
|
}
|
|
3680
3937
|
|
|
3938
|
+
// After shipping/pickup is set, decide whether to show the custom-fields step
|
|
3939
|
+
// or jump straight to payment. Returns the next step.
|
|
3940
|
+
async function loadCustomFieldsOrSkip(checkoutId: string): Promise<CheckoutStep> {
|
|
3941
|
+
try {
|
|
3942
|
+
const fields = await getClient().getCheckoutCustomFields(checkoutId);
|
|
3943
|
+
setCustomFields(fields);
|
|
3944
|
+
return fields.length > 0 ? 'custom-fields' : 'payment';
|
|
3945
|
+
} catch {
|
|
3946
|
+
// If the endpoint isn't available or fails, fall through to payment
|
|
3947
|
+
// rather than blocking the customer.
|
|
3948
|
+
setCustomFields([]);
|
|
3949
|
+
return 'payment';
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3681
3953
|
// Handle shipping method selection
|
|
3682
3954
|
async function handleShippingSelect(rateId: string) {
|
|
3683
3955
|
if (!checkout) return;
|
|
@@ -3690,7 +3962,7 @@ function CheckoutContent() {
|
|
|
3690
3962
|
|
|
3691
3963
|
const updated = await client.selectShippingMethod(checkout.id, rateId);
|
|
3692
3964
|
setCheckout(updated);
|
|
3693
|
-
setStep(
|
|
3965
|
+
setStep(await loadCustomFieldsOrSkip(updated.id));
|
|
3694
3966
|
} catch (err) {
|
|
3695
3967
|
const message = err instanceof Error ? err.message : t('failedToSelectShipping');
|
|
3696
3968
|
setError(message);
|
|
@@ -3699,6 +3971,23 @@ function CheckoutContent() {
|
|
|
3699
3971
|
}
|
|
3700
3972
|
}
|
|
3701
3973
|
|
|
3974
|
+
// Submit custom fields
|
|
3975
|
+
async function handleCustomFieldsApply() {
|
|
3976
|
+
if (!checkout) return;
|
|
3977
|
+
try {
|
|
3978
|
+
setCustomFieldsLoading(true);
|
|
3979
|
+
setError(null);
|
|
3980
|
+
const updated = await getClient().setCheckoutCustomFields(checkout.id, customFieldValues);
|
|
3981
|
+
setCheckout(updated);
|
|
3982
|
+
setStep('payment');
|
|
3983
|
+
} catch (err) {
|
|
3984
|
+
const message = err instanceof Error ? err.message : t('customFieldsFailed');
|
|
3985
|
+
setError(message);
|
|
3986
|
+
} finally {
|
|
3987
|
+
setCustomFieldsLoading(false);
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3702
3991
|
// Handle delivery method selection
|
|
3703
3992
|
async function handleDeliveryTypeSelect(method: 'shipping' | 'pickup') {
|
|
3704
3993
|
if (!checkout) return;
|
|
@@ -3744,7 +4033,7 @@ function CheckoutContent() {
|
|
|
3744
4033
|
phone: customerInfo.phone,
|
|
3745
4034
|
});
|
|
3746
4035
|
setCheckout(updated);
|
|
3747
|
-
setStep(
|
|
4036
|
+
setStep(await loadCustomFieldsOrSkip(updated.id));
|
|
3748
4037
|
} catch (err) {
|
|
3749
4038
|
const message = err instanceof Error ? err.message : t('failedToSelectPickup');
|
|
3750
4039
|
setError(message);
|
|
@@ -3806,9 +4095,15 @@ function CheckoutContent() {
|
|
|
3806
4095
|
);
|
|
3807
4096
|
}
|
|
3808
4097
|
|
|
4098
|
+
const customFieldsStep =
|
|
4099
|
+
customFields.length > 0
|
|
4100
|
+
? [{ key: 'custom-fields' as CheckoutStep, label: t('stepCustomFields') }]
|
|
4101
|
+
: [];
|
|
4102
|
+
|
|
3809
4103
|
const steps: { key: CheckoutStep; label: string }[] = isAllDigital
|
|
3810
4104
|
? [
|
|
3811
4105
|
{ key: 'address', label: t('stepContactInfo') },
|
|
4106
|
+
...customFieldsStep,
|
|
3812
4107
|
{ key: 'payment', label: t('stepPayment') },
|
|
3813
4108
|
]
|
|
3814
4109
|
: pickupLocations.length > 0
|
|
@@ -3816,17 +4111,20 @@ function CheckoutContent() {
|
|
|
3816
4111
|
? [
|
|
3817
4112
|
{ key: 'method', label: t('stepMethod') },
|
|
3818
4113
|
{ key: 'pickup', label: t('stepPickup') },
|
|
4114
|
+
...customFieldsStep,
|
|
3819
4115
|
{ key: 'payment', label: t('stepPayment') },
|
|
3820
4116
|
]
|
|
3821
4117
|
: [
|
|
3822
4118
|
{ key: 'method', label: t('stepMethod') },
|
|
3823
4119
|
{ key: 'address', label: t('stepAddress') },
|
|
3824
4120
|
{ key: 'shipping', label: t('stepShipping') },
|
|
4121
|
+
...customFieldsStep,
|
|
3825
4122
|
{ key: 'payment', label: t('stepPayment') },
|
|
3826
4123
|
]
|
|
3827
4124
|
: [
|
|
3828
4125
|
{ key: 'address', label: t('stepAddress') },
|
|
3829
4126
|
{ key: 'shipping', label: t('stepShipping') },
|
|
4127
|
+
...customFieldsStep,
|
|
3830
4128
|
{ key: 'payment', label: t('stepPayment') },
|
|
3831
4129
|
];
|
|
3832
4130
|
|
|
@@ -4025,19 +4323,54 @@ function CheckoutContent() {
|
|
|
4025
4323
|
</div>
|
|
4026
4324
|
)}
|
|
4027
4325
|
|
|
4326
|
+
{/* Custom Fields (optional, between shipping/pickup and payment) */}
|
|
4327
|
+
{step === 'custom-fields' && checkout && (
|
|
4328
|
+
<div>
|
|
4329
|
+
<div className="mb-4 flex items-center justify-between">
|
|
4330
|
+
<h2 className="text-foreground text-lg font-semibold">{t('customFieldsTitle')}</h2>
|
|
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
|
+
</div>
|
|
4339
|
+
<CustomFieldsStep
|
|
4340
|
+
fields={customFields}
|
|
4341
|
+
values={customFieldValues}
|
|
4342
|
+
onChange={(key, value) =>
|
|
4343
|
+
setCustomFieldValues((prev) => ({ ...prev, [key]: value }))
|
|
4344
|
+
}
|
|
4345
|
+
onApply={handleCustomFieldsApply}
|
|
4346
|
+
loading={customFieldsLoading}
|
|
4347
|
+
/>
|
|
4348
|
+
</div>
|
|
4349
|
+
)}
|
|
4350
|
+
|
|
4028
4351
|
{/* Payment */}
|
|
4029
4352
|
{step === 'payment' && checkout && (
|
|
4030
4353
|
<div>
|
|
4031
4354
|
<div className="mb-4 flex items-center justify-between">
|
|
4032
4355
|
<h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
|
|
4033
|
-
{
|
|
4356
|
+
{customFields.length > 0 ? (
|
|
4034
4357
|
<button
|
|
4035
4358
|
type="button"
|
|
4036
|
-
onClick={() => setStep(
|
|
4359
|
+
onClick={() => setStep('custom-fields')}
|
|
4037
4360
|
className="text-primary text-sm hover:underline"
|
|
4038
4361
|
>
|
|
4039
|
-
{
|
|
4362
|
+
{t('changeOptions')}
|
|
4040
4363
|
</button>
|
|
4364
|
+
) : (
|
|
4365
|
+
!isAllDigital && (
|
|
4366
|
+
<button
|
|
4367
|
+
type="button"
|
|
4368
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
4369
|
+
className="text-primary text-sm hover:underline"
|
|
4370
|
+
>
|
|
4371
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
4372
|
+
</button>
|
|
4373
|
+
)
|
|
4041
4374
|
)}
|
|
4042
4375
|
</div>
|
|
4043
4376
|
|
|
@@ -4224,6 +4557,20 @@ function CheckoutContent() {
|
|
|
4224
4557
|
taxBreakdown={checkout.taxBreakdown}
|
|
4225
4558
|
/>
|
|
4226
4559
|
|
|
4560
|
+
{/* Custom field surcharges (one line per applied surcharge) */}
|
|
4561
|
+
{checkout.appliedSurcharges && checkout.appliedSurcharges.length > 0 && (
|
|
4562
|
+
<>
|
|
4563
|
+
{checkout.appliedSurcharges.map((s) => (
|
|
4564
|
+
<div key={s.key} className="flex items-center justify-between">
|
|
4565
|
+
<span className="text-muted-foreground">{s.name}</span>
|
|
4566
|
+
<span className="text-foreground">
|
|
4567
|
+
{formatPrice(Number(s.amount), { currency }) as string}
|
|
4568
|
+
</span>
|
|
4569
|
+
</div>
|
|
4570
|
+
))}
|
|
4571
|
+
</>
|
|
4572
|
+
)}
|
|
4573
|
+
|
|
4227
4574
|
<div className="border-border mt-2 border-t pt-2">
|
|
4228
4575
|
<div className="flex items-center justify-between">
|
|
4229
4576
|
<span className="text-foreground font-semibold">{tc('total')}</span>
|
|
@@ -11242,7 +11589,17 @@ async function handleGetRequiredPages(args) {
|
|
|
11242
11589
|
var import_zod5 = require("zod");
|
|
11243
11590
|
|
|
11244
11591
|
// src/utils/fetch-store-info.ts
|
|
11245
|
-
|
|
11592
|
+
var KNOWN_API_URLS = {
|
|
11593
|
+
production: "https://api.brainerce.com",
|
|
11594
|
+
staging: "https://api-staging.brainerce.com"
|
|
11595
|
+
};
|
|
11596
|
+
var ConnectionNotFoundError = class extends Error {
|
|
11597
|
+
constructor(connectionId, baseUrl) {
|
|
11598
|
+
super(`Connection "${connectionId}" not found at ${baseUrl}`);
|
|
11599
|
+
this.code = "NOT_FOUND";
|
|
11600
|
+
}
|
|
11601
|
+
};
|
|
11602
|
+
async function fetchStoreInfo(connectionId, baseUrl = KNOWN_API_URLS.production) {
|
|
11246
11603
|
const url = `${baseUrl}/api/vc/${connectionId}/info`;
|
|
11247
11604
|
const controller = new AbortController();
|
|
11248
11605
|
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
@@ -11251,55 +11608,90 @@ async function fetchStoreInfo(connectionId, baseUrl = "https://api.brainerce.com
|
|
|
11251
11608
|
res = await fetch(url, { signal: controller.signal });
|
|
11252
11609
|
} catch (err) {
|
|
11253
11610
|
if (err.name === "AbortError") {
|
|
11254
|
-
throw new Error(
|
|
11611
|
+
throw new Error(`Request to ${baseUrl} timed out`);
|
|
11255
11612
|
}
|
|
11256
|
-
throw new Error(`Failed to connect to
|
|
11613
|
+
throw new Error(`Failed to connect to ${baseUrl}: ${err.message}`);
|
|
11257
11614
|
} finally {
|
|
11258
11615
|
clearTimeout(timeout);
|
|
11259
11616
|
}
|
|
11260
11617
|
if (res.status === 404) {
|
|
11261
|
-
throw new
|
|
11618
|
+
throw new ConnectionNotFoundError(connectionId, baseUrl);
|
|
11262
11619
|
}
|
|
11263
11620
|
if (!res.ok) {
|
|
11264
|
-
throw new Error(
|
|
11621
|
+
throw new Error(`${baseUrl} returned status ${res.status}`);
|
|
11265
11622
|
}
|
|
11266
11623
|
let json;
|
|
11267
11624
|
try {
|
|
11268
11625
|
json = await res.json();
|
|
11269
11626
|
} catch {
|
|
11270
|
-
throw new Error(
|
|
11627
|
+
throw new Error(`Invalid response from ${baseUrl}`);
|
|
11271
11628
|
}
|
|
11629
|
+
const storeName = json.storeName || json.name || "My Store";
|
|
11630
|
+
const displayName = json.channelName || storeName;
|
|
11272
11631
|
return {
|
|
11273
|
-
name:
|
|
11632
|
+
name: displayName,
|
|
11633
|
+
storeName,
|
|
11274
11634
|
currency: json.currency || "USD",
|
|
11275
11635
|
language: json.language || "en",
|
|
11276
11636
|
...json.i18n ? { i18n: json.i18n } : {}
|
|
11277
11637
|
};
|
|
11278
11638
|
}
|
|
11639
|
+
async function resolveStoreInfo(connectionId, candidateUrls) {
|
|
11640
|
+
const urls = candidateUrls.filter((u, i) => u && candidateUrls.indexOf(u) === i);
|
|
11641
|
+
if (urls.length === 0) {
|
|
11642
|
+
throw new Error("No API URLs to try");
|
|
11643
|
+
}
|
|
11644
|
+
let lastError;
|
|
11645
|
+
let allNotFound = true;
|
|
11646
|
+
for (let i = 0; i < urls.length; i++) {
|
|
11647
|
+
const baseUrl = urls[i];
|
|
11648
|
+
try {
|
|
11649
|
+
const info = await fetchStoreInfo(connectionId, baseUrl);
|
|
11650
|
+
return { info, apiBaseUrl: baseUrl, fellBack: i > 0 };
|
|
11651
|
+
} catch (err) {
|
|
11652
|
+
lastError = err;
|
|
11653
|
+
const isNotFound = err.code === "NOT_FOUND";
|
|
11654
|
+
if (!isNotFound) {
|
|
11655
|
+
allNotFound = false;
|
|
11656
|
+
}
|
|
11657
|
+
}
|
|
11658
|
+
}
|
|
11659
|
+
if (allNotFound) {
|
|
11660
|
+
throw new Error(
|
|
11661
|
+
`Connection "${connectionId}" not found in any known environment (${urls.join(", ")}).`
|
|
11662
|
+
);
|
|
11663
|
+
}
|
|
11664
|
+
throw lastError || new Error("Failed to resolve store info");
|
|
11665
|
+
}
|
|
11666
|
+
function getCandidateApiUrls() {
|
|
11667
|
+
const explicit = (process.env.BRAINERCE_API_URL || "").replace(/\/$/, "");
|
|
11668
|
+
if (explicit) return [explicit];
|
|
11669
|
+
return [KNOWN_API_URLS.production, KNOWN_API_URLS.staging];
|
|
11670
|
+
}
|
|
11279
11671
|
|
|
11280
11672
|
// src/tools/get-store-info.ts
|
|
11281
11673
|
var GET_STORE_INFO_NAME = "get-store-info";
|
|
11282
|
-
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.";
|
|
11674
|
+
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.";
|
|
11283
11675
|
var GET_STORE_INFO_SCHEMA = {
|
|
11284
11676
|
connectionId: import_zod5.z.string().describe("Vibe-coded connection ID (starts with vc_)")
|
|
11285
11677
|
};
|
|
11286
11678
|
async function handleGetStoreInfo(args) {
|
|
11287
11679
|
try {
|
|
11288
|
-
const
|
|
11289
|
-
/\/$/,
|
|
11290
|
-
""
|
|
11291
|
-
);
|
|
11292
|
-
const info = await fetchStoreInfo(args.connectionId, baseUrl);
|
|
11680
|
+
const resolved = await resolveStoreInfo(args.connectionId, getCandidateApiUrls());
|
|
11293
11681
|
return {
|
|
11294
11682
|
content: [
|
|
11295
11683
|
{
|
|
11296
11684
|
type: "text",
|
|
11297
11685
|
text: JSON.stringify(
|
|
11298
11686
|
{
|
|
11299
|
-
name
|
|
11300
|
-
|
|
11301
|
-
|
|
11302
|
-
|
|
11687
|
+
// `name` is the channel display name (from the dashboard sales-channel card);
|
|
11688
|
+
// falls back to storeName when a channel doesn't have its own name.
|
|
11689
|
+
name: resolved.info.name,
|
|
11690
|
+
storeName: resolved.info.storeName,
|
|
11691
|
+
currency: resolved.info.currency,
|
|
11692
|
+
language: resolved.info.language,
|
|
11693
|
+
connectionId: args.connectionId,
|
|
11694
|
+
apiBaseUrl: resolved.apiBaseUrl
|
|
11303
11695
|
},
|
|
11304
11696
|
null,
|
|
11305
11697
|
2
|
|
@@ -11320,6 +11712,12 @@ async function handleGetStoreInfo(args) {
|
|
|
11320
11712
|
var import_zod6 = require("zod");
|
|
11321
11713
|
|
|
11322
11714
|
// src/utils/fetch-store-capabilities.ts
|
|
11715
|
+
var ConnectionNotFoundError2 = class extends Error {
|
|
11716
|
+
constructor(connectionId, baseUrl) {
|
|
11717
|
+
super(`Connection "${connectionId}" not found at ${baseUrl}`);
|
|
11718
|
+
this.code = "NOT_FOUND";
|
|
11719
|
+
}
|
|
11720
|
+
};
|
|
11323
11721
|
async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brainerce.com") {
|
|
11324
11722
|
const url = `${baseUrl}/api/vc/${connectionId}/capabilities`;
|
|
11325
11723
|
const controller = new AbortController();
|
|
@@ -11329,24 +11727,51 @@ async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brain
|
|
|
11329
11727
|
res = await fetch(url, { signal: controller.signal });
|
|
11330
11728
|
} catch (err) {
|
|
11331
11729
|
if (err.name === "AbortError") {
|
|
11332
|
-
throw new Error(
|
|
11730
|
+
throw new Error(`Request to ${baseUrl} timed out`);
|
|
11333
11731
|
}
|
|
11334
|
-
throw new Error(`Failed to connect to
|
|
11732
|
+
throw new Error(`Failed to connect to ${baseUrl}: ${err.message}`);
|
|
11335
11733
|
} finally {
|
|
11336
11734
|
clearTimeout(timeout);
|
|
11337
11735
|
}
|
|
11338
11736
|
if (res.status === 404) {
|
|
11339
|
-
throw new
|
|
11737
|
+
throw new ConnectionNotFoundError2(connectionId, baseUrl);
|
|
11340
11738
|
}
|
|
11341
11739
|
if (!res.ok) {
|
|
11342
|
-
throw new Error(
|
|
11740
|
+
throw new Error(`${baseUrl} returned status ${res.status}`);
|
|
11343
11741
|
}
|
|
11344
11742
|
try {
|
|
11345
11743
|
return await res.json();
|
|
11346
11744
|
} catch {
|
|
11347
|
-
throw new Error(
|
|
11745
|
+
throw new Error(`Invalid response from ${baseUrl}`);
|
|
11348
11746
|
}
|
|
11349
11747
|
}
|
|
11748
|
+
async function resolveStoreCapabilities(connectionId, candidateUrls) {
|
|
11749
|
+
const urls = candidateUrls.filter((u, i) => u && candidateUrls.indexOf(u) === i);
|
|
11750
|
+
if (urls.length === 0) {
|
|
11751
|
+
throw new Error("No API URLs to try");
|
|
11752
|
+
}
|
|
11753
|
+
let lastError;
|
|
11754
|
+
let allNotFound = true;
|
|
11755
|
+
for (let i = 0; i < urls.length; i++) {
|
|
11756
|
+
const baseUrl = urls[i];
|
|
11757
|
+
try {
|
|
11758
|
+
const capabilities = await fetchStoreCapabilities(connectionId, baseUrl);
|
|
11759
|
+
return { capabilities, apiBaseUrl: baseUrl, fellBack: i > 0 };
|
|
11760
|
+
} catch (err) {
|
|
11761
|
+
lastError = err;
|
|
11762
|
+
const isNotFound = err.code === "NOT_FOUND";
|
|
11763
|
+
if (!isNotFound) {
|
|
11764
|
+
allNotFound = false;
|
|
11765
|
+
}
|
|
11766
|
+
}
|
|
11767
|
+
}
|
|
11768
|
+
if (allNotFound) {
|
|
11769
|
+
throw new Error(
|
|
11770
|
+
`Connection "${connectionId}" not found in any known environment (${urls.join(", ")}).`
|
|
11771
|
+
);
|
|
11772
|
+
}
|
|
11773
|
+
throw lastError || new Error("Failed to resolve store capabilities");
|
|
11774
|
+
}
|
|
11350
11775
|
|
|
11351
11776
|
// src/tools/get-store-capabilities.ts
|
|
11352
11777
|
var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
|
|
@@ -11356,7 +11781,8 @@ var GET_STORE_CAPABILITIES_SCHEMA = {
|
|
|
11356
11781
|
};
|
|
11357
11782
|
function formatCapabilities(caps) {
|
|
11358
11783
|
const lines = [];
|
|
11359
|
-
|
|
11784
|
+
const channelSuffix = caps.store.channelName && caps.store.channelName !== caps.store.name ? ` \u2014 Channel: "${caps.store.channelName}"` : "";
|
|
11785
|
+
lines.push(`## Store: ${caps.store.name} (${caps.store.currency})${channelSuffix}`);
|
|
11360
11786
|
lines.push(`Language: ${caps.store.language}`);
|
|
11361
11787
|
if (caps.store.i18n?.enabled) {
|
|
11362
11788
|
lines.push(
|
|
@@ -11389,6 +11815,9 @@ function formatCapabilities(caps) {
|
|
|
11389
11815
|
caps.features.hasDownloadableProducts ? "\u2705 Downloadable products available" : "\u274C No downloadable products"
|
|
11390
11816
|
);
|
|
11391
11817
|
lines.push(caps.features.hasCoupons ? "\u2705 Coupons available" : "\u274C No coupons");
|
|
11818
|
+
lines.push(
|
|
11819
|
+
caps.features.hasCheckoutCustomFields ? "\u2705 Checkout custom fields configured (surcharges may apply)" : "\u274C No checkout custom fields"
|
|
11820
|
+
);
|
|
11392
11821
|
lines.push("");
|
|
11393
11822
|
lines.push("## Connection Settings");
|
|
11394
11823
|
lines.push(
|
|
@@ -11434,6 +11863,11 @@ function formatCapabilities(caps) {
|
|
|
11434
11863
|
if (caps.features.hasCoupons) {
|
|
11435
11864
|
suggestions.push('Add a coupon input to the cart. Use get-code-example("coupon-input").');
|
|
11436
11865
|
}
|
|
11866
|
+
if (caps.features.hasCheckoutCustomFields) {
|
|
11867
|
+
suggestions.push(
|
|
11868
|
+
`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").`
|
|
11869
|
+
);
|
|
11870
|
+
}
|
|
11437
11871
|
if (caps.connection.requireEmailVerification) {
|
|
11438
11872
|
suggestions.push(
|
|
11439
11873
|
'Email verification is required \u2014 ALWAYS build the verify-email page. Use get-code-example("verify-email").'
|
|
@@ -11464,13 +11898,9 @@ function formatCapabilities(caps) {
|
|
|
11464
11898
|
}
|
|
11465
11899
|
async function handleGetStoreCapabilities(args) {
|
|
11466
11900
|
try {
|
|
11467
|
-
const
|
|
11468
|
-
/\/$/,
|
|
11469
|
-
""
|
|
11470
|
-
);
|
|
11471
|
-
const caps = await fetchStoreCapabilities(args.connectionId, baseUrl);
|
|
11901
|
+
const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
|
|
11472
11902
|
return {
|
|
11473
|
-
content: [{ type: "text", text: formatCapabilities(
|
|
11903
|
+
content: [{ type: "text", text: formatCapabilities(resolved.capabilities) }]
|
|
11474
11904
|
};
|
|
11475
11905
|
} catch (error) {
|
|
11476
11906
|
const message = error instanceof Error ? error.message : "Failed to fetch store capabilities";
|
|
@@ -11733,13 +12163,10 @@ var BUILD_STORE_SCHEMA = {
|
|
|
11733
12163
|
storeStyle: import_zod7.z.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
|
|
11734
12164
|
};
|
|
11735
12165
|
async function handleBuildStore(args) {
|
|
11736
|
-
const baseUrl = (process.env.BRAINERCE_API_URL || "https://api.brainerce.com").replace(
|
|
11737
|
-
/\/$/,
|
|
11738
|
-
""
|
|
11739
|
-
);
|
|
11740
12166
|
let capabilities = null;
|
|
11741
12167
|
try {
|
|
11742
|
-
|
|
12168
|
+
const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
|
|
12169
|
+
capabilities = resolved.capabilities;
|
|
11743
12170
|
} catch {
|
|
11744
12171
|
}
|
|
11745
12172
|
const bundle = buildStoreBundle({
|