ines-num lines-num-new"> 13
+    operations = [
14
+        migrations.CreateModel(
15
+            name='ProvidedService',
16
+            fields=[
17
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18
+                ('created_at', models.DateTimeField(auto_now_add=True)),
19
+                ('updated_at', models.DateTimeField(auto_now=True)),
20
+                ('title', models.CharField(max_length=100)),
21
+                ('description', models.TextField()),
22
+                ('icon_svg', models.TextField(blank=True, help_text='Paste SVG code for the icon.')),
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
+    ]

+ 18 - 0
orgs/migrations/0004_alter_providedservice_title.py

1
+# Generated by Django 4.2.24 on 2025-09-22 05:08
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('orgs', '0003_providedservice'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AlterField(
14
+            model_name='providedservice',
15
+            name='title',
16
+            field=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),
17
+        ),
18
+    ]

+ 17 - 0
orgs/migrations/0005_remove_providedservice_icon_svg.py

1
+# Generated by Django 4.2.24 on 2025-09-22 05:11
2
+
3
+from django.db import migrations
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('orgs', '0004_alter_providedservice_title'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.RemoveField(
14
+            model_name='providedservice',
15
+            name='icon_svg',
16
+        ),
17
+    ]

+ 16 - 0
orgs/migrations/0006_delete_providedservice.py

1
+# Generated by Django 4.2.24 on 2025-09-22 08:26
2
+
3
+from django.db import migrations
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('orgs', '0005_remove_providedservice_icon_svg'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.DeleteModel(
14
+            name='ProvidedService',
15
+        ),
16
+    ]

+ 0 - 1
orgs/urls_admin.py

7
     path("settings/", views.org_settings, name="org_settings"),
7
     path("settings/", views.org_settings, name="org_settings"),
8
     path("sites/", views.org_sites, name="org_sites"),
8
     path("sites/", views.org_sites, name="org_sites"),
9
 ]
9
 ]
10
-

+ 3 - 0
orgs/views_admin.py

3
 from django.shortcuts import render, redirect
3
 from django.shortcuts import render, redirect
4
 from django.contrib import messages
4
 from django.contrib import messages
5
 from django.urls import reverse
5
 from django.urls import reverse
6
+from django.views.decorators.http import require_POST
6
 
7
 
7
 from .models import Organization, OrganizationSite
8
 from .models import Organization, OrganizationSite
8
 from .forms import OrganizationForm
9
 from .forms import OrganizationForm
66
 
67
 
67
     return render(request, "orgs/org_sites.html", {"mappings": mappings, "current_site": getattr(request, "site", None)})
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

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

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
     <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex items-center justify-between h-16">
12
     <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex items-center justify-between h-16">
13
       <a href="/" class="text-lg font-semibold">{% if current_site %}{{ current_site.name }}{% else %}Ecoloop{% endif %}</a>
13
       <a href="/" class="text-lg font-semibold">{% if current_site %}{{ current_site.name }}{% else %}Ecoloop{% endif %}</a>
14
       <nav class="hidden md:flex items-center gap-6">
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
       </nav>
20
       </nav>
21
     </div>
21
     </div>
22
   </header>
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
     {% block content %}{% endblock %}
24
     {% block content %}{% endblock %}
25
   </main>
25
   </main>
26
   <footer class="bg-white border-t">
26
   <footer class="bg-white border-t">

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

1
 {% extends 'public_frontend/base.html' %}
1
 {% extends 'public_frontend/base.html' %}
2
+{% load static %}
2
 {% block title %}Home{% endblock %}
3
 {% block title %}Home{% endblock %}
4
+
3
 {% block content %}
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
     </div>
14
     </div>
12
   </section>
15
   </section>
13
 
16
 
14
-  <section class="bg-white rounded shadow p-6">
17
+  {# Services Section #}
18
+  {% if services %}
19
+  <section id="services">
15
     <div class="flex items-center justify-between mb-3">
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
     </div>
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
         </a>
31
         </a>
26
-      {% empty %}
27
-        <div class="text-gray-500">No posts yet.</div>
28
       {% endfor %}
32
       {% endfor %}
29
     </div>
33
     </div>
30
   </section>
34
   </section>
35
+  {% endif %}
31
 
36
 
32
-  <section class="bg-white rounded shadow p-6">
37
+  {# Materials Section #}
38
+  <section id="materials">
33
     <div class="flex items-center justify-between mb-3">
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
     </div>
62
     </div>
37
     <div class="grid md:grid-cols-3 gap-4">
63
     <div class="grid md:grid-cols-3 gap-4">
38
       {% for l in listings %}
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
           <div class="text-sm text-gray-500">Ends {{ l.ends_at|default:'—' }}</div>
66
           <div class="text-sm text-gray-500">Ends {{ l.ends_at|default:'—' }}</div>
41
           <div class="font-medium">{{ l.title }}</div>
67
           <div class="font-medium">{{ l.title }}</div>
42
           <div class="text-sm text-gray-600 line-clamp-2">{{ l.description|truncatechars:120 }}</div>
68
           <div class="text-sm text-gray-600 line-clamp-2">{{ l.description|truncatechars:120 }}</div>
43
         </a>
69
         </a>
44
       {% empty %}
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
       {% endfor %}
124
       {% endfor %}
47
     </div>
125
     </div>
48
   </section>
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
 </div>
156
 </div>
50
 
157
 
51
 <style>
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
 </style>
167
 </style>
55
 {% endblock %}
168
 {% endblock %}
56
-

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

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

+ 62 - 0
public_frontend/use_cases.md

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
 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
6
 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
7
 
7
 
8
 from orgs.models import Organization
8
 from orgs.models import Organization
9
+from recycle_core.models import ProvidedService
9
 from recycle_core.models import Material, ScrapListing, PickupOrder
10
 from recycle_core.models import Material, ScrapListing, PickupOrder
10
 from cms.models import Post, PostCategory
11
 from cms.models import Post, PostCategory
11
 
12
 
24
     if org is not None:
25
     if org is not None:
25
         listings_qs = listings_qs.filter(organization=org)
26
         listings_qs = listings_qs.filter(organization=org)
26
     listings = listings_qs.order_by("-created_at")[:6]
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
 def materials_list(request):
57
 def materials_list(request):
60
     return render(request, "public_frontend/listing_detail.html", {"listing": listing, "org": org})
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
 def pickup_request(request):
99
 def pickup_request(request):
64
     org = getattr(request, "org", None)
100
     org = getattr(request, "org", None)
65
     form = PickupRequestForm(request.POST or None)
101
     form = PickupRequestForm(request.POST or None)

+ 8 - 0
recycle_core/admin.py

168
 class ScrapListingInviteAdmin(OrgScopedAdmin):
168
 class ScrapListingInviteAdmin(OrgScopedAdmin):
169
     list_display = ("listing", "invited_org", "invited_user", "created_at")
169
     list_display = ("listing", "invited_org", "invited_user", "created_at")
170
     search_fields = ("listing__id", "invited_org__code", "invited_user__username")
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
 from .models import (
7
 from .models import (
8
     MaterialCategory,
8
     MaterialCategory,
9
     Material,
9
     Material,
10
+    ProvidedService,
10
     Customer,
11
     Customer,
11
     CustomerSite,
12
     CustomerSite,
12
 )
13
 )
14
+from markdownfield.widgets import MDEWidget
13
 from orgs.models import UserProfile
15
 from orgs.models import UserProfile
14
 
16
 
15
 
17
 
37
         fields = ["customer", "name", "address", "contact_name", "contact_phone", "contact_email"]
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
 # Operational forms ----------------------------------------------------------
52
 # Operational forms ----------------------------------------------------------
41
 
53
 
42
 User = get_user_model()
54
 User = get_user_model()

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

10
 from recycle_core.models import (
10
 from recycle_core.models import (
11
     MaterialCategory,
11
     MaterialCategory,
12
     Material,
12
     Material,
13
+    ProvidedService,
13
     PriceList,
14
     PriceList,
14
     PriceListItem,
15
     PriceListItem,
15
     Customer,
16
     Customer,
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
         pickup = PickupOrder.objects.create(
157
         pickup = PickupOrder.objects.create(
125
             organization=org,
158
             organization=org,
126
             customer=customer,
159
             customer=customer,

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

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

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

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

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
 from django.db import models
7
 from django.db import models
8
 from django.utils import timezone
8
 from django.utils import timezone
9
 from orgs.models import Organization
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
 User = get_user_model()
14
 User = get_user_model()
34
         return self.name
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
 class Material(TimestampedModel):
82
 class Material(TimestampedModel):
38
     organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="materials")
83
     organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="materials")
39
     category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, related_name="materials")
84
     category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, related_name="materials")

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

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

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
     path("audit-logs/export/", views.audit_logs_export_csv, name="audit_logs_export_csv"),
39
     path("audit-logs/export/", views.audit_logs_export_csv, name="audit_logs_export_csv"),
40
     path("payouts/", views.payouts_list, name="payouts_list"),
40
     path("payouts/", views.payouts_list, name="payouts_list"),
41
     path("payouts/export/", views.payouts_export_csv, name="payouts_export_csv"),
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
 from .models import (
14
 from .models import (
15
     MaterialCategory,
15
     MaterialCategory,
16
     Material,
16
     Material,
17
+    ProvidedService,
17
     Customer,
18
     Customer,
18
     CustomerSite,
19
     CustomerSite,
19
     PickupOrder,
20
     PickupOrder,
27
     AuditLog,
28
     AuditLog,
28
     Payout,
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
 from .services.billing import generate_invoice_for_pickup
41
 from .services.billing import generate_invoice_for_pickup
32
 from django.contrib.contenttypes.models import ContentType
42
 from django.contrib.contenttypes.models import ContentType
33
 from django.contrib.auth import get_user_model
43
 from django.contrib.auth import get_user_model
933
             log.metadata,
943
             log.metadata,
934
         ])
944
         ])
935
     return response
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
 django-allauth[socialaccount]
14
 django-allauth[socialaccount]
15
 django-markdownfield
15
 django-markdownfield
16
 django-mptt
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


tum/ecoloop - Gogs: Simplico Git Service

1 Commits (main)

Author SHA1 Message Date
  tum a5d82c3daa first commit 3 months ago