Skip to content

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

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

  1. Create service-point-picker.js with Sendcloud integration
  2. Create delivery-options.liquid snippet
  3. Create service-point-display.liquid snippet
  4. Add Sendcloud API key to theme settings
  5. Test picker in checkout flow

SP.2: Shopify Data Storage

  1. Implement hidden field storage in checkout form
  2. Verify note_attributes populated on order creation
  3. Test with Shopify webhook delivery

SP.3: Email Templates

  1. Update order confirmation email template
  2. Update shipping confirmation email template
  3. Test email rendering with service point orders

SP.4: Backend Integration

  1. Add service point fields to Prisma schema
  2. Run database migration
  3. Update webhook handler to parse service point data
  4. Update Sendcloud service to include service point in parcel
  5. Implement shipping method selection for carriers
  6. Test parcel creation with service point

SP.5: Dashboard Display

  1. Update order detail page with delivery info component
  2. Add delivery type badge
  3. Style service point address display

Testing & Validation

  1. Write unit tests for webhook parsing
  2. Write unit tests for Sendcloud parcel creation
  3. Manual testing of full flow
  4. 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:

  1. Enable service points in integration settings
  2. Configure webhook for parcel status updates
  3. Note API key for service point picker
  4. 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.