feat: finalize UI/UX
This commit is contained in:
+1
-1
@@ -456,5 +456,5 @@ poetry.toml
|
|||||||
|
|
||||||
# LSP config files
|
# LSP config files
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
|
*.md
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/python,django,macos,pycharm
|
# End of https://www.toptal.com/developers/gitignore/api/python,django,macos,pycharm
|
||||||
@@ -5,6 +5,12 @@ from .models import ClientProfile
|
|||||||
|
|
||||||
@admin.register(ClientProfile)
|
@admin.register(ClientProfile)
|
||||||
class ClientProfileAdmin(admin.ModelAdmin):
|
class ClientProfileAdmin(admin.ModelAdmin):
|
||||||
list_display = ("user", "company", "phone", "timezone", "updated_at")
|
list_display = ("user", "company", "job_title", "phone", "updated_at")
|
||||||
search_fields = ("user__username", "user__email", "user__first_name", "user__last_name", "company")
|
search_fields = (
|
||||||
list_filter = ("timezone",)
|
"user__username",
|
||||||
|
"user__email",
|
||||||
|
"user__first_name",
|
||||||
|
"user__last_name",
|
||||||
|
"company",
|
||||||
|
"job_title",
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ class SignUpForm(UserCreationForm):
|
|||||||
email = forms.EmailField(required=True)
|
email = forms.EmailField(required=True)
|
||||||
company = forms.CharField(max_length=200, required=False)
|
company = forms.CharField(max_length=200, required=False)
|
||||||
phone = forms.CharField(max_length=50, required=False)
|
phone = forms.CharField(max_length=50, required=False)
|
||||||
timezone = forms.ChoiceField(choices=ClientProfile.TIMEZONE_CHOICES, required=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@@ -19,7 +18,7 @@ class SignUpForm(UserCreationForm):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
for field_name in ("username", "first_name", "last_name", "email", "company", "phone", "timezone"):
|
for field_name in ("username", "first_name", "last_name", "email", "company", "phone"):
|
||||||
if field_name in self.fields:
|
if field_name in self.fields:
|
||||||
self.fields[field_name].widget.attrs.setdefault("class", "form-control")
|
self.fields[field_name].widget.attrs.setdefault("class", "form-control")
|
||||||
for field_name in ("password1", "password2"):
|
for field_name in ("password1", "password2"):
|
||||||
@@ -42,7 +41,6 @@ class SignUpForm(UserCreationForm):
|
|||||||
user=user,
|
user=user,
|
||||||
company=self.cleaned_data.get("company", ""),
|
company=self.cleaned_data.get("company", ""),
|
||||||
phone=self.cleaned_data.get("phone", ""),
|
phone=self.cleaned_data.get("phone", ""),
|
||||||
timezone=self.cleaned_data.get("timezone", "UTC"),
|
|
||||||
)
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -65,11 +63,17 @@ class ClientProfileForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ClientProfile
|
model = ClientProfile
|
||||||
fields = ("company", "phone", "timezone")
|
fields = ("avatar", "company", "job_title", "phone", "bio")
|
||||||
widgets = {
|
widgets = {
|
||||||
|
"avatar": forms.ClearableFileInput(
|
||||||
|
attrs={"class": "profile-avatar-input", "accept": "image/jpeg,image/png,image/webp"}
|
||||||
|
),
|
||||||
"company": forms.TextInput(attrs={"class": "form-control"}),
|
"company": forms.TextInput(attrs={"class": "form-control"}),
|
||||||
|
"job_title": forms.TextInput(attrs={"class": "form-control"}),
|
||||||
"phone": forms.TextInput(attrs={"class": "form-control"}),
|
"phone": forms.TextInput(attrs={"class": "form-control"}),
|
||||||
"timezone": forms.Select(attrs={"class": "form-control"}),
|
"bio": forms.Textarea(
|
||||||
|
attrs={"class": "form-control", "rows": 4, "placeholder": "A short introduction to your work and research interests"}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-08-01 12:31
|
||||||
|
|
||||||
|
import apps.accounts.models
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='clientprofile',
|
||||||
|
name='timezone',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clientprofile',
|
||||||
|
name='avatar',
|
||||||
|
field=models.ImageField(blank=True, upload_to=apps.accounts.models.profile_avatar_upload_to, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp']), apps.accounts.models.validate_avatar_size]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clientprofile',
|
||||||
|
name='bio',
|
||||||
|
field=models.TextField(blank=True, max_length=600),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clientprofile',
|
||||||
|
name='job_title',
|
||||||
|
field=models.CharField(blank=True, max_length=150),
|
||||||
|
),
|
||||||
|
]
|
||||||
+24
-18
@@ -1,33 +1,39 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import FileExtensionValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class ClientProfile(models.Model):
|
def profile_avatar_upload_to(instance, filename):
|
||||||
TIMEZONE_CHOICES = [
|
suffix = Path(filename).suffix.lower()
|
||||||
("UTC", "UTC"),
|
return f"accounts/avatars/{instance.user_id}/{uuid4().hex}{suffix}"
|
||||||
("America/New_York", "America/New York"),
|
|
||||||
("America/Chicago", "America/Chicago"),
|
|
||||||
("America/Denver", "America/Denver"),
|
|
||||||
("America/Los_Angeles", "America/Los Angeles"),
|
|
||||||
("Europe/London", "Europe/London"),
|
|
||||||
("Europe/Berlin", "Europe/Berlin"),
|
|
||||||
("Asia/Dubai", "Asia/Dubai"),
|
|
||||||
("Asia/Tehran", "Asia/Tehran"),
|
|
||||||
("Asia/Tokyo", "Asia/Tokyo"),
|
|
||||||
("Australia/Sydney", "Australia/Sydney"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
def validate_avatar_size(image):
|
||||||
|
if image.size > 5 * 1024 * 1024:
|
||||||
|
raise ValidationError("Profile pictures must be 5 MB or smaller.")
|
||||||
|
|
||||||
|
|
||||||
|
class ClientProfile(models.Model):
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="client_profile",
|
related_name="client_profile",
|
||||||
)
|
)
|
||||||
company = models.CharField(max_length=200, blank=True)
|
company = models.CharField(max_length=200, blank=True)
|
||||||
|
job_title = models.CharField(max_length=150, blank=True)
|
||||||
phone = models.CharField(max_length=50, blank=True)
|
phone = models.CharField(max_length=50, blank=True)
|
||||||
timezone = models.CharField(
|
bio = models.TextField(max_length=600, blank=True)
|
||||||
max_length=64,
|
avatar = models.ImageField(
|
||||||
choices=TIMEZONE_CHOICES,
|
upload_to=profile_avatar_upload_to,
|
||||||
default="UTC",
|
blank=True,
|
||||||
|
validators=[
|
||||||
|
FileExtensionValidator(["jpg", "jpeg", "png", "webp"]),
|
||||||
|
validate_avatar_size,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ class SignUpFormTest(TestCase):
|
|||||||
"email": "ada@example.com",
|
"email": "ada@example.com",
|
||||||
"company": "Analytical Engines",
|
"company": "Analytical Engines",
|
||||||
"phone": "+1 555 0100",
|
"phone": "+1 555 0100",
|
||||||
"timezone": "UTC",
|
|
||||||
"password1": "Str0ngPass!word",
|
"password1": "Str0ngPass!word",
|
||||||
"password2": "Str0ngPass!word",
|
"password2": "Str0ngPass!word",
|
||||||
}
|
}
|
||||||
@@ -26,7 +25,6 @@ class SignUpFormTest(TestCase):
|
|||||||
self.assertEqual(user.email, "ada@example.com")
|
self.assertEqual(user.email, "ada@example.com")
|
||||||
profile = ClientProfile.objects.get(user=user)
|
profile = ClientProfile.objects.get(user=user)
|
||||||
self.assertEqual(profile.company, "Analytical Engines")
|
self.assertEqual(profile.company, "Analytical Engines")
|
||||||
self.assertEqual(profile.timezone, "UTC")
|
|
||||||
|
|
||||||
|
|
||||||
class AccountViewsTest(TestCase):
|
class AccountViewsTest(TestCase):
|
||||||
@@ -34,6 +32,15 @@ class AccountViewsTest(TestCase):
|
|||||||
response = self.client.get(reverse("accounts:signup"))
|
response = self.client.get(reverse("accounts:signup"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_logged_out_nav_exposes_login_and_registration(self):
|
||||||
|
response = self.client.get(reverse("pages:home"))
|
||||||
|
navbar = response.content.decode().split("</nav>", 1)[0]
|
||||||
|
|
||||||
|
self.assertIn("Login", navbar)
|
||||||
|
self.assertIn("Start a Project", navbar)
|
||||||
|
self.assertIn(reverse("accounts:login"), navbar)
|
||||||
|
self.assertIn(reverse("accounts:signup"), navbar)
|
||||||
|
|
||||||
def test_signup_creates_account_and_logs_in(self):
|
def test_signup_creates_account_and_logs_in(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("accounts:signup"),
|
reverse("accounts:signup"),
|
||||||
@@ -44,7 +51,6 @@ class AccountViewsTest(TestCase):
|
|||||||
"email": "grace@example.com",
|
"email": "grace@example.com",
|
||||||
"company": "",
|
"company": "",
|
||||||
"phone": "",
|
"phone": "",
|
||||||
"timezone": "UTC",
|
|
||||||
"password1": "Str0ngPass!word",
|
"password1": "Str0ngPass!word",
|
||||||
"password2": "Str0ngPass!word",
|
"password2": "Str0ngPass!word",
|
||||||
},
|
},
|
||||||
@@ -58,6 +64,47 @@ class AccountViewsTest(TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertIn(reverse("accounts:login"), response.url)
|
self.assertIn(reverse("accounts:login"), response.url)
|
||||||
|
|
||||||
|
def test_authenticated_nav_uses_sidebar_for_account_actions(self):
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="workspace-user",
|
||||||
|
email="workspace@example.com",
|
||||||
|
password="Str0ngPass!word",
|
||||||
|
)
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("projects:dashboard"))
|
||||||
|
|
||||||
|
self.assertContains(response, "Profile & account")
|
||||||
|
self.assertContains(response, "Project briefs")
|
||||||
|
navbar = response.content.decode().split("</nav>", 1)[0]
|
||||||
|
self.assertNotIn("My Projects", navbar)
|
||||||
|
self.assertNotIn("Logout", navbar)
|
||||||
|
self.assertIn("navbar-account", navbar)
|
||||||
|
self.assertIn("navbar-account-dropdown", navbar)
|
||||||
|
self.assertIn("Your profile", navbar)
|
||||||
|
self.assertIn("New project brief", navbar)
|
||||||
|
self.assertIn("Contact support", navbar)
|
||||||
|
self.assertIn("Sign out", navbar)
|
||||||
|
self.assertIn("default-avatar.png", navbar)
|
||||||
|
|
||||||
|
def test_profile_exposes_professional_fields_and_avatar_upload(self):
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="researcher",
|
||||||
|
first_name="Marie",
|
||||||
|
last_name="Curie",
|
||||||
|
email="marie@example.com",
|
||||||
|
password="Str0ngPass!word",
|
||||||
|
)
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:profile"))
|
||||||
|
|
||||||
|
self.assertContains(response, 'enctype="multipart/form-data"')
|
||||||
|
self.assertContains(response, 'name="avatar"')
|
||||||
|
self.assertContains(response, 'name="job_title"')
|
||||||
|
self.assertContains(response, 'name="bio"')
|
||||||
|
self.assertNotContains(response, 'name="timezone"')
|
||||||
|
|
||||||
def test_logout_requires_post_and_clears_session(self):
|
def test_logout_requires_post_and_clears_session(self):
|
||||||
self.client.login(username="newclient", password="Str0ngPass!word")
|
self.client.login(username="newclient", password="Str0ngPass!word")
|
||||||
response = self.client.get(reverse("accounts:logout"))
|
response = self.client.get(reverse("accounts:logout"))
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class HomeViewTest(TestCase):
|
|||||||
from apps.projects.models import ProjectBrief
|
from apps.projects.models import ProjectBrief
|
||||||
|
|
||||||
user = User.objects.create_user(username="client", email="c@example.com", password="x")
|
user = User.objects.create_user(username="client", email="c@example.com", password="x")
|
||||||
ClientProfile.objects.create(user=user, timezone="UTC")
|
ClientProfile.objects.create(user=user)
|
||||||
HomepageSection.objects.create(
|
HomepageSection.objects.create(
|
||||||
section_type=HomepageSection.TYPE_PROJECTS,
|
section_type=HomepageSection.TYPE_PROJECTS,
|
||||||
title="Showcase",
|
title="Showcase",
|
||||||
@@ -86,7 +86,7 @@ class HomeViewTest(TestCase):
|
|||||||
from apps.projects.models import ProjectBrief
|
from apps.projects.models import ProjectBrief
|
||||||
|
|
||||||
user = User.objects.create_user(username="c2", email="c2@example.com", password="x")
|
user = User.objects.create_user(username="c2", email="c2@example.com", password="x")
|
||||||
ClientProfile.objects.create(user=user, timezone="UTC")
|
ClientProfile.objects.create(user=user)
|
||||||
HomepageSection.objects.create(
|
HomepageSection.objects.create(
|
||||||
section_type=HomepageSection.TYPE_PROJECTS,
|
section_type=HomepageSection.TYPE_PROJECTS,
|
||||||
title="Showcase",
|
title="Showcase",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ CLIENTS = [
|
|||||||
"password": "ClientDemo2026!",
|
"password": "ClientDemo2026!",
|
||||||
"company": "Northside Imaging Lab",
|
"company": "Northside Imaging Lab",
|
||||||
"phone": "+1 604 555 0101",
|
"phone": "+1 604 555 0101",
|
||||||
"timezone": "America/Vancouver",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "sarah.research",
|
"username": "sarah.research",
|
||||||
@@ -29,7 +28,6 @@ CLIENTS = [
|
|||||||
"password": "ClientDemo2026!",
|
"password": "ClientDemo2026!",
|
||||||
"company": "Pacific Oncology Research",
|
"company": "Pacific Oncology Research",
|
||||||
"phone": "+1 604 555 0102",
|
"phone": "+1 604 555 0102",
|
||||||
"timezone": "America/Los_Angeles",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "devteam",
|
"username": "devteam",
|
||||||
@@ -39,7 +37,6 @@ CLIENTS = [
|
|||||||
"password": "ClientDemo2026!",
|
"password": "ClientDemo2026!",
|
||||||
"company": "BioFlow Analytics",
|
"company": "BioFlow Analytics",
|
||||||
"phone": "+44 20 7946 0103",
|
"phone": "+44 20 7946 0103",
|
||||||
"timezone": "Europe/London",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -192,7 +189,6 @@ class Command(BaseCommand):
|
|||||||
defaults={
|
defaults={
|
||||||
"company": client["company"],
|
"company": client["company"],
|
||||||
"phone": client["phone"],
|
"phone": client["phone"],
|
||||||
"timezone": client["timezone"],
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
action = "Created" if created else "Updated"
|
action = "Created" if created else "Updated"
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ class ProjectPortalTest(TestCase):
|
|||||||
first_name="Client",
|
first_name="Client",
|
||||||
last_name="User",
|
last_name="User",
|
||||||
)
|
)
|
||||||
ClientProfile.objects.create(user=self.user, timezone="UTC")
|
ClientProfile.objects.create(user=self.user)
|
||||||
self.other = User.objects.create_user(
|
self.other = User.objects.create_user(
|
||||||
username="other",
|
username="other",
|
||||||
email="other@example.com",
|
email="other@example.com",
|
||||||
password="Str0ngPass!word",
|
password="Str0ngPass!word",
|
||||||
)
|
)
|
||||||
ClientProfile.objects.create(user=self.other, timezone="UTC")
|
ClientProfile.objects.create(user=self.other)
|
||||||
|
|
||||||
def _brief_payload(self):
|
def _brief_payload(self):
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
# Managed Projects Roadmap — Simple → Advanced
|
|
||||||
|
|
||||||
Expand the existing Tecvico Django site so **clients define projects; Tecvico delivers them**. No freelancer marketplace — users submit briefs, your team quotes, builds, and ships.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Is This Doable on the Current Stack?
|
|
||||||
|
|
||||||
**Yes — and simpler than a marketplace.** No two-sided matching, escrow to third parties, or dispute flows between strangers. You are the sole provider.
|
|
||||||
|
|
||||||
|
|
||||||
| Already in place | Reuse for managed projects |
|
|
||||||
| -------------------------------- | ------------------------------------------- |
|
|
||||||
| Django + PostgreSQL + Docker | Core backend |
|
|
||||||
| `apps.pages` + contact form | Evolve into project brief submission |
|
|
||||||
| File uploads (`contact_uploads`) | Brief attachments, deliverables |
|
|
||||||
| `apps.products` CMS patterns | Service catalog / package pages |
|
|
||||||
| Admin panel | Intake queue, quotes, assignment, delivery |
|
|
||||||
| Email + templates | Status updates, quote sent, milestone ready |
|
|
||||||
|
|
||||||
|
|
||||||
**New apps to add:**
|
|
||||||
|
|
||||||
- `apps.accounts` — client login, org profiles (Plan 2+)
|
|
||||||
- `apps.projects` — briefs, quotes, orders, milestones, deliverables
|
|
||||||
- `apps.messaging` — client ↔ Tecvico threads per project
|
|
||||||
- `apps.payments` — client invoicing via Stripe (Plan 2+)
|
|
||||||
|
|
||||||
**Internal-only (admin):** team assignment, internal notes, time tracking — no public freelancer profiles.
|
|
||||||
|
|
||||||
**Verdict:** Plan 1 is close to an enhanced contact flow. Plan 2–3 add quoting, payments, and a client portal — all standard Django.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Plan 1 — Simple (MVP) · 3–6 weeks
|
|
||||||
|
|
||||||
**Goal:** Clients submit project briefs online; Tecvico reviews and delivers off-platform billing if needed.
|
|
||||||
|
|
||||||
|
|
||||||
| Milestone | Weeks | Deliverable |
|
|
||||||
| ------------------ | ----- | ----------------------------------------------- |
|
|
||||||
| M1 Client accounts | 1 | Sign up, login, password reset |
|
|
||||||
| M2 Project briefs | 1–2 | Structured submission form + attachments |
|
|
||||||
| M3 Status tracking | 2–3 | Client sees request status; email notifications |
|
|
||||||
| M4 Admin intake | 3–4 | Review queue, status updates, internal notes |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Feature detail
|
|
||||||
|
|
||||||
**Accounts**
|
|
||||||
|
|
||||||
- Email/password signup (client role only)
|
|
||||||
- Profile: name, company, phone, timezone
|
|
||||||
- Dashboard: list of my project requests
|
|
||||||
|
|
||||||
**Project brief**
|
|
||||||
|
|
||||||
- Fields: title, category (web, data, research, etc.), description, goals, budget range, desired deadline, reference links
|
|
||||||
- Attachments: specs, mockups, datasets (reuse upload patterns)
|
|
||||||
- Status: `submitted` → `under_review` → `quoted` → `accepted` / `declined` → `in_progress` → `delivered` → `closed`
|
|
||||||
- Client can edit brief while status is `submitted` only
|
|
||||||
|
|
||||||
**Notifications**
|
|
||||||
|
|
||||||
- Email to client on status change
|
|
||||||
- Email to admin on new submission
|
|
||||||
|
|
||||||
**Admin**
|
|
||||||
|
|
||||||
- Intake list with filters (status, category, date)
|
|
||||||
- View brief + files; add internal notes (not visible to client)
|
|
||||||
- Manually set status and paste quote text (PDF/email off-platform OK)
|
|
||||||
|
|
||||||
**Out of scope:** online quotes, payments, messaging, milestones.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Plan 2 — Medium (Client Portal) · 1–2 months
|
|
||||||
|
|
||||||
**Goal:** Quote, pay, and deliver on-platform. Productized packages + custom projects.
|
|
||||||
|
|
||||||
|
|
||||||
| Milestone | Weeks | Deliverable |
|
|
||||||
| ------------- | ----- | ----------------------------------------------------- |
|
|
||||||
| M1 Foundation | 1–2 | Plan 1 + email verify, service catalog pages |
|
|
||||||
| M2 Quotes | 3–4 | Admin builds quote; client accepts/declines in portal |
|
|
||||||
| M3 Payments | 5–6 | Stripe deposit + milestone invoices |
|
|
||||||
| M4 Delivery | 7–8 | Milestones, deliverable uploads, revision rounds |
|
|
||||||
| M5 Comms | 9–10 | Per-project messaging, client dashboard polish |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Feature detail
|
|
||||||
|
|
||||||
**Service catalog**
|
|
||||||
|
|
||||||
- CMS pages for fixed offerings (e.g. “Landing page”, “Radiomics pipeline audit”)
|
|
||||||
- Package tiers: scope summary, starting price, typical timeline
|
|
||||||
- “Start from template” pre-fills brief fields
|
|
||||||
|
|
||||||
**Quotes**
|
|
||||||
|
|
||||||
- Admin creates quote from brief: line items, total, timeline, revision count included
|
|
||||||
- Client receives email + in-portal quote view
|
|
||||||
- Accept → order created; decline → brief archived with reason
|
|
||||||
|
|
||||||
**Orders & milestones**
|
|
||||||
|
|
||||||
- Order states: `awaiting_deposit` → `active` → `milestone_review` → `revision` → `completed`
|
|
||||||
- Milestones: name, due date, amount, description of deliverable
|
|
||||||
- Tecvico uploads files per milestone; client approves or requests revision (capped rounds)
|
|
||||||
- Change requests: client submits add-on brief → admin sends revised quote
|
|
||||||
|
|
||||||
**Payments (Stripe)**
|
|
||||||
|
|
||||||
- Deposit on accept (e.g. 30–50%); remainder split across milestones
|
|
||||||
- Checkout Session per invoice; receipt PDF
|
|
||||||
- Refund policy flags (admin-only)
|
|
||||||
|
|
||||||
**Messaging**
|
|
||||||
|
|
||||||
- Thread per project: client ↔ assigned PM
|
|
||||||
- File attach in thread; email digest on new message
|
|
||||||
|
|
||||||
**Admin**
|
|
||||||
|
|
||||||
- Assign internal owner (staff user) per project
|
|
||||||
- Pipeline board: new → quoted → active → blocked → done
|
|
||||||
- Basic reports: open projects, revenue this month
|
|
||||||
|
|
||||||
**Out of scope:** e-sign contracts, org/team accounts, automated estimates, real-time chat.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Plan 3 — Advanced (Full Client Platform) · 2–3 months
|
|
||||||
|
|
||||||
**Goal:** Scalable delivery ops — enterprise clients, SLAs, automation, integrations.
|
|
||||||
|
|
||||||
|
|
||||||
| Milestone | Months | Deliverable |
|
|
||||||
| --------------- | ------ | --------------------------------------------- |
|
|
||||||
| M1 Hardening | 1 | Plan 2 + 2FA, OAuth, org accounts |
|
|
||||||
| M2 Sales flow | 1–2 | E-sign, ballpark estimator, kickoff booking |
|
|
||||||
| M3 Delivery ops | 2–3 | SLA timers, internal time log, task checklist |
|
|
||||||
| M4 Enterprise | 3–4 | Multi-user orgs, API, webhooks |
|
|
||||||
| M5 Growth | 4–5 | Referrals, analytics, PWA, knowledge base |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Feature detail
|
|
||||||
|
|
||||||
**Auth & organizations**
|
|
||||||
|
|
||||||
- Google / LinkedIn OAuth; optional 2FA
|
|
||||||
- Organization account: owner + members, shared project list, role (viewer / submitter / billing)
|
|
||||||
- Billing contact separate from project contact
|
|
||||||
|
|
||||||
**Pre-sale**
|
|
||||||
|
|
||||||
- Ballpark estimator: category + scope sliders → indicative range (admin-configured rules)
|
|
||||||
- Discovery call booking (Calendly embed or built-in slot picker)
|
|
||||||
- Digital SOW + NDA (DocuSign hook or in-app accept with audit trail)
|
|
||||||
|
|
||||||
**Delivery workspace**
|
|
||||||
|
|
||||||
- Shared project hub: brief, quote, contract, timeline, files, messages
|
|
||||||
- Visual timeline / milestone Gantt for client
|
|
||||||
- SLA badges: response within X hours, milestone due alerts (Celery + email)
|
|
||||||
- Internal: task checklist, time entries per staff, capacity view (admin)
|
|
||||||
|
|
||||||
**Revisions & scope**
|
|
||||||
|
|
||||||
- Structured revision requests (what to change, priority)
|
|
||||||
- Scope creep flow: auto-flag when revision count exceeded → change order quote
|
|
||||||
|
|
||||||
**Payments+**
|
|
||||||
|
|
||||||
- Multi-currency quotes; recurring maintenance plans (Stripe Subscription)
|
|
||||||
- Pro-forma + final invoice; export for accounting
|
|
||||||
|
|
||||||
**Enterprise & integrations**
|
|
||||||
|
|
||||||
- REST API: submit brief, poll status, download deliverables
|
|
||||||
- Webhooks: `quote.sent`, `milestone.delivered`, `project.completed`
|
|
||||||
- SSO option (SAML) for large clients — optional phase
|
|
||||||
|
|
||||||
**Knowledge & retention**
|
|
||||||
|
|
||||||
- Client-facing FAQ / “how we work” tied to project phase
|
|
||||||
- Post-delivery satisfaction survey + testimonial request
|
|
||||||
- Referral codes (credit on next project)
|
|
||||||
|
|
||||||
**Admin analytics**
|
|
||||||
|
|
||||||
- Funnel: brief → quote → accept → complete
|
|
||||||
- Utilization, avg delivery time, revenue by service line
|
|
||||||
|
|
||||||
**Out of scope:** marketplace, freelancer payouts, native mobile apps.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Comparison
|
|
||||||
|
|
||||||
|
|
||||||
| | Simple | Medium | Advanced |
|
|
||||||
| ------------------------------- | ---------- | -------- | ----------------- |
|
|
||||||
| Project brief submission | ✓ | ✓ | ✓ + templates |
|
|
||||||
| Online quotes | — | ✓ | ✓ + estimator |
|
|
||||||
| Stripe payments | — | ✓ | ✓ + subscriptions |
|
|
||||||
| Milestones & deliverables | — | ✓ | ✓ + SLA |
|
|
||||||
| Client ↔ Tecvico messaging | — | ✓ | ✓ + digests |
|
|
||||||
| Service catalog / packages | — | ✓ | ✓ |
|
|
||||||
| Org / team accounts | — | — | ✓ |
|
|
||||||
| E-sign / contracts | — | — | ✓ |
|
|
||||||
| API / webhooks | — | — | ✓ |
|
|
||||||
| Internal ops (assign, time log) | notes only | pipeline | full |
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Managed vs Marketplace
|
|
||||||
|
|
||||||
|
|
||||||
| | Managed (this doc) | Marketplace (`marketplace-roadmap.md`) |
|
|
||||||
| ------------ | -------------------------- | -------------------------------------- |
|
|
||||||
| Who delivers | Tecvico team | Freelancers / clients hire each other |
|
|
||||||
| Payments | Client → Tecvico | Escrow, payouts, commissions |
|
|
||||||
| Complexity | Lower | Higher (trust, disputes, matching) |
|
|
||||||
| Best when | You sell your own services | You broker others' work |
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Recommended Path
|
|
||||||
|
|
||||||
1. **Plan 1** — ship brief + status portal; keep quoting via email if needed.
|
|
||||||
2. **Plan 2** — add quotes and Stripe once you have repeatable delivery workflow.
|
|
||||||
3. **Plan 3** — when project volume needs SLAs, org accounts, and ops dashboards.
|
|
||||||
|
|
||||||
*Timelines assume extending the current codebase; bespoke UI redesign adds ~20%.*
|
|
||||||
+385
-45
@@ -33,6 +33,19 @@
|
|||||||
--text-muted: #9ca3af;
|
--text-muted: #9ca3af;
|
||||||
--text-link: #3051ff;
|
--text-link: #3051ff;
|
||||||
|
|
||||||
|
/* Semantic UI colors */
|
||||||
|
--color-success: #087a53;
|
||||||
|
--color-success-soft: #e9f8f2;
|
||||||
|
--color-warning: #9a6b0b;
|
||||||
|
--color-warning-soft: #fff6df;
|
||||||
|
--color-danger: #b42318;
|
||||||
|
--color-danger-soft: #fff0ee;
|
||||||
|
--color-info: #3051ff;
|
||||||
|
--color-info-soft: #eef1ff;
|
||||||
|
--border-default: #e3e7ee;
|
||||||
|
--border-strong: #cbd2dd;
|
||||||
|
--focus-ring: 0 0 0 3px rgba(48, 81, 255, 0.16);
|
||||||
|
|
||||||
--footer-bg: #1f2937;
|
--footer-bg: #1f2937;
|
||||||
--footer-bottom-bg: #000000;
|
--footer-bottom-bg: #000000;
|
||||||
|
|
||||||
@@ -42,6 +55,10 @@
|
|||||||
--radius-xl: 20px;
|
--radius-xl: 20px;
|
||||||
--radius-pill: 999px;
|
--radius-pill: 999px;
|
||||||
|
|
||||||
|
--shadow-xs: 0 1px 2px rgba(17, 24, 39, 0.05);
|
||||||
|
--shadow-sm: 0 2px 8px rgba(17, 24, 39, 0.07);
|
||||||
|
--shadow-md: 0 10px 28px rgba(17, 24, 39, 0.10);
|
||||||
|
|
||||||
--spacing-xs: 0.5rem;
|
--spacing-xs: 0.5rem;
|
||||||
--spacing-sm: 1rem;
|
--spacing-sm: 1rem;
|
||||||
--spacing-md: 1.5rem;
|
--spacing-md: 1.5rem;
|
||||||
@@ -60,8 +77,8 @@
|
|||||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--transition-bounce: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
--transition-bounce: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
--font-sans: 'Ubuntu', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
--font-sans: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
--font-heading: 'Open Sans', 'Ubuntu', sans-serif;
|
--font-heading: 'Ubuntu', 'Open Sans', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -104,6 +121,11 @@ a:hover { color: var(--accent-blue-light); }
|
|||||||
ul, ol { list-style: none; }
|
ul, ol { list-style: none; }
|
||||||
address { font-style: normal; }
|
address { font-style: normal; }
|
||||||
|
|
||||||
|
:where(a, button, input, select, textarea):focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px; height: 1px;
|
width: 1px; height: 1px;
|
||||||
@@ -181,18 +203,25 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
Glass Card Component
|
Glass Card Component
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.glass-card {
|
.glass-card {
|
||||||
background: #ffffff;
|
background: var(--bg-surface);
|
||||||
border: none;
|
border: 1px solid var(--border-default);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card:hover {
|
.glass-card:hover {
|
||||||
background: #ffffff;
|
background: var(--bg-surface);
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
border-color: var(--border-default);
|
||||||
transform: scale(1.02);
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.glass-card:hover,
|
||||||
|
.section-item-link:hover,
|
||||||
|
.product-overview-card:has(.btn-primary):hover {
|
||||||
|
border-color: rgba(48, 81, 255, 0.24);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -203,14 +232,16 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.7rem 1.6rem;
|
justify-content: center;
|
||||||
border-radius: var(--radius-pill);
|
min-height: 44px;
|
||||||
font-size: 0.9rem;
|
padding: 0.65rem 1.25rem;
|
||||||
font-weight: 600;
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
transition: var(--transition-smooth);
|
transition: color .18s ease, background-color .18s ease, border-color .18s ease, box-shadow .18s ease, transform .18s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
@@ -218,37 +249,37 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent-blue);
|
background: var(--accent-blue);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: 2px solid transparent;
|
box-shadow: 0 5px 12px rgba(48, 81, 255, 0.18);
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
color: var(--accent-blue);
|
color: #fff;
|
||||||
background: transparent;
|
background: var(--accent-blue-dark);
|
||||||
border-color: var(--accent-blue);
|
border-color: var(--accent-blue-dark);
|
||||||
box-shadow: none;
|
box-shadow: 0 7px 16px rgba(48, 81, 255, 0.22);
|
||||||
transform: none;
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: var(--accent-blue-dark);
|
color: var(--accent-blue);
|
||||||
border: 2px solid transparent;
|
border-color: var(--border-strong);
|
||||||
border-radius: var(--radius-sm);
|
box-shadow: var(--shadow-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost:hover {
|
.btn-ghost:hover {
|
||||||
background: var(--accent-blue);
|
background: var(--color-info-soft);
|
||||||
border-color: var(--accent-blue);
|
border-color: var(--accent-blue);
|
||||||
color: #fff;
|
color: var(--accent-blue-dark);
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-xs);
|
||||||
transform: none;
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm,
|
||||||
padding: 0.5rem 1.1rem;
|
.btn--sm {
|
||||||
font-size: 0.82rem;
|
min-height: 36px;
|
||||||
|
padding: 0.45rem 0.9rem !important;
|
||||||
|
font-size: 0.78rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -394,6 +425,108 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
padding: 0.5rem 1.25rem;
|
padding: 0.5rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-link--cta:hover,
|
||||||
|
.nav-link--cta:focus-visible,
|
||||||
|
.nav-link--cta.active {
|
||||||
|
color: #fff !important;
|
||||||
|
background: var(--accent-blue);
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-account {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
margin-left: .35rem;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-account::-webkit-details-marker { display: none; }
|
||||||
|
.navbar-account-menu { position: relative; }
|
||||||
|
.navbar-account-menu > summary::marker { content: ''; }
|
||||||
|
|
||||||
|
.navbar-account:hover {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-account__avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(145deg, var(--accent-blue), #1736b8);
|
||||||
|
color: #fff;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-account__avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.navbar-account__status { position: absolute; right: 1px; bottom: 1px; width: 10px; height: 10px; border: 2px solid #fff; border-radius: 50%; background: var(--color-success); }
|
||||||
|
|
||||||
|
.navbar-account-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 12px);
|
||||||
|
right: 0;
|
||||||
|
width: min(320px, calc(100vw - 2rem));
|
||||||
|
padding: .65rem;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 48px rgba(17, 24, 39, .16);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity .16s ease, visibility .16s ease, transform .16s ease;
|
||||||
|
z-index: 1100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-account-dropdown::before { content: ''; position: absolute; left: 0; right: 0; top: -12px; height: 12px; }
|
||||||
|
|
||||||
|
.navbar-account-menu[open] .navbar-account-dropdown,
|
||||||
|
.navbar-account-menu:focus-within .navbar-account-dropdown {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.navbar-account-menu:hover .navbar-account-dropdown {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-account-dropdown__identity { display: grid; grid-template-columns: 44px minmax(0,1fr); gap: .75rem; align-items: center; padding: .65rem .7rem .85rem; }
|
||||||
|
.navbar-account-dropdown__identity img { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; }
|
||||||
|
.navbar-account-dropdown__identity div { min-width: 0; display: grid; }
|
||||||
|
.navbar-account-dropdown__identity strong { overflow: hidden; color: var(--text-primary); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.navbar-account-dropdown__identity span { overflow: hidden; color: var(--text-muted); font-size: .67rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.navbar-account-dropdown__section { display: grid; gap: .15rem; padding: .5rem 0; border-top: 1px solid var(--border-default); }
|
||||||
|
.navbar-account-dropdown__section a { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: .55rem; align-items: center; min-height: 48px; padding: .5rem .7rem; border-radius: var(--radius-sm); color: #445066; }
|
||||||
|
.navbar-account-dropdown__section a:hover { background: var(--color-info-soft); color: var(--accent-blue); }
|
||||||
|
.navbar-account-dropdown__section a > span:first-child { display: grid; place-items: center; width: 28px; font: 700 1rem Arial,sans-serif; }
|
||||||
|
.navbar-account-dropdown__section a > span:last-child { min-width: 0; display: grid; }
|
||||||
|
.navbar-account-dropdown__section a strong { color: inherit; font-size: .75rem; }
|
||||||
|
.navbar-account-dropdown__section a small { margin-top: .08rem; color: var(--text-muted); font-size: .62rem; }
|
||||||
|
.navbar-account-dropdown__section--compact a { display: flex; min-height: 36px; padding: .45rem .7rem; font-size: .72rem; font-weight: 600; }
|
||||||
|
.navbar-account-dropdown__section--compact form { margin: 0; }
|
||||||
|
.navbar-account-dropdown__section--compact button { width: 100%; min-height: 36px; padding: .45rem .7rem; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-danger); font: 600 .72rem var(--font-sans); text-align: left; cursor: pointer; }
|
||||||
|
.navbar-account-dropdown__section--compact button:hover { background: var(--color-danger-soft); }
|
||||||
|
|
||||||
.nav-logout-form {
|
.nav-logout-form {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -2880,7 +3013,8 @@ a.citation-count-badge:hover {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.72rem 1rem;
|
padding: 0.72rem 1rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid #d1d5db;
|
min-height: 44px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -2892,9 +3026,10 @@ a.citation-count-badge:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus,
|
.form-group input:focus,
|
||||||
.form-group textarea:focus {
|
.form-group textarea:focus,
|
||||||
|
.form-group select:focus {
|
||||||
border-color: var(--accent-blue);
|
border-color: var(--accent-blue);
|
||||||
box-shadow: 0 0 0 3px rgba(48, 81, 255, 0.12);
|
box-shadow: var(--focus-ring);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2905,15 +3040,37 @@ a.citation-count-badge:hover {
|
|||||||
|
|
||||||
.form-group.has-error input,
|
.form-group.has-error input,
|
||||||
.form-group.has-error textarea,
|
.form-group.has-error textarea,
|
||||||
|
.form-group.has-error select,
|
||||||
|
.form-group:has(.errorlist) input,
|
||||||
|
.form-group:has(.errorlist) textarea,
|
||||||
|
.form-group:has(.errorlist) select,
|
||||||
.form-group.has-error input[type="file"] {
|
.form-group.has-error input[type="file"] {
|
||||||
border-color: rgba(239, 68, 68, 0.5);
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-group input::placeholder,
|
||||||
|
.form-group textarea::placeholder { color: #9099a8; opacity: 1; }
|
||||||
|
|
||||||
|
.form-group input:disabled,
|
||||||
|
.form-group textarea:disabled,
|
||||||
|
.form-group select:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #7d8796;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorlist {
|
||||||
|
margin-top: .35rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: .76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.form-field-error {
|
.form-field-error {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: rgb(252, 165, 165);
|
color: var(--color-danger);
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
min-height: 1em;
|
min-height: 1em;
|
||||||
}
|
}
|
||||||
@@ -2947,11 +3104,6 @@ a.citation-count-badge:hover {
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--sm {
|
|
||||||
padding: 0.45rem 1.1rem !important;
|
|
||||||
font-size: 0.85rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-sidebar {
|
.contact-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -3292,6 +3444,11 @@ a.citation-count-badge:hover {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-item--account { padding-top: .5rem; border-top: 1px solid var(--border-default); }
|
||||||
|
.navbar-account { margin: 0; }
|
||||||
|
.navbar-account-dropdown { position: static; width: 100%; margin-top: .5rem; box-shadow: none; transform: none; }
|
||||||
|
.navbar-account-menu:not([open]) .navbar-account-dropdown { display: none; }
|
||||||
|
|
||||||
.nav-item.has-megamenu:hover .nav-arrow {
|
.nav-item.has-megamenu:hover .nav-arrow {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
@@ -3951,14 +4108,14 @@ a.citation-count-badge:hover {
|
|||||||
|
|
||||||
.flash-message--success,
|
.flash-message--success,
|
||||||
.flash-message--info {
|
.flash-message--info {
|
||||||
background: rgba(16, 185, 129, 0.12);
|
background: var(--color-success-soft);
|
||||||
color: #047857;
|
color: var(--color-success);
|
||||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash-message--error {
|
.flash-message--error {
|
||||||
background: rgba(239, 68, 68, 0.12);
|
background: var(--color-danger-soft);
|
||||||
color: #b91c1c;
|
color: var(--color-danger);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4077,6 +4234,179 @@ a.citation-count-badge:hover {
|
|||||||
padding-left: 1.2rem;
|
padding-left: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Authenticated client workspace
|
||||||
|
============================================================ */
|
||||||
|
.portal-body {
|
||||||
|
--dashboard-ink: #172033;
|
||||||
|
--dashboard-muted: #647087;
|
||||||
|
--dashboard-line: #e5e9f1;
|
||||||
|
--dashboard-canvas: #f5f7fb;
|
||||||
|
background: var(--dashboard-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-body #main-content { min-height: calc(100vh - var(--navbar-height)); }
|
||||||
|
|
||||||
|
.dashboard-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 264px minmax(0, 1fr);
|
||||||
|
min-height: calc(100vh - var(--navbar-height));
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--dashboard-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--navbar-height);
|
||||||
|
height: calc(100vh - var(--navbar-height));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.75rem 1.15rem 1.25rem;
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid var(--dashboard-line);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar__profile { display: flex; align-items: center; gap: .8rem; padding: 0 .55rem 1.6rem; }
|
||||||
|
.dashboard-avatar,
|
||||||
|
.profile-context-card__avatar {
|
||||||
|
display: grid; place-items: center; flex: 0 0 auto;
|
||||||
|
width: 42px; height: 42px; border-radius: 12px;
|
||||||
|
color: #fff; background: linear-gradient(145deg, var(--accent-blue), #1736b8);
|
||||||
|
font-weight: 700; box-shadow: 0 7px 18px rgba(48,81,255,.2);
|
||||||
|
}
|
||||||
|
.dashboard-avatar img,
|
||||||
|
.profile-context-card__avatar img,
|
||||||
|
.profile-avatar-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.dashboard-avatar img { border-radius: inherit; }
|
||||||
|
.dashboard-sidebar__identity { min-width: 0; display: grid; }
|
||||||
|
.dashboard-sidebar__identity strong { color: var(--dashboard-ink); font-size: .88rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dashboard-sidebar__identity span { color: var(--dashboard-muted); font-size: .7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dashboard-nav { display: grid; gap: .28rem; }
|
||||||
|
.dashboard-nav__label { margin: .3rem .75rem .55rem; color: #9aa3b3; font-size: .65rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
|
.dashboard-nav__label--secondary { margin-top: 1.35rem; }
|
||||||
|
.dashboard-nav__link { display: flex; align-items: center; gap: .78rem; min-height: 44px; padding: .65rem .75rem; border-radius: 9px; color: #505d73; font-size: .86rem; font-weight: 500; }
|
||||||
|
.dashboard-nav__link:hover { color: var(--accent-blue); background: #f4f6ff; }
|
||||||
|
.dashboard-nav__link.is-active { color: var(--accent-blue); background: #eef1ff; font-weight: 700; box-shadow: inset 3px 0 0 var(--accent-blue); }
|
||||||
|
.dashboard-nav__icon { display: grid; place-items: center; width: 24px; height: 24px; color: currentColor; font-family: Arial, sans-serif; font-size: 1.05rem; font-weight: 700; }
|
||||||
|
.dashboard-sidebar__footer { margin-top: auto; padding: 1rem .5rem 0; border-top: 1px solid var(--dashboard-line); }
|
||||||
|
.dashboard-security-note { display: flex; gap: .6rem; margin-bottom: .8rem; color: var(--dashboard-muted); font-size: .68rem; line-height: 1.45; }
|
||||||
|
.dashboard-security-note strong { display: block; color: #3d485b; font-size: .72rem; }
|
||||||
|
.dashboard-security-note__mark { width: 8px; height: 8px; margin-top: 5px; flex: 0 0 auto; border-radius: 50%; background: #22a06b; box-shadow: 0 0 0 4px rgba(34,160,107,.1); }
|
||||||
|
.dashboard-signout { width: 100%; padding: .6rem; border: 0; background: transparent; color: #7b8494; font: 600 .76rem var(--font-sans); text-align: left; cursor: pointer; }
|
||||||
|
.dashboard-signout:hover { color: #b42318; }
|
||||||
|
|
||||||
|
.dashboard-main { min-width: 0; padding: 2.4rem clamp(1.25rem, 3.5vw, 3.5rem) 4rem; }
|
||||||
|
.dashboard-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 2rem; max-width: 1180px; margin: 0 auto 2rem; }
|
||||||
|
.dashboard-header > div:first-child { max-width: 720px; }
|
||||||
|
.dashboard-eyebrow { margin-bottom: .45rem; color: var(--accent-gold) !important; font-size: .7rem !important; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
|
.dashboard-header h1 { color: var(--dashboard-ink); font: 700 clamp(1.75rem, 3vw, 2.35rem)/1.15 var(--font-heading); letter-spacing: -.035em; }
|
||||||
|
.dashboard-header p { margin-top: .55rem; color: var(--dashboard-muted); font-size: .92rem; }
|
||||||
|
.dashboard-header__meta { margin-left: .45rem; }
|
||||||
|
.dashboard-header__actions { display: flex; gap: .65rem; flex-wrap: wrap; }
|
||||||
|
.dashboard-content { max-width: 1180px; margin: 0 auto; }
|
||||||
|
.dashboard-content .btn-primary, .dashboard-header .btn-primary { box-shadow: 0 7px 16px rgba(48,81,255,.17); }
|
||||||
|
|
||||||
|
.dashboard-metrics { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 1rem; margin-bottom: 1.25rem; }
|
||||||
|
.metric-card { display: grid; min-height: 154px; padding: 1.2rem 1.25rem; background: #fff; border: 1px solid var(--dashboard-line); border-radius: 13px; box-shadow: 0 2px 8px rgba(22,32,51,.035); }
|
||||||
|
.metric-card__top { display: flex; align-items: center; justify-content: space-between; margin-bottom: .85rem; }
|
||||||
|
.metric-card__icon { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: #eef1ff; color: var(--accent-blue); font: 700 .75rem var(--font-heading); }
|
||||||
|
.metric-card__icon--gold { color: #9a6b0b; background: #fff6df; }
|
||||||
|
.metric-card__icon--green { color: #087a53; background: #e9f8f2; }
|
||||||
|
.metric-card__trend { color: #8b94a5; font-size: .66rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.metric-card > strong { color: var(--dashboard-ink); font: 700 1.55rem/1.15 var(--font-heading); letter-spacing: -.025em; }
|
||||||
|
.metric-card > span { align-self: end; color: var(--dashboard-muted); font-size: .76rem; }
|
||||||
|
|
||||||
|
.dashboard-panel { background: #fff; border: 1px solid var(--dashboard-line); border-radius: 13px; box-shadow: 0 2px 9px rgba(22,32,51,.035); overflow: hidden; }
|
||||||
|
.dashboard-panel__header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 1.25rem 1.4rem; border-bottom: 1px solid var(--dashboard-line); }
|
||||||
|
.dashboard-panel__header h2 { font-size: 1rem; letter-spacing: -.015em; }
|
||||||
|
.dashboard-panel__header p { margin-top: .25rem; color: var(--dashboard-muted); font-size: .76rem; }
|
||||||
|
.dashboard-panel__count { padding: .25rem .6rem; border-radius: 99px; background: #f1f3f7; color: #687386; font-size: .68rem; font-weight: 700; }
|
||||||
|
.portal-table th, .portal-table td { border-color: var(--dashboard-line); }
|
||||||
|
.portal-table th { padding: .78rem 1.15rem; background: #fafbfc; color: #7e8797; font-size: .65rem; letter-spacing: .07em; }
|
||||||
|
.portal-table td { padding: 1rem 1.15rem; color: #4c586c; font-size: .78rem; }
|
||||||
|
.portal-table tbody tr { transition: background .16s ease; }
|
||||||
|
.portal-table tbody tr:hover { background: #fafbff; }
|
||||||
|
.portal-table tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.project-title-link { color: var(--dashboard-ink); font-weight: 700; }
|
||||||
|
.project-title-link:hover, .table-action:hover { color: var(--accent-blue); }
|
||||||
|
.table-muted { color: #9ba3b1; }
|
||||||
|
.table-action { color: var(--accent-blue); font-size: .74rem; font-weight: 700; white-space: nowrap; }
|
||||||
|
.status-pill { position: relative; gap: .35rem; padding: .28rem .58rem; font-size: .65rem; }
|
||||||
|
.status-pill::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||||
|
.portal-empty { display: grid; justify-items: center; padding: 3.5rem 1.5rem; }
|
||||||
|
.portal-empty__symbol { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 1rem; border: 1px dashed #9daceb; border-radius: 14px; background: #f4f6ff; color: var(--accent-blue); font: 300 1.65rem Arial,sans-serif; }
|
||||||
|
.portal-empty h3 { font-size: 1.05rem; }
|
||||||
|
.portal-empty p { max-width: 500px; margin: .55rem auto 1.25rem; color: var(--dashboard-muted); font-size: .82rem; }
|
||||||
|
|
||||||
|
.dashboard-form-layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 1.25rem; align-items: start; }
|
||||||
|
.dashboard-form-layout--wizard { grid-template-columns: minmax(0, 1fr) 300px; }
|
||||||
|
.dashboard-form-card { padding: 0 1.4rem 1.5rem; }
|
||||||
|
.dashboard-form-card .dashboard-panel__header { margin: 0 -1.4rem 1.4rem; }
|
||||||
|
.dashboard-form-card .form-group { margin-bottom: 1.15rem; }
|
||||||
|
.dashboard-form-card .form-group label { color: #344054; font-size: .76rem; font-weight: 700; }
|
||||||
|
.dashboard-form-card .form-group input:not([type=file]), .dashboard-form-card .form-group textarea, .dashboard-form-card .form-group select { min-height: 44px; border: 1px solid #d7dce5; border-radius: 8px; background: #fff; color: var(--dashboard-ink); font-size: .82rem; box-shadow: 0 1px 2px rgba(16,24,40,.03); }
|
||||||
|
.dashboard-form-card .form-group textarea { min-height: 130px; }
|
||||||
|
.dashboard-form-card .form-group input:focus, .dashboard-form-card .form-group textarea:focus, .dashboard-form-card .form-group select:focus { border-color: #7790ff; outline: 3px solid rgba(48,81,255,.09); box-shadow: none; }
|
||||||
|
.dashboard-form-card .form-hint { color: #858fa0; font-size: .69rem; }
|
||||||
|
.dashboard-form-actions { display: flex; justify-content: flex-end; padding-top: 1.1rem; border-top: 1px solid var(--dashboard-line); }
|
||||||
|
.profile-context-card, .brief-guidance-card { padding: 1.5rem; border-radius: 13px; background: #172449; color: #fff; box-shadow: 0 12px 30px rgba(23,36,73,.14); }
|
||||||
|
.profile-context-card__avatar { width: 54px; height: 54px; margin-bottom: 1rem; background: rgba(255,255,255,.14); box-shadow: none; }
|
||||||
|
.profile-context-card__avatar { overflow: hidden; }
|
||||||
|
.profile-context-card h2, .brief-guidance-card h2 { color: #fff; font-size: 1rem; }
|
||||||
|
.profile-context-card > p { margin-top: .3rem; color: rgba(255,255,255,.65); font-size: .72rem; overflow-wrap: anywhere; }
|
||||||
|
.profile-context-card__rule { height: 1px; margin: 1.35rem 0; background: rgba(255,255,255,.13); }
|
||||||
|
.profile-context-card__label { display: block; margin-bottom: .5rem; color: rgba(255,255,255,.55); font-size: .62rem; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.profile-context-card__expertise { margin-top: 1rem; padding: .65rem .75rem; border: 1px solid rgba(255,255,255,.12); border-radius: var(--radius-sm); color: rgba(255,255,255,.72); font-size: .68rem; line-height: 1.5; }
|
||||||
|
.profile-status { display: flex; align-items: center; gap: .45rem; font-size: .72rem; }
|
||||||
|
.profile-status span { width: 7px; height: 7px; border-radius: 50%; background: #47d7a0; box-shadow: 0 0 0 4px rgba(71,215,160,.12); }
|
||||||
|
.brief-guidance-card__eyebrow { color: #e4ba65; font-size: .64rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||||||
|
.brief-guidance-card h2 { margin-top: .55rem; line-height: 1.4; }
|
||||||
|
.brief-guidance-card ul { display: grid; gap: 1.1rem; margin-top: 1.4rem; }
|
||||||
|
.brief-guidance-card li { display: flex; gap: .7rem; }
|
||||||
|
.brief-guidance-card li > span { display: grid; place-items: center; width: 24px; height: 24px; flex: 0 0 auto; border-radius: 7px; background: rgba(255,255,255,.1); color: #e4ba65; font-size: .67rem; font-weight: 700; }
|
||||||
|
.brief-guidance-card li strong { color: #fff; font-size: .72rem; }
|
||||||
|
.brief-guidance-card li p { margin-top: .15rem; color: rgba(255,255,255,.6); font-size: .67rem; line-height: 1.5; }
|
||||||
|
.brief-guidance-card__note { margin-top: 1.4rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,.12); color: rgba(255,255,255,.6); font-size: .67rem; }
|
||||||
|
.portal-detail-grid .portal-card { padding: 1.5rem; }
|
||||||
|
.profile-avatar-field { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 1.1rem; align-items: center; padding: 1rem; border: 1px solid var(--dashboard-line); border-radius: var(--radius-md); background: #fafbfc; }
|
||||||
|
.profile-avatar-preview { display: grid; place-items: center; width: 82px; height: 82px; overflow: hidden; border-radius: 18px; background: linear-gradient(145deg, var(--accent-blue), #1736b8); color: #fff; font: 700 1.35rem var(--font-heading); }
|
||||||
|
.profile-avatar-field input[type="file"] { width: 100%; padding: .45rem; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: #fff; font-size: .75rem; }
|
||||||
|
.profile-avatar-field input[type="checkbox"] { width: auto; min-height: auto; margin: .65rem .25rem 0 .75rem; }
|
||||||
|
.profile-form-section { padding-top: 1.25rem; margin-top: 1.25rem; border-top: 1px solid var(--dashboard-line); }
|
||||||
|
.profile-form-section h3 { margin-bottom: 1rem; color: #465166; font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.dashboard-shell { grid-template-columns: 78px minmax(0,1fr); }
|
||||||
|
.dashboard-sidebar { padding-inline: .7rem; }
|
||||||
|
.dashboard-sidebar__profile { justify-content: center; padding-inline: 0; }
|
||||||
|
.dashboard-sidebar__identity, .dashboard-nav__label, .dashboard-nav__link span:last-child, .dashboard-sidebar__footer { display: none; }
|
||||||
|
.dashboard-nav__link { justify-content: center; padding-inline: 0; }
|
||||||
|
.dashboard-form-layout, .dashboard-form-layout--wizard { grid-template-columns: 1fr; }
|
||||||
|
.profile-context-card, .brief-guidance-card { order: -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.dashboard-shell { display: block; }
|
||||||
|
.dashboard-sidebar { position: static; width: 100%; height: auto; padding: .65rem 1rem; border-right: 0; border-bottom: 1px solid var(--dashboard-line); }
|
||||||
|
.dashboard-sidebar__profile, .dashboard-nav__label, .dashboard-sidebar__footer { display: none; }
|
||||||
|
.dashboard-nav { display: flex; overflow-x: auto; }
|
||||||
|
.dashboard-nav__link { flex: 0 0 auto; min-height: 38px; padding: .45rem .7rem; white-space: nowrap; }
|
||||||
|
.dashboard-nav__link span:last-child { display: inline; }
|
||||||
|
.dashboard-nav__icon { width: 18px; }
|
||||||
|
.dashboard-main { padding: 1.5rem 1rem 3rem; }
|
||||||
|
.dashboard-header { align-items: flex-start; flex-direction: column; gap: 1rem; }
|
||||||
|
.dashboard-header__actions, .dashboard-header__actions .btn-primary { width: 100%; }
|
||||||
|
.dashboard-header__actions .btn-primary { justify-content: center; }
|
||||||
|
.dashboard-metrics { grid-template-columns: 1fr; }
|
||||||
|
.metric-card { min-height: 128px; }
|
||||||
|
.dashboard-panel__header { align-items: flex-start; }
|
||||||
|
.portal-table { min-width: 730px; }
|
||||||
|
.form-grid--2 { grid-template-columns: 1fr; }
|
||||||
|
.profile-avatar-field { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.form-grid--2,
|
.form-grid--2,
|
||||||
.portal-detail-grid {
|
.portal-detail-grid {
|
||||||
@@ -4270,3 +4600,13 @@ a.citation-count-badge:hover {
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: .01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: .01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 360" role="img" aria-label="Tecvico imaging workspace">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="spectral-bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#112038"/>
|
||||||
|
<stop offset="100%" stop-color="#0a1628"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="spectral-hot" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#009e82" stop-opacity="0.2"/>
|
||||||
|
<stop offset="50%" stop-color="#22d3ee" stop-opacity="0.55"/>
|
||||||
|
<stop offset="100%" stop-color="#8b7cf6" stop-opacity="0.45"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="spectral-grid" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M24 0H0V24" fill="none" stroke="rgba(255,255,255,0.04)" stroke-width="1"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="560" height="360" fill="url(#spectral-bg)"/>
|
||||||
|
<rect width="560" height="360" fill="url(#spectral-grid)"/>
|
||||||
|
<rect x="24" y="24" width="160" height="312" rx="8" fill="#0f2847" stroke="rgba(255,255,255,0.08)"/>
|
||||||
|
<rect x="40" y="48" width="128" height="12" rx="4" fill="rgba(255,255,255,0.08)"/>
|
||||||
|
<rect x="40" y="72" width="96" height="8" rx="3" fill="rgba(255,255,255,0.05)"/>
|
||||||
|
<rect x="40" y="88" width="112" height="8" rx="3" fill="rgba(255,255,255,0.05)"/>
|
||||||
|
<rect x="40" y="120" width="128" height="80" rx="4" fill="rgba(0,201,167,0.12)" stroke="rgba(0,201,167,0.25)"/>
|
||||||
|
<rect x="40" y="212" width="128" height="80" rx="4" fill="rgba(139,124,246,0.1)" stroke="rgba(139,124,246,0.2)"/>
|
||||||
|
<rect x="204" y="24" width="332" height="220" rx="8" fill="#060e1a" stroke="rgba(255,255,255,0.08)"/>
|
||||||
|
<ellipse cx="370" cy="134" rx="72" ry="88" fill="url(#spectral-hot)"/>
|
||||||
|
<ellipse cx="370" cy="134" rx="48" ry="58" fill="none" stroke="#00c9a7" stroke-width="2" stroke-dasharray="6 4"/>
|
||||||
|
<line x1="204" y1="256" x2="536" y2="256" stroke="rgba(255,255,255,0.06)"/>
|
||||||
|
<rect x="220" y="272" width="140" height="56" rx="6" fill="#112038" stroke="rgba(255,255,255,0.06)"/>
|
||||||
|
<rect x="376" y="272" width="144" height="56" rx="6" fill="#112038" stroke="rgba(255,255,255,0.06)"/>
|
||||||
|
<text x="290" y="296" text-anchor="middle" fill="#64748b" font-family="IBM Plex Mono, monospace" font-size="10">GLCM</text>
|
||||||
|
<text x="290" y="312" text-anchor="middle" fill="#00c9a7" font-family="IBM Plex Mono, monospace" font-size="11">0.847</text>
|
||||||
|
<text x="448" y="296" text-anchor="middle" fill="#64748b" font-family="IBM Plex Mono, monospace" font-size="10">SUV</text>
|
||||||
|
<text x="448" y="312" text-anchor="middle" fill="#22d3ee" font-family="IBM Plex Mono, monospace" font-size="11">4.2</text>
|
||||||
|
<rect x="204" y="340" width="332" height="4" rx="2" fill="rgba(255,255,255,0.06)"/>
|
||||||
|
<rect x="204" y="340" width="198" height="4" rx="2" fill="#00c9a7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -353,6 +353,24 @@ function initProjectFilters() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initAccountMenu() {
|
||||||
|
const accountMenu = document.querySelector('.navbar-account-menu');
|
||||||
|
if (!accountMenu) return;
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (accountMenu.open && !accountMenu.contains(event.target)) {
|
||||||
|
accountMenu.removeAttribute('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && accountMenu.open) {
|
||||||
|
accountMenu.removeAttribute('open');
|
||||||
|
accountMenu.querySelector('summary')?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
initNavbarScroll();
|
initNavbarScroll();
|
||||||
initMobileMenu();
|
initMobileMenu();
|
||||||
@@ -362,6 +380,7 @@ function init() {
|
|||||||
initReadMore();
|
initReadMore();
|
||||||
initCitationCopy();
|
initCitationCopy();
|
||||||
initProjectFilters();
|
initProjectFilters();
|
||||||
|
initAccountMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -1,64 +1,64 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "portal_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Profile{% endblock %}
|
{% block title %}Profile & Account{% endblock %}
|
||||||
|
{% block portal_title %}Profile & account{% endblock %}
|
||||||
|
{% block portal_subtitle %}Build a complete professional profile so Tecvico can understand your work and communicate clearly.{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block portal_content %}
|
||||||
<section class="page-hero" aria-labelledby="profile-heading">
|
<div class="dashboard-form-layout">
|
||||||
<div class="container">
|
<section class="dashboard-panel dashboard-form-card" aria-labelledby="profile-details-title">
|
||||||
<div class="page-hero-content">
|
<div class="dashboard-panel__header"><div><h2 id="profile-details-title">Professional profile</h2><p>Your profile information is visible only to you and the Tecvico team.</p></div></div>
|
||||||
<h1 class="page-hero-title" id="profile-heading">Your Profile</h1>
|
<form method="post" enctype="multipart/form-data" novalidate>
|
||||||
<p class="page-hero-subtitle">Keep your contact details up to date for project communication.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<div class="container container--narrow">
|
|
||||||
<div class="glass-card portal-card fade-in">
|
|
||||||
<form method="post" novalidate>
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}<div class="form-errors">{{ form.non_field_errors }}</div>{% endif %}
|
||||||
|
|
||||||
|
<div class="profile-avatar-field form-group{% if form.avatar.errors %} has-error{% endif %}">
|
||||||
|
<div class="profile-avatar-preview" aria-hidden="true">
|
||||||
|
<img src="{% if user.client_profile.avatar %}{{ user.client_profile.avatar.url }}{% else %}{% static 'images/tecvico/default-avatar.png' %}{% endif %}" alt="" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="id_avatar">Profile picture</label>
|
||||||
|
{{ form.avatar }}
|
||||||
|
<p class="form-hint">JPG, PNG, or WebP. Maximum 5 MB. A square image works best.</p>
|
||||||
|
{{ form.avatar.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-form-section">
|
||||||
|
<h3>Identity</h3>
|
||||||
<div class="form-grid form-grid--2">
|
<div class="form-grid form-grid--2">
|
||||||
<div class="form-group">
|
<div class="form-group"><label for="id_first_name">First name <span class="required-star">*</span></label>{{ form.first_name }}{{ form.first_name.errors }}</div>
|
||||||
<label for="id_first_name">First name <span class="required-star">*</span></label>
|
<div class="form-group"><label for="id_last_name">Last name <span class="required-star">*</span></label>{{ form.last_name }}{{ form.last_name.errors }}</div>
|
||||||
{{ form.first_name }}
|
|
||||||
{{ form.first_name.errors }}
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="id_last_name">Last name <span class="required-star">*</span></label>
|
|
||||||
{{ form.last_name }}
|
|
||||||
{{ form.last_name.errors }}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group"><label for="id_email">Email address <span class="required-star">*</span></label>{{ form.email }}<p class="form-hint">Used for project updates and secure account communication.</p>{{ form.email.errors }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="profile-form-section">
|
||||||
<label for="id_email">Email <span class="required-star">*</span></label>
|
<h3>Professional information</h3>
|
||||||
{{ form.email }}
|
|
||||||
{{ form.email.errors }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid form-grid--2">
|
<div class="form-grid form-grid--2">
|
||||||
<div class="form-group">
|
<div class="form-group"><label for="id_company">Organization</label>{{ form.company }}{{ form.company.errors }}</div>
|
||||||
<label for="id_company">Company</label>
|
<div class="form-group"><label for="id_job_title">Role or job title</label>{{ form.job_title }}{{ form.job_title.errors }}</div>
|
||||||
{{ form.company }}
|
|
||||||
{{ form.company.errors }}
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="id_phone">Phone</label>
|
|
||||||
{{ form.phone }}
|
|
||||||
{{ form.phone.errors }}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group"><label for="id_bio">Professional bio</label>{{ form.bio }}<p class="form-hint">Up to 600 characters about your research, technical work, or project interests.</p>{{ form.bio.errors }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="profile-form-section">
|
||||||
<label for="id_timezone">Timezone <span class="required-star">*</span></label>
|
<h3>Contact details</h3>
|
||||||
{{ form.timezone }}
|
<div class="form-group"><label for="id_phone">Phone number</label>{{ form.phone }}{{ form.phone.errors }}</div>
|
||||||
{{ form.timezone.errors }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn-primary btn-submit">Save profile</button>
|
<div class="dashboard-form-actions"><button type="submit" class="btn-primary">Save profile</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
<aside class="profile-context-card">
|
||||||
</section>
|
<div class="profile-context-card__avatar"><img src="{% if user.client_profile.avatar %}{{ user.client_profile.avatar.url }}{% else %}{% static 'images/tecvico/default-avatar.png' %}{% endif %}" alt="" /></div>
|
||||||
|
<h2>{{ user.get_full_name|default:user.username }}</h2>
|
||||||
|
{% if user.client_profile.job_title or user.client_profile.company %}<p>{% if user.client_profile.job_title %}{{ user.client_profile.job_title }}{% endif %}{% if user.client_profile.job_title and user.client_profile.company %} · {% endif %}{% if user.client_profile.company %}{{ user.client_profile.company }}{% endif %}</p>{% else %}<p>{{ user.email }}</p>{% endif %}
|
||||||
|
{% if user.client_profile.bio %}<div class="profile-context-card__expertise">{{ user.client_profile.bio|truncatechars:150 }}</div>{% endif %}
|
||||||
|
<div class="profile-context-card__rule"></div>
|
||||||
|
<span class="profile-context-card__label">Account status</span>
|
||||||
|
<strong class="profile-status"><span></span> Active client account</strong>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -67,12 +67,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="id_timezone">Timezone <span class="required-star">*</span></label>
|
|
||||||
{{ form.timezone }}
|
|
||||||
{{ form.timezone.errors }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid form-grid--2">
|
<div class="form-grid form-grid--2">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="id_password1">Password <span class="required-star">*</span></label>
|
<label for="id_password1">Password <span class="required-star">*</span></label>
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@
|
|||||||
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
|
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="{% block body_class %}{% endblock %}">
|
||||||
|
|
||||||
{% include "partials/_navbar.html" %}
|
{% include "partials/_navbar.html" %}
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{% include "partials/_footer.html" %}
|
{% block footer %}{% include "partials/_footer.html" %}{% endblock %}
|
||||||
|
|
||||||
<script src="{% static 'js/main.js' %}"></script>
|
<script src="{% static 'js/main.js' %}"></script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
|
|||||||
@@ -149,8 +149,8 @@
|
|||||||
<h3 class="modal-title" id="noEmailTitle">No reply address</h3>
|
<h3 class="modal-title" id="noEmailTitle">No reply address</h3>
|
||||||
<p class="modal-body">You haven't provided an email address. Without it, we won't be able to respond to your message. Would you like to add one, or send without a reply address?</p>
|
<p class="modal-body">You haven't provided an email address. Without it, we won't be able to respond to your message. Would you like to add one, or send without a reply address?</p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn-ghost" id="modalAddEmail">Add Email</button>
|
<button type="button" class="btn-ghost" id="modalAddEmail">Add Email</button>
|
||||||
<button class="btn-primary" id="modalSendAnyway">Send Without Email</button>
|
<button type="button" class="btn-primary" id="modalSendAnyway">Send Without Email</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="modal-title" id="successTitle">Message Sent!</h3>
|
<h3 class="modal-title" id="successTitle">Message Sent!</h3>
|
||||||
<p class="modal-body">Thank you for reaching out. We've received your message and will get back to you as soon as possible.</p>
|
<p class="modal-body">Thank you for reaching out. We've received your message and will get back to you as soon as possible.</p>
|
||||||
<button class="btn-primary" id="successClose">Done</button>
|
<button type="button" class="btn-primary" id="successClose">Done</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="modal-title" id="errorTitle">Something Went Wrong</h3>
|
<h3 class="modal-title" id="errorTitle">Something Went Wrong</h3>
|
||||||
<p class="modal-body" id="errorModalMsg">Please check your answers and try again.</p>
|
<p class="modal-body" id="errorModalMsg">Please check your answers and try again.</p>
|
||||||
<button class="btn-ghost" id="errorClose">Try Again</button>
|
<button type="button" class="btn-ghost" id="errorClose">Try Again</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
{% for entry in faq_entries %}
|
{% for entry in faq_entries %}
|
||||||
<div class="faq-item glass-card fade-in">
|
<div class="faq-item glass-card fade-in">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="faq-question"
|
class="faq-question"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-controls="faq-answer-{{ entry.pk }}"
|
aria-controls="faq-answer-{{ entry.pk }}"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% if site_contact.has_discord %}
|
{% if site_contact.has_discord %}
|
||||||
<a href="{{ site_contact.discord_url }}" class="{{ button_class|default:'btn-ghost btn-sm' }}" target="_blank" rel="noopener noreferrer">
|
<a href="{{ site_contact.discord_url }}" class="{{ button_class|default:'btn-ghost btn--sm' }}" target="_blank" rel="noopener noreferrer">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{% include "partials/_brand_icon.html" with placement="navbar" %}
|
{% include "partials/_brand_icon.html" with placement="navbar" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button class="navbar-toggle" id="navbarToggle" aria-expanded="false" aria-controls="navbarMenu" aria-label="Toggle navigation">
|
<button type="button" class="navbar-toggle" id="navbarToggle" aria-expanded="false" aria-controls="navbarMenu" aria-label="Toggle navigation">
|
||||||
<span class="toggle-bar"></span>
|
<span class="toggle-bar"></span>
|
||||||
<span class="toggle-bar"></span>
|
<span class="toggle-bar"></span>
|
||||||
<span class="toggle-bar"></span>
|
<span class="toggle-bar"></span>
|
||||||
@@ -91,21 +91,31 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<li class="nav-item">
|
<li class="nav-item nav-item--account">
|
||||||
<a href="{% url 'projects:dashboard' %}" class="nav-link {% if request.resolver_match.namespace == 'projects' %}active{% endif %}">
|
<details class="navbar-account-menu">
|
||||||
My Projects
|
<summary class="navbar-account" aria-label="Open account menu">
|
||||||
</a>
|
<span class="navbar-account__avatar" aria-hidden="true">
|
||||||
</li>
|
<img src="{% if user.client_profile.avatar %}{{ user.client_profile.avatar.url }}{% else %}{% static 'images/tecvico/default-avatar.png' %}{% endif %}" alt="" />
|
||||||
<li class="nav-item">
|
</span>
|
||||||
<a href="{% url 'accounts:profile' %}" class="nav-link {% if request.resolver_match.url_name == 'profile' %}active{% endif %}">
|
<span class="navbar-account__status" aria-hidden="true"></span>
|
||||||
Profile
|
</summary>
|
||||||
</a>
|
<div class="navbar-account-dropdown">
|
||||||
</li>
|
<div class="navbar-account-dropdown__identity">
|
||||||
<li class="nav-item">
|
<img src="{% if user.client_profile.avatar %}{{ user.client_profile.avatar.url }}{% else %}{% static 'images/tecvico/default-avatar.png' %}{% endif %}" alt="" />
|
||||||
<form method="post" action="{% url 'accounts:logout' %}" class="nav-logout-form">
|
<div><strong>{{ user.get_full_name|default:user.username }}</strong><span>{{ user.email }}</span></div>
|
||||||
{% csrf_token %}
|
</div>
|
||||||
<button type="submit" class="nav-link nav-link-button">Logout</button>
|
<div class="navbar-account-dropdown__section">
|
||||||
</form>
|
<a href="{% url 'projects:dashboard' %}"><span aria-hidden="true">⌂</span><span><strong>Workspace</strong><small>Projects and activity</small></span></a>
|
||||||
|
<a href="{% url 'accounts:profile' %}"><span aria-hidden="true">○</span><span><strong>Your profile</strong><small>Identity and account details</small></span></a>
|
||||||
|
<a href="{% url 'projects:create' %}"><span aria-hidden="true">+</span><span><strong>New project brief</strong><small>Start a scientific project</small></span></a>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-account-dropdown__section navbar-account-dropdown__section--compact">
|
||||||
|
<a href="{% url 'pages:faq' %}">Help center</a>
|
||||||
|
<a href="{% url 'pages:contact' %}">Contact support</a>
|
||||||
|
<form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit">Sign out</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
|
|||||||
@@ -507,6 +507,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="faq-item glass-card fade-in">
|
<div class="faq-item glass-card fade-in">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="faq-question"
|
class="faq-question"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-controls="faq-answer-{{ section.pk }}-{{ item.pk }}"
|
aria-controls="faq-answer-{{ section.pk }}-{{ item.pk }}"
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block body_class %}portal-body{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dashboard-shell">
|
||||||
|
<aside class="dashboard-sidebar" aria-label="Workspace navigation">
|
||||||
|
<div class="dashboard-sidebar__profile">
|
||||||
|
<div class="dashboard-avatar" aria-hidden="true">
|
||||||
|
<img src="{% if user.client_profile.avatar %}{{ user.client_profile.avatar.url }}{% else %}{% static 'images/tecvico/default-avatar.png' %}{% endif %}" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-sidebar__identity">
|
||||||
|
<strong>{{ user.get_full_name|default:user.username }}</strong>
|
||||||
|
<span>{{ user.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="dashboard-nav">
|
||||||
|
<p class="dashboard-nav__label">Workspace</p>
|
||||||
|
<a href="{% url 'projects:dashboard' %}" class="dashboard-nav__link{% if request.resolver_match.url_name == 'dashboard' or request.resolver_match.url_name == 'detail' %} is-active{% endif %}">
|
||||||
|
<span class="dashboard-nav__icon" aria-hidden="true">⌂</span>
|
||||||
|
<span>Overview</span>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'projects:dashboard' %}#project-briefs" class="dashboard-nav__link">
|
||||||
|
<span class="dashboard-nav__icon" aria-hidden="true">▤</span>
|
||||||
|
<span>Project briefs</span>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'projects:create' %}" class="dashboard-nav__link{% if request.resolver_match.url_name == 'create' %} is-active{% endif %}">
|
||||||
|
<span class="dashboard-nav__icon" aria-hidden="true">+</span>
|
||||||
|
<span>New project brief</span>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'accounts:profile' %}" class="dashboard-nav__link{% if request.resolver_match.url_name == 'profile' %} is-active{% endif %}">
|
||||||
|
<span class="dashboard-nav__icon" aria-hidden="true">○</span>
|
||||||
|
<span>Profile & account</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p class="dashboard-nav__label dashboard-nav__label--secondary">Resources</p>
|
||||||
|
<a href="{% url 'products:overview' %}" class="dashboard-nav__link">
|
||||||
|
<span class="dashboard-nav__icon" aria-hidden="true">◇</span>
|
||||||
|
<span>Scientific products</span>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'pages:faq' %}" class="dashboard-nav__link">
|
||||||
|
<span class="dashboard-nav__icon" aria-hidden="true">i</span>
|
||||||
|
<span>Knowledge & FAQ</span>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'pages:contact' %}" class="dashboard-nav__link">
|
||||||
|
<span class="dashboard-nav__icon" aria-hidden="true">?</span>
|
||||||
|
<span>Help & support</span>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'pages:home' %}" class="dashboard-nav__link">
|
||||||
|
<span class="dashboard-nav__icon" aria-hidden="true">↗</span>
|
||||||
|
<span>Back to website</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="dashboard-sidebar__footer">
|
||||||
|
<div class="dashboard-security-note">
|
||||||
|
<span class="dashboard-security-note__mark" aria-hidden="true"></span>
|
||||||
|
<span><strong>Secure workspace</strong>Your project data is private.</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{% url 'accounts:logout' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="dashboard-signout">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="dashboard-main">
|
||||||
|
<header class="dashboard-header">
|
||||||
|
<div>
|
||||||
|
<p class="dashboard-eyebrow">Tecvico client workspace</p>
|
||||||
|
<h1 id="dashboard-page-title">{% block portal_title %}Dashboard{% endblock %}</h1>
|
||||||
|
<p>{% block portal_subtitle %}{% endblock %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-header__actions">{% block portal_actions %}{% endblock %}</div>
|
||||||
|
</header>
|
||||||
|
<div class="dashboard-content" aria-labelledby="dashboard-page-title">
|
||||||
|
{% block portal_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block footer %}{% endblock %}
|
||||||
@@ -1,33 +1,14 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "portal_base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ brief.title }}{% endblock %}
|
{% block title %}{{ brief.title }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block portal_title %}{{ brief.title }}{% endblock %}
|
||||||
<section class="page-hero" aria-labelledby="brief-heading">
|
{% block portal_subtitle %}<span class="status-pill status-pill--{{ brief.status }}">{{ brief.get_status_display }}</span> <span class="dashboard-header__meta">Submitted {{ brief.submitted_at|date:"M j, Y" }}</span>{% endblock %}
|
||||||
<div class="container">
|
{% block portal_actions %}<a href="{% url 'projects:dashboard' %}" class="btn-ghost">Back to projects</a>{% if brief.is_editable_by_client %}<a href="{% url 'projects:edit' pk=brief.pk %}" class="btn-primary">Edit brief</a>{% endif %}{% endblock %}
|
||||||
<div class="page-hero-content page-hero-content--row">
|
|
||||||
<div>
|
|
||||||
{% if brief.category %}<div class="section-badge">{{ brief.get_category_display }}</div>{% endif %}
|
|
||||||
<h1 class="page-hero-title" id="brief-heading">{{ brief.title }}</h1>
|
|
||||||
<p class="page-hero-subtitle">
|
|
||||||
<span class="status-pill status-pill--{{ brief.status }}">{{ brief.get_status_display }}</span>
|
|
||||||
· Submitted {{ brief.submitted_at|date:"M j, Y" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="portal-actions">
|
|
||||||
<a href="{% url 'projects:dashboard' %}" class="btn-ghost">Back to list</a>
|
|
||||||
{% if brief.is_editable_by_client %}
|
|
||||||
<a href="{% url 'projects:edit' pk=brief.pk %}" class="btn-primary">Edit brief</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
{% block portal_content %}
|
||||||
<div class="container">
|
|
||||||
<div class="portal-detail-grid">
|
<div class="portal-detail-grid">
|
||||||
<div class="glass-card portal-card fade-in">
|
<div class="dashboard-panel portal-card">
|
||||||
<h2 class="portal-section-title">Overview</h2>
|
<h2 class="portal-section-title">Overview</h2>
|
||||||
<dl class="portal-meta-list">
|
<dl class="portal-meta-list">
|
||||||
{% if brief.budget_range %}<div><dt>Budget</dt><dd>{{ brief.get_budget_range_display }}</dd></div>{% endif %}
|
{% if brief.budget_range %}<div><dt>Budget</dt><dd>{{ brief.get_budget_range_display }}</dd></div>{% endif %}
|
||||||
@@ -56,19 +37,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if brief.shows_quote_to_client %}
|
{% if brief.shows_quote_to_client %}
|
||||||
<div class="glass-card portal-card fade-in">
|
<div class="dashboard-panel portal-card">
|
||||||
<h2 class="portal-section-title">Quote from Tecvico</h2>
|
<h2 class="portal-section-title">Quote from Tecvico</h2>
|
||||||
<p class="portal-body-text">{{ brief.quote_text|linebreaksbr }}</p>
|
<p class="portal-body-text">{{ brief.quote_text|linebreaksbr }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if brief.has_website_notes %}
|
{% if brief.has_website_notes %}
|
||||||
<div class="glass-card portal-card fade-in">
|
<div class="dashboard-panel portal-card">
|
||||||
<h2 class="portal-section-title">Notes from Tecvico</h2>
|
<h2 class="portal-section-title">Notes from Tecvico</h2>
|
||||||
<p class="portal-body-text">{{ brief.website_notes|linebreaksbr }}</p>
|
<p class="portal-body-text">{{ brief.website_notes|linebreaksbr }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "portal_base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}{{ form_title }}{% endblock %}
|
{% block title %}{{ form_title }}{% endblock %}
|
||||||
@@ -7,19 +7,12 @@
|
|||||||
<link rel="stylesheet" href="{% static 'css/brief-wizard.css' %}" />
|
<link rel="stylesheet" href="{% static 'css/brief-wizard.css' %}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block portal_title %}{{ form_title }}{% endblock %}
|
||||||
<section class="page-hero" aria-labelledby="brief-form-heading">
|
{% block portal_subtitle %}Turn your scientific or technical challenge into a clear, review-ready scope.{% endblock %}
|
||||||
<div class="container">
|
|
||||||
<div class="page-hero-content">
|
|
||||||
<h1 class="page-hero-title" id="brief-form-heading">{{ form_title }}</h1>
|
|
||||||
<p class="page-hero-subtitle">Complete each step to submit your project brief.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
{% block portal_content %}
|
||||||
<div class="container container--narrow">
|
<div class="dashboard-form-layout dashboard-form-layout--wizard">
|
||||||
<div class="glass-card portal-card fade-in">
|
<section class="dashboard-panel dashboard-form-card">
|
||||||
<form method="post" enctype="multipart/form-data" novalidate class="brief-wizard" data-brief-wizard data-initial-step="{{ initial_step }}">
|
<form method="post" enctype="multipart/form-data" novalidate class="brief-wizard" data-brief-wizard data-initial-step="{{ initial_step }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
@@ -140,9 +133,18 @@
|
|||||||
<button type="submit" class="btn-primary btn-submit" data-wizard-submit hidden>{{ submit_label }}</button>
|
<button type="submit" class="btn-primary btn-submit" data-wizard-submit hidden>{{ submit_label }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
<aside class="brief-guidance-card">
|
||||||
</section>
|
<span class="brief-guidance-card__eyebrow">Before you submit</span>
|
||||||
|
<h2>A strong brief helps us respond precisely.</h2>
|
||||||
|
<ul>
|
||||||
|
<li><span>1</span><div><strong>Define the outcome</strong><p>Focus on the scientific or technical result you need.</p></div></li>
|
||||||
|
<li><span>2</span><div><strong>Add useful context</strong><p>Include datasets, constraints, methods, or references.</p></div></li>
|
||||||
|
<li><span>3</span><div><strong>Set expectations</strong><p>Share your preferred timeline and budget range.</p></div></li>
|
||||||
|
</ul>
|
||||||
|
<p class="brief-guidance-card__note">You can review every field in the final step before submission.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
|||||||
@@ -1,70 +1,60 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "portal_base.html" %}
|
||||||
|
|
||||||
{% block title %}My Projects{% endblock %}
|
{% block title %}Project Dashboard{% endblock %}
|
||||||
|
{% block portal_title %}Welcome back{% if user.first_name %}, {{ user.first_name }}{% endif %}.{% endblock %}
|
||||||
|
{% block portal_subtitle %}Manage your research and technology projects from one focused workspace.{% endblock %}
|
||||||
|
{% block portal_actions %}<a href="{% url 'projects:create' %}" class="btn-primary">Start a new brief <span aria-hidden="true">→</span></a>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block portal_content %}
|
||||||
<section class="page-hero" aria-labelledby="dashboard-heading">
|
<section class="dashboard-metrics" aria-label="Workspace summary">
|
||||||
<div class="container">
|
<article class="metric-card">
|
||||||
<div class="page-hero-content page-hero-content--row">
|
<div class="metric-card__top"><span class="metric-card__icon">P</span><span class="metric-card__trend">Portfolio</span></div>
|
||||||
<div>
|
<strong>{{ page_obj.paginator.count|default:0 }}</strong>
|
||||||
<div class="section-badge">Client Portal</div>
|
<span>Total project brief{{ page_obj.paginator.count|pluralize }}</span>
|
||||||
<h1 class="page-hero-title" id="dashboard-heading">My Projects</h1>
|
</article>
|
||||||
<p class="page-hero-subtitle">Track briefs you've submitted to the Tecvico team.</p>
|
<article class="metric-card">
|
||||||
</div>
|
<div class="metric-card__top"><span class="metric-card__icon metric-card__icon--gold">R</span><span class="metric-card__trend">Structured</span></div>
|
||||||
<a href="{% url 'projects:create' %}" class="btn-primary">New project brief</a>
|
<strong>4 steps</strong>
|
||||||
</div>
|
<span>From scientific scope to delivery</span>
|
||||||
</div>
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<div class="metric-card__top"><span class="metric-card__icon metric-card__icon--green">✓</span><span class="metric-card__trend">Private</span></div>
|
||||||
|
<strong>Secure</strong>
|
||||||
|
<span>Visible only to you and Tecvico</span>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="dashboard-panel" id="project-briefs" aria-labelledby="recent-projects-title">
|
||||||
<div class="container">
|
<div class="dashboard-panel__header">
|
||||||
|
<div><h2 id="recent-projects-title">Your project briefs</h2><p>Track submissions, quotes, and delivery progress.</p></div>
|
||||||
|
{% if briefs %}<span class="dashboard-panel__count">{{ page_obj.paginator.count }} total</span>{% endif %}
|
||||||
|
</div>
|
||||||
{% if briefs %}
|
{% if briefs %}
|
||||||
<div class="portal-table-wrap glass-card fade-in">
|
<div class="portal-table-wrap">
|
||||||
<table class="portal-table">
|
<table class="portal-table">
|
||||||
<thead>
|
<thead><tr><th>Project</th><th>Field</th><th>Status</th><th>Budget</th><th>Submitted</th><th><span class="sr-only">Actions</span></th></tr></thead>
|
||||||
<tr>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Budget</th>
|
|
||||||
<th>Submitted</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for brief in briefs %}
|
{% for brief in briefs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ brief.title }}</td>
|
<td><a class="project-title-link" href="{% url 'projects:detail' pk=brief.pk %}">{{ brief.title }}</a></td>
|
||||||
<td>{% if brief.category %}{{ brief.get_category_display }}{% else %}—{% endif %}</td>
|
<td>{% if brief.category %}{{ brief.get_category_display }}{% else %}<span class="table-muted">Not set</span>{% endif %}</td>
|
||||||
<td><span class="status-pill status-pill--{{ brief.status }}">{{ brief.get_status_display }}</span></td>
|
<td><span class="status-pill status-pill--{{ brief.status }}">{{ brief.get_status_display }}</span></td>
|
||||||
<td>{% if brief.budget_range %}{{ brief.get_budget_range_display }}{% else %}—{% endif %}</td>
|
<td>{% if brief.budget_range %}{{ brief.get_budget_range_display }}{% else %}<span class="table-muted">Not set</span>{% endif %}</td>
|
||||||
<td>{{ brief.submitted_at|date:"M j, Y" }}</td>
|
<td>{{ brief.submitted_at|date:"M j, Y" }}</td>
|
||||||
<td><a href="{% url 'projects:detail' pk=brief.pk %}" class="portal-link">View</a></td>
|
<td><a href="{% url 'projects:detail' pk=brief.pk %}" class="table-action" aria-label="View {{ brief.title }}">View <span aria-hidden="true">→</span></a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{% if page_obj.has_other_pages %}<div class="portal-pagination">{% if page_obj.has_previous %}<a href="?page={{ page_obj.previous_page_number }}" class="portal-link">Previous</a>{% endif %}<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a href="?page={{ page_obj.next_page_number }}" class="portal-link">Next</a>{% endif %}</div>{% endif %}
|
||||||
{% if page_obj.has_other_pages %}
|
|
||||||
<div class="portal-pagination">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<a href="?page={{ page_obj.previous_page_number }}" class="portal-link">Previous</a>
|
|
||||||
{% endif %}
|
|
||||||
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<a href="?page={{ page_obj.next_page_number }}" class="portal-link">Next</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="glass-card portal-card portal-empty fade-in">
|
<div class="portal-empty">
|
||||||
<h2>No project briefs yet</h2>
|
<div class="portal-empty__symbol" aria-hidden="true">+</div>
|
||||||
<p class="portal-intro">Describe what you need and our team will review your request.</p>
|
<h3>Create your first project brief</h3>
|
||||||
<a href="{% url 'projects:create' %}" class="btn-primary">Submit your first brief</a>
|
<p>Define the scientific challenge, goals, timeline, and supporting materials. You can review everything before submitting.</p>
|
||||||
|
<a href="{% url 'projects:create' %}" class="btn-primary">Create project brief</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user