@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/bin/http.js
CHANGED
|
@@ -411,6 +411,234 @@ function PayPalPaymentForm({ clientId, orderId, checkoutId }: { clientId: string
|
|
|
411
411
|
}
|
|
412
412
|
\`\`\``;
|
|
413
413
|
}
|
|
414
|
+
function getCheckoutCustomFieldsSection() {
|
|
415
|
+
return `## Checkout Custom Fields (Optional Step Between Shipping & Payment)
|
|
416
|
+
|
|
417
|
+
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.
|
|
418
|
+
|
|
419
|
+
### When to Build This
|
|
420
|
+
|
|
421
|
+
Check capabilities first:
|
|
422
|
+
\`\`\`typescript
|
|
423
|
+
const caps = await fetch(\`/api/vc/\${connectionId}/capabilities\`).then(r => r.json());
|
|
424
|
+
if (caps.features.hasCheckoutCustomFields) {
|
|
425
|
+
// Build the step described below
|
|
426
|
+
}
|
|
427
|
+
\`\`\`
|
|
428
|
+
|
|
429
|
+
If \`hasCheckoutCustomFields\` is true, you MUST build this step or customers will be charged surcharges they never saw.
|
|
430
|
+
|
|
431
|
+
### Where to Place It
|
|
432
|
+
|
|
433
|
+
After \`selectShippingMethod()\` (or pickup confirmation), BEFORE \`createPaymentIntent()\`. Pseudocode:
|
|
434
|
+
|
|
435
|
+
\`\`\`
|
|
436
|
+
address \u2192 shipping/pickup \u2192 custom-fields (NEW) \u2192 payment
|
|
437
|
+
\`\`\`
|
|
438
|
+
|
|
439
|
+
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.
|
|
440
|
+
|
|
441
|
+
### Loading Field Definitions
|
|
442
|
+
|
|
443
|
+
\`\`\`typescript
|
|
444
|
+
import type { CheckoutCustomFieldDefinition } from 'brainerce';
|
|
445
|
+
|
|
446
|
+
const fields = await client.getCheckoutCustomFields(checkoutId);
|
|
447
|
+
if (fields.length === 0) {
|
|
448
|
+
setStep('payment'); // Nothing to collect
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
\`\`\`
|
|
452
|
+
|
|
453
|
+
Each \`CheckoutCustomFieldDefinition\` has:
|
|
454
|
+
- \`key\` \u2014 the form field name to use in your state
|
|
455
|
+
- \`name\` \u2014 display label
|
|
456
|
+
- \`description\` \u2014 helper text (render below the input)
|
|
457
|
+
- \`type\` \u2014 \`'TEXT' | 'TEXTAREA' | 'NUMBER' | 'BOOLEAN' | 'SELECT' | 'DATE'\`
|
|
458
|
+
- \`required\` \u2014 boolean
|
|
459
|
+
- \`minLength\` / \`maxLength\` \u2014 for TEXT/TEXTAREA
|
|
460
|
+
- \`minValue\` / \`maxValue\` \u2014 for NUMBER
|
|
461
|
+
- \`options\` \u2014 \`Array<{value, label}>\` for SELECT
|
|
462
|
+
- \`pricing\` \u2014 never render this; the server uses it to compute surcharges
|
|
463
|
+
|
|
464
|
+
### Rendering All 6 Field Types
|
|
465
|
+
|
|
466
|
+
\`\`\`typescript
|
|
467
|
+
function CustomFieldsStep({ fields, values, onChange, onApply, loading }: {
|
|
468
|
+
fields: CheckoutCustomFieldDefinition[];
|
|
469
|
+
values: Record<string, unknown>;
|
|
470
|
+
onChange: (key: string, value: unknown) => void;
|
|
471
|
+
onApply: () => void;
|
|
472
|
+
loading?: boolean;
|
|
473
|
+
}) {
|
|
474
|
+
return (
|
|
475
|
+
<div className="space-y-4">
|
|
476
|
+
{fields.map((field) => {
|
|
477
|
+
const value = values[field.key] ?? '';
|
|
478
|
+
const label = (
|
|
479
|
+
<label className="block text-sm font-medium">
|
|
480
|
+
{field.name}{field.required && <span className="text-red-500"> *</span>}
|
|
481
|
+
</label>
|
|
482
|
+
);
|
|
483
|
+
const help = field.description && (
|
|
484
|
+
<p className="text-xs text-gray-500 mt-1">{field.description}</p>
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
switch (field.type) {
|
|
488
|
+
case 'TEXT':
|
|
489
|
+
return (
|
|
490
|
+
<div key={field.key}>
|
|
491
|
+
{label}
|
|
492
|
+
<input
|
|
493
|
+
type="text"
|
|
494
|
+
value={value as string}
|
|
495
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
496
|
+
required={field.required}
|
|
497
|
+
minLength={field.minLength ?? undefined}
|
|
498
|
+
maxLength={field.maxLength ?? undefined}
|
|
499
|
+
className="w-full border rounded px-3 py-2"
|
|
500
|
+
/>
|
|
501
|
+
{help}
|
|
502
|
+
</div>
|
|
503
|
+
);
|
|
504
|
+
case 'TEXTAREA':
|
|
505
|
+
return (
|
|
506
|
+
<div key={field.key}>
|
|
507
|
+
{label}
|
|
508
|
+
<textarea
|
|
509
|
+
value={value as string}
|
|
510
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
511
|
+
required={field.required}
|
|
512
|
+
minLength={field.minLength ?? undefined}
|
|
513
|
+
maxLength={field.maxLength ?? undefined}
|
|
514
|
+
rows={3}
|
|
515
|
+
className="w-full border rounded px-3 py-2"
|
|
516
|
+
/>
|
|
517
|
+
{help}
|
|
518
|
+
</div>
|
|
519
|
+
);
|
|
520
|
+
case 'NUMBER':
|
|
521
|
+
return (
|
|
522
|
+
<div key={field.key}>
|
|
523
|
+
{label}
|
|
524
|
+
<input
|
|
525
|
+
type="number"
|
|
526
|
+
value={value as number}
|
|
527
|
+
onChange={(e) => onChange(field.key, e.target.valueAsNumber)}
|
|
528
|
+
required={field.required}
|
|
529
|
+
min={field.minValue ?? undefined}
|
|
530
|
+
max={field.maxValue ?? undefined}
|
|
531
|
+
className="w-full border rounded px-3 py-2"
|
|
532
|
+
/>
|
|
533
|
+
{help}
|
|
534
|
+
</div>
|
|
535
|
+
);
|
|
536
|
+
case 'BOOLEAN':
|
|
537
|
+
return (
|
|
538
|
+
<div key={field.key} className="flex items-start gap-2">
|
|
539
|
+
<input
|
|
540
|
+
type="checkbox"
|
|
541
|
+
checked={value === true}
|
|
542
|
+
onChange={(e) => onChange(field.key, e.target.checked)}
|
|
543
|
+
className="mt-1"
|
|
544
|
+
/>
|
|
545
|
+
<div>{label}{help}</div>
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
case 'SELECT':
|
|
549
|
+
return (
|
|
550
|
+
<div key={field.key}>
|
|
551
|
+
{label}
|
|
552
|
+
<select
|
|
553
|
+
value={value as string}
|
|
554
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
555
|
+
required={field.required}
|
|
556
|
+
className="w-full border rounded px-3 py-2"
|
|
557
|
+
>
|
|
558
|
+
<option value="">\u2014 Select \u2014</option>
|
|
559
|
+
{field.options?.map((opt) => (
|
|
560
|
+
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
561
|
+
))}
|
|
562
|
+
</select>
|
|
563
|
+
{help}
|
|
564
|
+
</div>
|
|
565
|
+
);
|
|
566
|
+
case 'DATE':
|
|
567
|
+
return (
|
|
568
|
+
<div key={field.key}>
|
|
569
|
+
{label}
|
|
570
|
+
<input
|
|
571
|
+
type="date"
|
|
572
|
+
value={value as string}
|
|
573
|
+
onChange={(e) => onChange(field.key, e.target.value)}
|
|
574
|
+
required={field.required}
|
|
575
|
+
className="w-full border rounded px-3 py-2"
|
|
576
|
+
/>
|
|
577
|
+
{help}
|
|
578
|
+
</div>
|
|
579
|
+
);
|
|
580
|
+
default:
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
})}
|
|
584
|
+
|
|
585
|
+
<button
|
|
586
|
+
type="button"
|
|
587
|
+
onClick={onApply}
|
|
588
|
+
disabled={loading}
|
|
589
|
+
className="w-full bg-black text-white py-3 rounded"
|
|
590
|
+
>
|
|
591
|
+
{loading ? 'Updating\u2026' : 'Continue to Payment'}
|
|
592
|
+
</button>
|
|
593
|
+
</div>
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
\`\`\`
|
|
597
|
+
|
|
598
|
+
### Submitting Values & Recalculating Surcharges
|
|
599
|
+
|
|
600
|
+
\`\`\`typescript
|
|
601
|
+
async function applyCustomFields() {
|
|
602
|
+
setLoading(true);
|
|
603
|
+
try {
|
|
604
|
+
// Server validates required + min/max + coerces types + recalculates totals
|
|
605
|
+
const updated = await client.setCheckoutCustomFields(checkoutId, customFieldValues);
|
|
606
|
+
setCheckout(updated); // updated.surchargeAmount and updated.appliedSurcharges are now fresh
|
|
607
|
+
setStep('payment');
|
|
608
|
+
} catch (err) {
|
|
609
|
+
setError(err instanceof Error ? err.message : 'Failed to save selections');
|
|
610
|
+
} finally {
|
|
611
|
+
setLoading(false);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
\`\`\`
|
|
615
|
+
|
|
616
|
+
### Order Summary \u2014 Show the Surcharges
|
|
617
|
+
|
|
618
|
+
**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:
|
|
619
|
+
|
|
620
|
+
\`\`\`typescript
|
|
621
|
+
{checkout.appliedSurcharges?.length > 0 && (
|
|
622
|
+
<>
|
|
623
|
+
{checkout.appliedSurcharges.map((s) => (
|
|
624
|
+
<div key={s.key} className="flex justify-between text-sm text-gray-600">
|
|
625
|
+
<span>{s.name}</span>
|
|
626
|
+
<span>{formatPrice(s.amount, { currency })}</span>
|
|
627
|
+
</div>
|
|
628
|
+
))}
|
|
629
|
+
</>
|
|
630
|
+
)}
|
|
631
|
+
\`\`\`
|
|
632
|
+
|
|
633
|
+
\`checkout.surchargeAmount\` is a string (use \`parseFloat()\`); \`checkout.totalAmount\` already includes it.
|
|
634
|
+
|
|
635
|
+
### Notes
|
|
636
|
+
|
|
637
|
+
- The server filters fields by \`visibility\` (\`always\` / \`shipping\` / \`pickup\` / \`products\`) \u2014 your client just renders what \`getCheckoutCustomFields()\` returns.
|
|
638
|
+
- 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.
|
|
639
|
+
- If \`getCheckoutCustomFields()\` returns 0 fields, skip the step entirely. Don't render an empty UI.
|
|
640
|
+
`;
|
|
641
|
+
}
|
|
414
642
|
function getPaymentProvidersSection() {
|
|
415
643
|
return `## Payment Provider Detection (DO THIS FIRST on checkout page!)
|
|
416
644
|
|
|
@@ -1260,6 +1488,10 @@ ${getCheckoutFlowSection(currency)}
|
|
|
1260
1488
|
|
|
1261
1489
|
---
|
|
1262
1490
|
|
|
1491
|
+
${getCheckoutCustomFieldsSection()}
|
|
1492
|
+
|
|
1493
|
+
---
|
|
1494
|
+
|
|
1263
1495
|
${getPaymentProvidersSection()}
|
|
1264
1496
|
|
|
1265
1497
|
---
|
|
@@ -1318,6 +1550,8 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1318
1550
|
return getCartSection(cur);
|
|
1319
1551
|
case "checkout":
|
|
1320
1552
|
return getCheckoutFlowSection(cur);
|
|
1553
|
+
case "checkout-custom-fields":
|
|
1554
|
+
return getCheckoutCustomFieldsSection();
|
|
1321
1555
|
case "payment":
|
|
1322
1556
|
return getPaymentProvidersSection();
|
|
1323
1557
|
case "auth":
|
|
@@ -1346,7 +1580,7 @@ function getSectionByTopic(topic, connectionId, currency) {
|
|
|
1346
1580
|
storeType: "E-commerce store"
|
|
1347
1581
|
});
|
|
1348
1582
|
default:
|
|
1349
|
-
return `Unknown topic: "${topic}". Available topics: setup, products, cart, checkout, payment, auth, order-confirmation, inventory, discounts, recommendations, tax, critical-rules, type-reference, admin, all`;
|
|
1583
|
+
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`;
|
|
1350
1584
|
}
|
|
1351
1585
|
}
|
|
1352
1586
|
|
|
@@ -1359,6 +1593,7 @@ var GET_SDK_DOCS_SCHEMA = {
|
|
|
1359
1593
|
"products",
|
|
1360
1594
|
"cart",
|
|
1361
1595
|
"checkout",
|
|
1596
|
+
"checkout-custom-fields",
|
|
1362
1597
|
"payment",
|
|
1363
1598
|
"auth",
|
|
1364
1599
|
"order-confirmation",
|
|
@@ -3380,6 +3615,7 @@ import type {
|
|
|
3380
3615
|
ShippingDestinations,
|
|
3381
3616
|
PickupLocation,
|
|
3382
3617
|
CheckoutBumpsResponse,
|
|
3618
|
+
CheckoutCustomFieldDefinition,
|
|
3383
3619
|
} from 'brainerce';
|
|
3384
3620
|
import { formatPrice } from 'brainerce';
|
|
3385
3621
|
import { getClient } from '@/lib/brainerce';
|
|
@@ -3389,6 +3625,7 @@ import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
|
3389
3625
|
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
3390
3626
|
import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
|
|
3391
3627
|
import { PickupStep } from '@/components/checkout/pickup-step';
|
|
3628
|
+
import { CustomFieldsStep } from '@/components/checkout/custom-fields-step';
|
|
3392
3629
|
import { TaxDisplay } from '@/components/checkout/tax-display';
|
|
3393
3630
|
import { OrderBumpCard } from '@/components/checkout/order-bump-card';
|
|
3394
3631
|
import { CouponInput } from '@/components/cart/coupon-input';
|
|
@@ -3397,7 +3634,7 @@ import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
|
3397
3634
|
import { useTranslations } from '@/lib/translations';
|
|
3398
3635
|
import { cn } from '@/lib/utils';
|
|
3399
3636
|
|
|
3400
|
-
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'payment';
|
|
3637
|
+
type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'custom-fields' | 'payment';
|
|
3401
3638
|
|
|
3402
3639
|
function CheckoutContent() {
|
|
3403
3640
|
const searchParams = useSearchParams();
|
|
@@ -3430,6 +3667,9 @@ function CheckoutContent() {
|
|
|
3430
3667
|
const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
|
|
3431
3668
|
const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
|
|
3432
3669
|
const [bumpLoading, setBumpLoading] = useState<string | null>(null);
|
|
3670
|
+
const [customFields, setCustomFields] = useState<CheckoutCustomFieldDefinition[]>([]);
|
|
3671
|
+
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
|
|
3672
|
+
const [customFieldsLoading, setCustomFieldsLoading] = useState(false);
|
|
3433
3673
|
|
|
3434
3674
|
// Check for returning from canceled payment
|
|
3435
3675
|
const canceled = searchParams.get('canceled') === 'true';
|
|
@@ -3481,6 +3721,23 @@ function CheckoutContent() {
|
|
|
3481
3721
|
const existing = await client.getCheckout(existingCheckoutId);
|
|
3482
3722
|
setCheckout(existing);
|
|
3483
3723
|
|
|
3724
|
+
// Preload custom field definitions and any existing values so the
|
|
3725
|
+
// step indicator and "change options" affordance work on resume.
|
|
3726
|
+
client
|
|
3727
|
+
.getCheckoutCustomFields(existing.id)
|
|
3728
|
+
.then((fields) => {
|
|
3729
|
+
setCustomFields(fields);
|
|
3730
|
+
const existingValues = (
|
|
3731
|
+
existing as unknown as {
|
|
3732
|
+
customFieldValues?: Record<string, unknown> | null;
|
|
3733
|
+
}
|
|
3734
|
+
).customFieldValues;
|
|
3735
|
+
if (existingValues) setCustomFieldValues(existingValues);
|
|
3736
|
+
})
|
|
3737
|
+
.catch(() => {
|
|
3738
|
+
setCustomFields([]);
|
|
3739
|
+
});
|
|
3740
|
+
|
|
3484
3741
|
// Determine step based on checkout state
|
|
3485
3742
|
const allDigital = existing.lineItems.every(
|
|
3486
3743
|
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
@@ -3651,6 +3908,21 @@ function CheckoutContent() {
|
|
|
3651
3908
|
}
|
|
3652
3909
|
}
|
|
3653
3910
|
|
|
3911
|
+
// After shipping/pickup is set, decide whether to show the custom-fields step
|
|
3912
|
+
// or jump straight to payment. Returns the next step.
|
|
3913
|
+
async function loadCustomFieldsOrSkip(checkoutId: string): Promise<CheckoutStep> {
|
|
3914
|
+
try {
|
|
3915
|
+
const fields = await getClient().getCheckoutCustomFields(checkoutId);
|
|
3916
|
+
setCustomFields(fields);
|
|
3917
|
+
return fields.length > 0 ? 'custom-fields' : 'payment';
|
|
3918
|
+
} catch {
|
|
3919
|
+
// If the endpoint isn't available or fails, fall through to payment
|
|
3920
|
+
// rather than blocking the customer.
|
|
3921
|
+
setCustomFields([]);
|
|
3922
|
+
return 'payment';
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
|
|
3654
3926
|
// Handle shipping method selection
|
|
3655
3927
|
async function handleShippingSelect(rateId: string) {
|
|
3656
3928
|
if (!checkout) return;
|
|
@@ -3663,7 +3935,7 @@ function CheckoutContent() {
|
|
|
3663
3935
|
|
|
3664
3936
|
const updated = await client.selectShippingMethod(checkout.id, rateId);
|
|
3665
3937
|
setCheckout(updated);
|
|
3666
|
-
setStep(
|
|
3938
|
+
setStep(await loadCustomFieldsOrSkip(updated.id));
|
|
3667
3939
|
} catch (err) {
|
|
3668
3940
|
const message = err instanceof Error ? err.message : t('failedToSelectShipping');
|
|
3669
3941
|
setError(message);
|
|
@@ -3672,6 +3944,23 @@ function CheckoutContent() {
|
|
|
3672
3944
|
}
|
|
3673
3945
|
}
|
|
3674
3946
|
|
|
3947
|
+
// Submit custom fields
|
|
3948
|
+
async function handleCustomFieldsApply() {
|
|
3949
|
+
if (!checkout) return;
|
|
3950
|
+
try {
|
|
3951
|
+
setCustomFieldsLoading(true);
|
|
3952
|
+
setError(null);
|
|
3953
|
+
const updated = await getClient().setCheckoutCustomFields(checkout.id, customFieldValues);
|
|
3954
|
+
setCheckout(updated);
|
|
3955
|
+
setStep('payment');
|
|
3956
|
+
} catch (err) {
|
|
3957
|
+
const message = err instanceof Error ? err.message : t('customFieldsFailed');
|
|
3958
|
+
setError(message);
|
|
3959
|
+
} finally {
|
|
3960
|
+
setCustomFieldsLoading(false);
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
|
|
3675
3964
|
// Handle delivery method selection
|
|
3676
3965
|
async function handleDeliveryTypeSelect(method: 'shipping' | 'pickup') {
|
|
3677
3966
|
if (!checkout) return;
|
|
@@ -3717,7 +4006,7 @@ function CheckoutContent() {
|
|
|
3717
4006
|
phone: customerInfo.phone,
|
|
3718
4007
|
});
|
|
3719
4008
|
setCheckout(updated);
|
|
3720
|
-
setStep(
|
|
4009
|
+
setStep(await loadCustomFieldsOrSkip(updated.id));
|
|
3721
4010
|
} catch (err) {
|
|
3722
4011
|
const message = err instanceof Error ? err.message : t('failedToSelectPickup');
|
|
3723
4012
|
setError(message);
|
|
@@ -3779,9 +4068,15 @@ function CheckoutContent() {
|
|
|
3779
4068
|
);
|
|
3780
4069
|
}
|
|
3781
4070
|
|
|
4071
|
+
const customFieldsStep =
|
|
4072
|
+
customFields.length > 0
|
|
4073
|
+
? [{ key: 'custom-fields' as CheckoutStep, label: t('stepCustomFields') }]
|
|
4074
|
+
: [];
|
|
4075
|
+
|
|
3782
4076
|
const steps: { key: CheckoutStep; label: string }[] = isAllDigital
|
|
3783
4077
|
? [
|
|
3784
4078
|
{ key: 'address', label: t('stepContactInfo') },
|
|
4079
|
+
...customFieldsStep,
|
|
3785
4080
|
{ key: 'payment', label: t('stepPayment') },
|
|
3786
4081
|
]
|
|
3787
4082
|
: pickupLocations.length > 0
|
|
@@ -3789,17 +4084,20 @@ function CheckoutContent() {
|
|
|
3789
4084
|
? [
|
|
3790
4085
|
{ key: 'method', label: t('stepMethod') },
|
|
3791
4086
|
{ key: 'pickup', label: t('stepPickup') },
|
|
4087
|
+
...customFieldsStep,
|
|
3792
4088
|
{ key: 'payment', label: t('stepPayment') },
|
|
3793
4089
|
]
|
|
3794
4090
|
: [
|
|
3795
4091
|
{ key: 'method', label: t('stepMethod') },
|
|
3796
4092
|
{ key: 'address', label: t('stepAddress') },
|
|
3797
4093
|
{ key: 'shipping', label: t('stepShipping') },
|
|
4094
|
+
...customFieldsStep,
|
|
3798
4095
|
{ key: 'payment', label: t('stepPayment') },
|
|
3799
4096
|
]
|
|
3800
4097
|
: [
|
|
3801
4098
|
{ key: 'address', label: t('stepAddress') },
|
|
3802
4099
|
{ key: 'shipping', label: t('stepShipping') },
|
|
4100
|
+
...customFieldsStep,
|
|
3803
4101
|
{ key: 'payment', label: t('stepPayment') },
|
|
3804
4102
|
];
|
|
3805
4103
|
|
|
@@ -3998,19 +4296,54 @@ function CheckoutContent() {
|
|
|
3998
4296
|
</div>
|
|
3999
4297
|
)}
|
|
4000
4298
|
|
|
4299
|
+
{/* Custom Fields (optional, between shipping/pickup and payment) */}
|
|
4300
|
+
{step === 'custom-fields' && checkout && (
|
|
4301
|
+
<div>
|
|
4302
|
+
<div className="mb-4 flex items-center justify-between">
|
|
4303
|
+
<h2 className="text-foreground text-lg font-semibold">{t('customFieldsTitle')}</h2>
|
|
4304
|
+
<button
|
|
4305
|
+
type="button"
|
|
4306
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
4307
|
+
className="text-primary text-sm hover:underline"
|
|
4308
|
+
>
|
|
4309
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
4310
|
+
</button>
|
|
4311
|
+
</div>
|
|
4312
|
+
<CustomFieldsStep
|
|
4313
|
+
fields={customFields}
|
|
4314
|
+
values={customFieldValues}
|
|
4315
|
+
onChange={(key, value) =>
|
|
4316
|
+
setCustomFieldValues((prev) => ({ ...prev, [key]: value }))
|
|
4317
|
+
}
|
|
4318
|
+
onApply={handleCustomFieldsApply}
|
|
4319
|
+
loading={customFieldsLoading}
|
|
4320
|
+
/>
|
|
4321
|
+
</div>
|
|
4322
|
+
)}
|
|
4323
|
+
|
|
4001
4324
|
{/* Payment */}
|
|
4002
4325
|
{step === 'payment' && checkout && (
|
|
4003
4326
|
<div>
|
|
4004
4327
|
<div className="mb-4 flex items-center justify-between">
|
|
4005
4328
|
<h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
|
|
4006
|
-
{
|
|
4329
|
+
{customFields.length > 0 ? (
|
|
4007
4330
|
<button
|
|
4008
4331
|
type="button"
|
|
4009
|
-
onClick={() => setStep(
|
|
4332
|
+
onClick={() => setStep('custom-fields')}
|
|
4010
4333
|
className="text-primary text-sm hover:underline"
|
|
4011
4334
|
>
|
|
4012
|
-
{
|
|
4335
|
+
{t('changeOptions')}
|
|
4013
4336
|
</button>
|
|
4337
|
+
) : (
|
|
4338
|
+
!isAllDigital && (
|
|
4339
|
+
<button
|
|
4340
|
+
type="button"
|
|
4341
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
4342
|
+
className="text-primary text-sm hover:underline"
|
|
4343
|
+
>
|
|
4344
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
4345
|
+
</button>
|
|
4346
|
+
)
|
|
4014
4347
|
)}
|
|
4015
4348
|
</div>
|
|
4016
4349
|
|
|
@@ -4197,6 +4530,20 @@ function CheckoutContent() {
|
|
|
4197
4530
|
taxBreakdown={checkout.taxBreakdown}
|
|
4198
4531
|
/>
|
|
4199
4532
|
|
|
4533
|
+
{/* Custom field surcharges (one line per applied surcharge) */}
|
|
4534
|
+
{checkout.appliedSurcharges && checkout.appliedSurcharges.length > 0 && (
|
|
4535
|
+
<>
|
|
4536
|
+
{checkout.appliedSurcharges.map((s) => (
|
|
4537
|
+
<div key={s.key} className="flex items-center justify-between">
|
|
4538
|
+
<span className="text-muted-foreground">{s.name}</span>
|
|
4539
|
+
<span className="text-foreground">
|
|
4540
|
+
{formatPrice(Number(s.amount), { currency }) as string}
|
|
4541
|
+
</span>
|
|
4542
|
+
</div>
|
|
4543
|
+
))}
|
|
4544
|
+
</>
|
|
4545
|
+
)}
|
|
4546
|
+
|
|
4200
4547
|
<div className="border-border mt-2 border-t pt-2">
|
|
4201
4548
|
<div className="flex items-center justify-between">
|
|
4202
4549
|
<span className="text-foreground font-semibold">{tc('total')}</span>
|
|
@@ -11215,7 +11562,17 @@ async function handleGetRequiredPages(args) {
|
|
|
11215
11562
|
var import_zod5 = require("zod");
|
|
11216
11563
|
|
|
11217
11564
|
// src/utils/fetch-store-info.ts
|
|
11218
|
-
|
|
11565
|
+
var KNOWN_API_URLS = {
|
|
11566
|
+
production: "https://api.brainerce.com",
|
|
11567
|
+
staging: "https://api-staging.brainerce.com"
|
|
11568
|
+
};
|
|
11569
|
+
var ConnectionNotFoundError = class extends Error {
|
|
11570
|
+
constructor(connectionId, baseUrl) {
|
|
11571
|
+
super(`Connection "${connectionId}" not found at ${baseUrl}`);
|
|
11572
|
+
this.code = "NOT_FOUND";
|
|
11573
|
+
}
|
|
11574
|
+
};
|
|
11575
|
+
async function fetchStoreInfo(connectionId, baseUrl = KNOWN_API_URLS.production) {
|
|
11219
11576
|
const url = `${baseUrl}/api/vc/${connectionId}/info`;
|
|
11220
11577
|
const controller = new AbortController();
|
|
11221
11578
|
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
@@ -11224,55 +11581,90 @@ async function fetchStoreInfo(connectionId, baseUrl = "https://api.brainerce.com
|
|
|
11224
11581
|
res = await fetch(url, { signal: controller.signal });
|
|
11225
11582
|
} catch (err) {
|
|
11226
11583
|
if (err.name === "AbortError") {
|
|
11227
|
-
throw new Error(
|
|
11584
|
+
throw new Error(`Request to ${baseUrl} timed out`);
|
|
11228
11585
|
}
|
|
11229
|
-
throw new Error(`Failed to connect to
|
|
11586
|
+
throw new Error(`Failed to connect to ${baseUrl}: ${err.message}`);
|
|
11230
11587
|
} finally {
|
|
11231
11588
|
clearTimeout(timeout);
|
|
11232
11589
|
}
|
|
11233
11590
|
if (res.status === 404) {
|
|
11234
|
-
throw new
|
|
11591
|
+
throw new ConnectionNotFoundError(connectionId, baseUrl);
|
|
11235
11592
|
}
|
|
11236
11593
|
if (!res.ok) {
|
|
11237
|
-
throw new Error(
|
|
11594
|
+
throw new Error(`${baseUrl} returned status ${res.status}`);
|
|
11238
11595
|
}
|
|
11239
11596
|
let json;
|
|
11240
11597
|
try {
|
|
11241
11598
|
json = await res.json();
|
|
11242
11599
|
} catch {
|
|
11243
|
-
throw new Error(
|
|
11600
|
+
throw new Error(`Invalid response from ${baseUrl}`);
|
|
11244
11601
|
}
|
|
11602
|
+
const storeName = json.storeName || json.name || "My Store";
|
|
11603
|
+
const displayName = json.channelName || storeName;
|
|
11245
11604
|
return {
|
|
11246
|
-
name:
|
|
11605
|
+
name: displayName,
|
|
11606
|
+
storeName,
|
|
11247
11607
|
currency: json.currency || "USD",
|
|
11248
11608
|
language: json.language || "en",
|
|
11249
11609
|
...json.i18n ? { i18n: json.i18n } : {}
|
|
11250
11610
|
};
|
|
11251
11611
|
}
|
|
11612
|
+
async function resolveStoreInfo(connectionId, candidateUrls) {
|
|
11613
|
+
const urls = candidateUrls.filter((u, i) => u && candidateUrls.indexOf(u) === i);
|
|
11614
|
+
if (urls.length === 0) {
|
|
11615
|
+
throw new Error("No API URLs to try");
|
|
11616
|
+
}
|
|
11617
|
+
let lastError;
|
|
11618
|
+
let allNotFound = true;
|
|
11619
|
+
for (let i = 0; i < urls.length; i++) {
|
|
11620
|
+
const baseUrl = urls[i];
|
|
11621
|
+
try {
|
|
11622
|
+
const info = await fetchStoreInfo(connectionId, baseUrl);
|
|
11623
|
+
return { info, apiBaseUrl: baseUrl, fellBack: i > 0 };
|
|
11624
|
+
} catch (err) {
|
|
11625
|
+
lastError = err;
|
|
11626
|
+
const isNotFound = err.code === "NOT_FOUND";
|
|
11627
|
+
if (!isNotFound) {
|
|
11628
|
+
allNotFound = false;
|
|
11629
|
+
}
|
|
11630
|
+
}
|
|
11631
|
+
}
|
|
11632
|
+
if (allNotFound) {
|
|
11633
|
+
throw new Error(
|
|
11634
|
+
`Connection "${connectionId}" not found in any known environment (${urls.join(", ")}).`
|
|
11635
|
+
);
|
|
11636
|
+
}
|
|
11637
|
+
throw lastError || new Error("Failed to resolve store info");
|
|
11638
|
+
}
|
|
11639
|
+
function getCandidateApiUrls() {
|
|
11640
|
+
const explicit = (process.env.BRAINERCE_API_URL || "").replace(/\/$/, "");
|
|
11641
|
+
if (explicit) return [explicit];
|
|
11642
|
+
return [KNOWN_API_URLS.production, KNOWN_API_URLS.staging];
|
|
11643
|
+
}
|
|
11252
11644
|
|
|
11253
11645
|
// src/tools/get-store-info.ts
|
|
11254
11646
|
var GET_STORE_INFO_NAME = "get-store-info";
|
|
11255
|
-
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.";
|
|
11647
|
+
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.";
|
|
11256
11648
|
var GET_STORE_INFO_SCHEMA = {
|
|
11257
11649
|
connectionId: import_zod5.z.string().describe("Vibe-coded connection ID (starts with vc_)")
|
|
11258
11650
|
};
|
|
11259
11651
|
async function handleGetStoreInfo(args) {
|
|
11260
11652
|
try {
|
|
11261
|
-
const
|
|
11262
|
-
/\/$/,
|
|
11263
|
-
""
|
|
11264
|
-
);
|
|
11265
|
-
const info = await fetchStoreInfo(args.connectionId, baseUrl);
|
|
11653
|
+
const resolved = await resolveStoreInfo(args.connectionId, getCandidateApiUrls());
|
|
11266
11654
|
return {
|
|
11267
11655
|
content: [
|
|
11268
11656
|
{
|
|
11269
11657
|
type: "text",
|
|
11270
11658
|
text: JSON.stringify(
|
|
11271
11659
|
{
|
|
11272
|
-
name
|
|
11273
|
-
|
|
11274
|
-
|
|
11275
|
-
|
|
11660
|
+
// `name` is the channel display name (from the dashboard sales-channel card);
|
|
11661
|
+
// falls back to storeName when a channel doesn't have its own name.
|
|
11662
|
+
name: resolved.info.name,
|
|
11663
|
+
storeName: resolved.info.storeName,
|
|
11664
|
+
currency: resolved.info.currency,
|
|
11665
|
+
language: resolved.info.language,
|
|
11666
|
+
connectionId: args.connectionId,
|
|
11667
|
+
apiBaseUrl: resolved.apiBaseUrl
|
|
11276
11668
|
},
|
|
11277
11669
|
null,
|
|
11278
11670
|
2
|
|
@@ -11293,6 +11685,12 @@ async function handleGetStoreInfo(args) {
|
|
|
11293
11685
|
var import_zod6 = require("zod");
|
|
11294
11686
|
|
|
11295
11687
|
// src/utils/fetch-store-capabilities.ts
|
|
11688
|
+
var ConnectionNotFoundError2 = class extends Error {
|
|
11689
|
+
constructor(connectionId, baseUrl) {
|
|
11690
|
+
super(`Connection "${connectionId}" not found at ${baseUrl}`);
|
|
11691
|
+
this.code = "NOT_FOUND";
|
|
11692
|
+
}
|
|
11693
|
+
};
|
|
11296
11694
|
async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brainerce.com") {
|
|
11297
11695
|
const url = `${baseUrl}/api/vc/${connectionId}/capabilities`;
|
|
11298
11696
|
const controller = new AbortController();
|
|
@@ -11302,24 +11700,51 @@ async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brain
|
|
|
11302
11700
|
res = await fetch(url, { signal: controller.signal });
|
|
11303
11701
|
} catch (err) {
|
|
11304
11702
|
if (err.name === "AbortError") {
|
|
11305
|
-
throw new Error(
|
|
11703
|
+
throw new Error(`Request to ${baseUrl} timed out`);
|
|
11306
11704
|
}
|
|
11307
|
-
throw new Error(`Failed to connect to
|
|
11705
|
+
throw new Error(`Failed to connect to ${baseUrl}: ${err.message}`);
|
|
11308
11706
|
} finally {
|
|
11309
11707
|
clearTimeout(timeout);
|
|
11310
11708
|
}
|
|
11311
11709
|
if (res.status === 404) {
|
|
11312
|
-
throw new
|
|
11710
|
+
throw new ConnectionNotFoundError2(connectionId, baseUrl);
|
|
11313
11711
|
}
|
|
11314
11712
|
if (!res.ok) {
|
|
11315
|
-
throw new Error(
|
|
11713
|
+
throw new Error(`${baseUrl} returned status ${res.status}`);
|
|
11316
11714
|
}
|
|
11317
11715
|
try {
|
|
11318
11716
|
return await res.json();
|
|
11319
11717
|
} catch {
|
|
11320
|
-
throw new Error(
|
|
11718
|
+
throw new Error(`Invalid response from ${baseUrl}`);
|
|
11321
11719
|
}
|
|
11322
11720
|
}
|
|
11721
|
+
async function resolveStoreCapabilities(connectionId, candidateUrls) {
|
|
11722
|
+
const urls = candidateUrls.filter((u, i) => u && candidateUrls.indexOf(u) === i);
|
|
11723
|
+
if (urls.length === 0) {
|
|
11724
|
+
throw new Error("No API URLs to try");
|
|
11725
|
+
}
|
|
11726
|
+
let lastError;
|
|
11727
|
+
let allNotFound = true;
|
|
11728
|
+
for (let i = 0; i < urls.length; i++) {
|
|
11729
|
+
const baseUrl = urls[i];
|
|
11730
|
+
try {
|
|
11731
|
+
const capabilities = await fetchStoreCapabilities(connectionId, baseUrl);
|
|
11732
|
+
return { capabilities, apiBaseUrl: baseUrl, fellBack: i > 0 };
|
|
11733
|
+
} catch (err) {
|
|
11734
|
+
lastError = err;
|
|
11735
|
+
const isNotFound = err.code === "NOT_FOUND";
|
|
11736
|
+
if (!isNotFound) {
|
|
11737
|
+
allNotFound = false;
|
|
11738
|
+
}
|
|
11739
|
+
}
|
|
11740
|
+
}
|
|
11741
|
+
if (allNotFound) {
|
|
11742
|
+
throw new Error(
|
|
11743
|
+
`Connection "${connectionId}" not found in any known environment (${urls.join(", ")}).`
|
|
11744
|
+
);
|
|
11745
|
+
}
|
|
11746
|
+
throw lastError || new Error("Failed to resolve store capabilities");
|
|
11747
|
+
}
|
|
11323
11748
|
|
|
11324
11749
|
// src/tools/get-store-capabilities.ts
|
|
11325
11750
|
var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
|
|
@@ -11329,7 +11754,8 @@ var GET_STORE_CAPABILITIES_SCHEMA = {
|
|
|
11329
11754
|
};
|
|
11330
11755
|
function formatCapabilities(caps) {
|
|
11331
11756
|
const lines = [];
|
|
11332
|
-
|
|
11757
|
+
const channelSuffix = caps.store.channelName && caps.store.channelName !== caps.store.name ? ` \u2014 Channel: "${caps.store.channelName}"` : "";
|
|
11758
|
+
lines.push(`## Store: ${caps.store.name} (${caps.store.currency})${channelSuffix}`);
|
|
11333
11759
|
lines.push(`Language: ${caps.store.language}`);
|
|
11334
11760
|
if (caps.store.i18n?.enabled) {
|
|
11335
11761
|
lines.push(
|
|
@@ -11362,6 +11788,9 @@ function formatCapabilities(caps) {
|
|
|
11362
11788
|
caps.features.hasDownloadableProducts ? "\u2705 Downloadable products available" : "\u274C No downloadable products"
|
|
11363
11789
|
);
|
|
11364
11790
|
lines.push(caps.features.hasCoupons ? "\u2705 Coupons available" : "\u274C No coupons");
|
|
11791
|
+
lines.push(
|
|
11792
|
+
caps.features.hasCheckoutCustomFields ? "\u2705 Checkout custom fields configured (surcharges may apply)" : "\u274C No checkout custom fields"
|
|
11793
|
+
);
|
|
11365
11794
|
lines.push("");
|
|
11366
11795
|
lines.push("## Connection Settings");
|
|
11367
11796
|
lines.push(
|
|
@@ -11407,6 +11836,11 @@ function formatCapabilities(caps) {
|
|
|
11407
11836
|
if (caps.features.hasCoupons) {
|
|
11408
11837
|
suggestions.push('Add a coupon input to the cart. Use get-code-example("coupon-input").');
|
|
11409
11838
|
}
|
|
11839
|
+
if (caps.features.hasCheckoutCustomFields) {
|
|
11840
|
+
suggestions.push(
|
|
11841
|
+
`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").`
|
|
11842
|
+
);
|
|
11843
|
+
}
|
|
11410
11844
|
if (caps.connection.requireEmailVerification) {
|
|
11411
11845
|
suggestions.push(
|
|
11412
11846
|
'Email verification is required \u2014 ALWAYS build the verify-email page. Use get-code-example("verify-email").'
|
|
@@ -11437,13 +11871,9 @@ function formatCapabilities(caps) {
|
|
|
11437
11871
|
}
|
|
11438
11872
|
async function handleGetStoreCapabilities(args) {
|
|
11439
11873
|
try {
|
|
11440
|
-
const
|
|
11441
|
-
/\/$/,
|
|
11442
|
-
""
|
|
11443
|
-
);
|
|
11444
|
-
const caps = await fetchStoreCapabilities(args.connectionId, baseUrl);
|
|
11874
|
+
const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
|
|
11445
11875
|
return {
|
|
11446
|
-
content: [{ type: "text", text: formatCapabilities(
|
|
11876
|
+
content: [{ type: "text", text: formatCapabilities(resolved.capabilities) }]
|
|
11447
11877
|
};
|
|
11448
11878
|
} catch (error) {
|
|
11449
11879
|
const message = error instanceof Error ? error.message : "Failed to fetch store capabilities";
|
|
@@ -11706,13 +12136,10 @@ var BUILD_STORE_SCHEMA = {
|
|
|
11706
12136
|
storeStyle: import_zod7.z.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
|
|
11707
12137
|
};
|
|
11708
12138
|
async function handleBuildStore(args) {
|
|
11709
|
-
const baseUrl = (process.env.BRAINERCE_API_URL || "https://api.brainerce.com").replace(
|
|
11710
|
-
/\/$/,
|
|
11711
|
-
""
|
|
11712
|
-
);
|
|
11713
12139
|
let capabilities = null;
|
|
11714
12140
|
try {
|
|
11715
|
-
|
|
12141
|
+
const resolved = await resolveStoreCapabilities(args.connectionId, getCandidateApiUrls());
|
|
12142
|
+
capabilities = resolved.capabilities;
|
|
11716
12143
|
} catch {
|
|
11717
12144
|
}
|
|
11718
12145
|
const bundle = buildStoreBundle({
|