s-num-new"> 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

二進制
static/service_icons/compliance-audits.png


二進制
static/service_icons/consulting-training.png


二進制
static/service_icons/invoicing-payouts.png


二進制
static/service_icons/marketplace-bidding.png


二進制
static/service_icons/material-sorting.png


二進制
static/service_icons/pickup-logistics.png


二進制
static/service_icons/reporting-analytics.png


二進制
static/service_icons/weighing-ticketing.png


mcot/spacemcot - Gogs: Simplico Git Service

Нет описания

karma.conf.js 2.7KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. // Karma configuration file
  2. var karma = require("karma");
  3. var ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
  4. var DEFAULT_NG_VERSION = "1.6";
  5. /**
  6. * This returns a Karma 'files configuration'.
  7. * http://karma-runner.github.io/0.8/config/files.html
  8. *
  9. * Specifies which files can be served by the Karma web server
  10. *
  11. * included: true -- files that are always served to the browser (like <script> tag)
  12. * included: false -- files *available to be served* by karma, for instance via require()
  13. */
  14. function karmaServedFiles(ngVersion) {
  15. // Returns necessary files for a specific version of angular
  16. function angular(version) {
  17. console.log('Using Angular ' + ngVersion + ' from test/angular/' + version + '/angular.js');
  18. return [
  19. 'test/angular/' + version + '/angular.js',
  20. 'test/angular/' + version + '/angular-mocks.js',
  21. 'test/angular/' + version + '/angular-animate.js',
  22. ];
  23. }
  24. var angularFiles = angular(ngVersion).map(function (pattern) {
  25. return { watched: false, included: true, nocache: true, pattern: pattern };
  26. });
  27. return angularFiles.concat('test/index.js');
  28. }
  29. var webpackConfig = module.exports = {
  30. resolve: {
  31. modules: ['node_modules'],
  32. extensions: ['.js', '.jsx', '.ts', '.tsx']
  33. },
  34. devtool: 'inline-source-map',
  35. module: {
  36. rules: [
  37. { test: /\.tsx?$/, loader: 'ts-loader', options: { transpileOnly: true } }
  38. ]
  39. },
  40. stats: false,
  41. plugins: [
  42. new ForkTsCheckerWebpackPlugin(),
  43. ],
  44. externals: [ 'angular' ]
  45. };
  46. module.exports = function(config) {
  47. var ngVersion = config.ngversion || DEFAULT_NG_VERSION;
  48. config.set({
  49. singleRun: true,
  50. autoWatch: false,
  51. autoWatchInterval: 0,
  52. // level of logging
  53. // possible values: LOG_DISABLE, LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG
  54. logLevel: "warn",
  55. // possible values: 'dots', 'progress'
  56. reporters: 'dots',
  57. colors: true,
  58. port: 8080,
  59. // base path, that will be used to resolve files and exclude
  60. basePath: '.',
  61. // Start these browsers, currently available:
  62. // Chrome, ChromeCanary, Firefox, Opera, Safari, PhantomJS
  63. browsers: ['PhantomJS'],
  64. frameworks: ['jasmine'],
  65. plugins: [
  66. require('karma-webpack'),
  67. require('karma-sourcemap-loader'),
  68. require('karma-jasmine'),
  69. require('karma-phantomjs-launcher'),
  70. require('karma-chrome-launcher')
  71. ],
  72. webpack: webpackConfig,
  73. webpackMiddleware: {
  74. stats: { chunks: false },
  75. },
  76. /* Files *available to be served* by karma, i.e., anything that will be require()'d */
  77. files: karmaServedFiles(ngVersion),
  78. preprocessors: {
  79. 'test/index.js': ['webpack', 'sourcemap'],
  80. '../src/ng1': ['webpack', 'sourcemap'],
  81. },
  82. });
  83. };