feat: finalize UI/UX

This commit is contained in:
mohamad
2026-08-01 16:30:24 +03:30
parent 3c50abf404
commit 28e0f7f8fe
26 changed files with 822 additions and 528 deletions
+1 -1
View File
@@ -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
+9 -3
View File
@@ -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",
)
+9 -5
View File
@@ -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):
@@ -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
View File
@@ -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)
+50 -3
View File
@@ -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 &amp; 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"))
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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 {
-256
View File
@@ -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 23 add quoting, payments, and a client portal — all standard Django.
---
## Plan 1 — Simple (MVP) · 36 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 | 12 | Structured submission form + attachments |
| M3 Status tracking | 23 | Client sees request status; email notifications |
| M4 Admin intake | 34 | 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) · 12 months
**Goal:** Quote, pay, and deliver on-platform. Productized packages + custom projects.
| Milestone | Weeks | Deliverable |
| ------------- | ----- | ----------------------------------------------------- |
| M1 Foundation | 12 | Plan 1 + email verify, service catalog pages |
| M2 Quotes | 34 | Admin builds quote; client accepts/declines in portal |
| M3 Payments | 56 | Stripe deposit + milestone invoices |
| M4 Delivery | 78 | Milestones, deliverable uploads, revision rounds |
| M5 Comms | 910 | 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. 3050%); 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) · 23 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 | 12 | E-sign, ballpark estimator, kickoff booking |
| M3 Delivery ops | 23 | SLA timers, internal time log, task checklist |
| M4 Enterprise | 34 | Multi-user orgs, API, webhooks |
| M5 Growth | 45 | 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
View File
@@ -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

+36
View File
@@ -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

+19
View File
@@ -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') {
+48 -48
View File
@@ -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 &amp; 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 %}
-6
View File
@@ -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
View File
@@ -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 %}
+4 -4
View File
@@ -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>
+1
View File
@@ -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 -1
View File
@@ -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>
+26 -16
View File
@@ -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">
+1
View File
@@ -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 }}"
+85
View File
@@ -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 &amp; 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 &amp; FAQ</span>
</a>
<a href="{% url 'pages:contact' %}" class="dashboard-nav__link">
<span class="dashboard-nav__icon" aria-hidden="true">?</span>
<span>Help &amp; 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 %}
+8 -29
View File
@@ -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 %}
+18 -16
View File
@@ -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 %}
+39 -49
View File
@@ -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 %}