AI Prompt: Forma3D.Connect — Service Points Integration¶
Purpose: This prompt instructs an AI to implement Sendcloud service point delivery options
Estimated Effort: 20-25 hours (~1 week)
Prerequisites: Sendcloud integration complete (Phase 5), Shopify theme deployed
Output: Customers can select service point or home delivery during checkout, with proper order confirmation display
Status: 📋 PLANNED (Future Enhancement)
🎯 Mission¶
You are continuing development of Forma3D.Connect, building on the existing Sendcloud shipping integration. Your task is to implement Service Point Delivery Options — allowing customers to choose between home delivery and pickup at 400,000+ service points across Europe during Shopify checkout.
This implementation delivers:
- Service point picker widget in Shopify checkout
- Customer choice between home delivery and service point pickup
- Service point address displayed in order confirmations and emails
- Forma3D.Connect reads and uses service point data when creating Sendcloud parcels
- Preserved orchestration flow (label created after printing completes)
Why not use Sendcloud's native Shopify app:
The Sendcloud Checkout App auto-creates shipping labels immediately after order, which conflicts with Forma3D.Connect's orchestration model (waiting for 3D prints to complete before shipping). This custom implementation preserves orchestration control while enabling service point selection.
📋 Context¶
What Was Built in Previous Phases¶
The complete shipping integration is already in place:
- Phase 5: Shipping Integration ✅
- Sendcloud API client for shipping labels
- Automated label generation on order completion
- Tracking sync to Shopify fulfillments
-
Webhook handler for parcel status updates
-
Shopify Theme ✅
- Custom Forma3D theme deployed to Shopify store
- Order confirmation email templates
-
Product display with IKEA compatibility badges
-
Current Sendcloud Integration ✅
- Direct API calls to create parcels
- Home delivery address from Shopify order
- Shipping method selection based on carrier rules
What This Implementation Builds¶
| Feature | Description | Effort |
|---|---|---|
| SP.1: Service Point Picker Widget | Sendcloud widget in Shopify checkout | 6 hours |
| SP.2: Shopify Metafield Storage | Store service point data in order metafields | 4 hours |
| SP.3: Email Template Customization | Display service point in order confirmations | 4 hours |
| SP.4: Backend Service Point Handling | Read and use service point data in parcel creation | 4 hours |
| SP.5: Dashboard Display | Show service point info in order details | 3 hours |
🛠️ Tech Stack Reference¶
New Dependencies¶
| Package/Resource | Purpose |
|---|---|
| Sendcloud Service Point Picker JS | Frontend widget for service point selection |
| Shopify Metafields API | Store service point data with order |
| Shopify Admin API (Liquid) | Custom email template rendering |
Sendcloud Service Point Picker¶
The Sendcloud service point picker is a JavaScript widget that displays a map with available pickup locations.
CDN URL:
<script src="https://embed.sendcloud.sc/spp/1.0.0/api.min.js"></script>
Documentation: - Widget Demo: https://sendcloud.github.io/spp-integration-example/ - API Reference: https://sendcloud.dev/docs/service-points/
🏗️ Architecture Reference¶
Service Point Selection Flow¶
┌──────────────────────────────────────────────────────────────────┐
│ SERVICE POINT INTEGRATION FLOW │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Shopify │───▶│ Sendcloud │───▶│ Customer │ │
│ │ Checkout │ │ SP Picker │ │ Selection │ │
│ │ Page │ │ Widget │ │ │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ ORDER CREATION ││
│ │ ││
│ │ shipping_address: Customer home (for billing/contact) ││
│ │ note_attributes: [ ││
│ │ { name: "service_point_id", value: "12345" } ││
│ │ { name: "service_point_name", value: "Albert Heijn..." }││
│ │ { name: "service_point_address", value: "..." } ││
│ │ { name: "delivery_type", value: "service_point" } ││
│ │ ] ││
│ │ OR ││
│ │ metafields: { sendcloud.service_point: {...} } ││
│ └─────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ FORMA3D.CONNECT ││
│ │ ││
│ │ 1. Receive webhook with order ││
│ │ 2. Parse service point data from note_attributes/metafields││
│ │ 3. Store servicePointId with order ││
│ │ 4. Wait for print jobs to complete... ││
│ │ 5. Create Sendcloud parcel with to_service_point ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
└──────────────────────────────────────────────────────────────────┘
Sendcloud Parcel with Service Point¶
When creating a parcel for service point delivery, the API request includes additional fields:
{
"parcel": {
"name": "John Doe",
"address": "Customer Home Street 123",
"city": "Eindhoven",
"postal_code": "5611 EM",
"country": "NL",
"telephone": "+31612345678",
"email": "john@example.com",
"shipment": {
"id": 8
},
"to_service_point": 12345,
"to_post_number": "1234"
}
}
Key fields:
- to_service_point: The service point ID from the picker
- to_post_number: Some carriers require a post number (entered by customer)
- shipment.id: Must be a service point shipping method ID
📁 Files to Create/Modify¶
Shopify Theme (Service Point Picker)¶
deployment/shopify-theme/
├── assets/
│ └── service-point-picker.js # NEW: Service point picker integration
│
├── snippets/
│ ├── service-point-picker.liquid # NEW: Picker widget snippet
│ ├── delivery-options.liquid # NEW: Home vs service point choice
│ └── service-point-display.liquid # NEW: Display selected service point
│
├── sections/
│ └── checkout-delivery-options.liquid # NEW: Checkout delivery section
│
└── templates/
└── checkout.liquid # UPDATE: Include delivery options
Shopify Email Templates¶
deployment/shopify-theme/
├── templates/customers/
│ └── order.liquid # UPDATE: Show service point in order status
│
└── notifications/
└── (configured in Shopify Admin) # Email templates modified via admin
Backend (Forma3D.Connect API)¶
apps/api/src/
├── orders/
│ ├── dto/
│ │ └── create-order.dto.ts # UPDATE: Add servicePoint fields
│ │
│ └── orders.service.ts # UPDATE: Parse service point data
│
├── sendcloud/
│ ├── dto/
│ │ └── create-parcel.dto.ts # UPDATE: Add to_service_point fields
│ │
│ ├── sendcloud.client.ts # UPDATE: Include service point in parcel
│ └── sendcloud.service.ts # UPDATE: Handle service point shipping
│
└── shopify/
└── shopify-webhook.handler.ts # UPDATE: Parse note_attributes
prisma/
└── schema.prisma # UPDATE: Add servicePoint fields to Order
Frontend Dashboard¶
apps/web/src/
├── pages/orders/
│ └── order-detail.tsx # UPDATE: Display service point info
│
└── components/orders/
└── shipping-info.tsx # UPDATE: Show service point details
🔧 Feature SP.1: Service Point Picker Widget¶
Requirements¶
- Display Sendcloud service point picker in Shopify checkout
- Customer can choose between "Home Delivery" and "Pickup Point"
- When pickup point selected, show map with available locations
- Store selection in order note_attributes or metafields
Implementation¶
1. Sendcloud Service Point Picker JavaScript¶
Create deployment/shopify-theme/assets/service-point-picker.js:
/**
* Sendcloud Service Point Picker Integration
*
* This script integrates Sendcloud's service point picker widget
* into the Shopify checkout flow.
*/
(function() {
'use strict';
// Configuration - these should be set via Liquid or theme settings
const CONFIG = {
apiKey: window.SENDCLOUD_API_KEY || '',
country: window.SHOP_COUNTRY || 'NL',
language: window.SHOP_LANGUAGE || 'en',
carriers: ['postnl', 'dhl', 'dpd', 'bpost'],
};
// State
let selectedServicePoint = null;
let deliveryType = 'home'; // 'home' or 'service_point'
/**
* Initialize the service point picker
*/
function initServicePointPicker() {
const container = document.getElementById('sendcloud-service-point-picker');
if (!container) {
console.warn('Service point picker container not found');
return;
}
// Get customer's postal code from Shopify checkout
const postalCodeInput = document.querySelector('[name="checkout[shipping_address][zip]"]');
const countrySelect = document.querySelector('[name="checkout[shipping_address][country]"]');
const postalCode = postalCodeInput?.value || '';
const country = countrySelect?.value || CONFIG.country;
// Load Sendcloud script if not already loaded
if (typeof sendcloud === 'undefined') {
loadSendcloudScript().then(() => openPicker(country, postalCode));
} else {
openPicker(country, postalCode);
}
}
/**
* Load Sendcloud service point picker script
*/
function loadSendcloudScript() {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://embed.sendcloud.sc/spp/1.0.0/api.min.js';
script.async = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
/**
* Open the service point picker modal
*/
function openPicker(country, postalCode) {
if (typeof sendcloud === 'undefined' || !sendcloud.servicePoints) {
console.error('Sendcloud service points not loaded');
return;
}
sendcloud.servicePoints.open(
// API Key (from Sendcloud integration settings)
CONFIG.apiKey,
// Country code
country,
// Language
CONFIG.language,
// Postal code (pre-fill from shipping address)
postalCode,
// Carriers (optional - filter by specific carriers)
CONFIG.carriers.join(','),
// Success callback
function(servicePoint, postNumber) {
handleServicePointSelected(servicePoint, postNumber);
},
// Failure callback
function(errors) {
console.error('Service point picker error:', errors);
handleServicePointError(errors);
}
);
}
/**
* Handle service point selection
*/
function handleServicePointSelected(servicePoint, postNumber) {
selectedServicePoint = servicePoint;
deliveryType = 'service_point';
// Update the display
const displayContainer = document.getElementById('selected-service-point');
if (displayContainer) {
displayContainer.innerHTML = `
<div class="service-point-selected">
<strong>${servicePoint.name}</strong><br>
${servicePoint.street} ${servicePoint.house_number}<br>
${servicePoint.postal_code} ${servicePoint.city}<br>
<button type="button" class="change-service-point-btn" onclick="Forma3D.ServicePoint.openPicker()">
Change pickup point
</button>
</div>
`;
displayContainer.style.display = 'block';
}
// Store in hidden fields for order submission
storeServicePointData(servicePoint, postNumber);
// Trigger Shopify checkout update
updateCheckoutWithServicePoint(servicePoint);
}
/**
* Store service point data in hidden form fields
* These will be submitted with the order as note_attributes
*/
function storeServicePointData(servicePoint, postNumber) {
const form = document.querySelector('form[data-customer-information-form]')
|| document.querySelector('form.edit_checkout');
if (!form) {
console.warn('Checkout form not found');
return;
}
// Remove existing hidden fields
form.querySelectorAll('input[name^="checkout[attributes]"][data-service-point]')
.forEach(el => el.remove());
// Add hidden fields for service point data
const fields = [
{ name: 'delivery_type', value: 'service_point' },
{ name: 'service_point_id', value: servicePoint.id.toString() },
{ name: 'service_point_name', value: servicePoint.name },
{ name: 'service_point_street', value: `${servicePoint.street} ${servicePoint.house_number}` },
{ name: 'service_point_city', value: servicePoint.city },
{ name: 'service_point_postal_code', value: servicePoint.postal_code },
{ name: 'service_point_country', value: servicePoint.country },
{ name: 'service_point_carrier', value: servicePoint.carrier },
{ name: 'service_point_post_number', value: postNumber || '' },
];
fields.forEach(field => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = `checkout[attributes][${field.name}]`;
input.value = field.value;
input.dataset.servicePoint = 'true';
form.appendChild(input);
});
}
/**
* Handle service point picker errors
*/
function handleServicePointError(errors) {
const displayContainer = document.getElementById('selected-service-point');
if (displayContainer) {
displayContainer.innerHTML = `
<div class="service-point-error">
<p>Unable to load pickup points. Please try again or select home delivery.</p>
<button type="button" onclick="Forma3D.ServicePoint.openPicker()">
Try Again
</button>
</div>
`;
}
}
/**
* Switch to home delivery
*/
function selectHomeDelivery() {
selectedServicePoint = null;
deliveryType = 'home';
// Clear display
const displayContainer = document.getElementById('selected-service-point');
if (displayContainer) {
displayContainer.style.display = 'none';
displayContainer.innerHTML = '';
}
// Remove hidden fields
const form = document.querySelector('form[data-customer-information-form]')
|| document.querySelector('form.edit_checkout');
if (form) {
form.querySelectorAll('input[name^="checkout[attributes]"][data-service-point]')
.forEach(el => el.remove());
// Add home delivery type
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'checkout[attributes][delivery_type]';
input.value = 'home';
input.dataset.servicePoint = 'true';
form.appendChild(input);
}
// Update radio button
const homeRadio = document.getElementById('delivery-type-home');
if (homeRadio) {
homeRadio.checked = true;
}
}
/**
* Update Shopify checkout with service point info
* Uses Shopify's checkout extension points if available
*/
function updateCheckoutWithServicePoint(servicePoint) {
// This may need to trigger a Shopify checkout update
// depending on your checkout implementation
// Dispatch custom event for other scripts
window.dispatchEvent(new CustomEvent('servicePointSelected', {
detail: { servicePoint, deliveryType: 'service_point' }
}));
}
// Expose public API
window.Forma3D = window.Forma3D || {};
window.Forma3D.ServicePoint = {
init: initServicePointPicker,
openPicker: function() {
const postalCodeInput = document.querySelector('[name="checkout[shipping_address][zip]"]');
const countrySelect = document.querySelector('[name="checkout[shipping_address][country]"]');
openPicker(countrySelect?.value || CONFIG.country, postalCodeInput?.value || '');
},
selectHome: selectHomeDelivery,
getSelected: function() {
return { servicePoint: selectedServicePoint, deliveryType };
},
setConfig: function(config) {
Object.assign(CONFIG, config);
}
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initServicePointPicker);
} else {
initServicePointPicker();
}
})();
2. Delivery Options Liquid Snippet¶
Create deployment/shopify-theme/snippets/delivery-options.liquid:
{% comment %}
Delivery Options - Home Delivery vs Service Point
This snippet renders the delivery type selector in checkout,
allowing customers to choose between home delivery and pickup point.
Usage:
{% render 'delivery-options' %}
{% endcomment %}
<div class="delivery-options" id="delivery-options">
<h3 class="delivery-options__title">{{ 'checkout.shipping.delivery_method' | t | default: 'Delivery Method' }}</h3>
<div class="delivery-options__choices">
<!-- Home Delivery Option -->
<label class="delivery-option delivery-option--home" for="delivery-type-home">
<input
type="radio"
id="delivery-type-home"
name="delivery_type"
value="home"
checked
onchange="Forma3D.ServicePoint.selectHome()"
>
<span class="delivery-option__content">
<span class="delivery-option__icon">🏠</span>
<span class="delivery-option__details">
<span class="delivery-option__name">{{ 'checkout.shipping.home_delivery' | t | default: 'Home Delivery' }}</span>
<span class="delivery-option__description">{{ 'checkout.shipping.home_delivery_desc' | t | default: 'Deliver to your address' }}</span>
</span>
</span>
</label>
<!-- Service Point Option -->
<label class="delivery-option delivery-option--pickup" for="delivery-type-service-point">
<input
type="radio"
id="delivery-type-service-point"
name="delivery_type"
value="service_point"
onchange="Forma3D.ServicePoint.openPicker()"
>
<span class="delivery-option__content">
<span class="delivery-option__icon">📍</span>
<span class="delivery-option__details">
<span class="delivery-option__name">{{ 'checkout.shipping.pickup_point' | t | default: 'Pickup Point' }}</span>
<span class="delivery-option__description">{{ 'checkout.shipping.pickup_point_desc' | t | default: 'Collect from a nearby location' }}</span>
</span>
</span>
</label>
</div>
<!-- Selected Service Point Display -->
<div id="selected-service-point" class="selected-service-point" style="display: none;">
<!-- Populated by JavaScript when service point is selected -->
</div>
</div>
<style>
.delivery-options {
margin: 1.5rem 0;
padding: 1rem;
border: 1px solid #e5e5e5;
border-radius: 8px;
background: #fafafa;
}
.delivery-options__title {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
}
.delivery-options__choices {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.delivery-option {
display: flex;
align-items: center;
padding: 1rem;
border: 2px solid #e5e5e5;
border-radius: 8px;
background: white;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
.delivery-option:hover {
border-color: #69A88E;
}
.delivery-option input[type="radio"] {
margin-right: 1rem;
width: 20px;
height: 20px;
accent-color: #69A88E;
}
.delivery-option input[type="radio"]:checked + .delivery-option__content {
/* Highlight selected option */
}
.delivery-option:has(input:checked) {
border-color: #69A88E;
background: #f0f7f4;
}
.delivery-option__content {
display: flex;
align-items: center;
gap: 1rem;
}
.delivery-option__icon {
font-size: 1.5rem;
}
.delivery-option__details {
display: flex;
flex-direction: column;
}
.delivery-option__name {
font-weight: 600;
color: #212121;
}
.delivery-option__description {
font-size: 0.875rem;
color: #666;
}
.selected-service-point {
margin-top: 1rem;
padding: 1rem;
background: white;
border: 1px solid #69A88E;
border-radius: 8px;
}
.service-point-selected {
line-height: 1.5;
}
.service-point-selected strong {
color: #69A88E;
}
.change-service-point-btn {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background: #69A88E;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.change-service-point-btn:hover {
background: #5a9a7f;
}
</style>
<script>
// Set configuration from Shopify settings
document.addEventListener('DOMContentLoaded', function() {
if (window.Forma3D && window.Forma3D.ServicePoint) {
Forma3D.ServicePoint.setConfig({
apiKey: '{{ settings.sendcloud_api_key | default: "" }}',
country: '{{ shop.address.country_code | default: "NL" }}',
language: '{{ shop.locale | slice: 0, 2 | default: "en" }}',
carriers: {{ settings.sendcloud_carriers | default: '["postnl", "dhl", "dpd"]' }}
});
}
});
</script>
3. Service Point Display Snippet¶
Create deployment/shopify-theme/snippets/service-point-display.liquid:
{% comment %}
Service Point Display
Displays the selected service point address for order confirmation
and emails.
Usage:
{% render 'service-point-display', order: order %}
Expects order.note_attributes to contain:
- delivery_type: 'service_point'
- service_point_name: 'Albert Heijn Eindhoven'
- service_point_street: 'Stationsplein 1'
- service_point_city: 'Eindhoven'
- service_point_postal_code: '5611 AB'
- service_point_country: 'NL'
{% endcomment %}
{% assign delivery_type = '' %}
{% assign sp_name = '' %}
{% assign sp_street = '' %}
{% assign sp_city = '' %}
{% assign sp_postal_code = '' %}
{% assign sp_country = '' %}
{% for attribute in order.note_attributes %}
{% case attribute.name %}
{% when 'delivery_type' %}
{% assign delivery_type = attribute.value %}
{% when 'service_point_name' %}
{% assign sp_name = attribute.value %}
{% when 'service_point_street' %}
{% assign sp_street = attribute.value %}
{% when 'service_point_city' %}
{% assign sp_city = attribute.value %}
{% when 'service_point_postal_code' %}
{% assign sp_postal_code = attribute.value %}
{% when 'service_point_country' %}
{% assign sp_country = attribute.value %}
{% endcase %}
{% endfor %}
{% if delivery_type == 'service_point' and sp_name != '' %}
<div class="service-point-address">
<h4>{{ 'orders.order.pickup_location' | t | default: 'Pickup Location' }}</h4>
<address>
<strong>{{ sp_name }}</strong><br>
{{ sp_street }}<br>
{{ sp_postal_code }} {{ sp_city }}<br>
{{ sp_country | upcase }}
</address>
</div>
<style>
.service-point-address {
margin: 1rem 0;
padding: 1rem;
background: #f5f5f5;
border-left: 4px solid #69A88E;
}
.service-point-address h4 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #666;
}
.service-point-address address {
font-style: normal;
line-height: 1.5;
}
.service-point-address strong {
color: #69A88E;
}
</style>
{% endif %}
🔧 Feature SP.2: Shopify Metafield Storage¶
Requirements¶
- Service point data stored with order for persistence
- Data accessible to Forma3D.Connect via webhook
- Data accessible in Shopify admin and emails
Implementation¶
Alternative: Using Metafields (Recommended)¶
While note_attributes works, metafields provide better structure and are recommended for production.
Metafield namespace: sendcloud
Metafield key: service_point
Update the JavaScript to store in metafields (requires Shopify Storefront API):
// In service-point-picker.js, add metafield storage
async function storeServicePointAsMetafield(servicePoint, orderId) {
const metafieldValue = JSON.stringify({
id: servicePoint.id,
name: servicePoint.name,
street: `${servicePoint.street} ${servicePoint.house_number}`,
city: servicePoint.city,
postalCode: servicePoint.postal_code,
country: servicePoint.country,
carrier: servicePoint.carrier,
postNumber: postNumber || null,
});
// This requires Shopify Admin API access
// Usually done via a custom app or backend
const response = await fetch('/apps/forma3d/store-service-point', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
orderId,
servicePoint: metafieldValue,
}),
});
return response.ok;
}
🔧 Feature SP.3: Email Template Customization¶
Requirements¶
- Order confirmation email shows service point address
- Service point replaces or supplements shipping address display
- Works with Shopify's standard email system
Implementation¶
1. Customize Order Confirmation Email¶
In Shopify Admin, go to Settings → Notifications → Order confirmation and update the email template:
Add after the shipping address section:
{% comment %} Service Point Display {% endcomment %}
{% assign delivery_type = '' %}
{% assign sp_name = '' %}
{% assign sp_street = '' %}
{% assign sp_city = '' %}
{% assign sp_postal_code = '' %}
{% for attribute in attributes %}
{% case attribute.name %}
{% when 'delivery_type' %}
{% assign delivery_type = attribute.value %}
{% when 'service_point_name' %}
{% assign sp_name = attribute.value %}
{% when 'service_point_street' %}
{% assign sp_street = attribute.value %}
{% when 'service_point_city' %}
{% assign sp_city = attribute.value %}
{% when 'service_point_postal_code' %}
{% assign sp_postal_code = attribute.value %}
{% endcase %}
{% endfor %}
{% if delivery_type == 'service_point' and sp_name != blank %}
<table class="row">
<tr>
<td class="pickup-location__wrapper">
<h4 style="font-weight: 600; margin: 0 0 10px; color: #69A88E;">
📍 Pickup Location
</h4>
<p style="margin: 0; line-height: 1.5;">
<strong>{{ sp_name }}</strong><br>
{{ sp_street }}<br>
{{ sp_postal_code }} {{ sp_city }}
</p>
<p style="margin: 10px 0 0; font-size: 14px; color: #666;">
Your order will be available for pickup at this location once shipped.
</p>
</td>
</tr>
</table>
{% endif %}
2. Modify Shipping Address Display¶
To replace the shipping address with service point when applicable:
{% if delivery_type == 'service_point' and sp_name != blank %}
<h4>Pickup at:</h4>
<p>
<strong>{{ sp_name }}</strong><br>
{{ sp_street }}<br>
{{ sp_postal_code }} {{ sp_city }}
</p>
{% else %}
<h4>Ship to:</h4>
{{ shipping_address | format_address }}
{% endif %}
🔧 Feature SP.4: Backend Service Point Handling¶
Requirements¶
- Parse service point data from Shopify webhook
- Store service point ID with order
- Include service point in Sendcloud parcel creation
Implementation¶
1. Update Prisma Schema¶
Update prisma/schema.prisma:
model Order {
id String @id @default(uuid())
shopifyOrderId String @unique
shopifyOrderNumber String
customerName String
customerEmail String
shippingAddress Json
// Service Point Fields
deliveryType String? @default("home") // "home" or "service_point"
servicePointId String?
servicePointName String?
servicePointAddress Json? // { street, city, postalCode, country, carrier }
servicePointPostNumber String?
// ... rest of fields
status OrderStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
lineItems LineItem[]
printJobs PrintJob[]
shipments Shipment[]
@@map("orders")
}
Run migration:
pnpm prisma migrate dev --name add_service_point_fields
2. Update Shopify Webhook Handler¶
Update apps/api/src/shopify/shopify-webhook.handler.ts:
interface ShopifyNoteAttribute {
name: string;
value: string;
}
interface ServicePointData {
deliveryType: 'home' | 'service_point';
servicePointId?: string;
servicePointName?: string;
servicePointStreet?: string;
servicePointCity?: string;
servicePointPostalCode?: string;
servicePointCountry?: string;
servicePointCarrier?: string;
servicePointPostNumber?: string;
}
/**
* Parse service point data from Shopify order note_attributes
*/
function parseServicePointData(noteAttributes: ShopifyNoteAttribute[]): ServicePointData {
const data: ServicePointData = { deliveryType: 'home' };
for (const attr of noteAttributes || []) {
switch (attr.name) {
case 'delivery_type':
data.deliveryType = attr.value === 'service_point' ? 'service_point' : 'home';
break;
case 'service_point_id':
data.servicePointId = attr.value;
break;
case 'service_point_name':
data.servicePointName = attr.value;
break;
case 'service_point_street':
data.servicePointStreet = attr.value;
break;
case 'service_point_city':
data.servicePointCity = attr.value;
break;
case 'service_point_postal_code':
data.servicePointPostalCode = attr.value;
break;
case 'service_point_country':
data.servicePointCountry = attr.value;
break;
case 'service_point_carrier':
data.servicePointCarrier = attr.value;
break;
case 'service_point_post_number':
data.servicePointPostNumber = attr.value;
break;
}
}
return data;
}
// In the order creation handler:
async handleOrderCreated(shopifyOrder: ShopifyOrder): Promise<Order> {
const servicePointData = parseServicePointData(shopifyOrder.note_attributes);
const order = await this.ordersRepository.create({
shopifyOrderId: shopifyOrder.id.toString(),
shopifyOrderNumber: shopifyOrder.order_number.toString(),
customerName: `${shopifyOrder.shipping_address?.first_name} ${shopifyOrder.shipping_address?.last_name}`,
customerEmail: shopifyOrder.email,
shippingAddress: shopifyOrder.shipping_address,
// Service point data
deliveryType: servicePointData.deliveryType,
servicePointId: servicePointData.servicePointId,
servicePointName: servicePointData.servicePointName,
servicePointAddress: servicePointData.deliveryType === 'service_point' ? {
street: servicePointData.servicePointStreet,
city: servicePointData.servicePointCity,
postalCode: servicePointData.servicePointPostalCode,
country: servicePointData.servicePointCountry,
carrier: servicePointData.servicePointCarrier,
} : null,
servicePointPostNumber: servicePointData.servicePointPostNumber,
// ... rest of order data
});
return order;
}
3. Update Sendcloud Service¶
Update apps/api/src/sendcloud/sendcloud.service.ts:
interface CreateParcelOptions {
order: Order;
weight?: number;
shippingMethodId?: number;
}
async createParcel(options: CreateParcelOptions): Promise<SendcloudParcel> {
const { order, weight = 1000, shippingMethodId } = options;
const shippingAddress = order.shippingAddress as ShippingAddress;
// Determine shipping method ID
// For service points, must use a service point shipping method
let shipmentId = shippingMethodId || this.config.defaultShippingMethodId;
if (order.deliveryType === 'service_point' && order.servicePointId) {
// Get appropriate service point shipping method
shipmentId = await this.getServicePointShippingMethod(
order.servicePointAddress?.carrier,
shippingAddress.country_code,
);
}
const parcelData: SendcloudCreateParcelRequest = {
parcel: {
name: order.customerName,
company_name: shippingAddress.company || '',
address: shippingAddress.address1,
address_2: shippingAddress.address2 || '',
city: shippingAddress.city,
postal_code: shippingAddress.zip,
country: shippingAddress.country_code,
telephone: shippingAddress.phone || '',
email: order.customerEmail,
weight: weight,
order_number: order.shopifyOrderNumber,
external_reference: order.id,
shipment: { id: shipmentId },
request_label: true,
// Service point fields (only if service point delivery)
...(order.deliveryType === 'service_point' && order.servicePointId ? {
to_service_point: parseInt(order.servicePointId, 10),
to_post_number: order.servicePointPostNumber || undefined,
} : {}),
},
};
this.logger.log(
`Creating Sendcloud parcel for order ${order.shopifyOrderNumber}` +
(order.deliveryType === 'service_point'
? ` (service point: ${order.servicePointName})`
: ' (home delivery)')
);
return this.sendcloudClient.createParcel(parcelData);
}
/**
* Get appropriate shipping method ID for service point delivery
*/
private async getServicePointShippingMethod(
carrier: string | undefined,
countryCode: string,
): Promise<number> {
// Map carrier to service point shipping method
// These IDs are specific to your Sendcloud account
const servicePointMethods: Record<string, Record<string, number>> = {
postnl: {
NL: 8, // PostNL Service Point 0-23kg
BE: 3001, // PostNL Belgium Service Point
},
dhl: {
NL: 2001, // DHL Parcel Service Point
DE: 2002, // DHL Germany Service Point
},
dpd: {
NL: 4001, // DPD Pickup
BE: 4002, // DPD Belgium Pickup
},
bpost: {
BE: 3002, // bpost Pickup Point
},
};
const carrierKey = carrier?.toLowerCase() || 'postnl';
const methodId = servicePointMethods[carrierKey]?.[countryCode];
if (!methodId) {
this.logger.warn(
`No service point method found for carrier ${carrier} in ${countryCode}, using default`
);
return this.config.defaultServicePointMethodId || this.config.defaultShippingMethodId;
}
return methodId;
}
🔧 Feature SP.5: Dashboard Display¶
Requirements¶
- Order detail page shows service point information
- Clear indication of delivery type (home vs service point)
- Service point address displayed prominently
Implementation¶
Update apps/web/src/pages/orders/order-detail.tsx:
import { Badge } from '../../components/ui/badge';
import { MapPinIcon, HomeIcon } from 'lucide-react';
interface ServicePointAddress {
street: string;
city: string;
postalCode: string;
country: string;
carrier?: string;
}
interface OrderDetailProps {
order: {
id: string;
shopifyOrderNumber: string;
customerName: string;
shippingAddress: ShippingAddress;
deliveryType: 'home' | 'service_point';
servicePointId?: string;
servicePointName?: string;
servicePointAddress?: ServicePointAddress;
// ... other fields
};
}
function DeliveryInfo({ order }: OrderDetailProps) {
const isServicePoint = order.deliveryType === 'service_point';
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Delivery Information
</h3>
<Badge variant={isServicePoint ? 'secondary' : 'default'}>
{isServicePoint ? (
<>
<MapPinIcon className="w-3 h-3 mr-1" />
Pickup Point
</>
) : (
<>
<HomeIcon className="w-3 h-3 mr-1" />
Home Delivery
</>
)}
</Badge>
</div>
{isServicePoint && order.servicePointName ? (
<div className="space-y-4">
{/* Service Point Address */}
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<MapPinIcon className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">
{order.servicePointName}
</p>
{order.servicePointAddress && (
<address className="text-sm text-green-700 dark:text-green-300 not-italic mt-1">
{order.servicePointAddress.street}<br />
{order.servicePointAddress.postalCode} {order.servicePointAddress.city}<br />
{order.servicePointAddress.country}
</address>
)}
{order.servicePointAddress?.carrier && (
<p className="text-xs text-green-600 dark:text-green-400 mt-2">
Carrier: {order.servicePointAddress.carrier.toUpperCase()}
</p>
)}
</div>
</div>
</div>
{/* Customer Billing Address */}
<div>
<h4 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Customer Address (for billing/contact)
</h4>
<address className="text-sm text-gray-700 dark:text-gray-300 not-italic">
{order.customerName}<br />
{order.shippingAddress.address1}<br />
{order.shippingAddress.zip} {order.shippingAddress.city}<br />
{order.shippingAddress.country}
</address>
</div>
</div>
) : (
/* Home Delivery Address */
<div>
<h4 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Shipping Address
</h4>
<address className="text-sm text-gray-700 dark:text-gray-300 not-italic">
{order.customerName}<br />
{order.shippingAddress.address1}<br />
{order.shippingAddress.address2 && <>{order.shippingAddress.address2}<br /></>}
{order.shippingAddress.zip} {order.shippingAddress.city}<br />
{order.shippingAddress.country}
</address>
</div>
)}
</div>
);
}
🧪 Testing Requirements¶
Unit Test Scenarios¶
| Category | Scenario | Priority |
|---|---|---|
| Webhook Parsing | Parse service point from note_attributes | High |
| Webhook Parsing | Handle missing service point data gracefully | High |
| Webhook Parsing | Handle malformed service point data | Medium |
| Sendcloud Client | Create parcel with service point ID | High |
| Sendcloud Client | Create parcel without service point (home) | High |
| Sendcloud Client | Get correct shipping method for carrier | Medium |
| Order Display | Show service point in order detail | Medium |
| Order Display | Show home delivery in order detail | Medium |
Integration Test Scenarios¶
| Scenario | Priority |
|---|---|
| Order with service point creates parcel with to_service_point | High |
| Order with home delivery creates parcel without service point | High |
| Service point data persisted through full order lifecycle | High |
| Email notification includes service point address | Medium |
Manual Testing Checklist¶
- Service point picker opens in Shopify checkout
- Can select a service point on the map
- Selected service point displays correctly
- Can switch between home and service point delivery
- Order confirmation shows service point address
- Order confirmation email shows service point address
- Forma3D.Connect dashboard shows service point info
- Sendcloud parcel created with correct service point ID
✅ Validation Checklist¶
Shopify Theme¶
- Service point picker script loads without errors
- Delivery options display correctly on checkout
- Service point selection works on desktop
- Service point selection works on mobile
- Selected service point data stored in order
- Order confirmation page shows service point
- Order confirmation email shows service point
Backend¶
- Prisma migration runs successfully
- Webhook handler parses service point data
- Service point data stored in database
- Sendcloud parcel includes to_service_point
- Correct shipping method selected for carrier
- Home delivery still works correctly
Dashboard¶
- Order detail shows delivery type badge
- Service point address displayed correctly
- Customer address still visible for billing
End-to-End¶
- Complete order flow with service point
- Complete order flow with home delivery
- Print completion triggers correct parcel creation
- Tracking number returned to Shopify
🚫 Constraints and Rules¶
MUST DO¶
- Use Sendcloud's official service point picker widget
- Store service point data in note_attributes (cross-compatible)
- Keep home delivery as the default option
- Validate service point ID before sending to Sendcloud
- Log delivery type in order event logs
MUST NOT¶
- Break existing home delivery functionality
- Auto-select service points without customer choice
- Send parcel requests without valid shipping method
- Expose Sendcloud API credentials in frontend code
- Skip validation of service point data
🎬 Execution Order¶
SP.1: Service Point Picker Widget¶
- Create
service-point-picker.jswith Sendcloud integration - Create
delivery-options.liquidsnippet - Create
service-point-display.liquidsnippet - Add Sendcloud API key to theme settings
- Test picker in checkout flow
SP.2: Shopify Data Storage¶
- Implement hidden field storage in checkout form
- Verify note_attributes populated on order creation
- Test with Shopify webhook delivery
SP.3: Email Templates¶
- Update order confirmation email template
- Update shipping confirmation email template
- Test email rendering with service point orders
SP.4: Backend Integration¶
- Add service point fields to Prisma schema
- Run database migration
- Update webhook handler to parse service point data
- Update Sendcloud service to include service point in parcel
- Implement shipping method selection for carriers
- Test parcel creation with service point
SP.5: Dashboard Display¶
- Update order detail page with delivery info component
- Add delivery type badge
- Style service point address display
Testing & Validation¶
- Write unit tests for webhook parsing
- Write unit tests for Sendcloud parcel creation
- Manual testing of full flow
- Update documentation
📊 Expected Output¶
When service points integration is complete:
Verification Commands¶
# Run all tests
pnpm test
# Check for TypeScript errors
pnpm typecheck
# Build and verify
pnpm nx build api
pnpm nx build web
Success Metrics¶
| Metric | Target | Verification |
|---|---|---|
| Service point picker loads | < 2 seconds | Manual testing |
| Order data includes service point | 100% when selected | Database inspection |
| Parcel created correctly | to_service_point set | Sendcloud dashboard |
| Email shows service point | Correct rendering | Test order email |
📝 Sendcloud Configuration Required¶
Before implementing, ensure these are configured in Sendcloud:
- Enable service points in integration settings
- Configure webhook for parcel status updates
- Note API key for service point picker
- Identify shipping method IDs for each carrier's service point option
Finding Shipping Method IDs¶
Use the Sendcloud API to list shipping methods:
curl -u "PUBLIC_KEY:SECRET_KEY" \
"https://panel.sendcloud.sc/api/v2/shipping_methods?to_country=NL"
Look for methods with "service point" or "pickup" in the name.
END OF PROMPT
This prompt implements Sendcloud service point delivery options for Forma3D.Connect. The AI should implement all features to allow customers to choose between home delivery and pickup point collection during Shopify checkout, with proper data flow through to Sendcloud parcel creation.