656b82985e7c1d82acR7">7
 
7 8
 from .models import Organization, OrganizationSite
8 9
 from .forms import OrganizationForm
@@ -66,3 +67,5 @@ def org_sites(request):
66 67
 
67 68
     return render(request, "orgs/org_sites.html", {"mappings": mappings, "current_site": getattr(request, "site", None)})
68 69
 
70
+
71
+## ProvidedService management moved to recycle_core views

+ 293 - 0
package-lock.json

@@ -0,0 +1,293 @@
1
+{
2
+  "name": "app",
3
+  "version": "1.0.0",
4
+  "lockfileVersion": 3,
5
+  "requires": true,
6
+  "packages": {
7
+    "": {
8
+      "name": "app",
9
+      "version": "1.0.0",
10
+      "license": "ISC",
11
+      "dependencies": {
12
+        "alpinejs": "^3.15.0",
13
+        "flowbite": "^3.1.2"
14
+      }
15
+    },
16
+    "node_modules/@popperjs/core": {
17
+      "version": "2.11.8",
18
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
19
+      "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
20
+      "funding": {
21
+        "type": "opencollective",
22
+        "url": "https://opencollective.com/popperjs"
23
+      }
24
+    },
25
+    "node_modules/@rollup/plugin-node-resolve": {
26
+      "version": "15.3.1",
27
+      "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
28
+      "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
29
+      "dependencies": {
30
+        "@rollup/pluginutils": "^5.0.1",
31
+        "@types/resolve": "1.20.2",
32
+        "deepmerge": "^4.2.2",
33
+        "is-module": "^1.0.0",
34
+        "resolve": "^1.22.1"
35
+      },
36
+      "engines": {
37
+        "node": ">=14.0.0"
38
+      },
39
+      "peerDependencies": {
40
+        "rollup": "^2.78.0||^3.0.0||^4.0.0"
41
+      },
42
+      "peerDependenciesMeta": {
43
+        "rollup": {
44
+          "optional": true
45
+        }
46
+      }
47
+    },
48
+    "node_modules/@rollup/pluginutils": {
49
+      "version": "5.3.0",
50
+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
51
+      "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
52
+      "dependencies": {
53
+        "@types/estree": "^1.0.0",
54
+        "estree-walker": "^2.0.2",
55
+        "picomatch": "^4.0.2"
56
+      },
57
+      "engines": {
58
+        "node": ">=14.0.0"
59
+      },
60
+      "peerDependencies": {
61
+        "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
62
+      },
63
+      "peerDependenciesMeta": {
64
+        "rollup": {
65
+          "optional": true
66
+        }
67
+      }
68
+    },
69
+    "node_modules/@types/estree": {
70
+      "version": "1.0.8",
71
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
72
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
73
+    },
74
+    "node_modules/@types/resolve": {
75
+      "version": "1.20.2",
76
+      "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
77
+      "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="
78
+    },
79
+    "node_modules/@vue/reactivity": {
80
+      "version": "3.1.5",
81
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
82
+      "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
83
+      "dependencies": {
84
+        "@vue/shared": "3.1.5"
85
+      }
86
+    },
87
+    "node_modules/@vue/shared": {
88
+      "version": "3.1.5",
89
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
90
+      "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
91
+    },
92
+    "node_modules/alpinejs": {
93
+      "version": "3.15.0",
94
+      "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz",
95
+      "integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==",
96
+      "dependencies": {
97
+        "@vue/reactivity": "~3.1.1"
98
+      }
99
+    },
100
+    "node_modules/deepmerge": {
101
+      "version": "4.3.1",
102
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
103
+      "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
104
+      "engines": {
105
+        "node": ">=0.10.0"
106
+      }
107
+    },
108
+    "node_modules/estree-walker": {
109
+      "version": "2.0.2",
110
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
111
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
112
+    },
113
+    "node_modules/flowbite": {
114
+      "version": "3.1.2",
115
+      "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-3.1.2.tgz",
116
+      "integrity": "sha512-MkwSgbbybCYgMC+go6Da5idEKUFfMqc/AmSjm/2ZbdmvoKf5frLPq/eIhXc9P+rC8t9boZtUXzHDgt5whZ6A/Q==",
117
+      "dependencies": {
118
+        "@popperjs/core": "^2.9.3",
119
+        "flowbite-datepicker": "^1.3.1",
120
+        "mini-svg-data-uri": "^1.4.3",
121
+        "postcss": "^8.5.1"
122
+      }
123
+    },
124
+    "node_modules/flowbite-datepicker": {
125
+      "version": "1.3.2",
126
+      "resolved": "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-1.3.2.tgz",
127
+      "integrity": "sha512-6Nfm0MCVX3mpaR7YSCjmEO2GO8CDt6CX8ZpQnGdeu03WUCWtEPQ/uy0PUiNtIJjJZWnX0Cm3H55MOhbD1g+E/g==",
128
+      "dependencies": {
129
+        "@rollup/plugin-node-resolve": "^15.2.3",
130
+        "flowbite": "^2.0.0"
131
+      }
132
+    },
133
+    "node_modules/flowbite-datepicker/node_modules/flowbite": {
134
+      "version": "2.5.2",
135
+      "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-2.5.2.tgz",
136
+      "integrity": "sha512-kwFD3n8/YW4EG8GlY3Od9IoKND97kitO+/ejISHSqpn3vw2i5K/+ZI8Jm2V+KC4fGdnfi0XZ+TzYqQb4Q1LshA==",
137
+      "dependencies": {
138
+        "@popperjs/core": "^2.9.3",
139
+        "flowbite-datepicker": "^1.3.0",
140
+        "mini-svg-data-uri": "^1.4.3"
141
+      }
142
+    },
143
+    "node_modules/function-bind": {
144
+      "version": "1.1.2",
145
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
146
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
147
+      "funding": {
148
+        "url": "https://github.com/sponsors/ljharb"
149
+      }
150
+    },
151
+    "node_modules/hasown": {
152
+      "version": "2.0.2",
153
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
154
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
155
+      "dependencies": {
156
+        "function-bind": "^1.1.2"
157
+      },
158
+      "engines": {
159
+        "node": ">= 0.4"
160
+      }
161
+    },
162
+    "node_modules/is-core-module": {
163
+      "version": "2.16.1",
164
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
165
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
166
+      "dependencies": {
167
+        "hasown": "^2.0.2"
168
+      },
169
+      "engines": {
170
+        "node": ">= 0.4"
171
+      },
172
+      "funding": {
173
+        "url": "https://github.com/sponsors/ljharb"
174
+      }
175
+    },
176
+    "node_modules/is-module": {
177
+      "version": "1.0.0",
178
+      "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
179
+      "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="
180
+    },
181
+    "node_modules/mini-svg-data-uri": {
182
+      "version": "1.4.4",
183
+      "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
184
+      "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
185
+      "bin": {
186
+        "mini-svg-data-uri": "cli.js"
187
+      }
188
+    },
189
+    "node_modules/nanoid": {
190
+      "version": "3.3.11",
191
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
192
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
193
+      "funding": [
194
+        {
195
+          "type": "github",
196
+          "url": "https://github.com/sponsors/ai"
197
+        }
198
+      ],
199
+      "bin": {
200
+        "nanoid": "bin/nanoid.cjs"
201
+      },
202
+      "engines": {
203
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
204
+      }
205
+    },
206
+    "node_modules/path-parse": {
207
+      "version": "1.0.7",
208
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
209
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
210
+    },
211
+    "node_modules/picocolors": {
212
+      "version": "1.1.1",
213
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
214
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
215
+    },
216
+    "node_modules/picomatch": {
217
+      "version": "4.0.3",
218
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
219
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
220
+      "engines": {
221
+        "node": ">=12"
222
+      },
223
+      "funding": {
224
+        "url": "https://github.com/sponsors/jonschlinkert"
225
+      }
226
+    },
227
+    "node_modules/postcss": {
228
+      "version": "8.5.6",
229
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
230
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
231
+      "funding": [
232
+        {
233
+          "type": "opencollective",
234
+          "url": "https://opencollective.com/postcss/"
235
+        },
236
+        {
237
+          "type": "tidelift",
238
+          "url": "https://tidelift.com/funding/github/npm/postcss"
239
+        },
240
+        {
241
+          "type": "github",
242
+          "url": "https://github.com/sponsors/ai"
243
+        }
244
+      ],
245
+      "dependencies": {
246
+        "nanoid": "^3.3.11",
247
+        "picocolors": "^1.1.1",
248
+        "source-map-js": "^1.2.1"
249
+      },
250
+      "engines": {
251
+        "node": "^10 || ^12 || >=14"
252
+      }
253
+    },
254
+    "node_modules/resolve": {
255
+      "version": "1.22.10",
256
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
257
+      "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
258
+      "dependencies": {
259
+        "is-core-module": "^2.16.0",
260
+        "path-parse": "^1.0.7",
261
+        "supports-preserve-symlinks-flag": "^1.0.0"
262
+      },
263
+      "bin": {
264
+        "resolve": "bin/resolve"
265
+      },
266
+      "engines": {
267
+        "node": ">= 0.4"
268
+      },
269
+      "funding": {
270
+        "url": "https://github.com/sponsors/ljharb"
271
+      }
272
+    },
273
+    "node_modules/source-map-js": {
274
+      "version": "1.2.1",
275
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
276
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
277
+      "engines": {
278
+        "node": ">=0.10.0"
279
+      }
280
+    },
281
+    "node_modules/supports-preserve-symlinks-flag": {
282
+      "version": "1.0.0",
283
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
284
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
285
+      "engines": {
286
+        "node": ">= 0.4"
287
+      },
288
+      "funding": {
289
+        "url": "https://github.com/sponsors/ljharb"
290
+      }
291
+    }
292
+  }
293
+}

+ 20 - 0
package.json

@@ -0,0 +1,20 @@
1
+{
2
+  "name": "app",
3
+  "version": "1.0.0",
4
+  "description": "Ecoloop is a Django + DRF backend for recycling businesses and factories to manage pickups, pricing, invoices/payouts, and a bidding marketplace for scrap lots. It supports multi-organization setups, JWT auth, and a Tailwind-powered UI layer.",
5
+  "main": "index.js",
6
+  "scripts": {
7
+    "test": "echo \"Error: no test specified\" && exit 1"
8
+  },
9
+  "repository": {
10
+    "type": "git",
11
+    "url": "http://git2.simplico.net/tum/ecoloop.git"
12
+  },
13
+  "keywords": [],
14
+  "author": "",
15
+  "license": "ISC",
16
+  "dependencies": {
17
+    "alpinejs": "^3.15.0",
18
+    "flowbite": "^3.1.2"
19
+  }
20
+}

+ 6 - 6
public_frontend/templates/public_frontend/base.html

@@ -12,15 +12,15 @@
12 12
     <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex items-center justify-between h-16">
13 13
       <a href="/" class="text-lg font-semibold">{% if current_site %}{{ current_site.name }}{% else %}Ecoloop{% endif %}</a>
14 14
       <nav class="hidden md:flex items-center gap-6">
15
-        <a href="{% url 'public_frontend:materials_list' %}" class="hover:text-black/70">Materials</a>
16
-        <a href="{% url 'public_frontend:listings_list' %}" class="hover:text-black/70">Listings</a>
17
-        <a href="{% url 'public_frontend:pickup_request' %}" class="hover:text-black/70">Request Pickup</a>
18
-        <a href="{% url 'public_frontend:blog_list' %}" class="hover:text-black/70">Blog</a>
19
-        <a href="{% url 'public_frontend:contact' %}" class="hover:text-black/70">Contact</a>
15
+        <a href="/#materials" class="hover:text-black/70">Materials</a>
16
+        <a href="/#listings" class="hover:text-black/70">Listings</a>
17
+        <a href="/#pickup-request" class="hover:text-black/70">Request Pickup</a>
18
+        <a href="/#blog" class="hover:text-black/70">Blog</a>
19
+        <a href="/#contact" class="hover:text-black/70">Contact</a>
20 20
       </nav>
21 21
     </div>
22 22
   </header>
23
-  <main class="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8">
23
+  <main class="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8 min-h-[80vh]">
24 24
     {% block content %}{% endblock %}
25 25
   </main>
26 26
   <footer class="bg-white border-t">

+ 138 - 26
public_frontend/templates/public_frontend/home.html

@@ -1,56 +1,168 @@
1 1
 {% extends 'public_frontend/base.html' %}
2
+{% load static %}
2 3
 {% block title %}Home{% endblock %}
4
+
3 5
 {% block content %}
4
-<div class="grid gap-8">
5
-  <section class="bg-white rounded shadow p-6">
6
-    <h2 class="text-xl font-semibold mb-2">Welcome{% if org %} to {{ org.name }}{% endif %}</h2>
7
-    <p class="text-gray-600">We help you manage recycling operations: pickups, weighing, invoices, and public listings.</p>
8
-    <div class="mt-4 flex gap-3">
9
-      <a href="{% url 'public_frontend:pickup_request' %}" class="btn-primary">Request Pickup</a>
10
-      <a href="{% url 'public_frontend:listings_list' %}" class="btn-outline">Browse Listings</a>
6
+<div class="grid gap-12 md:gap-16">
7
+  {# Hero Section #}
8
+  <section id="hero" class="bg-white rounded-lg shadow-md p-6 md:p-8 text-center">
9
+    <h1 class="text-3xl md:text-4xl font-bold mb-2">Streamline Your Factory's Recycling</h1>
10
+    <p class="text-gray-600 max-w-2xl mx-auto mb-6">We partner with businesses like yours to manage scrap materials efficiently. Browse listings, request a pickup, and turn your waste into a resource.</p>
11
+    <div class="flex gap-3 justify-center">
12
+      <a href="#pickup-request" class="btn-primary">Request a Pickup</a>
13
+      <a href="#listings" class="btn-outline">Browse Listings</a>
11 14
     </div>
12 15
   </section>
13 16
 
14
-  <section class="bg-white rounded shadow p-6">
17
+  {# Services Section #}
18
+  {% if services %}
19
+  <section id="services">
15 20
     <div class="flex items-center justify-between mb-3">
16
-      <h3 class="font-semibold">Latest Blog</h3>
17
-      <a href="{% url 'public_frontend:blog_list' %}" class="text-blue-700 hover:underline">View all</a>
21
+      <h2 class="text-2xl font-semibold">Our Services</h2>
18 22
     </div>
19
-    <div class="grid md:grid-cols-3 gap-4">
20
-      {% for p in posts %}
21
-        <a href="{% url 'public_frontend:blog_detail' p.slug %}" class="block p-4 border rounded hover:bg-gray-50">
22
-          <div class="text-sm text-gray-500">{{ p.published_at|date:'M j, Y' }}</div>
23
-          <div class="font-medium">{{ p.title }}</div>
24
-          <div class="text-sm text-gray-600 line-clamp-2">{{ p.excerpt|default:p.content|striptags|truncatechars:120 }}</div>
23
+    <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
24
+      {% for s in services %}
25
+        <a href="{% url 'public_frontend:service_detail' s.pk %}" class="block p-4 bg-white rounded-lg shadow-md hover:bg-gray-50 transition flex items-start gap-4">
26
+          <img src="{% static 'service_icons/' %}{{ s.image_name }}" alt="{{ s.title }} icon" class="w-12 h-12 object-contain"/>
27
+          <div>
28
+            <div class="font-medium">{{ s.title }}</div>
29
+            <div class="text-sm text-gray-600">{{ s.description }}</div>
30
+          </div>
25 31
         </a>
26
-      {% empty %}
27
-        <div class="text-gray-500">No posts yet.</div>
28 32
       {% endfor %}
29 33
     </div>
30 34
   </section>
35
+  {% endif %}
31 36
 
32
-  <section class="bg-white rounded shadow p-6">
37
+  {# Materials Section #}
38
+  <section id="materials">
33 39
     <div class="flex items-center justify-between mb-3">
34
-      <h3 class="font-semibold">Open Listings</h3>
35
-      <a href="{% url 'public_frontend:listings_list' %}" class="text-blue-700 hover:underline">Browse</a>
40
+      <h2 class="text-2xl font-semibold">Materials We Accept</h2>
41
+      <a href="{% url 'public_frontend:materials_list' %}" class="text-blue-700 hover:underline">View all</a>
42
+    </div>
43
+    <div class="bg-white rounded-lg shadow-md p-4">
44
+        <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
45
+            {% for m in materials %}
46
+                <div class="p-2 text-center">
47
+                    <p class="font-medium">{{ m.name }}</p>
48
+                    <p class="text-sm text-gray-500">{{ m.category.name }}</p>
49
+                </div>
50
+            {% empty %}
51
+                <p class="text-gray-500 col-span-full">No materials published.</p>
52
+            {% endfor %}
53
+        </div>
54
+    </div>
55
+  </section>
56
+
57
+  {# Open Listings Section #}
58
+  <section id="listings">
59
+    <div class="flex items-center justify-between mb-3">
60
+      <h2 class="text-2xl font-semibold">Open Listings</h2>
61
+      <a href="{% url 'public_frontend:listings_list' %}" class="text-blue-700 hover:underline">Browse all</a>
36 62
     </div>
37 63
     <div class="grid md:grid-cols-3 gap-4">
38 64
       {% for l in listings %}
39
-        <a href="{% url 'public_frontend:listing_detail' l.id %}" class="block p-4 border rounded hover:bg-gray-50">
65
+        <a href="{% url 'public_frontend:listing_detail' l.id %}" class="block p-4 border rounded bg-white hover:bg-gray-50 transition">
40 66
           <div class="text-sm text-gray-500">Ends {{ l.ends_at|default:'—' }}</div>
41 67
           <div class="font-medium">{{ l.title }}</div>
42 68
           <div class="text-sm text-gray-600 line-clamp-2">{{ l.description|truncatechars:120 }}</div>
43 69
         </a>
44 70
       {% empty %}
45
-        <div class="text-gray-500">No public listings.</div>
71
+        <div class="text-gray-500 p-4 bg-white rounded-lg shadow-md col-span-full">No public listings at the moment.</div>
72
+      {% endfor %}
73
+    </div>
74
+  </section>
75
+
76
+  {# Pickup Request Section #}
77
+  <section id="pickup-request">
78
+    <h2 class="text-2xl font-semibold mb-3">Request a Pickup</h2>
79
+    <form method="post" action="{% url 'public_frontend:pickup_request' %}" class="bg-white rounded-lg shadow-md p-4 grid gap-4">
80
+      {% csrf_token %}
81
+      <div>
82
+        <label class="block text-sm font-medium mb-1">Name</label>
83
+        <input type="text" name="name" value="{{ pickup_form.name.value|default:'' }}" required class="w-full border rounded px-3 py-2">
84
+      </div>
85
+      <div class="grid md:grid-cols-2 gap-4">
86
+        <div>
87
+          <label class="block text-sm font-medium mb-1">Email</label>
88
+          <input type="email" name="email" value="{{ pickup_form.email.value|default:'' }}" class="w-full border rounded px-3 py-2">
89
+        </div>
90
+        <div>
91
+          <label class="block text-sm font-medium mb-1">Phone</label>
92
+          <input type="text" name="phone" value="{{ pickup_form.phone.value|default:'' }}" class="w-full border rounded px-3 py-2">
93
+        </div>
94
+      </div>
95
+      <div>
96
+        <label class="block text-sm font-medium mb-1">Pickup Address</label>
97
+        <textarea name="address" required class="w-full border rounded px-3 py-2" rows="3">{{ pickup_form.address.value|default:'' }}</textarea>
98
+      </div>
99
+      <div>
100
+        <label class="block text-sm font-medium mb-1">Materials / Notes</label>
101
+        <textarea name="materials" class="w-full border rounded px-3 py-2" rows="4">{{ pickup_form.materials.value|default:'' }}</textarea>
102
+      </div>
103
+      <div>
104
+        <button class="btn-primary" type="submit">Submit Request</button>
105
+      </div>
106
+    </form>
107
+  </section>
108
+
109
+  {# Blog Section #}
110
+  <section id="blog">
111
+    <div class="flex items-center justify-between mb-3">
112
+      <h2 class="text-2xl font-semibold">Industry Insights</h2>
113
+      <a href="{% url 'public_frontend:blog_list' %}" class="text-blue-700 hover:underline">View all</a>
114
+    </div>
115
+    <div class="grid md:grid-cols-3 gap-4">
116
+      {% for p in posts %}
117
+        <a href="{% url 'public_frontend:blog_detail' p.slug %}" class="block p-4 border rounded bg-white hover:bg-gray-50 transition">
118
+          <div class="text-sm text-gray-500">{{ p.published_at|date:'M j, Y' }}</div>
119
+          <div class="font-medium">{{ p.title }}</div>
120
+          <div class="text-sm text-gray-600 line-clamp-2">{{ p.excerpt|default:p.content|striptags|truncatechars:120 }}</div>
121
+        </a>
122
+      {% empty %}
123
+        <div class="text-gray-500 p-4 bg-white rounded-lg shadow-md col-span-full">No posts yet.</div>
46 124
       {% endfor %}
47 125
     </div>
48 126
   </section>
127
+
128
+  {# Contact Section #}
129
+  <section id="contact">
130
+    <h2 class="text-2xl font-semibold mb-3">Contact Us</h2>
131
+    <form method="post" action="{% url 'public_frontend:contact' %}" class="bg-white rounded-lg shadow-md p-4 grid gap-4">
132
+      {% csrf_token %}
133
+      <div class="grid md:grid-cols-2 gap-4">
134
+        <div>
135
+          <label class="block text-sm font-medium mb-1">Name</label>
136
+          <input type="text" name="name" value="{{ contact_form.name.value|default:'' }}" required class="w-full border rounded px-3 py-2">
137
+        </div>
138
+        <div>
139
+          <label class="block text-sm font-medium mb-1">Email</label>
140
+          <input type="email" name="email" value="{{ contact_form.email.value|default:'' }}" required class="w-full border rounded px-3 py-2">
141
+        </div>
142
+      </div>
143
+      <div>
144
+        <label class="block text-sm font-medium mb-1">Subject</label>
145
+        <input type="text" name="subject" value="{{ contact_form.subject.value|default:'' }}" class="w-full border rounded px-3 py-2">
146
+      </div>
147
+      <div>
148
+        <label class="block text-sm font-medium mb-1">Message</label>
149
+        <textarea name="message" rows="5" class="w-full border rounded px-3 py-2">{{ contact_form.message.value|default:'' }}</textarea>
150
+      </div>
151
+      <div>
152
+        <button class="btn-primary" type="submit">Send Message</button>
153
+      </div>
154
+    </form>
155
+  </section>
49 156
 </div>
50 157
 
51 158
 <style>
52
-.btn-primary{background:#1d4ed8;color:white;padding:.5rem .75rem;border-radius:.375rem}
53
-.btn-outline{border:1px solid #cbd5e1;padding:.5rem .75rem;border-radius:.375rem}
159
+  html {
160
+    scroll-behavior: smooth;
161
+  }
162
+  .btn-primary { background:#1d4ed8; color:white; padding:.625rem 1rem; border-radius:.375rem; font-weight:500; transition: background-color 0.2s; }
163
+  .btn-primary:hover { background:#1e40af; }
164
+  .btn-outline { border:1px solid #cbd5e1; padding:.625rem 1rem; border-radius:.375rem; font-weight:500; transition: background-color 0.2s, border-color 0.2s; }
165
+  .btn-outline:hover { background-color: #f8fafc; border-color: #94a3b8; }
166
+  section { scroll-margin-top: 80px; } /* Offset for fixed header */
54 167
 </style>
55 168
 {% endblock %}
56
-

+ 32 - 0
public_frontend/templates/public_frontend/service_detail.html

@@ -0,0 +1,32 @@
1
+{% extends 'public_frontend/base.html' %}
2
+{% load static %}
3
+{% block title %}{{ service.title }}{% endblock %}
4
+
5
+{% block content %}
6
+<article class="grid gap-6">
7
+  <header class="bg-white rounded-lg shadow-md p-6 flex items-start gap-4">
8
+    <img src="{% static 'service_icons/' %}{{ service.image_name }}" alt="{{ service.title }} icon" class="w-16 h-16 object-contain"/>
9
+    <div>
10
+      <h1 class="text-3xl font-bold">{{ service.title }}</h1>
11
+      {% if service.description %}
12
+        <p class="text-gray-600 mt-1">{{ service.description }}</p>
13
+      {% endif %}
14
+    </div>
15
+  </header>
16
+
17
+  {% if service.body_html %}
18
+    <section class="bg-white rounded-lg shadow-md p-6 prose max-w-none">
19
+      {{ service.body_html|safe }}
20
+    </section>
21
+  {% elif service.body %}
22
+    <section class="bg-white rounded-lg shadow-md p-6">
23
+      <pre class="whitespace-pre-wrap text-gray-700">{{ service.body }}</pre>
24
+    </section>
25
+  {% endif %}
26
+
27
+  <div>
28
+    <a href="/" class="text-blue-700 hover:underline">← Back to Home</a>
29
+  </div>
30
+</article>
31
+{% endblock %}
32
+

+ 1 - 1
public_frontend/urls.py

@@ -5,6 +5,7 @@ app_name = "public_frontend"
5 5
 
6 6
 urlpatterns = [
7 7
     path("", views.home, name="home"),
8
+    path("services/<int:pk>/", views.service_detail, name="service_detail"),
8 9
     path("materials/", views.materials_list, name="materials_list"),
9 10
     path("listings/", views.listings_list, name="listings_list"),
10 11
     path("listings/<int:pk>/", views.listing_detail, name="listing_detail"),
@@ -13,4 +14,3 @@ urlpatterns = [
13 14
     path("blog/", views.blog_list, name="blog_list"),
14 15
     path("blog/<slug:slug>/", views.blog_detail, name="blog_detail"),
15 16
 ]
16
-

+ 62 - 0
public_frontend/use_cases.md

@@ -0,0 +1,62 @@
1
+# Use Cases for Factory Owner Finding a Recycling Business
2
+
3
+This document outlines the primary use cases for a factory owner (or a similar industrial producer of scrap materials) interacting with the public-facing frontend of a recycling business that uses the Ecoloop platform.
4
+
5
+## Actors
6
+
7
+*   **Factory Owner:** An individual or entity representing a business that generates recyclable scrap materials as a byproduct of its operations.
8
+*   **Recycling Business:** The organization running this specific instance of the Ecoloop platform, which processes, buys, or manages recyclable materials.
9
+
10
+---
11
+
12
+### Use Case 1: Discover and Vet a Recycling Partner
13
+
14
+**Goal:** The factory owner wants to find a reliable, local recycling business to handle their specific types of industrial scrap.
15
+
16
+**Scenario:**
17
+1.  The factory owner searches online for "recycling services for plastic scrap" and discovers the recycling business's public website (powered by Ecoloop).
18
+2.  On the homepage, they understand the business's core services: managing recycling, pickups, and material listings.
19
+3.  They navigate to the **Materials** page to verify that the business handles the materials they produce (e.g., "LDPE Film," "Corrugated Cardboard").
20
+4.  They browse the **Listings** page to see what materials the business is actively seeking or selling, giving them confidence that this is an active and relevant marketplace.
21
+5.  Feeling confident, the factory owner decides to initiate contact to discuss a potential partnership.
22
+
23
+---
24
+
25
+### Use Case 2: Request a One-Time Pickup for Scrap Materials
26
+
27
+**Goal:** The factory owner has an accumulation of scrap and needs to schedule a pickup without committing to a long-term contract.
28
+
29
+**Scenario:**
30
+1.  The factory owner lands on the public site and navigates directly to the **"Request Pickup"** page.
31
+2.  They fill out the pickup request form, providing:
32
+    *   Their name and contact information (email/phone).
33
+    *   The factory's address for the pickup.
34
+    *   A preferred date and time.
35
+    *   A brief description of the materials to be collected (e.g., "Approx. 2 tons of baled cardboard and 500 lbs of shrink wrap").
36
+3.  Upon submission, the form is sent to the recycling business as a **Lead**.
37
+4.  The factory owner sees a success message confirming their request has been submitted and awaits a direct follow-up from the business's staff to finalize logistics.
38
+
39
+---
40
+
41
+### Use Case 3: Inquire About a Public Listing
42
+
43
+**Goal:** The factory owner sees a listing for a material they either have or need and wants to engage with the recycling business.
44
+
45
+**Scenario:**
46
+1.  While browsing the **Listings** page, the factory owner finds a listing of interest, for example, "Wanted: Clean PET plastic regrind."
47
+2.  They click on the listing to view the **Listing Detail** page, which provides more information about quantity, quality specifications, and terms.
48
+3.  To act on this listing, the factory owner navigates to the **"Contact"** page.
49
+4.  They fill out the contact form, mentioning the specific listing in their message (e.g., "Hello, I'm writing in response to your listing for PET regrind. We produce this material and would like to discuss supplying it.").
50
+5.  This inquiry is captured as a **Lead** for the recycling business, providing their sales or procurement team with a direct, actionable request.
51
+
52
+---
53
+
54
+### Use Case 4: Gain Knowledge on Recycling Best Practices
55
+
56
+**Goal:** The factory owner wants to improve their internal waste sorting and handling processes to increase the value of their scrap.
57
+
58
+**Scenario:**
59
+1.  The factory owner explores the site and clicks on the **"Blog"** section.
60
+2.  They find and read an article titled, for example, "5 Ways to Reduce Contamination in Your Plastic Waste Stream."
61
+3.  The article provides valuable insights that help the owner implement better sorting practices at their factory.
62
+4.  This knowledge empowers the factory owner to produce higher-quality scrap, making them a more valuable supplier for recycling businesses.

+ 37 - 1
public_frontend/views.py

@@ -6,6 +6,7 @@ from django.urls import reverse
6 6
 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
7 7
 
8 8
 from orgs.models import Organization
9
+from recycle_core.models import ProvidedService
9 10
 from recycle_core.models import Material, ScrapListing, PickupOrder
10 11
 from cms.models import Post, PostCategory
11 12
 
@@ -24,7 +25,33 @@ def home(request):
24 25
     if org is not None:
25 26
         listings_qs = listings_qs.filter(organization=org)
26 27
     listings = listings_qs.order_by("-created_at")[:6]
27
-    return render(request, "public_frontend/home.html", {"posts": posts, "listings": listings, "org": org})
28
+
29
+    materials_qs = Material.objects.all().order_by("name")
30
+    if org is not None:
31
+        materials_qs = materials_qs.filter(organization=org)
32
+    materials = materials_qs[:12]
33
+
34
+    services_qs = ProvidedService.objects.none()
35
+    if org is not None:
36
+        services_qs = ProvidedService.objects.filter(organization=org, is_enabled=True).order_by("display_order", "id")
37
+    services = list(services_qs)
38
+
39
+    pickup_form = PickupRequestForm()
40
+    contact_form = ContactForm()
41
+
42
+    return render(
43
+        request,
44
+        "public_frontend/home.html",
45
+        {
46
+            "posts": posts,
47
+            "listings": listings,
48
+            "materials": materials,
49
+            "services": services,
50
+            "pickup_form": pickup_form,
51
+            "contact_form": contact_form,
52
+            "org": org,
53
+        },
54
+    )
28 55
 
29 56
 
30 57
 def materials_list(request):
@@ -60,6 +87,15 @@ def listing_detail(request, pk: int):
60 87
     return render(request, "public_frontend/listing_detail.html", {"listing": listing, "org": org})
61 88
 
62 89
 
90
+def service_detail(request, pk: int):
91
+    org = getattr(request, "org", None)
92
+    base_qs = ProvidedService.objects.filter(is_enabled=True)
93
+    if org is not None:
94
+        base_qs = base_qs.filter(organization=org)
95
+    service = get_object_or_404(base_qs, pk=pk)
96
+    return render(request, "public_frontend/service_detail.html", {"service": service, "org": org})
97
+
98
+
63 99
 def pickup_request(request):
64 100
     org = getattr(request, "org", None)
65 101
     form = PickupRequestForm(request.POST or None)

+ 8 - 0
recycle_core/admin.py

@@ -168,3 +168,11 @@ class ScrapAwardAdmin(OrgScopedAdmin):
168 168
 class ScrapListingInviteAdmin(OrgScopedAdmin):
169 169
     list_display = ("listing", "invited_org", "invited_user", "created_at")
170 170
     search_fields = ("listing__id", "invited_org__code", "invited_user__username")
171
+
172
+
173
+@admin.register(models.ProvidedService)
174
+class ProvidedServiceAdmin(OrgScopedAdmin):
175
+    list_display = ("title", "organization", "is_enabled", "display_order", "created_at")
176
+    list_filter = ("organization", "is_enabled")
177
+    search_fields = ("title", "description")
178
+    ordering = ("organization", "display_order", "id")

+ 12 - 0
recycle_core/forms.py

@@ -7,9 +7,11 @@ from django.contrib.contenttypes.models import ContentType
7 7
 from .models import (
8 8
     MaterialCategory,
9 9
     Material,
10
+    ProvidedService,
10 11
     Customer,
11 12
     CustomerSite,
12 13
 )
14
+from markdownfield.widgets import MDEWidget
13 15
 from orgs.models import UserProfile
14 16
 
15 17
 
@@ -37,6 +39,16 @@ class CustomerSiteForm(forms.ModelForm):
37 39
         fields = ["customer", "name", "address", "contact_name", "contact_phone", "contact_email"]
38 40
 
39 41
 
42
+class ProvidedServiceForm(forms.ModelForm):
43
+    class Meta:
44
+        model = ProvidedService
45
+        fields = ["title", "description", "body", "display_order", "is_enabled"]
46
+        widgets = {
47
+            "description": forms.Textarea(attrs={"rows": 3}),
48
+            "body": MDEWidget(),
49
+        }
50
+
51
+
40 52
 # Operational forms ----------------------------------------------------------
41 53
 
42 54
 User = get_user_model()

+ 33 - 0
recycle_core/management/commands/seed_ecoloop.py

@@ -10,6 +10,7 @@ from orgs.models import Organization, UserProfile
10 10
 from recycle_core.models import (
11 11
     MaterialCategory,
12 12
     Material,
13
+    ProvidedService,
13 14
     PriceList,
14 15
     PriceListItem,
15 16
     Customer,
@@ -121,6 +122,38 @@ class Command(BaseCommand):
121 122
             },
122 123
         )
123 124
 
125
+        # Provided services for the public website
126
+        demo_services = [
127
+            ("Pickup & Logistics", "Scheduled and on-demand scrap pickups handled safely and on time.",
128
+             "We provide reliable pickup scheduling, routing, and documentation for your facilities.\n\n- Route planning and dispatch\n- On-demand requests\n- Driver assignments and tracking"),
129
+            ("Material Sorting", "Sorting and consolidation to maximize recycling value.",
130
+             "Our team sorts materials to your specifications to improve purity and value.\n\n- On-site sorting support\n- Bale and bag standards\n- Quality checks"),
131
+            ("Weighing & Ticketing", "Accurate weighing with digital tickets and audit trail.",
132
+             "Every pickup is weighed with calibrated equipment and recorded.\n\n- Calibrated scale records\n- Digital weigh tickets\n- Audit logs"),
133
+            ("Invoicing & Payouts", "Transparent invoices and fast payouts.",
134
+             "Automated invoicing and payouts reduce admin overhead.\n\n- Invoice generation\n- Payment tracking\n- Reconciliations"),
135
+            ("Reporting & Analytics", "Reports that track volumes, value, and sustainability.",
136
+             "Dashboards keep stakeholders informed.\n\n- Material volumes\n- Revenue and cost\n- ESG metrics"),
137
+            ("Marketplace & Bidding", "Invite vetted recyclers and get competitive bids.",
138
+             "Run open or sealed listings to find the best offer.\n\n- Public or invite-only\n- Bid history\n- Award workflows"),
139
+            ("Compliance & Audits", "Documentation and controls for compliance.",
140
+             "Stay compliant with audit-ready records.\n\n- Document control\n- Chain of custody\n- Access controls"),
141
+            ("Consulting & Training", "Best practices and training for your team.",
142
+             "Improve recycling outcomes with training and SOPs.\n\n- SOP development\n- Staff workshops\n- Continuous improvement"),
143
+        ]
144
+
145
+        for idx, (title, desc, body) in enumerate(demo_services):
146
+            ProvidedService.objects.get_or_create(
147
+                organization=org,
148
+                title=title,
149
+                defaults={
150
+                    "description": desc,
151
+                    "body": body,
152
+                    "display_order": idx,
153
+                    "is_enabled": True,
154
+                },
155
+            )
156
+
124 157
         pickup = PickupOrder.objects.create(
125 158
             organization=org,
126 159
             customer=customer,

+ 30 - 0
recycle_core/migrations/0002_providedservice.py

@@ -0,0 +1,30 @@
1
+# Generated by Django 4.2.24 on 2025-09-22 08:26
2
+
3
+from django.db import migrations, models
4
+import django.db.models.deletion
5
+
6
+
7
+class Migration(migrations.Migration):
8
+
9
+    dependencies = [
10
+        ('orgs', '0006_delete_providedservice'),
11
+        ('recycle_core', '0001_initial'),
12
+    ]
13
+
14
+    operations = [
15
+        migrations.CreateModel(
16
+            name='ProvidedService',
17
+            fields=[
18
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19
+                ('created_at', models.DateTimeField(auto_now_add=True)),
20
+                ('updated_at', models.DateTimeField(auto_now=True)),
21
+                ('title', models.CharField(choices=[('Pickup & Logistics', 'Pickup & Logistics'), ('Material Sorting', 'Material Sorting'), ('Weighing & Ticketing', 'Weighing & Ticketing'), ('Invoicing & Payouts', 'Invoicing & Payouts'), ('Reporting & Analytics', 'Reporting & Analytics'), ('Marketplace & Bidding', 'Marketplace & Bidding'), ('Compliance & Audits', 'Compliance & Audits'), ('Consulting & Training', 'Consulting & Training')], max_length=100)),
22
+                ('description', models.TextField()),
23
+                ('display_order', models.PositiveIntegerField(default=0)),
24
+                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='orgs.organization')),
25
+            ],
26
+            options={
27
+                'ordering': ['display_order'],
28
+            },
29
+        ),
30
+    ]

+ 24 - 0
recycle_core/migrations/0003_providedservice_description_html_and_more.py

@@ -0,0 +1,24 @@
1
+# Generated by Django 4.2.24 on 2025-09-22 08:30
2
+
3
+from django.db import migrations
4
+import markdownfield.models
5
+
6
+
7
+class Migration(migrations.Migration):
8
+
9
+    dependencies = [
10
+        ('recycle_core', '0002_providedservice'),
11
+    ]
12
+
13
+    operations = [
14
+        migrations.AddField(
15
+            model_name='providedservice',
16
+            name='description_html',
17
+            field=markdownfield.models.RenderedMarkdownField(null=True),
18
+        ),
19
+        migrations.AlterField(
20
+            model_name='providedservice',
21
+            name='description',
22
+            field=markdownfield.models.MarkdownField(rendered_field='description_html'),
23
+        ),
24
+    ]

+ 29 - 0
recycle_core/migrations/0004_rename_description_html_providedservice_body_html_and_more.py

@@ -0,0 +1,29 @@
1
+# Generated by Django 4.2.24 on 2025-09-22 08:34
2
+
3
+from django.db import migrations, models
4
+import markdownfield.models
5
+
6
+
7
+class Migration(migrations.Migration):
8
+
9
+    dependencies = [
10
+        ('recycle_core', '0003_providedservice_description_html_and_more'),
11
+    ]
12
+
13
+    operations = [
14
+        migrations.RenameField(
15
+            model_name='providedservice',
16
+            old_name='description_html',
17
+            new_name='body_html',
18
+        ),
19
+        migrations.AddField(
20
+            model_name='providedservice',
21
+            name='body',
22
+            field=markdownfield.models.MarkdownField(blank=True, null=True, rendered_field='body_html'),
23
+        ),
24
+        migrations.AlterField(
25
+            model_name='providedservice',
26
+            name='description',
27
+            field=models.TextField(),
28
+        ),
29
+    ]

+ 18 - 0
recycle_core/migrations/0005_providedservice_is_enabled.py

@@ -0,0 +1,18 @@
1
+# Generated by Django 4.2.24 on 2025-09-22 08:43
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('recycle_core', '0004_rename_description_html_providedservice_body_html_and_more'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AddField(
14
+            model_name='providedservice',
15
+            name='is_enabled',
16
+            field=models.BooleanField(default=True),
17
+        ),
18
+    ]

+ 45 - 0
recycle_core/models.py

@@ -7,6 +7,8 @@ from django.contrib.contenttypes.models import ContentType
7 7
 from django.db import models
8 8
 from django.utils import timezone
9 9
 from orgs.models import Organization
10
+from markdownfield.models import MarkdownField, RenderedMarkdownField
11
+from markdownfield.validators import VALIDATOR_STANDARD
10 12
 
11 13
 
12 14
 User = get_user_model()
@@ -34,6 +36,49 @@ class MaterialCategory(TimestampedModel):
34 36
         return self.name
35 37
 
36 38
 
39
+class ProvidedService(TimestampedModel):
40
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="services")
41
+    TITLE_CHOICES = (
42
+        ("Pickup & Logistics", "Pickup & Logistics"),
43
+        ("Material Sorting", "Material Sorting"),
44
+        ("Weighing & Ticketing", "Weighing & Ticketing"),
45
+        ("Invoicing & Payouts", "Invoicing & Payouts"),
46
+        ("Reporting & Analytics", "Reporting & Analytics"),
47
+        ("Marketplace & Bidding", "Marketplace & Bidding"),
48
+        ("Compliance & Audits", "Compliance & Audits"),
49
+        ("Consulting & Training", "Consulting & Training"),
50
+    )
51
+    title = models.CharField(max_length=100, choices=TITLE_CHOICES)
52
+    # Short summary text
53
+    description = models.TextField()
54
+    # Long-form markdown body
55
+    body = MarkdownField(rendered_field="body_html", validator=VALIDATOR_STANDARD, blank=True, null=True)
56
+    body_html = RenderedMarkdownField(blank=True, null=True)
57
+    display_order = models.PositiveIntegerField(default=0)
58
+    is_enabled = models.BooleanField(default=True)
59
+
60
+    IMAGE_NAME_MAP = {
61
+        "Pickup & Logistics": "pickup-logistics.png",
62
+        "Material Sorting": "material-sorting.png",
63
+        "Weighing & Ticketing": "weighing-ticketing.png",
64
+        "Invoicing & Payouts": "invoicing-payouts.png",
65
+        "Reporting & Analytics": "reporting-analytics.png",
66
+        "Marketplace & Bidding": "marketplace-bidding.png",
67
+        "Compliance & Audits": "compliance-audits.png",
68
+        "Consulting & Training": "consulting-training.png",
69
+    }
70
+
71
+    @property
72
+    def image_name(self) -> str:
73
+        return self.IMAGE_NAME_MAP.get(self.title, "default-service.png")
74
+
75
+    class Meta:
76
+        ordering = ["display_order"]
77
+
78
+    def __str__(self) -> str:
79
+        return self.title
80
+
81
+
37 82
 class Material(TimestampedModel):
38 83
     organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="materials")
39 84
     category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, related_name="materials")

+ 13 - 0
recycle_core/templates/recycle_core/service_form.html

@@ -0,0 +1,13 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% load crispy_forms_tags breadcrumbs %}
3
+{% block title %}{% if item %}Edit Service{% else %}New Service{% endif %}{% endblock %}
4
+{% block content %}
5
+{% render_breadcrumbs breadcrumbs %}
6
+<div class="flex items-center justify-between mb-4">
7
+  <h1 class="text-xl font-semibold">{% if item %}Edit Service{% else %}New Service{% endif %}</h1>
8
+  <a href="{% url 'recycle_core:services_list' %}" class="btn-outline">Back</a>
9
+  <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
10
+</div>
11
+
12
+{% include 'admin_frontend/_form.html' with form=form cancel_url=cancel_url submit_label='Save' %}
13
+{% endblock %}

+ 62 - 0
recycle_core/templates/recycle_core/services_list.html

@@ -0,0 +1,62 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% load breadcrumbs %}
3
+{% block title %}Provided Services{% endblock %}
4
+{% block content %}
5
+{% render_breadcrumbs breadcrumbs %}
6
+<div class="flex items-center justify-between mb-4">
7
+  <h1 class="text-xl font-semibold">Provided Services</h1>
8
+  <a href="{% url 'recycle_core:service_create' %}" class="btn-outline">New Service</a>
9
+  <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
10
+</div>
11
+
12
+<div class="bg-white rounded shadow overflow-hidden">
13
+  <table class="min-w-full text-sm">
14
+    <thead class="bg-gray-50 text-left">
15
+      <tr>
16
+        <th class="px-4 py-2 w-16">Order</th>
17
+        <th class="px-4 py-2">Title</th>
18
+        <th class="px-4 py-2">Description</th>
19
+        <th class="px-4 py-2 w-24">Status</th>
20
+        <th class="px-4 py-2 w-48">Actions</th>
21
+      </tr>
22
+    </thead>
23
+    <tbody>
24
+      {% for s in services %}
25
+      <tr class="border-t">
26
+        <td class="px-4 py-2 text-gray-600">{{ s.display_order }}</td>
27
+        <td class="px-4 py-2 font-medium">{{ s.title }}</td>
28
+        <td class="px-4 py-2 text-gray-700">{{ s.description|truncatechars:140 }}</td>
29
+        <td class="px-4 py-2">
30
+          {% if s.is_enabled %}
31
+            <span class="inline-flex items-center rounded bg-green-50 text-green-700 px-2 py-0.5 text-xs">Enabled</span>
32
+          {% else %}
33
+            <span class="inline-flex items-center rounded bg-gray-100 text-gray-600 px-2 py-0.5 text-xs">Disabled</span>
34
+          {% endif %}
35
+        </td>
36
+        <td class="px-4 py-2">
37
+          <div class="flex items-center gap-3">
38
+            <a href="{% url 'recycle_core:service_edit' s.pk %}" class="text-blue-600 hover:text-blue-700">Edit</a>
39
+            <form method="post" action="{% url 'recycle_core:service_toggle_enabled' s.pk %}">
40
+              {% csrf_token %}
41
+              <button type="submit" class="text-gray-700 hover:text-gray-900">{% if s.is_enabled %}Disable{% else %}Enable{% endif %}</button>
42
+            </form>
43
+            <form method="post" action="{% url 'recycle_core:service_delete' s.pk %}" onsubmit="return confirm('Delete this service?');">
44
+              {% csrf_token %}
45
+              <button type="submit" class="text-red-600 hover:text-red-700">Delete</button>
46
+            </form>
47
+          </div>
48
+        </td>
49
+      </tr>
50
+      {% empty %}
51
+      <tr>
52
+        <td colspan="4" class="px-4 py-6 text-center text-gray-500">No services yet. Click New Service to add one.</td>
53
+      </tr>
54
+      {% endfor %}
55
+    </tbody>
56
+  </table>
57
+  <div class="px-4 py-3 text-xs text-gray-500 border-t">Sorted by display order ascending.</div>
58
+  <style>
59
+    .btn{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}
60
+  </style>
61
+</div>
62
+{% endblock %}

+ 6 - 0
recycle_core/urls.py

@@ -39,4 +39,10 @@ urlpatterns = [
39 39
     path("audit-logs/export/", views.audit_logs_export_csv, name="audit_logs_export_csv"),
40 40
     path("payouts/", views.payouts_list, name="payouts_list"),
41 41
     path("payouts/export/", views.payouts_export_csv, name="payouts_export_csv"),
42
+    # Services
43
+    path("services/", views.services_list, name="services_list"),
44
+    path("services/new/", views.service_create, name="service_create"),
45
+    path("services/<int:pk>/edit/", views.service_edit, name="service_edit"),
46
+    path("services/<int:pk>/delete/", views.service_delete, name="service_delete"),
47
+    path("services/<int:pk>/toggle-enabled/", views.service_toggle_enabled, name="service_toggle_enabled"),
42 48
 ]

+ 87 - 1
recycle_core/views.py

@@ -14,6 +14,7 @@ from orgs.models import Organization, UserProfile
14 14
 from .models import (
15 15
     MaterialCategory,
16 16
     Material,
17
+    ProvidedService,
17 18
     Customer,
18 19
     CustomerSite,
19 20
     PickupOrder,
@@ -27,7 +28,16 @@ from .models import (
27 28
     AuditLog,
28 29
     Payout,
29 30
 )
30
-from .forms import MaterialForm, MaterialCategoryForm, CustomerForm, PickupAssignForm, PickupStatusForm, PaymentForm, DocumentForm
31
+from .forms import (
32
+    MaterialForm,
33
+    MaterialCategoryForm,
34
+    CustomerForm,
35
+    PickupAssignForm,
36
+    PickupStatusForm,
37
+    PaymentForm,
38
+    DocumentForm,
39
+    ProvidedServiceForm,
40
+)
31 41
 from .services.billing import generate_invoice_for_pickup
32 42
 from django.contrib.contenttypes.models import ContentType
33 43
 from django.contrib.auth import get_user_model
@@ -933,3 +943,79 @@ def audit_logs_export_csv(request):
933 943
             log.metadata,
934 944
         ])
935 945
     return response
946
+@staff_member_required
947
+@public_route(label="Provided Services", order=15, icon="sparkles")
948
+@breadcrumbs(label="Provided Services", name="rc_services")
949
+def services_list(request):
950
+    if not request.user.is_authenticated or not getattr(request.user, "is_staff", False):
951
+        from django.contrib.auth.views import redirect_to_login
952
+        return redirect_to_login(request.get_full_path(), login_url=reverse("admin_frontend:login"))
953
+    org = getattr(request, "org", None)
954
+    services = ProvidedService.objects.filter(organization=org).order_by("display_order", "id")
955
+    return render(request, "recycle_core/services_list.html", {"nav": _nav_items(), "services": services})
956
+
957
+
958
+@breadcrumbs(label="New Service", parent="rc_services")
959
+@owner_required
960
+def service_create(request):
961
+    if request.method == "POST":
962
+        form = ProvidedServiceForm(request.POST)
963
+        if form.is_valid():
964
+            obj = form.save(commit=False)
965
+            obj.organization = getattr(request, "org", None)
966
+            obj.save()
967
+            messages.success(request, "Service created.")
968
+            return redirect("recycle_core:services_list")
969
+        messages.error(request, "Please correct the errors below.")
970
+    else:
971
+        form = ProvidedServiceForm()
972
+    return render(request, "recycle_core/service_form.html", {"nav": _nav_items(), "form": form, "cancel_url": reverse("recycle_core:services_list")})
973
+
974
+
975
+@breadcrumbs(label="Edit Service", parent="rc_services")
976
+@owner_required
977
+def service_edit(request, pk: int):
978
+    org = getattr(request, "org", None)
979
+    item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
980
+    if not item:
981
+        messages.error(request, "Service not found.")
982
+        return redirect("recycle_core:services_list")
983
+    if request.method == "POST":
984
+        form = ProvidedServiceForm(request.POST, instance=item)
985
+        if form.is_valid():
986
+            obj = form.save(commit=False)
987
+            obj.organization = org
988
+            obj.save()
989
+            messages.success(request, "Service updated.")
990
+            return redirect("recycle_core:service_edit", pk=item.pk)
991
+        messages.error(request, "Please correct the errors below.")
992
+    else:
993
+        form = ProvidedServiceForm(instance=item)
994
+    return render(request, "recycle_core/service_form.html", {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:services_list")})
995
+
996
+
997
+@require_POST
998
+@owner_required
999
+def service_delete(request, pk: int):
1000
+    org = getattr(request, "org", None)
1001
+    item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
1002
+    if not item:
1003
+        messages.error(request, "Service not found.")
1004
+    else:
1005
+        item.delete()
1006
+        messages.success(request, "Service deleted.")
1007
+    return redirect("recycle_core:services_list")
1008
+
1009
+
1010
+@require_POST
1011
+@owner_required
1012
+def service_toggle_enabled(request, pk: int):
1013
+    org = getattr(request, "org", None)
1014
+    item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
1015
+    if not item:
1016
+        messages.error(request, "Service not found.")
1017
+    else:
1018
+        item.is_enabled = not item.is_enabled
1019
+        item.save(update_fields=["is_enabled"])
1020
+        messages.success(request, f"Service '{item.title}' {'enabled' if item.is_enabled else 'disabled'}.")
1021
+    return redirect("recycle_core:services_list")

+ 1 - 0
requirements.txt

@@ -14,3 +14,4 @@ django-browser-reload
14 14
 django-allauth[socialaccount]
15 15
 django-markdownfield
16 16
 django-mptt
17
+django-npm

BIN
static/service_icons/compliance-audits.png


BIN
static/service_icons/consulting-training.png


BIN
static/service_icons/invoicing-payouts.png


BIN
static/service_icons/marketplace-bidding.png


BIN
static/service_icons/material-sorting.png


BIN
static/service_icons/pickup-logistics.png


BIN
static/service_icons/reporting-analytics.png


BIN
static/service_icons/weighing-ticketing.png


tmt/tiger_frontend - Gogs: Simplico Git Service

Aucune description

golf 77628cf8bf first comm il y a 3 ans
..
lib 77628cf8bf first comm il y a 3 ans
CHANGELOG.md 77628cf8bf first comm il y a 3 ans
LICENSE 77628cf8bf first comm il y a 3 ans
README.md 77628cf8bf first comm il y a 3 ans
package.json 77628cf8bf first comm il y a 3 ans
source-map.d.ts 77628cf8bf first comm il y a 3 ans
source-map.js 77628cf8bf first comm il y a 3 ans

README.md

Source Map JS

NPM

Difference between original source-map:

TL,DR: it's fork of original source-map@0.6, but with perfomance optimizations.

This journey starts from source-map@0.7.0. Some part of it was rewritten to Rust and WASM and API became async.

It's still a major block for many libraries like PostCSS or Sass for example because they need to migrate the whole API to the async way. This is the reason why 0.6.1 has 2x more downloads than 0.7.3 while it's faster several times.

Downloads count

More important that WASM version has some optimizations in JS code too. This is why community asked to create branch for 0.6 version and port these optimizations but, sadly, the answer was «no». A bit later I discovered the issue created by Ben Rothman (@benthemonkey) with no response at all.

Roman Dvornov (@lahmatiy) wrote a serveral posts (russian, only, sorry) about source-map library in his own Telegram channel. He mentioned the article «Maybe you don't need Rust and WASM to speed up your JS» written by Vyacheslav Egorov (@mraleph). This article contains optimizations and hacks that lead to almost the same performance compare to WASM implementation.

I decided to fork the original source-map and port these optimizations from the article and several others PR from the original source-map.


This is a library to generate and consume the source map format described here.

Use with Node

$ npm install source-map-js

Table of Contents

Examples

Consuming a source map

var rawSourceMap = {
  version: 3,
  file: 'min.js',
  names: ['bar', 'baz', 'n'],
  sources: ['one.js', 'two.js'],
  sourceRoot: 'http://example.com/www/js/',
  mappings: 'CAAC,IAAI,IAAM,SAAUA,GAClB,OAAOC,IAAID;CCDb,IAAI,IAAM,SAAUE,GAClB,OAAOA'
};

var smc = new SourceMapConsumer(rawSourceMap);

console.log(smc.sources);
// [ 'http://example.com/www/js/one.js',
//   'http://example.com/www/js/two.js' ]

console.log(smc.originalPositionFor({
  line: 2,
  column: 28
}));
// { source: 'http://example.com/www/js/two.js',
//   line: 2,
//   column: 10,
//   name: 'n' }

console.log(smc.generatedPositionFor({
  source: 'http://example.com/www/js/two.js',
  line: 2,
  column: 10
}));
// { line: 2, column: 28 }

smc.eachMapping(function (m) {
  // ...
});

Generating a source map

In depth guide: Compiling to JavaScript, and Debugging with Source Maps

With SourceNode (high level API)

function compile(ast) {
  switch (ast.type) {
  case 'BinaryExpression':
    return new SourceNode(
      ast.location.line,
      ast.location.column,
      ast.location.source,
      [compile(ast.left), " + ", compile(ast.right)]
    );
  case 'Literal':
    return new SourceNode(
      ast.location.line,
      ast.location.column,
      ast.location.source,
      String(ast.value)
    );
  // ...
  default:
    throw new Error("Bad AST");
  }
}

var ast = parse("40 + 2", "add.js");
console.log(compile(ast).toStringWithSourceMap({
  file: 'add.js'
}));
// { code: '40 + 2',
//   map: [object SourceMapGenerator] }

With SourceMapGenerator (low level API)

var map = new SourceMapGenerator({
  file: "source-mapped.js"
});

map.addMapping({
  generated: {
    line: 10,
    column: 35
  },
  source: "foo.js",
  original: {
    line: 33,
    column: 2
  },
  name: "christopher"
});

console.log(map.toString());
// '{"version":3,"file":"source-mapped.js","sources":["foo.js"],"names":["christopher"],"mappings":";;;;;;;;;mCAgCEA"}'

API

Get a reference to the module:

// Node.js
var sourceMap = require('source-map');

// Browser builds
var sourceMap = window.sourceMap;

// Inside Firefox
const sourceMap = require("devtools/toolkit/sourcemap/source-map.js");

SourceMapConsumer

A SourceMapConsumer instance represents a parsed source map which we can query for information about the original file positions by giving it a file position in the generated source.

new SourceMapConsumer(rawSourceMap)

The only parameter is the raw source map (either as a string which can be JSON.parse'd, or an object). According to the spec, source maps have the following attributes:

  • version: Which version of the source map spec this map is following.

  • sources: An array of URLs to the original source files.

  • names: An array of identifiers which can be referenced by individual mappings.

  • sourceRoot: Optional. The URL root from which all sources are relative.

  • sourcesContent: Optional. An array of contents of the original source files.

  • mappings: A string of base64 VLQs which contain the actual mappings.

  • file: Optional. The generated filename this source map is associated with.

var consumer = new sourceMap.SourceMapConsumer(rawSourceMapJsonData);

SourceMapConsumer.prototype.computeColumnSpans()

Compute the last column for each generated mapping. The last column is inclusive.

// Before:
consumer.allGeneratedPositionsFor({ line: 2, source: "foo.coffee" })
// [ { line: 2,
//     column: 1 },
//   { line: 2,
//     column: 10 },
//   { line: 2,
//     column: 20 } ]

consumer.computeColumnSpans();

// After:
consumer.allGeneratedPositionsFor({ line: 2, source: "foo.coffee" })
// [ { line: 2,
//     column: 1,
//     lastColumn: 9 },
//   { line: 2,
//     column: 10,
//     lastColumn: 19 },
//   { line: 2,
//     column: 20,
//     lastColumn: Infinity } ]

SourceMapConsumer.prototype.originalPositionFor(generatedPosition)

Returns the original source, line, and column information for the generated source's line and column positions provided. The only argument is an object with the following properties:

  • line: The line number in the generated source. Line numbers in this library are 1-based (note that the underlying source map specification uses 0-based line numbers -- this library handles the translation).

  • column: The column number in the generated source. Column numbers in this library are 0-based.

  • bias: Either SourceMapConsumer.GREATEST_LOWER_BOUND or SourceMapConsumer.LEAST_UPPER_BOUND. Specifies whether to return the closest element that is smaller than or greater than the one we are searching for, respectively, if the exact element cannot be found. Defaults to SourceMapConsumer.GREATEST_LOWER_BOUND.

and an object is returned with the following properties:

  • source: The original source file, or null if this information is not available.

  • line: The line number in the original source, or null if this information is not available. The line number is 1-based.

  • column: The column number in the original source, or null if this information is not available. The column number is 0-based.

  • name: The original identifier, or null if this information is not available.

consumer.originalPositionFor({ line: 2, column: 10 })
// { source: 'foo.coffee',
//   line: 2,
//   column: 2,
//   name: null }

consumer.originalPositionFor({ line: 99999999999999999, column: 999999999999999 })
// { source: null,
//   line: null,
//   column: null,
//   name: null }

SourceMapConsumer.prototype.generatedPositionFor(originalPosition)

Returns the generated line and column information for the original source, line, and column positions provided. The only argument is an object with the following properties:

  • source: The filename of the original source.

  • line: The line number in the original source. The line number is 1-based.

  • column: The column number in the original source. The column number is 0-based.

and an object is returned with the following properties:

  • line: The line number in the generated source, or null. The line number is 1-based.

  • column: The column number in the generated source, or null. The column number is 0-based.

consumer.generatedPositionFor({ source: "example.js", line: 2, column: 10 })
// { line: 1,
//   column: 56 }

SourceMapConsumer.prototype.allGeneratedPositionsFor(originalPosition)

Returns all generated line and column information for the original source, line, and column provided. If no column is provided, returns all mappings corresponding to a either the line we are searching for or the next closest line that has any mappings. Otherwise, returns all mappings corresponding to the given line and either the column we are searching for or the next closest column that has any offsets.

The only argument is an object with the following properties:

  • source: The filename of the original source.

  • line: The line number in the original source. The line number is 1-based.

  • column: Optional. The column number in the original source. The column number is 0-based.

and an array of objects is returned, each with the following properties:

  • line: The line number in the generated source, or null. The line number is 1-based.

  • column: The column number in the generated source, or null. The column number is 0-based.

consumer.allGeneratedpositionsfor({ line: 2, source: "foo.coffee" })
// [ { line: 2,
//     column: 1 },
//   { line: 2,
//     column: 10 },
//   { line: 2,
//     column: 20 } ]

SourceMapConsumer.prototype.hasContentsOfAllSources()

Return true if we have the embedded source content for every source listed in the source map, false otherwise.

In other words, if this method returns true, then consumer.sourceContentFor(s) will succeed for every source s in consumer.sources.

// ...
if (consumer.hasContentsOfAllSources()) {
  consumerReadyCallback(consumer);
} else {
  fetchSources(consumer, consumerReadyCallback);
}
// ...

SourceMapConsumer.prototype.sourceContentFor(source[, returnNullOnMissing])

Returns the original source content for the source provided. The only argument is the URL of the original source file.

If the source content for the given source is not found, then an error is thrown. Optionally, pass true as the second param to have null returned instead.

consumer.sources
// [ "my-cool-lib.clj" ]

consumer.sourceContentFor("my-cool-lib.clj")
// "..."

consumer.sourceContentFor("this is not in the source map");
// Error: "this is not in the source map" is not in the source map

consumer.sourceContentFor("this is not in the source map", true);
// null

SourceMapConsumer.prototype.eachMapping(callback, context, order)

Iterate over each mapping between an original source/line/column and a generated line/column in this source map.

  • callback: The function that is called with each mapping. Mappings have the form { source, generatedLine, generatedColumn, originalLine, originalColumn, name }

  • context: Optional. If specified, this object will be the value of this every time that callback is called.

  • order: Either SourceMapConsumer.GENERATED_ORDER or SourceMapConsumer.ORIGINAL_ORDER. Specifies whether you want to iterate over the mappings sorted by the generated file's line/column order or the original's source/line/column order, respectively. Defaults to SourceMapConsumer.GENERATED_ORDER.

consumer.eachMapping(function (m) { console.log(m); })
// ...
// { source: 'illmatic.js',
//   generatedLine: 1,
//   generatedColumn: 0,
//   originalLine: 1,
//   originalColumn: 0,
//   name: null }
// { source: 'illmatic.js',
//   generatedLine: 2,
//   generatedColumn: 0,
//   originalLine: 2,
//   originalColumn: 0,
//   name: null }
// ...

SourceMapGenerator

An instance of the SourceMapGenerator represents a source map which is being built incrementally.

new SourceMapGenerator([startOfSourceMap])

You may pass an object with the following properties:

  • file: The filename of the generated source that this source map is associated with.

  • sourceRoot: A root for all relative URLs in this source map.

  • skipValidation: Optional. When true, disables validation of mappings as they are added. This can improve performance but should be used with discretion, as a last resort. Even then, one should avoid using this flag when running tests, if possible.

var generator = new sourceMap.SourceMapGenerator({
  file: "my-generated-javascript-file.js",
  sourceRoot: "http://example.com/app/js/"
});

SourceMapGenerator.fromSourceMap(sourceMapConsumer)

Creates a new SourceMapGenerator from an existing SourceMapConsumer instance.

  • sourceMapConsumer The SourceMap.
var generator = sourceMap.SourceMapGenerator.fromSourceMap(consumer);

SourceMapGenerator.prototype.addMapping(mapping)

Add a single mapping from original source line and column to the generated source's line and column for this source map being created. The mapping object should have the following properties:

  • generated: An object with the generated line and column positions.

  • original: An object with the original line and column positions.

  • source: The original source file (relative to the sourceRoot).

  • name: An optional original token name for this mapping.

generator.addMapping({
  source: "module-one.scm",
  original: { line: 128, column: 0 },
  generated: { line: 3, column: 456 }
})

SourceMapGenerator.prototype.setSourceContent(sourceFile, sourceContent)

Set the source content for an original source file.

  • sourceFile the URL of the original source file.

  • sourceContent the content of the source file.

generator.setSourceContent("module-one.scm",
                           fs.readFileSync("path/to/module-one.scm"))

SourceMapGenerator.prototype.applySourceMap(sourceMapConsumer[, sourceFile[, sourceMapPath]])

Applies a SourceMap for a source file to the SourceMap. Each mapping to the supplied source file is rewritten using the supplied SourceMap. Note: The resolution for the resulting mappings is the minimum of this map and the supplied map.

  • sourceMapConsumer: The SourceMap to be applied.

  • sourceFile: Optional. The filename of the source file. If omitted, sourceMapConsumer.file will be used, if it exists. Otherwise an error will be thrown.

  • sourceMapPath: Optional. The dirname of the path to the SourceMap to be applied. If relative, it is relative to the SourceMap.

This parameter is needed when the two SourceMaps aren't in the same directory, and the SourceMap to be applied contains relative source paths. If so, those relative source paths need to be rewritten relative to the SourceMap.

If omitted, it is assumed that both SourceMaps are in the same directory, thus not needing any rewriting. (Supplying '.' has the same effect.)

SourceMapGenerator.prototype.toString()

Renders the source map being generated to a string.

generator.toString()
// '{"version":3,"sources":["module-one.scm"],"names":[],"mappings":"...snip...","file":"my-generated-javascript-file.js","sourceRoot":"http://example.com/app/js/"}'

SourceNode

SourceNodes provide a way to abstract over interpolating and/or concatenating snippets of generated JavaScript source code, while maintaining the line and column information associated between those snippets and the original source code. This is useful as the final intermediate representation a compiler might use before outputting the generated JS and source map.

new SourceNode([line, column, source[, chunk[, name]]])

  • line: The original line number associated with this source node, or null if it isn't associated with an original line. The line number is 1-based.

  • column: The original column number associated with this source node, or null if it isn't associated with an original column. The column number is 0-based.

  • source: The original source's filename; null if no filename is provided.

  • chunk: Optional. Is immediately passed to SourceNode.prototype.add, see below.

  • name: Optional. The original identifier.

var node = new SourceNode(1, 2, "a.cpp", [
  new SourceNode(3, 4, "b.cpp", "extern int status;\n"),
  new SourceNode(5, 6, "c.cpp", "std::string* make_string(size_t n);\n"),
  new SourceNode(7, 8, "d.cpp", "int main(int argc, char** argv) {}\n"),
]);

SourceNode.fromStringWithSourceMap(code, sourceMapConsumer[, relativePath])

Creates a SourceNode from generated code and a SourceMapConsumer.

  • code: The generated code

  • sourceMapConsumer The SourceMap for the generated code

  • relativePath The optional path that relative sources in sourceMapConsumer should be relative to.

var consumer = new SourceMapConsumer(fs.readFileSync("path/to/my-file.js.map", "utf8"));
var node = SourceNode.fromStringWithSourceMap(fs.readFileSync("path/to/my-file.js"),
                                              consumer);

SourceNode.prototype.add(chunk)

Add a chunk of generated JS to this source node.

  • chunk: A string snippet of generated JS code, another instance of SourceNode, or an array where each member is one of those things.
node.add(" + ");
node.add(otherNode);
node.add([leftHandOperandNode, " + ", rightHandOperandNode]);

SourceNode.prototype.prepend(chunk)

Prepend a chunk of generated JS to this source node.

  • chunk: A string snippet of generated JS code, another instance of SourceNode, or an array where each member is one of those things.
node.prepend("/** Build Id: f783haef86324gf **/\n\n");

SourceNode.prototype.setSourceContent(sourceFile, sourceContent)

Set the source content for a source file. This will be added to the SourceMap in the sourcesContent field.

  • sourceFile: The filename of the source file

  • sourceContent: The content of the source file

node.setSourceContent("module-one.scm",
                      fs.readFileSync("path/to/module-one.scm"))

SourceNode.prototype.walk(fn)

Walk over the tree of JS snippets in this node and its children. The walking function is called once for each snippet of JS and is passed that snippet and the its original associated source's line/column location.

  • fn: The traversal function.
var node = new SourceNode(1, 2, "a.js", [
  new SourceNode(3, 4, "b.js", "uno"),
  "dos",
  [
    "tres",
    new SourceNode(5, 6, "c.js", "quatro")
  ]
]);

node.walk(function (code, loc) { console.log("WALK:", code, loc); })
// WALK: uno { source: 'b.js', line: 3, column: 4, name: null }
// WALK: dos { source: 'a.js', line: 1, column: 2, name: null }
// WALK: tres { source: 'a.js', line: 1, column: 2, name: null }
// WALK: quatro { source: 'c.js', line: 5, column: 6, name: null }

SourceNode.prototype.walkSourceContents(fn)

Walk over the tree of SourceNodes. The walking function is called for each source file content and is passed the filename and source content.

  • fn: The traversal function.
var a = new SourceNode(1, 2, "a.js", "generated from a");
a.setSourceContent("a.js", "original a");
var b = new SourceNode(1, 2, "b.js", "generated from b");
b.setSourceContent("b.js", "original b");
var c = new SourceNode(1, 2, "c.js", "generated from c");
c.setSourceContent("c.js", "original c");

var node = new SourceNode(null, null, null, [a, b, c]);
node.walkSourceContents(function (source, contents) { console.log("WALK:", source, ":", contents); })
// WALK: a.js : original a
// WALK: b.js : original b
// WALK: c.js : original c

SourceNode.prototype.join(sep)

Like Array.prototype.join except for SourceNodes. Inserts the separator between each of this source node's children.

  • sep: The separator.
var lhs = new SourceNode(1, 2, "a.rs", "my_copy");
var operand = new SourceNode(3, 4, "a.rs", "=");
var rhs = new SourceNode(5, 6, "a.rs", "orig.clone()");

var node = new SourceNode(null, null, null, [ lhs, operand, rhs ]);
var joinedNode = node.join(" ");

SourceNode.prototype.replaceRight(pattern, replacement)

Call String.prototype.replace on the very right-most source snippet. Useful for trimming white space from the end of a source node, etc.

  • pattern: The pattern to replace.

  • replacement: The thing to replace the pattern with.

// Trim trailing white space.
node.replaceRight(/\s*$/, "");

SourceNode.prototype.toString()

Return the string representation of this source node. Walks over the tree and concatenates all the various snippets together to one string.

var node = new SourceNode(1, 2, "a.js", [
  new SourceNode(3, 4, "b.js", "uno"),
  "dos",
  [
    "tres",
    new SourceNode(5, 6, "c.js", "quatro")
  ]
]);

node.toString()
// 'unodostresquatro'

SourceNode.prototype.toStringWithSourceMap([startOfSourceMap])

Returns the string representation of this tree of source nodes, plus a SourceMapGenerator which contains all the mappings between the generated and original sources.

The arguments are the same as those to new SourceMapGenerator.

var node = new SourceNode(1, 2, "a.js", [
  new SourceNode(3, 4, "b.js", "uno"),
  "dos",
  [
    "tres",
    new SourceNode(5, 6, "c.js", "quatro")
  ]
]);

node.toStringWithSourceMap({ file: "my-output-file.js" })
// { code: 'unodostresquatro',
//   map: [object SourceMapGenerator] }