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