Building Apps
This guide walks you through building a complete Karrio app, from setup to deployment. Weβll build a shipping task manager app that demonstrates all key concepts.
App Structure
A Karrio app follows a modular structure:
my-shipping-app/
βββ manifest.ts # App configuration
βββ component.tsx # Main React component
βββ configuration.tsx # Settings interface (optional)
βββ api/ # Server-side API routes
β βββ tasks/
β β βββ route.ts # CRUD operations
β β βββ sync.ts # Background sync
β βββ webhooks/
β βββ route.ts # Webhook handlers
βββ assets/ # Static assets
β βββ icon.svg
β βββ screenshot1.png
β βββ README.md
βββ types.ts # TypeScript definitions
βββ utils.ts # Helper functions
Creating the App Manifest
The manifest defines your appβs metadata, permissions, and configuration:
manifest.ts1import { AppManifest } from "@karrio/app-store/types"; 2 3export const manifest: AppManifest = { 4 id: "shipping-tasks", 5 name: "Shipping Task Manager", 6 version: "1.0.0", 7 description: "Manage shipping tasks and track progress", 8 author: { 9 name: "Your Company", 10 email: "support@yourcompany.com", 11 website: "https://yourcompany.com", 12 }, 13 permissions: ["manage_shipments", "manage_orders"], 14 assets: { 15 icon: "./assets/icon.svg", 16 screenshots: ["./assets/screenshot1.png", "./assets/screenshot2.png"], 17 readme: "./README.md", 18 }, 19 components: { 20 main: "./component.tsx", 21 configuration: "./configuration.tsx", 22 }, 23 api: { 24 routes: { 25 tasks: "./api/tasks/route.ts", 26 "tasks/sync": "./api/tasks/sync.ts", 27 webhooks: "./api/webhooks/route.ts", 28 }, 29 }, 30 settings: { 31 required_metafields: [ 32 { 33 key: "external_api_key", 34 type: "password", 35 label: "External API Key", 36 description: "API key for external task system", 37 }, 38 { 39 key: "sync_interval", 40 type: "number", 41 label: "Sync Interval (minutes)", 42 default: "30", 43 description: "How often to sync tasks", 44 }, 45 ], 46 }, 47};
Building the Main Component
Create your appβs main React component:
component.tsx1import React, { useState, useEffect } from "react"; 2import { AppComponentProps } from "@karrio/app-store/types"; 3import { Button, Card, Badge, Input, Select } from "@karrio/ui"; 4import { Plus, Clock, CheckCircle } from "lucide-react"; 5 6interface Task { 7 id: string; 8 title: string; 9 description?: string; 10 priority: "low" | "medium" | "high" | "urgent"; 11 status: "pending" | "in_progress" | "completed"; 12 shipment_id?: string; 13 due_date?: string; 14 created_at: string; 15 updated_at: string; 16} 17 18export default function ShippingTasksApp({ 19 app, 20 context, 21 karrio, 22}: AppComponentProps) { 23 const [tasks, setTasks] = useState<Task[]>([]); 24 const [loading, setLoading] = useState(true); 25 const [showAddTask, setShowAddTask] = useState(false); 26 const [newTask, setNewTask] = useState({ 27 title: "", 28 description: "", 29 priority: "medium" as Task["priority"], 30 shipment_id: "", 31 }); 32 33 // Load tasks on component mount 34 useEffect(() => { 35 loadTasks(); 36 }, []); 37 38 const loadTasks = async () => { 39 try { 40 setLoading(true); 41 const response = await app.api.get("/tasks"); 42 setTasks(response.tasks || []); 43 } catch (error) { 44 console.error("Failed to load tasks:", error); 45 } finally { 46 setLoading(false); 47 } 48 }; 49 50 const addTask = async () => { 51 if (!newTask.title.trim()) return; 52 53 try { 54 const response = await app.api.post("/tasks", { 55 ...newTask, 56 status: "pending", 57 created_at: new Date().toISOString(), 58 }); 59 60 setTasks([response.task, ...tasks]); 61 setNewTask({ 62 title: "", 63 description: "", 64 priority: "medium", 65 shipment_id: "", 66 }); 67 setShowAddTask(false); 68 } catch (error) { 69 console.error("Failed to add task:", error); 70 } 71 }; 72 73 const updateTaskStatus = async (taskId: string, status: Task["status"]) => { 74 try { 75 await app.api.patch(`/tasks/${taskId}`, { status }); 76 setTasks( 77 tasks.map((task) => 78 task.id === taskId 79 ? { ...task, status, updated_at: new Date().toISOString() } 80 : task, 81 ), 82 ); 83 } catch (error) { 84 console.error("Failed to update task:", error); 85 } 86 }; 87 88 const deleteTask = async (taskId: string) => { 89 try { 90 await app.api.delete(`/tasks/${taskId}`); 91 setTasks(tasks.filter((task) => task.id !== taskId)); 92 } catch (error) { 93 console.error("Failed to delete task:", error); 94 } 95 }; 96 97 const getPriorityColor = (priority: Task["priority"]) => { 98 const colors = { 99 low: "bg-gray-100 text-gray-800", 100 medium: "bg-blue-100 text-blue-800", 101 high: "bg-orange-100 text-orange-800", 102 urgent: "bg-red-100 text-red-800", 103 }; 104 return colors[priority]; 105 }; 106 107 const getStatusIcon = (status: Task["status"]) => { 108 switch (status) { 109 case "completed": 110 return <CheckCircle className="h-4 w-4 text-green-500" />; 111 case "in_progress": 112 return <Clock className="h-4 w-4 text-blue-500" />; 113 default: 114 return <Clock className="h-4 w-4 text-gray-400" />; 115 } 116 }; 117 118 if (loading) { 119 return ( 120 <div className="flex items-center justify-center h-64"> 121 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div> 122 </div> 123 ); 124 } 125 126 return ( 127 <div className="p-6 max-w-6xl mx-auto"> 128 {/* Header */} 129 <div className="flex justify-between items-center mb-6"> 130 <div> 131 <h1 className="text-2xl font-bold text-gray-900">Shipping Tasks</h1> 132 <p className="text-gray-600"> 133 Manage tasks for {context.organization.name} 134 </p> 135 </div> 136 <Button 137 onClick={() => setShowAddTask(true)} 138 className="flex items-center gap-2" 139 > 140 <Plus className="h-4 w-4" /> 141 Add Task 142 </Button> 143 </div> 144 145 {/* Add Task Form */} 146 {showAddTask && ( 147 <Card className="p-4 mb-6"> 148 <h3 className="text-lg font-semibold mb-4">Add New Task</h3> 149 <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 150 <div className="md:col-span-2"> 151 <Input 152 placeholder="Task title" 153 value={newTask.title} 154 onChange={(e) => 155 setNewTask({ ...newTask, title: e.target.value }) 156 } 157 /> 158 </div> 159 <div className="md:col-span-2"> 160 <Input 161 placeholder="Description (optional)" 162 value={newTask.description} 163 onChange={(e) => 164 setNewTask({ ...newTask, description: e.target.value }) 165 } 166 /> 167 </div> 168 <Select 169 value={newTask.priority} 170 onValueChange={(value) => 171 setNewTask({ ...newTask, priority: value as Task["priority"] }) 172 } 173 > 174 <option value="low">Low Priority</option> 175 <option value="medium">Medium Priority</option> 176 <option value="high">High Priority</option> 177 <option value="urgent">Urgent</option> 178 </Select> 179 <Input 180 placeholder="Shipment ID (optional)" 181 value={newTask.shipment_id} 182 onChange={(e) => 183 setNewTask({ ...newTask, shipment_id: e.target.value }) 184 } 185 /> 186 </div> 187 <div className="flex gap-2 mt-4"> 188 <Button onClick={addTask}>Add Task</Button> 189 <Button variant="outline" onClick={() => setShowAddTask(false)}> 190 Cancel 191 </Button> 192 </div> 193 </Card> 194 )} 195 196 {/* Task List */} 197 <div className="grid gap-4"> 198 {tasks.length === 0 ? ( 199 <Card className="p-8 text-center"> 200 <h3 className="text-lg font-medium text-gray-900 mb-2"> 201 No tasks yet 202 </h3> 203 <p className="text-gray-600"> 204 Create your first shipping task to get started. 205 </p> 206 </Card> 207 ) : ( 208 tasks.map((task) => ( 209 <Card key={task.id} className="p-4"> 210 <div className="flex items-start justify-between"> 211 <div className="flex-1"> 212 <div className="flex items-center gap-2 mb-2"> 213 {getStatusIcon(task.status)} 214 <h3 className="text-lg font-medium">{task.title}</h3> 215 <Badge className={getPriorityColor(task.priority)}> 216 {task.priority} 217 </Badge> 218 </div> 219 220 {task.description && ( 221 <p className="text-gray-600 mb-2">{task.description}</p> 222 )} 223 224 {task.shipment_id && ( 225 <p className="text-sm text-blue-600"> 226 Shipment: {task.shipment_id} 227 </p> 228 )} 229 230 <p className="text-xs text-gray-500 mt-2"> 231 Created {new Date(task.created_at).toLocaleDateString()} 232 </p> 233 </div> 234 235 <div className="flex items-center gap-2"> 236 <Select 237 value={task.status} 238 onValueChange={(value) => 239 updateTaskStatus(task.id, value as Task["status"]) 240 } 241 > 242 <option value="pending">Pending</option> 243 <option value="in_progress">In Progress</option> 244 <option value="completed">Completed</option> 245 </Select> 246 247 <Button 248 variant="outline" 249 size="sm" 250 onClick={() => deleteTask(task.id)} 251 > 252 Delete 253 </Button> 254 </div> 255 </div> 256 </Card> 257 )) 258 )} 259 </div> 260 </div> 261 ); 262}
Creating API Routes
Add server-side functionality with API routes:
api/tasks/route.ts1import { NextRequest, NextResponse } from "next/server"; 2import { authenticateAppRequest } from "@karrio/app-store/auth"; 3 4export async function GET(request: NextRequest) { 5 try { 6 const context = await authenticateAppRequest("shipping-tasks", request); 7 const { karrio } = context; 8 9 // Get tasks from your data store or external API 10 const tasks = await getTasksFromDatabase(context.installation.id); 11 12 return NextResponse.json({ tasks }); 13 } catch (error) { 14 return NextResponse.json( 15 { error: "Failed to fetch tasks" }, 16 { status: 500 }, 17 ); 18 } 19} 20 21export async function POST(request: NextRequest) { 22 try { 23 const context = await authenticateAppRequest("shipping-tasks", request); 24 const taskData = await request.json(); 25 26 // Validate task data 27 if (!taskData.title) { 28 return NextResponse.json({ error: "Title is required" }, { status: 400 }); 29 } 30 31 // Create task 32 const task = await createTask({ 33 ...taskData, 34 installation_id: context.installation.id, 35 id: generateId(), 36 created_at: new Date().toISOString(), 37 updated_at: new Date().toISOString(), 38 }); 39 40 // Optionally sync with external system 41 await syncTaskWithExternalSystem(task, context); 42 43 return NextResponse.json({ task }); 44 } catch (error) { 45 return NextResponse.json( 46 { error: "Failed to create task" }, 47 { status: 500 }, 48 ); 49 } 50} 51 52export async function PATCH(request: NextRequest) { 53 try { 54 const context = await authenticateAppRequest("shipping-tasks", request); 55 const { pathname } = new URL(request.url); 56 const taskId = pathname.split("/").pop(); 57 const updates = await request.json(); 58 59 // Update task 60 const task = await updateTask(taskId, { 61 ...updates, 62 updated_at: new Date().toISOString(), 63 }); 64 65 return NextResponse.json({ task }); 66 } catch (error) { 67 return NextResponse.json( 68 { error: "Failed to update task" }, 69 { status: 500 }, 70 ); 71 } 72} 73 74// Helper functions 75async function getTasksFromDatabase(installationId: string) { 76 // Implement your data access logic 77 return []; 78} 79 80async function createTask(taskData: any) { 81 // Implement task creation logic 82 return taskData; 83} 84 85async function updateTask(taskId: string, updates: any) { 86 // Implement task update logic 87 return { id: taskId, ...updates }; 88} 89 90async function syncTaskWithExternalSystem(task: any, context: any) { 91 // Sync with external task management system 92 const externalApiKey = context.installation.getMetafield("external_api_key"); 93 if (externalApiKey) { 94 // Make external API call 95 } 96} 97 98function generateId() { 99 return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 100}
Configuration Component
Create a configuration interface for your app:
configuration.tsx1import React, { useState, useEffect } from "react"; 2import { AppConfigurationProps } from "@karrio/app-store/types"; 3import { Button, Input, Select, Card } from "@karrio/ui"; 4 5export default function ShippingTasksConfiguration({ 6 app, 7 context, 8 onSave, 9 onCancel, 10}: AppConfigurationProps) { 11 const [config, setConfig] = useState({ 12 external_api_key: "", 13 sync_interval: "30", 14 default_priority: "medium", 15 auto_sync: true, 16 }); 17 const [loading, setLoading] = useState(false); 18 19 useEffect(() => { 20 // Load existing configuration 21 loadConfiguration(); 22 }, []); 23 24 const loadConfiguration = async () => { 25 try { 26 const metafields = await app.getMetafields(); 27 const configData = metafields.reduce((acc, field) => { 28 acc[field.key] = field.value; 29 return acc; 30 }, {} as any); 31 32 setConfig({ ...config, ...configData }); 33 } catch (error) { 34 console.error("Failed to load configuration:", error); 35 } 36 }; 37 38 const handleSave = async () => { 39 try { 40 setLoading(true); 41 42 // Save configuration as metafields 43 await app.updateMetafields([ 44 { 45 key: "external_api_key", 46 value: config.external_api_key, 47 type: "password", 48 is_sensitive: true, 49 }, 50 { 51 key: "sync_interval", 52 value: config.sync_interval, 53 type: "number", 54 }, 55 { 56 key: "default_priority", 57 value: config.default_priority, 58 type: "string", 59 }, 60 { 61 key: "auto_sync", 62 value: config.auto_sync.toString(), 63 type: "boolean", 64 }, 65 ]); 66 67 onSave?.(config); 68 } catch (error) { 69 console.error("Failed to save configuration:", error); 70 } finally { 71 setLoading(false); 72 } 73 }; 74 75 return ( 76 <div className="max-w-2xl mx-auto p-6"> 77 <h2 className="text-xl font-semibold mb-6">App Configuration</h2> 78 79 <div className="space-y-6"> 80 <Card className="p-4"> 81 <h3 className="text-lg font-medium mb-4">External Integration</h3> 82 83 <div className="space-y-4"> 84 <div> 85 <label className="block text-sm font-medium mb-2"> 86 External API Key 87 </label> 88 <Input 89 type="password" 90 value={config.external_api_key} 91 onChange={(e) => 92 setConfig({ 93 ...config, 94 external_api_key: e.target.value, 95 }) 96 } 97 placeholder="Enter your external API key" 98 /> 99 <p className="text-sm text-gray-600 mt-1"> 100 API key for external task management system 101 </p> 102 </div> 103 </div> 104 </Card> 105 106 <Card className="p-4"> 107 <h3 className="text-lg font-medium mb-4">Sync Settings</h3> 108 109 <div className="space-y-4"> 110 <div> 111 <label className="block text-sm font-medium mb-2"> 112 Sync Interval (minutes) 113 </label> 114 <Select 115 value={config.sync_interval} 116 onValueChange={(value) => 117 setConfig({ 118 ...config, 119 sync_interval: value, 120 }) 121 } 122 > 123 <option value="15">15 minutes</option> 124 <option value="30">30 minutes</option> 125 <option value="60">1 hour</option> 126 <option value="240">4 hours</option> 127 </Select> 128 </div> 129 130 <div> 131 <label className="block text-sm font-medium mb-2"> 132 Default Task Priority 133 </label> 134 <Select 135 value={config.default_priority} 136 onValueChange={(value) => 137 setConfig({ 138 ...config, 139 default_priority: value, 140 }) 141 } 142 > 143 <option value="low">Low</option> 144 <option value="medium">Medium</option> 145 <option value="high">High</option> 146 <option value="urgent">Urgent</option> 147 </Select> 148 </div> 149 150 <div className="flex items-center"> 151 <input 152 type="checkbox" 153 id="auto_sync" 154 checked={config.auto_sync} 155 onChange={(e) => 156 setConfig({ 157 ...config, 158 auto_sync: e.target.checked, 159 }) 160 } 161 className="mr-2" 162 /> 163 <label htmlFor="auto_sync" className="text-sm font-medium"> 164 Enable automatic sync 165 </label> 166 </div> 167 </div> 168 </Card> 169 </div> 170 171 <div className="flex gap-3 mt-8"> 172 <Button onClick={handleSave} disabled={loading}> 173 {loading ? "Saving..." : "Save Configuration"} 174 </Button> 175 <Button variant="outline" onClick={onCancel}> 176 Cancel 177 </Button> 178 </div> 179 </div> 180 ); 181}
Testing Your App
Test your app thoroughly before deployment:
tests/shipping-tasks.test.ts1import { describe, it, expect, beforeEach } from 'vitest'; 2import { render, screen, fireEvent, waitFor } from '@testing-library/react'; 3import ShippingTasksApp from '../component'; 4 5describe('ShippingTasksApp', () => { 6 const mockProps = { 7 app: { 8 id: 'shipping-tasks', 9 installation: { id: 'test-installation' }, 10 api: { 11 get: jest.fn(), 12 post: jest.fn(), 13 patch: jest.fn(), 14 delete: jest.fn() 15 } 16 }, 17 context: { 18 user: { id: 'user1', full_name: 'Test User' }, 19 organization: { id: 'org1', name: 'Test Org' } 20 }, 21 karrio: {} 22 }; 23 24 beforeEach(() => { 25 mockProps.app.api.get.mockResolvedValue({ tasks: [] }); 26 }); 27 28 it('renders task list', async () => { 29 render(<ShippingTasksApp {...mockProps} />); 30 31 await waitFor(() => { 32 expect(screen.getByText('Shipping Tasks')).toBeInTheDocument(); 33 }); 34 }); 35 36 it('adds new task', async () => { 37 const newTask = { 38 id: 'task1', 39 title: 'Test Task', 40 priority: 'medium', 41 status: 'pending' 42 }; 43 44 mockProps.app.api.post.mockResolvedValue({ task: newTask }); 45 46 render(<ShippingTasksApp {...mockProps} />); 47 48 fireEvent.click(screen.getByText('Add Task')); 49 fireEvent.change(screen.getByPlaceholderText('Task title'), { 50 target: { value: 'Test Task' } 51 }); 52 fireEvent.click(screen.getByText('Add Task')); 53 54 await waitFor(() => { 55 expect(mockProps.app.api.post).toHaveBeenCalledWith('/tasks', expect.objectContaining({ 56 title: 'Test Task' 57 })); 58 }); 59 }); 60});
Next Steps
- App Manifest - Learn about app configuration
- UI Components - Use Karrioβs design system
- API Integration - Connect with Karrioβs APIs
- Deployment - Deploy your app