# SURVAM 16 APR 2026 - REPOSITORY
================================================================================
Project Name: Survam 16 Apr 2026
Created: 2026-04-16 02:15:15
Last Updated: 2026-04-16 02:15:22
Source ZIP: SURVAM.zip
Total Files: 46
Total Folders: 13
================================================================================
## FILE STRUCTURE
================================================================================
Survam 16 Apr 2026/
├── dashboard.php
├── default.php
├── index.php
├── api/
│ ├── surveys.php
│ ├── upload.php
│ └── billing.php
├── account/
│ ├── profile.php
│ ├── upgrade.php
│ └── billing.php
├── admin/
│ ├── surveys.php
│ ├── users.php
│ ├── settings.php
│ ├── index.php
│ ├── plans.php
│ ├── reports.php
│ └── transactions.php
├── s/
│ └── index.php
├── surveys/
│ ├── delete.php
│ ├── builder.php
│ ├── index.php
│ ├── results.php
│ ├── create.php
│ └── export.php
├── assets/
│ ├── css/
│ │ ├── style.css
│ │ └── admin.css
│ └── js/
│ ├── admin.js
│ ├── app.js
│ └── builder.js
├── README.md
├── vendor/
│ └── README.txt
├── uploads/
│ └── media/
│ ├── media_1_1776222718_df5f8684.png
│ └── media_1_1776222776_e54cdee1.png
├── auth/
│ ├── login.php
│ ├── logout.php
│ ├── reset.php
│ ├── register.php
│ └── forgot.php
└── includes/
├── auth.php
├── index.php
├── db.php
├── footer.php
├── admin_footer.php
├── functions.php
├── config.php
├── admin_header.php
└── header.php
================================================================================
## FILE CONTENTS
================================================================================
### FILE 1: dashboard.php
- Type: PHP
- Size: 9.09 KB
- Path: .
- Name: dashboard.php
------------------------------------------------------------
0 ? min(100, round(($totalSurveys / $user['surveys_limit']) * 100)) : 0;
$responsePct = $user['responses_monthly'] > 0 ? min(100, round(($thisMonthResp / $user['responses_monthly']) * 100)) : 0;
require_once __DIR__ . '/includes/header.php';
?>
= $totalSurveys ?>
Total Surveys
= $surveyLimit ?> allowed on plan
= $activeSurveys ?>
Active Surveys
= number_format($totalResponses) ?>
Total Responses
= $thisMonthResp ?>
Responses This Month
= $responseLimit ?> allowed
📋
No surveys yet
Create your first survey to get started with collecting responses.
Create First Survey
Survey Status Responses Completion Actions
= sanitize($s['title']) ?>
= timeSince($s['created_at']) ?>
= surveyStatusBadge($s['status']) ?>
= number_format($s['response_count']) ?>
= completionRate($s['response_count'], $s['complete_count']) ?>
Current Plan
= planBadge($user['plan_name']) ?>
Wallet
= formatINR((float)$user['credits']) ?>
Surveys = $totalSurveys ?> / = $surveyLimit ?>
Monthly Responses = $thisMonthResp ?> / = $responseLimit ?>
= 80): ?>
You're near your response limit. Upgrade to collect more.
-------------------- END OF FILE --------------------
### FILE 2: default.php
- Type: PHP
- Size: 15.99 KB
- Path: .
- Name: default.php
------------------------------------------------------------
Default page
You Are All Set to Go!
All you have to do now is upload your website files and start your journey. Check out how to do that below:
-------------------- END OF FILE --------------------
### FILE 3: index.php
- Type: PHP
- Size: 9.01 KB
- Path: .
- Name: index.php
------------------------------------------------------------
SURVAM — Professional Survey Tool · Relevant Reflex Consulting
Built for Market Research in India
Professional Surveys, Built to Scale
28+ question types · Advanced logic & branching · Real-time analytics · ₹-first pricing. All from one platform built by researchers for researchers.
Trusted by panel companies and research agencies across India
📋
28+ Question Types
Single choice, rating scales, NPS, matrix, MaxDiff, card sort, heatmap, signature, file upload and more.
⚡
Advanced Logic
Branching, skip logic, piping, looping, quotas, and disqualification — all drag-and-drop configured.
📊
Real-time Analytics
Summary charts, individual response view, trend analysis, device breakdown, and completion rates.
⬇
5 Export Formats
Download data as CSV, Excel, PDF, JSON, or SPSS syntax — compatible with every analysis tool.
📱
Mobile-First Renderer
Surveys look beautiful on every device. Drag-to-rank, touch-friendly inputs, and smooth page transitions.
💳
Razorpay Payments
Prepaid wallet model with Razorpay integration. Top up instantly via UPI, cards, or net banking.
Most Popular
= sanitize($plan['name']) ?>
= $plan['price_monthly'] == 0 ? 'Free' : '₹' . number_format($plan['price_monthly']) ?>
= $plan['price_monthly'] > 0 ? '/ month' : '' ?>
= sanitize($plan['description'] ?? '') ?>
= $plan['surveys_limit'] == -1 ? 'Unlimited surveys' : $plan['surveys_limit'] . ' surveys' ?>
= $plan['responses_monthly'] == -1 ? 'Unlimited responses' : number_format($plan['responses_monthly']) . ' responses / mo' ?>
= $plan['questions_limit'] == -1 ? 'Unlimited questions' : $plan['questions_limit'] . ' questions max' ?>
= sanitize($f) ?>
No custom branding Custom branding
= $plan['price_monthly'] == 0 ? 'Start for Free' : 'Get Started' ?>
Ready to launch your first survey?
Join research teams across India using SURVAM to power their fieldwork.
Create Your Free Account →
-------------------- END OF FILE --------------------
### FILE 4: README.md
- Type: MD
- Size: 7.22 KB
- Path: .
- Name: README.md
------------------------------------------------------------
# SURVAM v1.0 — Deployment Guide
**Relevant Reflex Consulting · survam.relevantreflex.shop**
---
## Overview
SURVAM is a full-stack survey programming tool built in PHP + MySQL for Hostinger shared hosting.
| Layer | Technology |
|---|---|
| Backend | PHP 8.0+ |
| Database | MySQL 5.7+ / MariaDB 10.3+ |
| Frontend | Vanilla JS + Chart.js (CDN) |
| Payments | Razorpay (INR wallet model) |
| Hosting | Hostinger shared hosting |
---
## File Structure
```
survam/
├── .htaccess # URL routing, security headers
├── index.php # Public landing page
├── dashboard.php # Client portal home
├── account/
│ ├── billing.php # Wallet & top-up
│ ├── profile.php # User settings
│ └── upgrade.php # Plan selection
├── admin/
│ ├── index.php # Platform dashboard
│ ├── users.php # User management (super_admin)
│ ├── surveys.php # All surveys view
│ ├── transactions.php # Payment log
│ ├── plans.php # Plan CRUD (super_admin)
│ ├── reports.php # Analytics & charts
│ └── settings.php # App config (super_admin)
├── api/
│ ├── surveys.php # Builder AJAX API
│ └── billing.php # Razorpay orders & webhooks
├── assets/
│ ├── css/style.css # Portal + survey styles
│ ├── css/admin.css # Admin panel styles
│ ├── js/app.js # Core JS (nav, modals, survey renderer)
│ ├── js/builder.js # Drag-drop survey builder
│ └── js/admin.js # Admin panel JS
├── auth/
│ ├── login.php / register.php / logout.php
│ ├── forgot.php / reset.php
├── includes/
│ ├── config.php # DB credentials & constants ← EDIT THIS
│ ├── db.php # PDO singleton
│ ├── auth.php # Session, CSRF, plan limits
│ ├── functions.php # Helpers, badges, formatters
│ ├── header.php / footer.php
│ └── admin_header.php / admin_footer.php
├── install/
│ ├── schema.sql # Full DB schema + seed data
│ └── install.php # Web-based installer
├── s/
│ └── index.php # Public survey renderer
└── surveys/
├── create.php / builder.php / index.php
├── results.php / export.php / delete.php
```
---
## Hostinger Deployment Steps
### 1 — Upload files
Upload the entire `survam/` folder contents to:
```
public_html/ (if running at root)
OR
public_html/survam/ (if running in subdirectory)
```
For `survam.relevantreflex.shop` subdomain, upload to the subdomain's root folder.
### 2 — Create MySQL database
In Hostinger hPanel → Databases → MySQL Databases:
- Create database: `survam_db`
- Create user: `survam_user` with a strong password
- Grant user ALL PRIVILEGES on `survam_db`
### 3 — Run the installer
Visit: `https://survam.relevantreflex.shop/install/install.php`
Fill in:
- DB Host: `localhost`
- DB Name: `survam_db`
- DB User: `survam_user`
- DB Password: (the password you set)
- App URL: `https://survam.relevantreflex.shop`
- Admin email & password
The installer will:
- Write `includes/config.php` with your settings
- Run `install/schema.sql` to create all tables
- Seed the 4 pricing plans
- Create your admin account
**⚠ Delete `/install/install.php` immediately after installation.**
### 4 — Manual config (if not using installer)
Edit `includes/config.php`:
```php
define('DB_HOST', 'localhost');
define('DB_NAME', 'survam_db');
define('DB_USER', 'survam_user');
define('DB_PASS', 'YOUR_PASSWORD');
define('APP_URL', 'https://survam.relevantreflex.shop');
define('CSRF_SECRET', 'GENERATE_32_RANDOM_CHARS_HERE');
define('RAZORPAY_KEY_ID', 'rzp_live_...');
define('RAZORPAY_KEY_SECRET', '...');
define('RAZORPAY_WEBHOOK_SECRET','...');
```
Then import `install/schema.sql` via phpMyAdmin.
### 5 — Razorpay setup
1. Log in to [dashboard.razorpay.com](https://dashboard.razorpay.com)
2. Settings → API Keys → Generate Live Key
3. Add Key ID and Key Secret to Admin → Settings (or config.php)
4. Settings → Webhooks → Add webhook URL:
`https://survam.relevantreflex.shop/api/billing.php?webhook=1`
5. Select event: `payment.captured`
6. Copy webhook secret to Admin → Settings
### 6 — Post-install checklist
- [ ] Delete `/install/install.php`
- [ ] Change default admin password (admin@relevantreflex.com / Admin@2024)
- [ ] Set Razorpay keys in Admin → Settings
- [ ] Set SMTP credentials for password reset emails
- [ ] Test a survey end-to-end
- [ ] Test a payment (₹1 test transaction)
- [ ] Verify `.htaccess` is working (short survey URLs `/s/{token}`)
---
## Default Accounts
| Email | Password | Role |
|---|---|---|
| admin@relevantreflex.com | Admin@2024 | super_admin |
| manager@relevantreflex.com | Admin@2024 | manager |
**Change these immediately after first login.**
---
## Pricing Plans (pre-seeded)
| Plan | Price | Surveys | Responses/mo |
|---|---|---|---|
| Free | ₹0 | 3 | 100 |
| Starter | ₹999 | 10 | 1,000 |
| Growth | ₹2,999 | 50 | 10,000 |
| Scale | ₹7,999 | Unlimited | 100,000 |
Plans are editable via Admin → Plans.
---
## Key URLs
| Path | Purpose |
|---|---|
| `/` | Public landing page |
| `/auth/login.php` | Sign in |
| `/auth/register.php` | New account |
| `/dashboard.php` | Client home |
| `/surveys/create.php` | New survey |
| `/s/{token}` | Public survey URL |
| `/admin/index.php` | Admin dashboard |
| `/api/surveys.php` | Builder API (POST) |
| `/api/billing.php` | Payments API (POST) |
| `/api/billing.php?webhook=1` | Razorpay webhook (POST) |
---
## Roles & Permissions
| Feature | client | manager | super_admin |
|---|---|---|---|
| Create surveys | ✓ (plan limit) | ✓ | ✓ |
| View all surveys | own only | all | all |
| Admin dashboard | ✗ | ✓ | ✓ |
| Users page | ✗ | ✗ | ✓ |
| Plans & Settings | ✗ | ✗ | ✓ |
---
## Troubleshooting
**Short URLs not working (`/s/{token}` → 404)**
→ Check `.htaccess` is uploaded and `mod_rewrite` is enabled (Hostinger supports this).
**"Database connection failed"**
→ Verify DB credentials in `includes/config.php`. Check DB user has correct privileges.
**Razorpay payment not crediting wallet**
→ Ensure webhook URL is set and webhook secret matches `RAZORPAY_WEBHOOK_SECRET`.
→ Check PHP `error_log` for verification failures.
**Emails not sending**
→ Set SMTP credentials in Admin → Settings. On shared hosting `mail()` may be blocked.
**SPSS export not working**
→ SPSS export generates a `.sps` syntax file (not `.sav`). Open in SPSS and run to import data.
→ For true `.sav` files, install `composer require phpoffice/phpspreadsheet`.
---
## Tech Notes
- Session name: `survam_sess`
- CSRF token: per-session, validated on all POST forms
- Passwords: bcrypt cost 12
- Survey tokens: 64-char hex (32 random bytes)
- All user input is sanitized via `htmlspecialchars()` before output
- SQL uses PDO prepared statements throughout
- Response monthly counter resets on the 1st of each month (via `responses_reset_date`)
---
© 2024 Relevant Reflex Consulting Pvt. Ltd. · SURVAM v1.0
-------------------- END OF FILE --------------------
### FILE 5: account/billing.php
- Type: PHP
- Size: 7.81 KB
- Path: account
- Name: billing.php
------------------------------------------------------------
Plan
= planBadge($user['plan_name']) ?>
Monthly Responses
= number_format($user['responses_used_this_month']) ?> / = $user['responses_monthly'] == -1 ? '∞' : number_format($user['responses_monthly']) ?>
Surveys
= DB::row("SELECT COUNT(*) c FROM surveys WHERE user_id=?", [$user['id']])['c'] ?> / = $user['surveys_limit'] == -1 ? '∞' : $user['surveys_limit'] ?>
💳
No transactions yet
Top up your wallet to purchase or upgrade a plan.
Date Description Type Amount Status
= date('d M Y H:i', strtotime($t['created_at'])) ?>
= sanitize($t['description'] ?? '—') ?>
= ucfirst($t['type']) ?>
= $t['type'] === 'credit' ? '+' : '-' ?>= formatINR((float)$t['amount']) ?>
= surveyStatusBadge($t['status']) ?>
= formatINR((float)$user['credits']) ?>
Available balance
➕ Add Credits
'Starter Pack', 'amount'=>999, 'tag'=>''],
['label'=>'Growth Pack', 'amount'=>2999, 'tag'=>'Popular'],
['label'=>'Scale Pack', 'amount'=>7999, 'tag'=>'Best Value'],
];
foreach ($packs as $pack): ?>
= $pack['label'] ?> — = formatINR($pack['amount']) ?>
= $pack['tag'] ?>
-------------------- END OF FILE --------------------
### FILE 6: account/profile.php
- Type: PHP
- Size: 5.99 KB
- Path: account
- Name: profile.php
------------------------------------------------------------
HASH_COST]);
DB::query("UPDATE users SET password_hash=? WHERE id=?", [$hash, $user['id']]);
$success = 'Password changed successfully.';
}
}
}
if ($success) flash('success', $success);
if ($error) flash('error', $error);
redirect(APP_URL . '/account/profile.php');
}
require_once __DIR__ . '/../includes/header.php';
?>
Member Since
= date('d M Y', strtotime($user['created_at'])) ?>
Current Plan
= planBadge($user['plan_name']) ?>
Wallet Balance
= formatINR((float)$user['credits']) ?>
-------------------- END OF FILE --------------------
### FILE 7: account/upgrade.php
- Type: PHP
- Size: 6.09 KB
- Path: account
- Name: upgrade.php
------------------------------------------------------------
Most Popular
= sanitize($plan['name']) ?>
= $plan['price_monthly'] == 0 ? 'Free' : '₹' . number_format($plan['price_monthly']) ?>
= $plan['price_monthly'] > 0 ? '/ month' : '' ?>
= sanitize($plan['description'] ?? '') ?>
= $plan['surveys_limit'] == -1 ? 'Unlimited surveys' : $plan['surveys_limit'] . ' surveys' ?>
= $plan['responses_monthly'] == -1 ? 'Unlimited responses / mo' : number_format($plan['responses_monthly']) . ' responses / mo' ?>
= $plan['questions_limit'] == -1 ? 'Unlimited questions' : $plan['questions_limit'] . ' questions / survey' ?>
= sanitize($feat) ?>
Custom branding & logo No custom branding
API access
✓ Current Plan
Switch to Free
= $plan['price_monthly']): ?>
Use Wallet Credits — = formatINR($plan['price_monthly']) ?>
Pay = formatINR($plan['price_monthly']) ?> / mo
Need Enterprise pricing?
Custom volume plans with dedicated support. Contact us →
-------------------- END OF FILE --------------------
### FILE 8: admin/index.php
- Type: PHP
- Size: 8.11 KB
- Path: admin
- Name: index.php
------------------------------------------------------------
= DATE_FORMAT(NOW(),'%Y-%m-01')")['c'];
// Revenue summary
$totalRevenue = DB::row("SELECT IFNULL(SUM(amount),0) c FROM transactions WHERE status='completed' AND type='credit'")['c'];
$monthRevenue = DB::row("SELECT IFNULL(SUM(amount),0) c FROM transactions WHERE status='completed' AND type='credit' AND created_at >= DATE_FORMAT(NOW(),'%Y-%m-01')")['c'];
// Plan distribution
$planDist = DB::all("SELECT p.name, COUNT(u.id) cnt FROM plans p LEFT JOIN users u ON u.plan_id=p.id AND u.role='client' GROUP BY p.id, p.name ORDER BY p.id");
// Recent signups
$recentUsers = DB::all("SELECT id, name, email, plan_id, created_at, is_active FROM users WHERE role='client' ORDER BY created_at DESC LIMIT 8");
// Response trend (last 14 days)
$trend = DB::all("SELECT DATE(created_at) dt, COUNT(*) cnt FROM survey_responses WHERE created_at >= DATE_SUB(NOW(), INTERVAL 14 DAY) GROUP BY DATE(created_at) ORDER BY dt");
// Recent surveys (active)
$recentSurveys = DB::all("SELECT s.id, s.title, s.status, s.response_count, s.created_at, u.name owner FROM surveys s JOIN users u ON s.user_id=u.id ORDER BY s.created_at DESC LIMIT 8");
require_once __DIR__ . '/../includes/admin_header.php';
?>
= number_format($totalUsers) ?>
Total Clients
= $activeUsers ?> active
= number_format($totalSurveys) ?>
Total Surveys
= $activeSurveys ?> live
= number_format($totalResponses) ?>
All-time Responses
= number_format($monthResponses) ?> this month
= formatINR((float)$totalRevenue) ?>
Total Revenue
= formatINR((float)$monthRevenue) ?> this month
Survey Owner Status Responses Created
= sanitize($s['title']) ?>
= sanitize($s['owner']) ?>
= surveyStatusBadge($s['status']) ?>
= number_format($s['response_count']) ?>
= timeSince($s['created_at']) ?>
Name Email Status Joined
= sanitize($u['name']) ?>
= sanitize($u['email']) ?>
= $u['is_active'] ? 'Active' : 'Inactive' ?>
= timeSince($u['created_at']) ?>
= sanitize($plan['name']) ?> = $plan['cnt'] ?> users
-------------------- END OF FILE --------------------
### FILE 9: admin/plans.php
- Type: PHP
- Size: 10.16 KB
- Path: admin
- Name: plans.php
------------------------------------------------------------
Plan Price/mo Surveys Responses
Users Active Actions
= sanitize($p['name']) ?>
= sanitize($p['slug']) ?>
= $p['price_monthly'] == 0 ? 'Free ' : '₹' . number_format($p['price_monthly']) ?>
= $p['surveys_limit'] == -1 ? '∞' : number_format($p['surveys_limit']) ?>
= $p['responses_monthly'] == -1 ? '∞' : number_format($p['responses_monthly']) ?>
= $p['user_count'] ?>
= $p['is_active'] ? 'Active' : 'Hidden' ?>
-------------------- END OF FILE --------------------
### FILE 10: admin/reports.php
- Type: PHP
- Size: 13.28 KB
- Path: admin
- Name: reports.php
------------------------------------------------------------
= ? AND created_at <= ?
GROUP BY DATE(created_at) ORDER BY dt",
[$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']
);
// ── Responses by day ──────────────────────────────────────────
$responseTrend = DB::all(
"SELECT DATE(created_at) dt, COUNT(*) cnt FROM survey_responses
WHERE created_at >= ? AND created_at <= ?
GROUP BY DATE(created_at) ORDER BY dt",
[$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']
);
// ── Revenue by day ────────────────────────────────────────────
$revenueTrend = DB::all(
"SELECT DATE(created_at) dt, SUM(amount) total FROM transactions
WHERE type='credit' AND status='completed' AND created_at >= ? AND created_at <= ?
GROUP BY DATE(created_at) ORDER BY dt",
[$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']
);
// ── Summary totals for the period ─────────────────────────────
$newUsers = DB::row("SELECT COUNT(*) c FROM users WHERE role='client' AND created_at >= ? AND created_at <= ?", [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'])['c'];
$newSurveys = DB::row("SELECT COUNT(*) c FROM surveys WHERE created_at >= ? AND created_at <= ?", [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'])['c'];
$newResp = DB::row("SELECT COUNT(*) c FROM survey_responses WHERE created_at >= ? AND created_at <= ?", [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'])['c'];
$revenue = DB::row("SELECT IFNULL(SUM(amount),0) c FROM transactions WHERE type='credit' AND status='completed' AND created_at >= ? AND created_at <= ?", [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'])['c'];
// ── Top surveys by responses ──────────────────────────────────
$topSurveys = DB::all(
"SELECT s.title, s.response_count, s.complete_count, u.name owner
FROM surveys s JOIN users u ON s.user_id=u.id
ORDER BY s.response_count DESC LIMIT 10"
);
// ── Top clients by survey count ───────────────────────────────
$topClients = DB::all(
"SELECT u.name, u.email, p.name plan_name,
COUNT(s.id) survey_count, SUM(s.response_count) total_responses
FROM users u
JOIN plans p ON u.plan_id=p.id
LEFT JOIN surveys s ON s.user_id=u.id
WHERE u.role='client'
GROUP BY u.id ORDER BY total_responses DESC LIMIT 10"
);
// ── Question type usage ───────────────────────────────────────
$qtypeUsage = DB::all(
"SELECT type, COUNT(*) cnt FROM survey_questions GROUP BY type ORDER BY cnt DESC LIMIT 15"
);
// ── Device breakdown ──────────────────────────────────────────
$deviceBreakdown = DB::all(
"SELECT device_type, COUNT(*) cnt FROM survey_responses
WHERE created_at >= ? AND created_at <= ? GROUP BY device_type",
[$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']
);
$totalDevice = max(1, array_sum(array_column($deviceBreakdown, 'cnt')));
require_once __DIR__ . '/../includes/admin_header.php';
?>
From
To
Apply
This Month
Last Month
This Year
= number_format($newUsers) ?>
New Signups
= sanitize($dateFrom) ?> – = sanitize($dateTo) ?>
= number_format($newSurveys) ?>
Surveys Created
= number_format($newResp) ?>
Responses Collected
= formatINR((float)$revenue) ?>
Revenue
No responses in this period.
No revenue in this period.
No signups in this period.
= $d['device_type']==='mobile'?'📱':($d['device_type']==='tablet'?'⬜':'🖥') ?>
= $d['cnt'] ?>
= ucfirst($d['device_type'] ?? '?') ?> · = $pct ?>%
Survey
Responses
$ts): ?>
= $i+1 ?>.
= sanitize(mb_substr($ts['title'], 0, 40)) ?>
= sanitize($ts['owner']) ?>
= number_format($ts['response_count']) ?>
= questionTypeLabel($qt['type']) ?>
= number_format($qt['cnt']) ?>
# Client Plan Surveys Total Responses
$c): ?>
= $i+1 ?>
= sanitize($c['name']) ?> = sanitize($c['email']) ?>
= planBadge($c['plan_name']) ?>
= number_format($c['survey_count'] ?? 0) ?>
= number_format($c['total_responses'] ?? 0) ?>
-------------------- END OF FILE --------------------
### FILE 11: admin/settings.php
- Type: PHP
- Size: 5.8 KB
- Path: admin
- Name: settings.php
------------------------------------------------------------
sanitize($cfg[$k] ?? $def);
require_once __DIR__ . '/../includes/admin_header.php';
?>
-------------------- END OF FILE --------------------
### FILE 12: admin/surveys.php
- Type: PHP
- Size: 4.23 KB
- Path: admin
- Name: surveys.php
------------------------------------------------------------
All Statuses
>= ucfirst($st) ?>
Filter
# Survey Owner Status Responses Created Actions
$s): ?>
= $offset + $i + 1 ?>
= sanitize($s['title']) ?>
= sanitize($s['owner_name']) ?>= sanitize($s['owner_email']) ?>
= surveyStatusBadge($s['status']) ?>
= number_format($s['response_count']) ?>
= timeSince($s['created_at']) ?>
1): ?>
-------------------- END OF FILE --------------------
### FILE 13: admin/transactions.php
- Type: PHP
- Size: 4.51 KB
- Path: admin
- Name: transactions.php
------------------------------------------------------------
= DATE_FORMAT(NOW(),'%Y-%m-01')")['s'];
require_once __DIR__ . '/../includes/admin_header.php';
?>
= formatINR((float)$totalRevenue) ?>
Total Revenue
= formatINR((float)$monthRevenue) ?>
This Month
= number_format($total) ?>
All Transactions
All Types
>Credit
>Debit
All Statuses
>Pending
>Completed
>Failed
Filter
Date User Description Type Amount Status Razorpay ID
= date('d M Y H:i', strtotime($t['created_at'])) ?>
= sanitize($t['u_name']) ?>= sanitize($t['u_email']) ?>
= sanitize($t['description'] ?? '—') ?>
= ucfirst($t['type']) ?>
= formatINR((float)$t['amount']) ?>
= surveyStatusBadge($t['status']) ?>
= sanitize($t['razorpay_payment_id'] ?? '—') ?>
1): ?>
-------------------- END OF FILE --------------------
### FILE 14: admin/users.php
- Type: PHP
- Size: 9.98 KB
- Path: admin
- Name: users.php
------------------------------------------------------------
All Plans
>= sanitize($p['name']) ?>
All Roles
>Client
>Manager
>Super Admin
Filter
# Name / Email Company Role Plan
Surveys Credits Status Joined Actions
$u): ?>
= $offset + $i + 1 ?>
= sanitize($u['name']) ?>
= sanitize($u['email']) ?>
= sanitize($u['company_name'] ?? '—') ?>
= planBadge($u['role']) ?>
= planBadge($u['plan_name'] ?? 'Free') ?>
= $u['surveys_count'] ?>
= formatINR((float)($u['credits'] ?? 0)) ?>
= $u['is_active'] ? 'Active' : 'Inactive' ?>
= date('d M Y', strtotime($u['created_at'])) ?>
1): ?>
= csrfField() ?>
Select Plan
= sanitize($p['name']) ?>
-------------------- END OF FILE --------------------
### FILE 15: api/billing.php
- Type: PHP
- Size: 6.96 KB
- Path: api
- Name: billing.php
------------------------------------------------------------
'Invalid signature']); exit;
}
$event = json_decode($payload, true);
if ($event['event'] === 'payment.captured') {
$paymentId = $event['payload']['payment']['entity']['id'] ?? '';
$orderId = $event['payload']['payment']['entity']['order_id'] ?? '';
// Credit the wallet based on transaction record
$tx = DB::row("SELECT * FROM transactions WHERE razorpay_order_id=? AND status='pending'", [$orderId]);
if ($tx) {
DB::query("UPDATE transactions SET status='completed', razorpay_payment_id=? WHERE id=?", [$paymentId, $tx['id']]);
DB::query("UPDATE users SET credits=credits+? WHERE id=?", [$tx['amount'], $tx['user_id']]);
}
}
echo json_encode(['ok' => true]); exit;
}
// ── Authenticated API ─────────────────────────────────────────
if (!isLoggedIn()) { echo json_encode(['error' => 'Unauthenticated']); exit; }
$user = currentUser();
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? '';
// Load Razorpay keys
$keyId = DB::getSetting('razorpay_key_id') ?: RAZORPAY_KEY_ID;
$keySecret = DB::getSetting('razorpay_key_secret') ?: RAZORPAY_KEY_SECRET;
if (!$keyId || !$keySecret) {
echo json_encode(['error' => 'Payment gateway not configured.']); exit;
}
switch ($action) {
// ── Create Razorpay order ─────────────────────────────────
case 'create_order': {
$amount = abs((float)($input['amount'] ?? 0));
$desc = substr(strip_tags($input['description'] ?? 'Wallet Top-up'), 0, 255);
if ($amount < 100) { echo json_encode(['error' => 'Minimum ₹100']); exit; }
$amountPaise = (int)($amount * 100);
// Call Razorpay API
$orderData = [
'amount' => $amountPaise,
'currency' => 'INR',
'receipt' => 'survam_' . $user['id'] . '_' . time(),
'notes' => ['user_id' => $user['id'], 'description' => $desc],
];
$ch = curl_init('https://api.razorpay.com/v1/orders');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($orderData),
CURLOPT_USERPWD => "$keyId:$keySecret",
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$rzpOrder = json_decode($response, true);
if ($httpCode !== 200 || empty($rzpOrder['id'])) {
error_log('Razorpay order error: ' . $response);
echo json_encode(['error' => 'Payment gateway error. Please try again.']); exit;
}
// Record pending transaction
DB::insert("INSERT INTO transactions (user_id, type, amount, status, description, razorpay_order_id)
VALUES (?,?,?,?,?,?)",
[$user['id'], 'credit', $amount, 'pending', $desc, $rzpOrder['id']]);
echo json_encode([
'success' => true,
'order_id' => $rzpOrder['id'],
'amount_paise' => $amountPaise,
]);
break;
}
// ── Verify payment ────────────────────────────────────────
case 'verify_payment': {
$paymentId = $input['razorpay_payment_id'] ?? '';
$orderId = $input['razorpay_order_id'] ?? '';
$signature = $input['razorpay_signature'] ?? '';
if (!$paymentId || !$orderId || !$signature) {
echo json_encode(['error' => 'Missing payment data.']); exit;
}
// Verify signature
$expectedSig = hash_hmac('sha256', "$orderId|$paymentId", $keySecret);
if (!hash_equals($expectedSig, $signature)) {
echo json_encode(['error' => 'Payment signature mismatch. Contact support.']); exit;
}
// Credit wallet
$tx = DB::row("SELECT * FROM transactions WHERE razorpay_order_id=? AND user_id=? AND status='pending'",
[$orderId, $user['id']]);
if (!$tx) { echo json_encode(['error' => 'Transaction not found or already processed.']); exit; }
DB::query("UPDATE transactions SET status='completed', razorpay_payment_id=? WHERE id=?",
[$paymentId, $tx['id']]);
DB::query("UPDATE users SET credits=credits+? WHERE id=?", [$tx['amount'], $user['id']]);
echo json_encode(['success' => true, 'credited' => $tx['amount']]);
break;
}
// ── Apply plan (deduct credits) ───────────────────────────
case 'apply_plan': {
$planId = (int)($input['plan_id'] ?? 0);
$plan = DB::row("SELECT * FROM plans WHERE id=?", [$planId]);
if (!$plan) { echo json_encode(['error' => 'Plan not found']); exit; }
$price = (float)$plan['price_monthly'];
if ($price === 0.0) {
// Free plan — just switch
DB::query("UPDATE users SET plan_id=? WHERE id=?", [$planId, $user['id']]);
echo json_encode(['success' => true, 'message' => 'Switched to Free plan.']); exit;
}
if ((float)$user['credits'] < $price) {
echo json_encode(['error' => 'Insufficient credits. Please top up your wallet.']); exit;
}
DB::query("UPDATE users SET plan_id=?, credits=credits-? WHERE id=?", [$planId, $price, $user['id']]);
DB::insert("INSERT INTO transactions (user_id, type, amount, status, description)
VALUES (?,?,?,?,?)",
[$user['id'], 'debit', $price, 'completed', 'Plan: ' . $plan['name']]);
echo json_encode(['success' => true, 'message' => 'Plan activated: ' . $plan['name']]);
break;
}
default:
echo json_encode(['error' => 'Unknown action']);
}
-------------------- END OF FILE --------------------
### FILE 16: api/surveys.php
- Type: PHP
- Size: 15.53 KB
- Path: api
- Name: surveys.php
------------------------------------------------------------
'Unauthenticated']); }
$user = currentUser();
// Accept JSON or form data
$input = [];
$raw = file_get_contents('php://input');
if ($raw) {
$input = json_decode($raw, true) ?? [];
} else {
$input = $_POST;
}
$action = $input['action'] ?? '';
// ── Helper: owns survey? ──────────────────────────────────────
function getSurveyForUser(int $id, array $user): ?array {
if (in_array($user['role'], ['super_admin', 'manager'])) {
return DB::row("SELECT * FROM surveys WHERE id = ?", [$id]);
}
return DB::row("SELECT * FROM surveys WHERE id = ? AND user_id = ?", [$id, $user['id']]);
}
try {
switch ($action) {
// ── save_structure ────────────────────────────────────────
case 'save_structure': {
try {
$surveyId = (int)($input['survey_id'] ?? 0);
$survey = getSurveyForUser($surveyId, $user);
if (!$survey) { echo json_encode(['error' => 'Not found']); exit; }
$pages = $input['pages'] ?? [];
$pageIds = [];
$questionIds = [];
$pageNum = 1;
// Get current page/question IDs to know what to delete later
$oldPageIds = array_column(DB::all("SELECT id FROM survey_pages WHERE survey_id=?", [$surveyId]), 'id');
foreach ($pages as $page) {
$pid = (int)($page['id'] ?? 0);
$pageTitle = substr(strip_tags($page['title'] ?? 'Page ' . $pageNum), 0, 255);
$pageDesc = substr(strip_tags($page['description'] ?? ''), 0, 1000);
if ($pid && in_array($pid, $oldPageIds)) {
DB::query("UPDATE survey_pages SET page_number=?, title=?, description=?, updated_at=NOW() WHERE id=? AND survey_id=?",
[$pageNum, $pageTitle, $pageDesc, $pid, $surveyId]);
} else {
$pid = DB::insert("INSERT INTO survey_pages (survey_id, page_number, title, description) VALUES (?,?,?,?)",
[$surveyId, $pageNum, $pageTitle, $pageDesc]);
}
$pageIds[] = $pid;
// Questions
$oldQIds = array_column(DB::all("SELECT id FROM survey_questions WHERE page_id=?", [$pid]), 'id');
$incomingQIds = [];
$orderNum = 1;
foreach (($page['questions'] ?? []) as $q) {
$qid = (int)($q['id'] ?? 0);
$qType = preg_replace('/[^a-z_]/', '', $q['type'] ?? 'text_short');
$qTitle = substr(strip_tags($q['title'] ?? 'Question'), 0, 500);
$qDesc = substr(strip_tags($q['description'] ?? ''), 0, 2000);
$qRequired = !empty($q['required']) ? 1 : 0;
$qOptions = json_encode($q['options'] ?? null);
// Merge media stimulus fields into settings JSON
$qSettingsArr = $q['settings'] ?? [];
if (!is_array($qSettingsArr)) $qSettingsArr = [];
if (isset($q['media_url'])) $qSettingsArr['media_url'] = filter_var($q['media_url'], FILTER_SANITIZE_URL);
if (isset($q['media_type'])) $qSettingsArr['media_type'] = in_array($q['media_type'], ['none','image','video','image_upload']) ? $q['media_type'] : 'none';
if (isset($q['media_position'])) $qSettingsArr['media_position'] = in_array($q['media_position'], ['above','below']) ? $q['media_position'] : 'above';
$qSettings = json_encode($qSettingsArr ?: null);
$qLogic = json_encode($q['logic'] ?? []);
$qPipe = json_encode($q['pipe_config'] ?? null);
// Store screenout_url inside pipe_config or as part of logic JSON
if (!empty($q['screenout_url'])) {
$logicArr = $q['logic'] ?? [];
$logicArr['screenout_url'] = filter_var($q['screenout_url'], FILTER_SANITIZE_URL);
$qLogic = json_encode($logicArr);
}
$qLoop = json_encode($q['loop_config'] ?? null);
$qLogicMatch = in_array($q['logic_match'] ?? 'all', ['all','any']) ? ($q['logic_match'] ?? 'all') : 'all';
$qLogicAct = in_array($q['logic_action'] ?? 'show', ['show','skip','jump_page','end','disqualify']) ? ($q['logic_action'] ?? 'show') : 'show';
if ($qid && in_array($qid, $oldQIds)) {
DB::query("UPDATE survey_questions SET page_id=?, survey_id=?, type=?, title=?, description=?, required=?,
options=?, settings=?, logic=?, logic_match=?, logic_action=?, pipe_config=?, loop_config=?, order_num=?, updated_at=NOW()
WHERE id=?",
[$pid, $surveyId, $qType, $qTitle, $qDesc, $qRequired,
$qOptions, $qSettings, $qLogic, $qLogicMatch, $qLogicAct, $qPipe, $qLoop, $orderNum, $qid]);
} else {
$qid = DB::insert("INSERT INTO survey_questions
(survey_id, page_id, type, title, description, required, options, settings, logic, logic_match, logic_action, pipe_config, loop_config, order_num)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[$surveyId, $pid, $qType, $qTitle, $qDesc, $qRequired,
$qOptions, $qSettings, $qLogic, $qLogicMatch, $qLogicAct, $qPipe, $qLoop, $orderNum]);
}
$incomingQIds[] = $qid;
$questionIds[] = $qid;
$orderNum++;
}
// Delete removed questions
$toDeleteQ = array_diff($oldQIds, $incomingQIds);
foreach ($toDeleteQ as $dqid) {
DB::query("DELETE FROM survey_questions WHERE id=?", [$dqid]);
}
$pageNum++;
}
// Delete removed pages
$toDeleteP = array_diff($oldPageIds, $pageIds);
foreach ($toDeleteP as $dpid) {
DB::query("DELETE FROM survey_questions WHERE page_id=?", [$dpid]);
DB::query("DELETE FROM survey_pages WHERE id=?", [$dpid]);
}
DB::query("UPDATE surveys SET updated_at=NOW() WHERE id=?", [$surveyId]);
sendJson(['success' => true, 'page_ids' => $pageIds, 'question_ids' => $questionIds]);
} catch (Exception $e) { echo json_encode(['error' => 'Save error: ' . $e->getMessage()]); }
break;
}
// ── update_status ─────────────────────────────────────────
case 'update_status': {
$surveyId = (int)($input['id'] ?? 0);
$status = $input['status'] ?? '';
$allowed = ['draft', 'active', 'paused', 'closed', 'archived'];
if (!in_array($status, $allowed)) { echo json_encode(['error' => 'Invalid status']); exit; }
$survey = getSurveyForUser($surveyId, $user);
if (!$survey) { echo json_encode(['error' => 'Not found']); exit; }
DB::query("UPDATE surveys SET status=?, updated_at=NOW() WHERE id=?", [$status, $surveyId]);
sendJson(['success' => true]);
break;
}
// ── update_settings ───────────────────────────────────────
case 'update_settings': {
$surveyId = (int)($input['survey_id'] ?? 0);
$survey = getSurveyForUser($surveyId, $user);
if (!$survey) { echo json_encode(['error' => 'Not found']); exit; }
$title = substr(strip_tags($input['title'] ?? $survey['title']), 0, 255);
$desc = substr(strip_tags($input['description'] ?? ''), 0, 2000);
$startsAt = !empty($input['starts_at']) ? date('Y-m-d H:i:s', strtotime($input['starts_at'])) : null;
$closesAt = !empty($input['closes_at']) ? date('Y-m-d H:i:s', strtotime($input['closes_at'])) : null;
$settings = [
'one_response_per_ip' => !empty($input['one_response_per_ip']),
'show_progress_bar' => !empty($input['show_progress_bar']),
'randomize_pages' => !empty($input['randomize_pages']),
'allow_back' => !empty($input['allow_back']),
'save_partial' => !empty($input['save_partial']),
'anonymous' => !empty($input['anonymous']),
'thank_you_message' => substr(strip_tags($input['thank_you_message'] ?? 'Thank you for completing this survey!'), 0, 500),
'redirect_url' => filter_var($input['redirect_url'] ?? '', FILTER_SANITIZE_URL),
'response_limit' => max(0, (int)($input['response_limit'] ?? 0)),
];
DB::query("UPDATE surveys SET title=?, description=?, settings=?, starts_at=?, closes_at=?, updated_at=NOW() WHERE id=?",
[$title, $desc, json_encode($settings), $startsAt, $closesAt, $surveyId]);
sendJson(['success' => true, 'message' => 'Settings saved']);
break;
}
// ── update_theme ──────────────────────────────────────────
case 'update_theme': {
$surveyId = (int)($input['survey_id'] ?? 0);
$survey = getSurveyForUser($surveyId, $user);
if (!$survey) { echo json_encode(['error' => 'Not found']); exit; }
// Admins always allowed; clients need Starter+ for remove_branding
$isAdmin = in_array($user['role'], ['super_admin','manager']);
$u = DB::row("SELECT p.custom_branding FROM users u JOIN plans p ON u.plan_id=p.id WHERE u.id=?", [$user['id']]);
$hasBranding = $isAdmin || !empty($u['custom_branding']);
$theme = [
'primary_color' => preg_match('/^#[0-9a-fA-F]{6}$/', $input['primary_color'] ?? '') ? $input['primary_color'] : '#e94560',
'bg_color' => preg_match('/^#[0-9a-fA-F]{6}$/', $input['bg_color'] ?? '') ? $input['bg_color'] : '#f4f6fb',
'logo_url' => filter_var($input['logo_url'] ?? '', FILTER_SANITIZE_URL),
'remove_branding' => $hasBranding && !empty($input['remove_branding']),
];
DB::query("UPDATE surveys SET theme=?, updated_at=NOW() WHERE id=?", [json_encode($theme), $surveyId]);
sendJson(['success' => true, 'message' => 'Theme saved']);
break;
}
// ── get_quotas ────────────────────────────────────────────
case 'get_quotas': {
$surveyId = (int)($input['survey_id'] ?? 0);
$survey = getSurveyForUser($surveyId, $user);
if (!$survey) { echo json_encode(['error' => 'Not found']); exit; }
$quotas = DB::all("SELECT * FROM survey_quotas WHERE survey_id=? ORDER BY id", [$surveyId]);
echo json_encode(['success' => true, 'quotas' => $quotas]);
break;
}
// ── add_quota ─────────────────────────────────────────────
case 'add_quota': {
$surveyId = (int)($input['survey_id'] ?? 0);
$survey = getSurveyForUser($surveyId, $user);
if (!$survey) { echo json_encode(['error' => 'Not found']); exit; }
$name = substr(strip_tags($input['name'] ?? ''), 0, 255);
$limit = max(1, (int)($input['limit_count'] ?? 1));
$action = in_array($input['action'] ?? 'disqualify', ['disqualify','complete','redirect']) ? $input['action'] : 'disqualify';
$conditions = json_encode($input['conditions'] ?? []);
if (!$name) { echo json_encode(['error' => 'Quota name is required']); exit; }
$id = DB::insert("INSERT INTO survey_quotas (survey_id, name, limit_count, action_when_full, conditions) VALUES (?,?,?,?,?)",
[$surveyId, $name, $limit, $action, $conditions]);
echo json_encode(['success' => true, 'id' => $id]);
break;
}
// ── delete_quota ──────────────────────────────────────────
case 'delete_quota': {
$quotaId = (int)($input['id'] ?? 0);
$quota = DB::row("SELECT sq.*, s.user_id FROM survey_quotas sq JOIN surveys s ON sq.survey_id=s.id WHERE sq.id=?", [$quotaId]);
if (!$quota) { echo json_encode(['error' => 'Not found']); exit; }
if ($quota['user_id'] != $user['id'] && !in_array($user['role'], ['super_admin','manager'])) {
echo json_encode(['error' => 'Forbidden']); exit;
}
DB::query("DELETE FROM survey_quotas WHERE id=?", [$quotaId]);
echo json_encode(['success' => true]);
break;
}
// ── duplicate survey ──────────────────────────────────────
case 'duplicate': {
$surveyId = (int)($input['id'] ?? 0);
$survey = getSurveyForUser($surveyId, $user);
if (!$survey) { echo json_encode(['error' => 'Not found']); exit; }
if (!canCreateSurvey()) { echo json_encode(['error' => 'Survey limit reached. Upgrade your plan.']); exit; }
$token = generateToken(32);
$newTitle = 'Copy of ' . $survey['title'];
$newId = DB::insert("INSERT INTO surveys (user_id, title, description, token, settings, theme)
VALUES (?,?,?,?,?,?)",
[$user['id'], $newTitle, $survey['description'], $token, $survey['settings'], $survey['theme']]);
$pages = DB::all("SELECT * FROM survey_pages WHERE survey_id=? ORDER BY page_number", [$surveyId]);
foreach ($pages as $page) {
$newPageId = DB::insert("INSERT INTO survey_pages (survey_id, page_number, title, description) VALUES (?,?,?,?)",
[$newId, $page['page_number'], $page['title'], $page['description']]);
$questions = DB::all("SELECT * FROM survey_questions WHERE page_id=? ORDER BY order_num", [$page['id']]);
foreach ($questions as $q) {
DB::insert("INSERT INTO survey_questions (survey_id, page_id, type, title, description, required, options, settings, logic, order_num)
VALUES (?,?,?,?,?,?,?,?,?,?)",
[$newId, $newPageId, $q['type'], $q['title'], $q['description'], $q['required'],
$q['options'], $q['settings'], '[]', $q['order_num']]);
}
}
DB::query("UPDATE users SET surveys_created=surveys_created+1 WHERE id=?", [$user['id']]);
echo json_encode(['success' => true, 'new_id' => $newId, 'redirect' => APP_URL . '/surveys/builder.php?id=' . $newId]);
break;
}
default:
ob_clean();
echo json_encode(['error' => 'Unknown action']);
}
} catch (Throwable $e) {
sendJson(['error' => 'Server error: ' . $e->getMessage()]);
}
-------------------- END OF FILE --------------------
### FILE 17: api/upload.php
- Type: PHP
- Size: 2.53 KB
- Path: api
- Name: upload.php
------------------------------------------------------------
'Unauthenticated']); exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['error' => 'POST required']); exit;
}
$type = $_POST['type'] ?? 'media'; // media | logo
// Allowed types
$allowedMime = ['image/jpeg','image/jpg','image/png','image/gif','image/webp'];
$maxBytes = 5 * 1024 * 1024; // 5MB
$file = $_FILES['file'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
$errMsg = match($file['error'] ?? 99) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File too large.',
UPLOAD_ERR_NO_FILE => 'No file selected.',
default => 'Upload failed. Try again.'
};
echo json_encode(['error' => $errMsg]); exit;
}
if ($file['size'] > $maxBytes) {
echo json_encode(['error' => 'File must be under 5MB.']); exit;
}
// Verify MIME by reading file header (don't trust $_FILES['type'])
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $allowedMime)) {
echo json_encode(['error' => 'Only JPG, PNG, GIF, WebP images allowed.']); exit;
}
// Build safe filename
$ext = match($mime) {
'image/jpeg', 'image/jpg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'jpg'
};
$user = currentUser();
$filename = 'media_' . $user['id'] . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
// Ensure upload directory exists
$uploadDir = __DIR__ . '/../uploads/media/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
// Protect directory
file_put_contents($uploadDir . '.htaccess', "\n Deny from all\n \n");
}
$destPath = $uploadDir . $filename;
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
echo json_encode(['error' => 'Failed to save file. Check server permissions.']); exit;
}
// Return the public URL
$publicUrl = APP_URL . '/uploads/media/' . $filename;
echo json_encode(['success' => true, 'url' => $publicUrl, 'filename' => $filename]);
-------------------- END OF FILE --------------------
### FILE 18: assets/css/admin.css
- Type: CSS
- Size: 6.7 KB
- Path: assets/css
- Name: admin.css
------------------------------------------------------------
/* ============================================================
SURVAM — Admin Panel Styles
============================================================ */
.admin-body { background: #f0f2f7; }
/* ── Layout ─────────────────────────────────────────────── */
.admin-layout {
display: flex;
min-height: 100vh;
}
/* ── Sidebar ────────────────────────────────────────────── */
.admin-sidebar {
width: 240px; flex-shrink: 0;
background: var(--brand-primary);
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh;
overflow-y: auto; z-index: 200;
transition: var(--transition-slow);
}
.sidebar-brand {
padding: 1.5rem 1rem 1.25rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
display: flex; align-items: center; gap: 0.6rem;
}
.sidebar-brand a { display: flex; align-items: center; gap: 0.6rem; text-decoration: none; }
.sidebar-brand .brand-text { font-family: var(--font-display); font-size: 1.1rem; font-weight: 800; color: white; }
.admin-badge {
background: var(--brand-accent); color: white;
font-size: 0.62rem; font-weight: 800; padding: 1px 6px;
border-radius: var(--radius-full); text-transform: uppercase; letter-spacing: 0.05em;
margin-left: auto;
}
.sidebar-nav { padding: 1rem 0; flex: 1; }
.nav-section { padding: 0 0 0.5rem; }
.nav-section-label {
font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.12em; color: rgba(255,255,255,0.35);
padding: 0.6rem 1rem 0.3rem; display: block;
}
.sidebar-nav a {
display: flex; align-items: center; gap: 0.7rem;
padding: 0.6rem 1rem; color: rgba(255,255,255,0.65);
font-size: 0.87rem; font-weight: 500; text-decoration: none;
border-radius: 0; transition: var(--transition);
border-left: 3px solid transparent;
}
.sidebar-nav a svg { width: 17px; height: 17px; flex-shrink: 0; opacity: 0.7; }
.sidebar-nav a:hover { background: var(--bg-sidebar-hover); color: white; opacity: 1; }
.sidebar-nav a.active { background: rgba(233,69,96,0.15); color: var(--brand-accent); border-left-color: var(--brand-accent); }
.sidebar-nav a.active svg { opacity: 1; color: var(--brand-accent); }
.sidebar-footer {
border-top: 1px solid rgba(255,255,255,0.1);
padding: 0.75rem;
}
.back-to-portal {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 0.75rem;
color: rgba(255,255,255,0.5); font-size: 0.82rem; text-decoration: none;
border-radius: var(--radius-sm); transition: var(--transition);
}
.back-to-portal svg { width: 15px; height: 15px; }
.back-to-portal:hover { background: rgba(255,255,255,0.08); color: white; opacity: 1; }
/* ── Content ────────────────────────────────────────────── */
.admin-content-wrap {
flex: 1; display: flex; flex-direction: column;
min-width: 0;
}
.admin-topbar {
background: white; height: 60px;
border-bottom: 1px solid var(--border-color);
display: flex; align-items: center; gap: 1rem;
padding: 0 1.5rem; position: sticky; top: 0; z-index: 100;
box-shadow: var(--shadow-xs);
}
.topbar-title {
font-size: 1rem; font-weight: 700;
color: var(--text-primary); flex: 1;
}
.topbar-right { display: flex; align-items: center; gap: 1rem; }
.admin-role-badge {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
background: var(--bg-page); color: var(--text-muted);
padding: 0.25rem 0.65rem; border-radius: var(--radius-full);
border: 1px solid var(--border-color);
}
.topbar-logout {
font-size: 0.83rem; font-weight: 600; color: var(--brand-accent);
}
.sidebar-toggle {
background: none; border: none; padding: 0.3rem;
color: var(--text-muted); display: none;
}
.sidebar-toggle svg { width: 20px; height: 20px; }
.admin-main {
padding: 1.75rem;
flex: 1;
}
/* ── Admin Cards ────────────────────────────────────────── */
.admin-card {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xs);
margin-bottom: 1.5rem;
}
.admin-card-header {
padding: 1.1rem 1.4rem;
border-bottom: 1px solid var(--border-color);
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 0.75rem;
}
.admin-card-header h3 { font-size: 0.95rem; font-weight: 700; }
.admin-card-body { padding: 1.4rem; }
/* ── Admin Stats ────────────────────────────────────────── */
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1.1rem; margin-bottom: 1.75rem;
}
.admin-stat {
background: white; border: 1px solid var(--border-color);
border-radius: var(--radius-lg); padding: 1.25rem;
box-shadow: var(--shadow-xs);
}
.admin-stat-val { font-family: var(--font-display); font-size: 2rem; font-weight: 800; }
.admin-stat-label { font-size: 0.8rem; color: var(--text-muted); font-weight: 500; }
.admin-stat-sub { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem; }
/* ── Table actions column ───────────────────────────────── */
.action-cell { display: flex; gap: 0.4rem; }
/* ── User status indicator ──────────────────────────────── */
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
display: inline-block; margin-right: 0.4rem;
}
.status-dot.active { background: #22c55e; }
.status-dot.inactive { background: #ef4444; }
/* ── Settings form ──────────────────────────────────────── */
.settings-form-section { margin-bottom: 2rem; }
.settings-form-section h4 {
font-size: 0.85rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--text-muted);
margin-bottom: 1rem; padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 900px) {
.admin-sidebar {
position: fixed; left: -240px; top: 0; height: 100%;
transition: left 0.3s ease; z-index: 900;
}
.admin-sidebar.open { left: 0; }
.sidebar-toggle { display: flex; }
.admin-main { padding: 1.25rem 1rem; }
.admin-stats-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 500px) {
.admin-stats-grid { grid-template-columns: 1fr; }
}
-------------------- END OF FILE --------------------
### FILE 19: assets/css/style.css
- Type: CSS
- Size: 43 KB
- Path: assets/css
- Name: style.css
------------------------------------------------------------
/* ============================================================
SURVAM — Master Stylesheet
Relevant Reflex Consulting
============================================================ */
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap");
/* ── Custom Properties ──────────────────────────────────── */
:root {
--brand-primary: #1a1a2e;
--brand-accent: #e94560;
--brand-teal: #0f9b8e;
--brand-light: #f8f9fc;
--brand-white: #ffffff;
--text-primary: #1a1a2e;
--text-secondary: #5a6478;
--text-muted: #9aa3b4;
--text-inverse: #ffffff;
--border-color: #e5e9f0;
--border-focus: #e94560;
--bg-page: #f4f6fb;
--bg-card: #ffffff;
--bg-sidebar: #1a1a2e;
--bg-sidebar-hover:#252545;
--shadow-xs: 0 1px 2px rgba(26,26,46,0.06);
--shadow-sm: 0 2px 8px rgba(26,26,46,0.08);
--shadow-md: 0 4px 20px rgba(26,26,46,0.10);
--shadow-lg: 0 8px 40px rgba(26,26,46,0.14);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 999px;
--font-display: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--transition: all 0.2s ease;
--transition-slow: all 0.35s cubic-bezier(0.4,0,0.2,1);
--nav-h: 64px;
}
/* ── Reset & Base ───────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; scroll-behavior: smooth; }
body {
font-family: var(--font-body);
color: var(--text-primary);
background: var(--bg-page);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a { color: var(--brand-accent); text-decoration: none; transition: var(--transition); }
a:hover { opacity: 0.85; }
img, svg { display: block; max-width: 100%; }
button { cursor: pointer; font-family: var(--font-body); }
input, textarea, select, button { outline: none; }
ul { list-style: none; }
/* ── Typography ─────────────────────────────────────────── */
h1,h2,h3,h4,h5,h6 { font-family: var(--font-display); font-weight: 700; line-height: 1.2; }
h1 { font-size: clamp(1.7rem, 3.5vw, 2.5rem); }
h2 { font-size: clamp(1.4rem, 3vw, 2rem); }
h3 { font-size: clamp(1.1rem, 2vw, 1.4rem); }
h4 { font-size: 1.1rem; }
p { line-height: 1.7; color: var(--text-secondary); }
small { font-size: 0.8rem; color: var(--text-muted); }
/* ── Portal Nav ─────────────────────────────────────────── */
.portal-nav {
position: sticky; top: 0; z-index: 900;
background: var(--brand-white);
border-bottom: 1px solid var(--border-color);
height: var(--nav-h);
box-shadow: var(--shadow-xs);
}
.nav-inner {
max-width: 1280px; margin: 0 auto;
display: flex; align-items: center; gap: 1.5rem;
height: 100%; padding: 0 1.5rem;
}
.nav-brand {
display: flex; align-items: center; gap: 0.5rem;
font-family: var(--font-display);
font-weight: 800; font-size: 1.2rem;
color: var(--brand-primary);
text-decoration: none;
flex-shrink: 0;
}
.brand-mark {
width: 34px; height: 34px;
background: var(--brand-accent);
color: white;
border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center;
font-size: 1rem; font-weight: 800;
}
.brand-text { letter-spacing: 0.05em; }
.nav-links {
display: flex; align-items: center; gap: 0.25rem;
margin-left: 1rem;
}
.nav-links a {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 0.85rem;
border-radius: var(--radius-sm);
font-size: 0.88rem; font-weight: 500;
color: var(--text-secondary);
transition: var(--transition);
}
.nav-links a svg { width: 16px; height: 16px; opacity: 0.7; }
.nav-links a:hover, .nav-links a.active {
background: var(--bg-page);
color: var(--brand-primary);
}
.nav-links a.active { color: var(--brand-accent); font-weight: 600; }
.nav-user {
display: flex; align-items: center; gap: 0.75rem;
margin-left: auto;
}
.user-menu-wrap { position: relative; }
.user-avatar {
width: 36px; height: 36px;
background: var(--brand-primary);
color: white;
border: none;
border-radius: var(--radius-full);
font-family: var(--font-display);
font-size: 0.9rem; font-weight: 700;
transition: var(--transition);
}
.user-avatar:hover { background: var(--brand-accent); }
.user-dropdown {
position: absolute; top: calc(100% + 8px); right: 0;
width: 200px;
background: white;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
overflow: hidden;
display: none;
z-index: 1000;
animation: dropIn 0.15s ease;
}
.user-dropdown.open { display: block; }
.dropdown-header {
padding: 0.85rem 1rem;
background: var(--bg-page);
border-bottom: 1px solid var(--border-color);
}
.dropdown-header strong { display: block; font-size: 0.9rem; color: var(--text-primary); }
.dropdown-header small { font-size: 0.75rem; color: var(--text-muted); }
.user-dropdown a {
display: block;
padding: 0.6rem 1rem;
font-size: 0.88rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
.user-dropdown a:last-child { border-bottom: none; }
.user-dropdown a:hover { background: var(--bg-page); color: var(--brand-primary); }
.user-dropdown .logout-link:hover { color: var(--brand-accent); }
.nav-toggle {
display: none; flex-direction: column; gap: 5px;
background: none; border: none; padding: 4px; margin-left: auto;
}
.nav-toggle span {
display: block; width: 22px; height: 2px;
background: var(--text-primary); border-radius: 2px;
transition: var(--transition);
}
/* ── Portal Main & Layout ───────────────────────────────── */
.portal-body { display: flex; flex-direction: column; min-height: 100vh; }
.portal-main { flex: 1; }
.page-container {
max-width: 1280px; margin: 0 auto;
padding: 2rem 1.5rem;
}
.page-header {
display: flex; align-items: flex-start; justify-content: space-between;
flex-wrap: wrap; gap: 1rem;
margin-bottom: 2rem;
}
.page-header-left h2 { margin-bottom: 0.25rem; }
.page-header-left p { font-size: 0.9rem; }
/* ── Cards ──────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xs);
}
.card-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex; align-items: center; justify-content: space-between;
}
.card-header h3 { font-size: 1rem; font-weight: 600; }
.card-body { padding: 1.5rem; }
.card-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
background: var(--bg-page);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
/* ── Stat Cards (Dashboard) ─────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.25rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-xs);
display: flex; align-items: flex-start; gap: 1rem;
transition: var(--transition-slow);
}
.stat-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
.stat-icon {
width: 48px; height: 48px; flex-shrink: 0;
border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
}
.stat-icon svg { width: 22px; height: 22px; }
.stat-icon.accent { background: #fff1f3; color: var(--brand-accent); }
.stat-icon.teal { background: #e8f7f6; color: var(--brand-teal); }
.stat-icon.blue { background: #eef2ff; color: #4f6ef7; }
.stat-icon.purple { background: #f3e8ff; color: #9333ea; }
.stat-icon.orange { background: #fff7ed; color: #f97316; }
.stat-value { font-family: var(--font-display); font-size: 1.9rem; font-weight: 800; line-height: 1; margin-bottom: 0.2rem; }
.stat-label { font-size: 0.82rem; color: var(--text-muted); font-weight: 500; }
.stat-delta { font-size: 0.78rem; margin-top: 0.25rem; }
.stat-delta.up { color: var(--brand-teal); }
.stat-delta.down { color: var(--brand-accent); }
/* ── Buttons ────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
padding: 0.6rem 1.25rem;
border-radius: var(--radius-sm);
font-family: var(--font-body); font-size: 0.9rem; font-weight: 600;
border: 2px solid transparent;
transition: var(--transition);
cursor: pointer; white-space: nowrap;
text-decoration: none;
}
.btn svg { width: 16px; height: 16px; }
.btn-primary { background: var(--brand-accent); color: white; border-color: var(--brand-accent); }
.btn-primary:hover { background: #c73750; border-color: #c73750; color: white; opacity: 1; }
.btn-secondary { background: transparent; color: var(--brand-primary); border-color: var(--border-color); }
.btn-secondary:hover { border-color: var(--brand-primary); background: var(--bg-page); }
.btn-teal { background: var(--brand-teal); color: white; border-color: var(--brand-teal); }
.btn-teal:hover { background: #0d8578; color: white; opacity: 1; }
.btn-dark { background: var(--brand-primary); color: white; border-color: var(--brand-primary); }
.btn-dark:hover { background: #252545; color: white; opacity: 1; }
.btn-danger { background: #fee2e2; color: #dc2626; border-color: #fecaca; }
.btn-danger:hover { background: #dc2626; color: white; border-color: #dc2626; }
.btn-sm { padding: 0.4rem 0.9rem; font-size: 0.82rem; }
.btn-lg { padding: 0.85rem 2rem; font-size: 1rem; }
.btn-icon { padding: 0.5rem; border-radius: var(--radius-sm); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Forms ──────────────────────────────────────────────── */
.form-group { margin-bottom: 1.25rem; }
.form-label {
display: block; margin-bottom: 0.45rem;
font-size: 0.875rem; font-weight: 600; color: var(--text-primary);
}
.form-label .required { color: var(--brand-accent); margin-left: 2px; }
.form-control {
width: 100%; padding: 0.65rem 0.85rem;
border: 1.5px solid var(--border-color);
border-radius: var(--radius-sm);
font-family: var(--font-body); font-size: 0.9rem;
color: var(--text-primary);
background: white;
transition: var(--transition);
}
.form-control:focus { border-color: var(--brand-accent); box-shadow: 0 0 0 3px rgba(233,69,96,0.1); }
.form-control::placeholder { color: var(--text-muted); }
textarea.form-control { resize: vertical; min-height: 100px; }
select.form-control { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239aa3b4' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.75rem center; padding-right: 2.5rem; }
.form-hint { font-size: 0.78rem; color: var(--text-muted); margin-top: 0.3rem; }
.form-error { font-size: 0.78rem; color: var(--brand-accent); margin-top: 0.3rem; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } }
.form-check { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
.form-check input[type="checkbox"], .form-check input[type="radio"] { width: 16px; height: 16px; accent-color: var(--brand-accent); cursor: pointer; }
/* ── Tables ─────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--border-color); }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
thead th {
background: var(--bg-page);
padding: 0.75rem 1rem;
text-align: left; font-weight: 600; font-size: 0.78rem;
color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em;
border-bottom: 1px solid var(--border-color);
}
tbody tr { border-bottom: 1px solid var(--border-color); transition: var(--transition); }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--bg-page); }
tbody td { padding: 0.85rem 1rem; color: var(--text-secondary); vertical-align: middle; }
tbody td strong { color: var(--text-primary); }
/* ── Badges ─────────────────────────────────────────────── */
.badge {
display: inline-flex; align-items: center;
padding: 0.2rem 0.65rem;
border-radius: var(--radius-full);
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase;
}
.badge-gray { background: #f1f5f9; color: #64748b; }
.badge-green { background: #dcfce7; color: #16a34a; }
.badge-yellow { background: #fef9c3; color: #ca8a04; }
.badge-red { background: #fee2e2; color: #dc2626; }
.badge-dark { background: #e2e8f0; color: #334155; }
.badge-blue { background: #dbeafe; color: #2563eb; }
.badge-teal { background: #ccfbf1; color: #0f766e; }
.badge-purple { background: #f3e8ff; color: #7c3aed; }
.badge-orange { background: #ffedd5; color: #c2410c; }
/* ── Flash Messages ─────────────────────────────────────── */
.flash-container { max-width: 1280px; margin: 1rem auto 0; padding: 0 1.5rem; }
.flash {
display: flex; align-items: center; justify-content: space-between;
padding: 0.85rem 1.1rem;
border-radius: var(--radius-md);
margin-bottom: 0.75rem;
font-size: 0.9rem;
animation: slideInDown 0.25s ease;
}
.flash-success { background: #dcfce7; color: #15803d; border: 1px solid #bbf7d0; }
.flash-error { background: #fee2e2; color: #dc2626; border: 1px solid #fecaca; }
.flash-warning { background: #fef9c3; color: #92400e; border: 1px solid #fde68a; }
.flash-info { background: #dbeafe; color: #1d4ed8; border: 1px solid #bfdbfe; }
.flash-close {
background: none; border: none; font-size: 1.2rem;
cursor: pointer; opacity: 0.6; color: inherit; margin-left: 1rem;
}
.flash-close:hover { opacity: 1; }
/* ── Auth Pages ─────────────────────────────────────────── */
.auth-page {
min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center;
background: var(--brand-primary);
background-image: radial-gradient(ellipse at 20% 50%, rgba(233,69,96,0.15) 0%, transparent 60%),
radial-gradient(ellipse at 80% 20%, rgba(15,155,142,0.12) 0%, transparent 50%);
padding: 2rem 1rem;
}
.auth-box {
width: 100%; max-width: 440px;
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.auth-box-header {
padding: 2.5rem 2.5rem 1.5rem;
text-align: center;
background: var(--bg-page);
border-bottom: 1px solid var(--border-color);
}
.auth-logo { display: flex; align-items: center; justify-content: center; gap: 0.6rem; margin-bottom: 1rem; }
.auth-logo .brand-mark { width: 42px; height: 42px; font-size: 1.2rem; }
.auth-logo .brand-text { font-family: var(--font-display); font-size: 1.4rem; font-weight: 800; color: var(--brand-primary); }
.auth-box-header h2 { font-size: 1.2rem; margin-bottom: 0.3rem; }
.auth-box-header p { font-size: 0.85rem; }
.auth-box-body { padding: 2rem 2.5rem; }
.auth-box-footer {
padding: 1.25rem 2.5rem;
text-align: center;
background: var(--bg-page);
border-top: 1px solid var(--border-color);
font-size: 0.85rem; color: var(--text-muted);
}
.auth-divider { display: flex; align-items: center; gap: 0.75rem; margin: 1.5rem 0; }
.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--border-color); }
.auth-divider span { font-size: 0.78rem; color: var(--text-muted); }
/* ── Survey Cards (Client Dashboard) ───────────────────── */
.surveys-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.25rem;
}
.survey-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-xs);
transition: var(--transition-slow);
cursor: pointer;
position: relative;
}
.survey-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); border-color: var(--brand-accent); }
.survey-card-top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 0.75rem; }
.survey-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: 0.25rem; line-height: 1.3; }
.survey-card p { font-size: 0.82rem; color: var(--text-muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.survey-card-meta {
display: flex; gap: 1rem; margin-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--border-color);
font-size: 0.78rem; color: var(--text-muted);
}
.survey-card-meta span { display: flex; align-items: center; gap: 0.3rem; }
.survey-card-meta svg { width: 12px; height: 12px; }
.survey-card-actions {
display: flex; gap: 0.5rem; margin-top: 1rem;
opacity: 0; transition: var(--transition);
}
.survey-card:hover .survey-card-actions { opacity: 1; }
.survey-card-status-dot {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0; margin-top: 5px;
}
.survey-card-status-dot.active { background: #22c55e; box-shadow: 0 0 0 3px #dcfce7; }
.survey-card-status-dot.draft { background: #94a3b8; }
.survey-card-status-dot.paused { background: #eab308; }
.survey-card-status-dot.closed { background: var(--brand-accent); }
.survey-card-status-dot.archived { background: #64748b; }
/* ── Pricing Page ───────────────────────────────────────── */
.pricing-page { padding: 5rem 1.5rem; background: white; }
.pricing-header { text-align: center; margin-bottom: 3rem; }
.pricing-header h1 { margin-bottom: 0.75rem; }
.pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
max-width: 1100px; margin: 0 auto;
}
.pricing-card {
border: 2px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 2.5rem 2rem;
position: relative; transition: var(--transition-slow);
}
.pricing-card.featured {
border-color: var(--brand-accent);
box-shadow: 0 0 0 4px rgba(233,69,96,0.1);
transform: scale(1.02);
}
.pricing-badge {
position: absolute; top: -12px; left: 50%; transform: translateX(-50%);
background: var(--brand-accent); color: white;
padding: 0.25rem 1rem; border-radius: var(--radius-full);
font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
}
.pricing-name { font-family: var(--font-display); font-size: 0.85rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.5rem; }
.pricing-price { font-family: var(--font-display); font-size: 2.4rem; font-weight: 800; line-height: 1; margin-bottom: 0.25rem; }
.pricing-price span { font-size: 1rem; font-weight: 400; color: var(--text-muted); }
.pricing-desc { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1.5rem; }
.pricing-features { list-style: none; margin-bottom: 2rem; }
.pricing-features li {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.45rem 0; font-size: 0.88rem; border-bottom: 1px solid var(--border-color);
}
.pricing-features li:last-child { border-bottom: none; }
.pricing-features li::before { content: '✓'; color: var(--brand-teal); font-weight: 700; flex-shrink: 0; }
.pricing-features li.dim { color: var(--text-muted); }
.pricing-features li.dim::before { content: '—'; color: var(--border-color); }
/* ── Builder ─────────────────────────────────────────────── */
.builder-layout {
display: grid;
grid-template-columns: 260px 1fr 300px;
height: calc(100vh - var(--nav-h));
overflow: hidden;
}
.builder-sidebar-left {
background: var(--brand-white);
border-right: 1px solid var(--border-color);
overflow-y: auto;
display: flex; flex-direction: column;
}
.builder-sidebar-right {
background: var(--brand-white);
border-left: 1px solid var(--border-color);
overflow-y: auto;
}
.builder-canvas {
background: var(--bg-page);
overflow-y: auto;
padding: 2rem;
}
.builder-topbar {
height: 52px;
background: white;
border-bottom: 1px solid var(--border-color);
display: flex; align-items: center; justify-content: space-between;
padding: 0 1rem; gap: 0.75rem;
grid-column: 1 / -1;
}
.builder-topbar-title { font-family: var(--font-display); font-weight: 700; font-size: 0.95rem; flex: 1; }
.builder-sidebar-title {
padding: 1rem; font-size: 0.72rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
}
.question-type-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 0.5rem; padding: 0.75rem;
}
.q-type-btn {
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
padding: 0.6rem 0.4rem;
border: 1.5px solid var(--border-color);
border-radius: var(--radius-sm);
background: white; cursor: pointer;
font-size: 0.7rem; font-weight: 600; color: var(--text-secondary);
text-align: center; line-height: 1.2;
transition: var(--transition);
}
.q-type-btn:hover { border-color: var(--brand-accent); color: var(--brand-accent); background: #fff1f3; }
.q-type-btn svg { width: 20px; height: 20px; opacity: 0.7; }
.q-type-btn:hover svg { opacity: 1; }
.q-type-section { padding: 0.5rem 0.75rem 0; }
.q-type-section-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 0.4rem; }
/* Question blocks in canvas */
.canvas-page {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
margin-bottom: 1.5rem;
overflow: hidden;
}
.canvas-page-header {
background: var(--bg-page);
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
display: flex; align-items: center; gap: 0.75rem;
}
.canvas-page-header h4 { font-size: 0.85rem; font-weight: 700; flex: 1; }
.question-block {
border: 2px solid transparent;
border-radius: var(--radius-md);
padding: 1.25rem;
margin: 0.75rem;
background: white;
cursor: grab;
transition: var(--transition);
position: relative;
}
.question-block:hover { border-color: var(--border-color); box-shadow: var(--shadow-sm); }
.question-block.selected { border-color: var(--brand-accent); box-shadow: 0 0 0 4px rgba(233,69,96,0.08); }
.question-block.dragging { opacity: 0.5; cursor: grabbing; }
.question-num {
position: absolute; top: -1px; left: -1px;
background: var(--brand-accent); color: white;
font-size: 0.7rem; font-weight: 700;
padding: 2px 7px; border-radius: 0 0 var(--radius-sm) 0;
}
.question-type-tag {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text-muted);
background: var(--bg-page); padding: 2px 7px; border-radius: var(--radius-full);
margin-bottom: 0.5rem; display: inline-block;
}
.question-title-display { font-size: 1rem; font-weight: 600; color: var(--text-primary); }
.question-block-actions {
position: absolute; right: 0.75rem; top: 0.75rem;
display: flex; gap: 0.35rem;
opacity: 0; transition: var(--transition);
}
.question-block:hover .question-block-actions,
.question-block.selected .question-block-actions { opacity: 1; }
/* Right panel – properties */
.prop-section { padding: 1rem; border-bottom: 1px solid var(--border-color); }
.prop-section:last-child { border-bottom: none; }
.prop-section-title { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 0.75rem; }
.prop-toggle {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.6rem;
}
.prop-toggle span { font-size: 0.85rem; color: var(--text-secondary); }
/* Toggle switch */
.toggle-switch { position: relative; width: 38px; height: 20px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-track {
position: absolute; inset: 0;
background: var(--border-color);
border-radius: var(--radius-full);
cursor: pointer; transition: var(--transition);
}
.toggle-track::before {
content: ''; position: absolute;
width: 14px; height: 14px; left: 3px; top: 3px;
background: white; border-radius: 50%;
transition: var(--transition);
}
.toggle-switch input:checked + .toggle-track { background: var(--brand-accent); }
.toggle-switch input:checked + .toggle-track::before { transform: translateX(18px); }
/* ── Public Survey Renderer ─────────────────────────────── */
.survey-page-body {
min-height: 100vh; background: var(--bg-page);
display: flex; flex-direction: column;
font-family: var(--font-body);
}
.survey-header {
background: white;
border-bottom: 3px solid var(--brand-accent);
padding: 1.25rem 1.5rem;
}
.survey-header-inner { max-width: 760px; margin: 0 auto; }
.survey-progress-wrap { margin-top: 0.75rem; }
.survey-progress-bar {
height: 4px; background: var(--border-color);
border-radius: var(--radius-full); overflow: hidden;
}
.survey-progress-fill {
height: 100%; background: var(--brand-accent);
border-radius: var(--radius-full);
transition: width 0.4s ease;
}
.survey-progress-label { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.35rem; }
.survey-body { flex: 1; display: flex; flex-direction: column; }
.survey-form { max-width: 760px; margin: 2rem auto; padding: 0 1rem; width: 100%; }
.survey-question-block {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 1.25rem;
box-shadow: var(--shadow-xs);
animation: fadeUp 0.3s ease;
}
.sq-title {
font-family: var(--font-display); font-size: 1.05rem; font-weight: 700;
color: var(--text-primary); margin-bottom: 0.5rem; line-height: 1.4;
}
.sq-title .required-star { color: var(--brand-accent); margin-left: 2px; }
.sq-desc { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1.25rem; }
/* Choice options */
.choice-list { display: flex; flex-direction: column; gap: 0.6rem; }
.choice-item {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer; transition: var(--transition);
font-size: 0.9rem;
}
.choice-item:hover { border-color: var(--brand-accent); background: #fff1f3; }
.choice-item.selected { border-color: var(--brand-accent); background: #fff1f3; color: var(--brand-primary); font-weight: 600; }
.choice-item input[type="radio"],
.choice-item input[type="checkbox"] { accent-color: var(--brand-accent); width: 16px; height: 16px; }
/* Rating */
.rating-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.rating-btn {
width: 48px; height: 48px;
border: 2px solid var(--border-color);
border-radius: var(--radius-sm);
background: white; font-weight: 700; font-size: 1rem;
color: var(--text-secondary); cursor: pointer; transition: var(--transition);
display: flex; align-items: center; justify-content: center;
}
.rating-btn:hover, .rating-btn.selected { border-color: var(--brand-accent); background: var(--brand-accent); color: white; }
.rating-labels { display: flex; justify-content: space-between; margin-top: 0.4rem; }
.rating-labels span { font-size: 0.72rem; color: var(--text-muted); }
/* Stars */
.star-row { display: flex; gap: 0.35rem; }
.star-btn {
font-size: 2rem; background: none; border: none;
color: var(--border-color); cursor: pointer; transition: var(--transition);
line-height: 1;
}
.star-btn.lit, .star-btn:hover { color: #f59e0b; }
.star-row:hover .star-btn { color: #f59e0b; }
.star-row .star-btn:hover ~ .star-btn { color: var(--border-color); }
/* NPS */
.nps-row { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.nps-btn {
min-width: 44px; height: 44px; padding: 0 0.5rem;
border: 2px solid var(--border-color);
border-radius: var(--radius-sm);
background: white; font-weight: 700;
cursor: pointer; transition: var(--transition);
}
.nps-btn.selected { background: var(--brand-accent); border-color: var(--brand-accent); color: white; }
.nps-labels { display: flex; justify-content: space-between; margin-top: 0.4rem; }
.nps-labels span { font-size: 0.72rem; color: var(--text-muted); }
/* Slider */
.slider-wrap { padding: 1rem 0; }
.slider-input { width: 100%; accent-color: var(--brand-accent); }
.slider-val { text-align: center; font-size: 1.5rem; font-weight: 700; color: var(--brand-accent); margin-bottom: 0.5rem; }
/* Matrix */
.matrix-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.matrix-table th { padding: 0.5rem; text-align: center; font-weight: 600; color: var(--text-muted); font-size: 0.78rem; }
.matrix-table td { padding: 0.5rem; text-align: center; border-top: 1px solid var(--border-color); }
.matrix-table tr:last-child td { border-bottom: none; }
.matrix-table td:first-child { text-align: left; font-weight: 500; }
.matrix-table input[type="radio"],
.matrix-table input[type="checkbox"] { accent-color: var(--brand-accent); width: 16px; height: 16px; }
/* Ranking */
.rank-list { display: flex; flex-direction: column; gap: 0.5rem; }
.rank-item {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1.5px solid var(--border-color); border-radius: var(--radius-md);
background: white; cursor: grab;
}
.rank-item .rank-handle { color: var(--text-muted); cursor: grab; }
.rank-item .rank-num {
width: 26px; height: 26px; background: var(--brand-accent);
color: white; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 700; flex-shrink: 0;
}
/* Emoji scale */
.emoji-row { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.emoji-btn {
display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
padding: 0.6rem 0.8rem;
border: 2px solid var(--border-color); border-radius: var(--radius-md);
background: white; cursor: pointer; transition: var(--transition);
font-size: 0.75rem; color: var(--text-muted);
}
.emoji-btn span.emoji-icon { font-size: 2rem; }
.emoji-btn:hover, .emoji-btn.selected { border-color: var(--brand-accent); background: #fff1f3; }
/* Survey nav buttons */
.survey-nav {
max-width: 760px; margin: 0 auto 2rem;
padding: 0 1rem;
display: flex; justify-content: space-between; align-items: center; gap: 1rem;
}
/* Survey footer */
.survey-footer {
text-align: center; padding: 1.5rem;
font-size: 0.75rem; color: var(--text-muted);
border-top: 1px solid var(--border-color);
background: white;
}
.survey-footer a { color: var(--text-muted); }
.powered-logo { font-family: var(--font-display); font-weight: 800; color: var(--brand-primary); }
/* ── Portal Footer ──────────────────────────────────────── */
.portal-footer {
background: var(--brand-primary); color: white;
margin-top: auto;
}
.footer-inner {
max-width: 1280px; margin: 0 auto;
display: grid; grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 2.5rem; padding: 3rem 1.5rem 2rem;
}
.footer-brand .nav-brand { color: white; margin-bottom: 0.75rem; }
.footer-brand p { font-size: 0.85rem; color: rgba(255,255,255,0.5); line-height: 1.6; }
.footer-brand a { color: rgba(255,255,255,0.7); }
.footer-links h4 { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(255,255,255,0.4); margin-bottom: 1rem; }
.footer-links a { display: block; font-size: 0.85rem; color: rgba(255,255,255,0.65); margin-bottom: 0.5rem; }
.footer-links a:hover { color: white; opacity: 1; }
.footer-bottom {
border-top: 1px solid rgba(255,255,255,0.1);
padding: 1.25rem 1.5rem; text-align: center;
}
.footer-bottom p { font-size: 0.8rem; color: rgba(255,255,255,0.4); }
/* ── Landing Page ───────────────────────────────────────── */
.landing-hero {
background: var(--brand-primary);
background-image: radial-gradient(ellipse at 15% 60%, rgba(233,69,96,0.2) 0%, transparent 55%),
radial-gradient(ellipse at 85% 20%, rgba(15,155,142,0.15) 0%, transparent 50%);
color: white; padding: 7rem 1.5rem 5rem;
text-align: center;
}
.landing-hero h1 { color: white; margin-bottom: 1.25rem; }
.landing-hero p { font-size: 1.1rem; color: rgba(255,255,255,0.7); max-width: 580px; margin: 0 auto 2.5rem; }
.hero-cta { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
.hero-badge {
display: inline-block; background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.8); padding: 0.3rem 0.85rem;
border-radius: var(--radius-full); font-size: 0.78rem; font-weight: 600;
border: 1px solid rgba(255,255,255,0.2); margin-bottom: 1.5rem;
}
.features-section { padding: 5rem 1.5rem; }
.features-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem; max-width: 1100px; margin: 3rem auto 0;
}
.feature-card {
padding: 2rem; border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
background: white; transition: var(--transition-slow);
}
.feature-card:hover { box-shadow: var(--shadow-md); transform: translateY(-3px); }
.feature-icon {
width: 48px; height: 48px; margin-bottom: 1.25rem;
background: var(--bg-page); border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center; font-size: 1.4rem;
}
.feature-card h3 { font-size: 1rem; margin-bottom: 0.5rem; }
.section-header { text-align: center; margin-bottom: 1rem; }
.section-header h2 { margin-bottom: 0.5rem; }
/* ── Dashboard layout ───────────────────────────────────── */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 340px;
gap: 1.5rem;
align-items: start;
}
.usage-bar-wrap { margin-top: 0.5rem; }
.usage-bar {
height: 6px; background: var(--border-color);
border-radius: var(--radius-full); overflow: hidden; margin-bottom: 0.3rem;
}
.usage-bar-fill {
height: 100%; background: var(--brand-teal);
border-radius: var(--radius-full); transition: width 0.6s ease;
}
.usage-bar-fill.warning { background: #f59e0b; }
.usage-bar-fill.danger { background: var(--brand-accent); }
.usage-label { display: flex; justify-content: space-between; font-size: 0.78rem; color: var(--text-muted); }
/* ── Modals ─────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(26,26,46,0.6);
backdrop-filter: blur(3px);
z-index: 9000;
display: flex; align-items: center; justify-content: center;
padding: 1rem;
opacity: 0; visibility: hidden; transition: var(--transition);
}
.modal-overlay.open { opacity: 1; visibility: visible; }
.modal {
background: white; border-radius: var(--radius-xl);
width: 100%; max-width: 560px; max-height: 90vh;
overflow-y: auto; box-shadow: var(--shadow-lg);
transform: translateY(20px) scale(0.97);
transition: var(--transition);
}
.modal-overlay.open .modal { transform: translateY(0) scale(1); }
.modal-header {
padding: 1.5rem; border-bottom: 1px solid var(--border-color);
display: flex; align-items: center; justify-content: space-between;
}
.modal-header h3 { font-size: 1.1rem; }
.modal-close { background: none; border: none; font-size: 1.4rem; color: var(--text-muted); cursor: pointer; }
.modal-body { padding: 1.5rem; }
.modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border-color); display: flex; gap: 0.75rem; justify-content: flex-end; }
.modal-lg { max-width: 800px; }
.modal-xl { max-width: 1100px; }
/* ── Empty States ───────────────────────────────────────── */
.empty-state {
text-align: center; padding: 4rem 2rem;
color: var(--text-muted);
}
.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; }
.empty-state h3 { font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--text-secondary); }
.empty-state p { font-size: 0.88rem; max-width: 320px; margin: 0 auto 1.5rem; }
/* ── Pagination ─────────────────────────────────────────── */
.pagination {
display: flex; gap: 0.35rem; justify-content: center;
margin-top: 1.5rem; flex-wrap: wrap;
}
.page-link {
min-width: 36px; height: 36px; padding: 0 0.5rem;
display: flex; align-items: center; justify-content: center;
border: 1.5px solid var(--border-color); border-radius: var(--radius-sm);
font-size: 0.85rem; font-weight: 600; color: var(--text-secondary);
cursor: pointer; transition: var(--transition); text-decoration: none;
}
.page-link:hover, .page-link.active {
border-color: var(--brand-accent);
background: var(--brand-accent); color: white;
}
.page-link.disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Tabs ───────────────────────────────────────────────── */
.tabs { display: flex; gap: 0; border-bottom: 2px solid var(--border-color); margin-bottom: 1.5rem; }
.tab-btn {
padding: 0.75rem 1.25rem;
border: none; background: none;
font-family: var(--font-body); font-size: 0.88rem; font-weight: 600;
color: var(--text-muted); cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -2px;
transition: var(--transition);
}
.tab-btn:hover { color: var(--text-primary); }
.tab-btn.active { color: var(--brand-accent); border-bottom-color: var(--brand-accent); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ── Dropdown filter ────────────────────────────────────── */
.filter-bar { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1.25rem; align-items: center; }
.filter-bar .form-control { width: auto; flex: 0 1 180px; }
.search-box { position: relative; flex: 1; max-width: 320px; }
.search-box svg { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-muted); }
.search-box input { padding-left: 2.25rem; }
/* ── Loader ─────────────────────────────────────────────── */
.loader-overlay {
position: fixed; inset: 0; z-index: 9999;
background: rgba(255,255,255,0.8); backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center;
}
.spinner {
width: 42px; height: 42px;
border: 3px solid var(--border-color);
border-top-color: var(--brand-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.btn .spinner { width: 16px; height: 16px; border-width: 2px; }
/* ── Charts placeholder ─────────────────────────────────── */
.chart-box {
background: var(--bg-page);
border-radius: var(--radius-md);
padding: 1rem;
min-height: 200px;
display: flex; align-items: center; justify-content: center;
}
/* ── Tooltip ────────────────────────────────────────────── */
[data-tip] { position: relative; }
[data-tip]::after {
content: attr(data-tip);
position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%);
background: var(--brand-primary); color: white;
padding: 0.35rem 0.65rem; border-radius: var(--radius-sm);
font-size: 0.72rem; white-space: nowrap;
opacity: 0; pointer-events: none; transition: var(--transition);
z-index: 100;
}
[data-tip]:hover::after { opacity: 1; }
/* ── Animations ─────────────────────────────────────────── */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
@keyframes slideInDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: none; } }
@keyframes dropIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 1100px) {
.builder-layout { grid-template-columns: 240px 1fr; }
.builder-sidebar-right { display: none; }
.builder-sidebar-right.open { display: flex; flex-direction: column; position: fixed; right: 0; top: var(--nav-h); bottom: 0; width: 300px; z-index: 800; box-shadow: var(--shadow-lg); }
.footer-inner { grid-template-columns: 1fr 1fr; }
.dashboard-grid { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.nav-links, .nav-user .user-plan { display: none; }
.nav-toggle { display: flex; }
.nav-links.open {
display: flex; flex-direction: column;
position: absolute; top: var(--nav-h); left: 0; right: 0;
background: white; border-bottom: 1px solid var(--border-color);
padding: 1rem; z-index: 800; gap: 0.25rem;
}
.builder-layout { grid-template-columns: 1fr; }
.builder-sidebar-left { display: none; position: fixed; left: 0; top: 0; bottom: 0; width: 280px; z-index: 800; box-shadow: var(--shadow-lg); }
.builder-sidebar-left.open { display: flex; }
.footer-inner { grid-template-columns: 1fr; gap: 1.5rem; }
.surveys-grid { grid-template-columns: 1fr; }
.pricing-grid { grid-template-columns: 1fr; }
.pricing-card.featured { transform: none; }
.form-row { grid-template-columns: 1fr; }
.page-container { padding: 1.25rem 1rem; }
.auth-box-body, .auth-box-header { padding-left: 1.5rem; padding-right: 1.5rem; }
.auth-box-footer { padding-left: 1.5rem; padding-right: 1.5rem; }
}
@media (max-width: 480px) {
.stats-grid { grid-template-columns: 1fr 1fr; }
.rating-btn { width: 40px; height: 40px; font-size: 0.9rem; }
.nps-btn { min-width: 36px; height: 36px; font-size: 0.85rem; }
}
/* ── Print ──────────────────────────────────────────────── */
@media print {
.portal-nav, .portal-footer, .btn, .survey-nav { display: none !important; }
.survey-question-block { break-inside: avoid; box-shadow: none; border: 1px solid #ddd; }
}
-------------------- END OF FILE --------------------
### FILE 20: assets/js/admin.js
- Type: JS
- Size: 3.02 KB
- Path: assets/js
- Name: admin.js
------------------------------------------------------------
/* ============================================================
SURVAM — Admin JavaScript (admin.js)
============================================================ */
'use strict';
document.addEventListener('DOMContentLoaded', () => {
// ── Sidebar toggle (mobile) ───────────────────────────────
const toggle = document.getElementById('sidebarToggle');
const sidebar = document.getElementById('adminSidebar');
if (toggle && sidebar) {
toggle.addEventListener('click', () => sidebar.classList.toggle('open'));
// Close on outside click
document.addEventListener('click', (e) => {
if (sidebar.classList.contains('open') && !sidebar.contains(e.target) && e.target !== toggle) {
sidebar.classList.remove('open');
}
});
}
// ── Confirm-delete links ──────────────────────────────────
document.querySelectorAll('[data-confirm]').forEach(el => {
el.addEventListener('click', (e) => {
if (!confirm(el.dataset.confirm)) e.preventDefault();
});
});
// ── Auto-submit filter forms on select change ─────────────
document.querySelectorAll('[data-autosubmit]').forEach(sel => {
sel.addEventListener('change', () => sel.closest('form')?.submit());
});
// ── Slug auto-generate from name input ────────────────────
const nameInput = document.getElementById('planName');
const slugInput = document.getElementById('planSlug');
if (nameInput && slugInput && !slugInput.value) {
nameInput.addEventListener('input', () => {
slugInput.value = nameInput.value.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
});
}
// ── Stat card hover sparkle ───────────────────────────────
document.querySelectorAll('.admin-stat').forEach(card => {
card.addEventListener('mouseenter', () => card.style.transform = 'translateY(-2px)');
card.addEventListener('mouseleave', () => card.style.transform = '');
});
// ── Flash auto-dismiss ────────────────────────────────────
document.querySelectorAll('.flash').forEach(f => {
setTimeout(() => { f.style.transition = 'opacity 0.5s'; f.style.opacity = '0'; setTimeout(() => f.remove(), 500); }, 4500);
});
});
// ── Admin user dropdown ───────────────────────────────────
const adminUserBtn = document.getElementById('adminUserMenuBtn');
const adminUserDropdown = document.getElementById('adminUserDropdown');
if (adminUserBtn && adminUserDropdown) {
adminUserBtn.addEventListener('click', (e) => {
e.stopPropagation();
adminUserDropdown.classList.toggle('open');
});
document.addEventListener('click', () => adminUserDropdown.classList.remove('open'));
}
-------------------- END OF FILE --------------------
### FILE 21: assets/js/app.js
- Type: JS
- Size: 16.32 KB
- Path: assets/js
- Name: app.js
------------------------------------------------------------
/* ============================================================
SURVAM — Main JavaScript (app.js) · v1.4
============================================================ */
'use strict';
document.addEventListener('DOMContentLoaded', () => {
// ── Nav toggle (mobile) ──────────────────────────────────
const navToggle = document.getElementById('navToggle');
const navLinks = document.querySelector('.nav-links');
if (navToggle && navLinks) {
navToggle.addEventListener('click', () => {
navLinks.classList.toggle('open');
});
}
// ── User dropdown ────────────────────────────────────────
const userMenuBtn = document.getElementById('userMenuBtn');
const userDropdown = document.getElementById('userDropdown');
if (userMenuBtn && userDropdown) {
userMenuBtn.addEventListener('click', (e) => {
e.stopPropagation();
userDropdown.classList.toggle('open');
});
document.addEventListener('click', () => userDropdown.classList.remove('open'));
}
// ── Flash auto-dismiss ───────────────────────────────────
document.querySelectorAll('.flash').forEach(f => {
setTimeout(() => f.style.transition = 'opacity 0.5s', 3500);
setTimeout(() => { f.style.opacity = '0'; setTimeout(() => f.remove(), 500); }, 4000);
});
// ── Tabs ─────────────────────────────────────────────────
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const group = btn.closest('[data-tabs]') || btn.closest('.tabs')?.parentElement;
if (!group) return;
const target = btn.dataset.tab;
group.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
group.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
const panel = group.querySelector(`[data-panel="${target}"]`);
if (panel) panel.classList.add('active');
});
});
// ── Modals ───────────────────────────────────────────────
document.querySelectorAll('[data-modal-open]').forEach(btn => {
btn.addEventListener('click', () => openModal(btn.dataset.modalOpen));
});
document.querySelectorAll('[data-modal-close]').forEach(btn => {
btn.addEventListener('click', () => closeModal(btn.dataset.modalClose || btn.closest('.modal-overlay')?.id));
});
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal(overlay.id);
});
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
});
});
function openModal(id) {
const el = document.getElementById(id);
if (el) { el.classList.add('open'); document.body.style.overflow = 'hidden'; }
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) { el.classList.remove('open'); document.body.style.overflow = ''; }
}
// ── AJAX helper ─────────────────────────────────────────────
async function apiPost(url, data) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify(data)
});
return res.json();
}
// ── Form AJAX submit with loading state ──────────────────────
document.querySelectorAll('[data-ajax-form]').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = form.querySelector('[type="submit"]');
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = ' Working…';
const fd = new FormData(form);
const data = Object.fromEntries(fd.entries());
try {
const res = await apiPost(form.action, data);
if (res.redirect) { window.location.href = res.redirect; return; }
if (res.reload) { window.location.reload(); return; }
if (res.success) { showToast(res.message || 'Saved!', 'success'); }
if (res.error) { showToast(res.error, 'error'); }
} catch(ex) {
showToast('Something went wrong. Please try again.', 'error');
}
btn.disabled = false; btn.innerHTML = origText;
});
});
// ── Toast ─────────────────────────────────────────────────────
function showToast(msg, type = 'info') {
const toast = document.createElement('div');
toast.className = `flash flash-${type}`;
toast.style.cssText = 'position:fixed;top:1.25rem;right:1.25rem;z-index:9999;min-width:280px;max-width:420px;animation:slideInDown 0.25s ease';
toast.innerHTML = `${escHtml(msg)} × `;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.4s'; setTimeout(() => toast.remove(), 400); }, 4000);
}
function escHtml(str) {
return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
// ── Copy to clipboard ─────────────────────────────────────────
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => showToast('Copied to clipboard!', 'success'));
}
// ── Survey status toggler (inline) ───────────────────────────
function toggleSurveyStatus(surveyId, currentStatus) {
const newStatus = currentStatus === 'active' ? 'paused' : 'active';
apiPost('/api/surveys.php', { action: 'update_status', id: surveyId, status: newStatus })
.then(r => { if (r.success) window.location.reload(); else showToast(r.error || 'Failed', 'error'); });
}
// ── Confirm delete ────────────────────────────────────────────
function confirmDelete(url, msg) {
if (confirm(msg || 'Are you sure? This cannot be undone.')) {
window.location.href = url;
}
}
// ── Admin sidebar toggle ──────────────────────────────────────
const sidebarToggle = document.getElementById('sidebarToggle');
const adminSidebar = document.getElementById('adminSidebar');
if (sidebarToggle && adminSidebar) {
sidebarToggle.addEventListener('click', () => adminSidebar.classList.toggle('open'));
}
// ── Drag-and-drop ranking (survey renderer) ───────────────────
function initRankDrag(listId) {
const list = document.getElementById(listId);
if (!list) return;
let dragging = null;
list.querySelectorAll('.rank-item').forEach(item => {
item.draggable = true;
item.addEventListener('dragstart', () => { dragging = item; item.style.opacity = '0.4'; });
item.addEventListener('dragend', () => { dragging = null; item.style.opacity = '1'; updateRankValues(list); });
item.addEventListener('dragover', (e) => { e.preventDefault(); const after = getDragAfter(list, e.clientY); if (after) list.insertBefore(dragging, after); else list.appendChild(dragging); });
});
}
function getDragAfter(list, y) {
const items = [...list.querySelectorAll('.rank-item:not([style*="opacity: 0.4"])')];
return items.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > (closest.offset || -Infinity)) return { offset, element: child };
return closest;
}, {}).element;
}
function updateRankValues(list) {
list.querySelectorAll('.rank-item').forEach((item, i) => {
const numEl = item.querySelector('.rank-num');
if (numEl) numEl.textContent = i + 1;
const inp = item.querySelector('input[type="hidden"]');
if (inp) inp.value = i + 1;
});
}
// ── Slider live value display ─────────────────────────────────
document.querySelectorAll('.slider-input').forEach(slider => {
const valEl = slider.closest('.slider-wrap')?.querySelector('.slider-val');
if (valEl) {
valEl.textContent = slider.value;
slider.addEventListener('input', () => valEl.textContent = slider.value);
}
});
// ── Star rating ───────────────────────────────────────────────
document.querySelectorAll('.star-row').forEach(row => {
const stars = [...row.querySelectorAll('.star-btn')];
stars.forEach((star, idx) => {
star.addEventListener('click', () => {
stars.forEach((s, i) => s.classList.toggle('lit', i <= idx));
const hiddenInp = row.querySelector('input[type="hidden"]');
if (hiddenInp) hiddenInp.value = idx + 1;
});
star.addEventListener('mouseenter', () => stars.forEach((s, i) => s.style.color = i <= idx ? '#f59e0b' : ''));
});
row.addEventListener('mouseleave', () => stars.forEach(s => s.style.color = ''));
});
// ── NPS / Rating select ───────────────────────────────────────
document.querySelectorAll('.rating-btn, .nps-btn, .emoji-btn').forEach(btn => {
btn.addEventListener('click', function() {
const group = this.closest('.rating-row, .nps-row, .emoji-row');
if (group) group.querySelectorAll(this.tagName).forEach(b => b.classList.remove('selected'));
this.classList.add('selected');
const hiddenInp = group?.querySelector('input[type="hidden"]');
if (hiddenInp) hiddenInp.value = this.dataset.value || this.textContent.trim();
});
});
// ── Choice item (radio/checkbox visual) ──────────────────────
document.querySelectorAll('.choice-item').forEach(item => {
item.addEventListener('click', () => {
const inp = item.querySelector('input');
if (!inp) return;
if (inp.type === 'radio') {
item.closest('.choice-list')?.querySelectorAll('.choice-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
} else {
item.classList.toggle('selected', inp.checked);
}
});
const inp = item.querySelector('input');
if (inp) inp.addEventListener('change', () => {
if (inp.type === 'radio') {
item.closest('.choice-list')?.querySelectorAll('.choice-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
} else {
item.classList.toggle('selected', inp.checked);
}
});
});
// ── Survey page-by-page navigation ───────────────────────────
window.SurveyNav = {
currentPage: 0,
pages: [],
init(pages) {
this.pages = pages;
this.show(0);
},
show(idx) {
this.pages.forEach((p, i) => p.style.display = i === idx ? '' : 'none');
this.currentPage = idx;
this.updateProgress();
},
next() {
if (this.currentPage < this.pages.length - 1) {
// Skip validation entirely in preview mode
if (!window.SURVAM_PREVIEW_MODE && !this.validatePage(this.currentPage)) return;
this.show(this.currentPage + 1);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
},
prev() {
if (this.currentPage > 0) { this.show(this.currentPage - 1); window.scrollTo({ top: 0, behavior: 'smooth' }); }
},
validatePage(idx) {
let valid = true;
const page = this.pages[idx];
// Find required blocks — skip display-only types entirely
const requiredBlocks = [...page.querySelectorAll('[data-required="1"]')]
.filter(b => !b.classList.contains('sq-display-only'));
// If no required questions on this page, always allow navigation
if (requiredBlocks.length === 0) return true;
requiredBlocks.forEach(block => {
let answered = false;
// Radio / checkbox
if (block.querySelector('input[type="radio"]:checked, input[type="checkbox"]:checked'))
answered = true;
// Text inputs
if (!answered) {
const inp = block.querySelector(
'textarea, select, input[type="text"], input[type="number"], ' +
'input[type="email"], input[type="date"], input[type="datetime-local"], ' +
'input[type="time"], input[type="tel"]'
);
if (inp && inp.value && inp.value.trim()) answered = true;
}
// Hidden value fields (rating, NPS, slider, emoji — default value "50" counts)
if (!answered) {
const hidden = block.querySelector('input[type="hidden"][id$="_val"]');
if (hidden && hidden.value !== '' && hidden.value !== '0') answered = true;
}
// Visually selected
if (!answered && block.querySelector('.selected')) answered = true;
if (!answered) {
block.classList.add('shake');
setTimeout(() => block.classList.remove('shake'), 600);
valid = false;
}
});
if (!valid) showToast('Please answer all required questions before continuing.', 'warning');
return valid;
},
updateProgress() {
const pct = Math.round(((this.currentPage) / Math.max(this.pages.length - 1, 1)) * 100);
const fill = document.getElementById('progressFill');
const label = document.getElementById('progressLabel');
if (fill) fill.style.width = pct + '%';
if (label) label.textContent = `Page ${this.currentPage + 1} of ${this.pages.length}`;
}
};
// ── Constant sum validation ───────────────────────────────────
document.querySelectorAll('.const-sum-inputs').forEach(wrap => {
const total = parseInt(wrap.dataset.total || '100');
const inputs = wrap.querySelectorAll('input[type="number"]');
const remaining = wrap.querySelector('.const-sum-remaining');
const update = () => {
let used = 0;
inputs.forEach(inp => used += parseInt(inp.value || 0));
const left = total - used;
if (remaining) { remaining.textContent = left; remaining.style.color = left === 0 ? 'var(--brand-teal)' : left < 0 ? 'var(--brand-accent)' : 'inherit'; }
};
inputs.forEach(inp => inp.addEventListener('input', update));
update();
});
// ── Shake animation CSS (injected) ───────────────────────────
const shakeStyle = document.createElement('style');
shakeStyle.textContent = `@keyframes shake { 0%,100%{transform:translateX(0)} 20%,60%{transform:translateX(-6px)} 40%,80%{transform:translateX(6px)} } .shake{animation:shake 0.5s ease;border-color:var(--brand-accent)!important;}`;
document.head.appendChild(shakeStyle);
// ── Survey renderer: named select helpers (called inline from s/index.php) ──
function selectRating(qid, val) {
document.getElementById(qid + '_val').value = val;
const row = document.getElementById('rating_' + qid);
if (row) row.querySelectorAll('.rating-btn').forEach(b => {
b.classList.toggle('selected', parseInt(b.dataset.value) <= val);
});
}
function selectStar(qid, val) {
document.getElementById(qid + '_val').value = val;
const row = document.getElementById('stars_' + qid);
if (row) row.querySelectorAll('.star-btn').forEach((b, i) => {
b.classList.toggle('lit', i < val);
});
}
function selectNPS(qid, val) {
document.getElementById(qid + '_val').value = val;
document.querySelectorAll('.nps-row .nps-btn').forEach(b => {
b.classList.toggle('selected', parseInt(b.textContent.trim()) === val);
});
}
function selectEmoji(qid, label, el) {
document.getElementById(qid + '_val').value = label;
el.closest('.emoji-row').querySelectorAll('.emoji-btn').forEach(b => b.classList.remove('selected'));
el.classList.add('selected');
}
function clearSignature(qid) {
const canvas = document.getElementById('sig_' + qid);
if (canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0,0,canvas.width,canvas.height); }
document.getElementById(qid + '_data').value = '';
}
-------------------- END OF FILE --------------------
### FILE 22: assets/js/builder.js
- Type: JS
- Size: 56.31 KB
- Path: assets/js
- Name: builder.js
------------------------------------------------------------
/* ============================================================
SURVAM — Survey Builder (builder.js)
============================================================ */
'use strict';
const Builder = (() => {
let state = {
surveyId: null,
pages: [], // [{id, page_number, title, questions:[...]}]
activePageIdx: 0,
activeQIdx: null,
dirty: false,
saving: false,
};
// ── Init ───────────────────────────────────────────────────
function init(surveyId, surveyData) {
state.surveyId = surveyId;
if (surveyData && surveyData.pages) {
state.pages = surveyData.pages;
} else {
state.pages = [newPage(1)];
}
renderAll();
bindEvents();
autoSave();
}
// ── Data helpers ──────────────────────────────────────────
function newPage(num) {
return { id: null, page_number: num, title: `Page ${num}`, description: '', questions: [] };
}
function newQuestion(type) {
const q = {
id: null, type, title: 'Untitled question', description: '',
required: false, options: defaultOptions(type), settings: defaultSettings(type),
logic: [], loop_config: null, pipe_config: null,
media_url: '', media_type: 'none',
_uid: 'q_' + Math.random().toString(36).substr(2,9),
};
return q;
}
function defaultOptions(type) {
const choiceTypes = ['single_choice','multi_choice','dropdown','image_choice'];
if (choiceTypes.includes(type)) return { choices: ['Option 1','Option 2','Option 3'] };
if (type === 'rating_scale') return { min:1, max:10, min_label:'Not at all', max_label:'Extremely' };
if (type === 'star_rating') return { stars:5 };
if (type === 'nps') return { min_label:'Not likely', max_label:'Extremely likely' };
if (type === 'slider') return { min:0, max:100, step:1, default:50 };
if (type === 'likert_scale') return { scale: ['Strongly Disagree','Disagree','Neutral','Agree','Strongly Agree'], statements: ['Statement 1'] };
if (type === 'matrix_single' || type === 'matrix_multi') return { rows:['Row 1','Row 2'], cols:['Col 1','Col 2','Col 3'] };
if (type === 'ranking') return { items: ['Item 1','Item 2','Item 3'] };
if (type === 'emoji_scale') return { emojis: [{icon:'😡',label:'Very Bad'},{icon:'😟',label:'Bad'},{icon:'😐',label:'Neutral'},{icon:'😊',label:'Good'},{icon:'😍',label:'Excellent'}] };
if (type === 'constant_sum') return { items:['Item 1','Item 2','Item 3'], total:100 };
if (type === 'max_diff') return { items:['Option A','Option B','Option C','Option D'], sets_count:3, per_set:3 };
if (type === 'demographic') return { fields: ['name','email','age','gender','location'] };
if (type === 'date_time') return { mode: 'date' }; // date|time|datetime
if (type === 'number') return { min:'', max:'', decimal:false };
if (type === 'file_upload') return { max_mb:5, allowed:'image/*,application/pdf' };
if (type === 'heatmap') return { image_url:'', max_clicks:3 };
if (type === 'card_sort') return { cards:['Card 1','Card 2','Card 3'], categories:['Category A','Category B'] };
return {};
}
function defaultSettings(type) {
return { randomize: false, other_option: false, none_option: false, other_text: 'Other (please specify)' };
}
// ── Render ────────────────────────────────────────────────
function renderAll() {
renderCanvas();
renderPageTabs();
if (state.activeQIdx !== null) renderProps();
else renderNoSelection();
}
function renderPageTabs() {
const tabs = document.getElementById('pageTabs');
if (!tabs) return;
tabs.innerHTML = state.pages.map((p,i) => `
${escH(p.title || `Page ${i+1}`)}
`).join('') + `+ `;
}
function renderCanvas() {
const canvas = document.getElementById('builderCanvas');
if (!canvas) return;
const page = state.pages[state.activePageIdx];
if (!page) return;
canvas.innerHTML = `
${page.questions.length === 0 ? `
📋
Drag a question type from the left panel, or click a type to add it here.
` : ''}
${page.questions.map((q,qi) => renderQuestionBlock(q, qi)).join('')}
`;
initQuestionDrag();
}
function renderQuestionBlock(q, qi) {
const isActive = qi === state.activeQIdx;
const mediaPreview = (q.media_url && q.media_type && q.media_type !== 'none') ? `
${q.media_type === 'image' ? `
` : ''}
${q.media_type === 'image_upload' ? `
` : ''}
${q.media_type === 'video' ? `
🎬 Video stimulus ${escH(q.media_url.substring(0,50))}${q.media_url.length>50?'...':''}
` : ''}
` : '';
return `
${qi + 1}
${questionTypeLabel(q.type)}
${mediaPreview}
${escH(q.title || 'Untitled question')}${q.required ? ' * ' : ''}
👁
⧉
↑
↓
×
`;
}
function renderProps() {
const panel = document.getElementById('propsPanel');
if (!panel) return;
const page = state.pages[state.activePageIdx];
const q = page?.questions[state.activeQIdx];
if (!q) { renderNoSelection(); return; }
panel.innerHTML = buildPropsHTML(q, state.activeQIdx);
}
function renderNoSelection() {
const panel = document.getElementById('propsPanel');
if (!panel) return;
panel.innerHTML = `
👈
Click a question to edit its properties
`;
}
function buildPropsHTML(q, qi) {
const s = q.settings || {};
const o = q.options || {};
let optionsHTML = buildOptionsHTML(q);
return `
Question Type
${ALL_QUESTION_TYPES.map(t => `${t.label} `).join('')}
Question Text *
${escH(q.title)}
Description / Help Text
${escH(q.description||'')}
${optionsHTML}
Branching / Skip Logic
⚡ Edit Logic Rules ${(q.logic&&q.logic.length)?`${q.logic.length} `:''}
📎 Stimulus Media
Media Type
None
Image (URL)
Video (YouTube / Vimeo URL)
Image (Upload to server)
${(q.media_type && q.media_type !== 'none') ? `
${q.media_type === 'image_upload' ? `
Upload Image
${q.media_url ? `
` : '
🖼
'}
${q.media_url ? '🔄 Replace Image' : '📁 Browse Image'}
${q.media_url ? `
✓ Image uploaded
` : '
JPG, PNG, GIF, WebP · Max 5MB
'}
` : `
${q.media_type==='video' ? 'Video URL (YouTube / Vimeo)' : 'Image URL'}
${q.media_url && q.media_type==='image' ? `
` : ''}
${q.media_url && q.media_type==='video' ? `
✓ Video URL saved — will embed in survey
` : ''}
`}
Show media above question
` : ''}
Piping
Pipe answer from earlier question
${q.pipe_config ? `
-- Select source question --
${getPipeableQuestions(qi).map(pq => `${escH(pq.title)} `).join('')}
` : ''}
`;
}
function buildOptionsHTML(q) {
const o = q.options || {};
const qi = state.activeQIdx;
if (['single_choice','multi_choice','dropdown','image_choice'].includes(q.type)) {
const choices = o.choices || [];
const screenouts = o.screenout_urls || {};
return `
Answer Options
Tip: Add a 🚫 Screenout URL per option to redirect disqualified respondents.
${choices.map((c,ci) => `
`).join('')}
+ Add Option
✏ Bulk Edit
`;
}
if (q.type === 'ranking') {
const items = o.items || [];
return `
Items to Rank
${items.map((item,ii) => `
×
`).join('')}
+ Add Item
`;
}
if (q.type === 'likert_scale') {
const stmts = o.statements || [];
return `
Statements
${stmts.map((s,si) => `
×
`).join('')}
+ Add Statement
Scale Labels
${(o.scale||[]).map((lbl,li) => `
`).join('')}
`;
}
if (q.type === 'matrix_single' || q.type === 'matrix_multi') {
return `
Rows
${(o.rows||[]).map((r,ri) => `
×
`).join('')}
+ Add Row
Columns
${(o.cols||[]).map((c,ci) => `
×
`).join('')}
+ Add Column
`;
}
if (q.type === 'rating_scale' || q.type === 'nps') {
return ``;
}
if (q.type === 'slider') {
return ``;
}
if (q.type === 'constant_sum') {
return ``;
}
return '';
}
// ── Logic Editor ──────────────────────────────────────────
function openLogicEditor(qi) {
const page = state.pages[state.activePageIdx];
const q = page.questions[qi];
const prevQs = page.questions.slice(0, qi);
const el = document.getElementById('logicEditorModal');
if (!el) return;
document.getElementById('logicEditorTitle').textContent = `Logic: ${q.title}`;
document.getElementById('logicEditorBody').innerHTML = buildLogicEditorHTML(q, prevQs, qi);
openModal('logicEditorModal');
}
function buildLogicEditorHTML(q, prevQs, qi) {
const rules = q.logic || [];
if (prevQs.length === 0) return `Add at least one question before this one to create logic rules.
`;
return `
Show/skip this question based on answers to previous questions.
${rules.map((rule,ri) => renderLogicRule(rule, ri, prevQs)).join('')}
+ Add Rule
If rules match:
ALL conditions must be true (AND)
ANY condition is true (OR)
Then:
Show this question
Skip / hide this question
Jump to page…
End survey
Disqualify / Screenout
Save Logic
`;
}
function renderLogicRule(rule, ri, prevQs) {
return `
-- Question --
${prevQs.map(pq => `${escH(pq.title).substring(0,30)} `).join('')}
${['equals','not_equals','contains','not_contains','is_answered','is_not_answered','greater_than','less_than'].map(op =>
`${op.replace(/_/g,' ')} `).join('')}
×
`;
}
// ── Quota Editor ──────────────────────────────────────────
function openQuotaEditor() {
apiPost(window.SURVAM_API_URL, { action:'get_quotas', survey_id: state.surveyId })
.then(r => {
const el = document.getElementById('quotaEditorModal');
if (!el) return;
document.getElementById('quotaEditorBody').innerHTML = buildQuotaHTML(r.quotas || []);
openModal('quotaEditorModal');
});
}
function buildQuotaHTML(quotas) {
return `
${quotas.length === 0 ? `
No quotas set. Add a quota to cap responses by demographic or answer.
` : ''}
${quotas.map((q,i) => `
${escH(q.name)} — Limit: ${q.limit_count} (Used: ${q.current_count})
Delete
`).join('')}
Add New Quota
Quota Name
Add Quota `;
}
// ── Save ──────────────────────────────────────────────────
async function save(showMsg = true) {
if (state.saving) return;
state.saving = true;
const saveBtn = document.getElementById('saveBtn');
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = ' Saving…'; }
try {
const res = await apiPost(window.SURVAM_API_URL, {
action: 'save_structure', survey_id: state.surveyId, pages: state.pages,
});
if (res.success) {
state.dirty = false;
// Update IDs from server response
if (res.page_ids) res.page_ids.forEach((pid,i) => { if (state.pages[i]) state.pages[i].id = pid; });
if (res.question_ids) {
let qi = 0;
state.pages.forEach(p => { p.questions.forEach(q => { if (res.question_ids[qi]) q.id = res.question_ids[qi]; qi++; }); });
}
if (showMsg) showToast('Survey saved!', 'success');
} else { showToast(res.error || 'Save failed', 'error'); }
} catch(e) { showToast('Save failed. Check connection.', 'error'); }
state.saving = false;
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = 'Save'; }
}
function autoSave() {
setInterval(() => { if (state.dirty) save(false); }, 30000);
window.addEventListener('beforeunload', (e) => { if (state.dirty) { e.preventDefault(); e.returnValue = ''; } });
}
// ── Public API (prefixed with _ for internal use) ─────────
function _addQ(type) {
const page = state.pages[state.activePageIdx];
const q = newQuestion(type);
page.questions.push(q);
state.activeQIdx = page.questions.length - 1;
state.dirty = true;
renderAll();
document.querySelector(`[data-qi="${state.activeQIdx}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function _selectQ(qi) {
state.activeQIdx = qi;
renderCanvas();
renderProps();
}
function _deleteQ(qi) {
if (!confirm('Delete this question?')) return;
state.pages[state.activePageIdx].questions.splice(qi, 1);
state.activeQIdx = null; state.dirty = true;
renderAll();
}
function _duplicateQ(qi) {
const q = JSON.parse(JSON.stringify(state.pages[state.activePageIdx].questions[qi]));
q.id = null; q._uid = 'q_' + Math.random().toString(36).substr(2,9);
q.title = q.title + ' (copy)';
state.pages[state.activePageIdx].questions.splice(qi + 1, 0, q);
state.activeQIdx = qi + 1; state.dirty = true;
renderAll();
}
function _moveQ(qi, dir) {
const qs = state.pages[state.activePageIdx].questions;
const ni = qi + dir;
if (ni < 0 || ni >= qs.length) return;
[qs[qi], qs[ni]] = [qs[ni], qs[qi]];
state.activeQIdx = ni; state.dirty = true;
renderAll();
}
function _setField(qi, field, val) {
state.pages[state.activePageIdx].questions[qi][field] = val;
state.dirty = true;
// Re-render only the block title
const block = document.querySelector(`[data-qi="${qi}"] .question-title-display`);
if (block && field === 'title') block.innerHTML = escH(val) + (state.pages[state.activePageIdx].questions[qi].required ? ' * ' : '');
if (field === 'required') { const star = document.querySelector(`[data-qi="${qi}"] .question-title-display`); if(star) star.innerHTML = escH(state.pages[state.activePageIdx].questions[qi].title) + (val ? ' * ' : ''); }
}
function _setSetting(qi, key, val) {
if (!state.pages[state.activePageIdx].questions[qi].settings) state.pages[state.activePageIdx].questions[qi].settings = {};
state.pages[state.activePageIdx].questions[qi].settings[key] = val;
state.dirty = true;
}
function _setOpt(qi, key, val) {
if (!state.pages[state.activePageIdx].questions[qi].options) state.pages[state.activePageIdx].questions[qi].options = {};
state.pages[state.activePageIdx].questions[qi].options[key] = val;
state.dirty = true;
}
function _setChoice(qi, ci, val) {
state.pages[state.activePageIdx].questions[qi].options.choices[ci] = val;
state.dirty = true;
}
function _addChoice(qi) {
const choices = state.pages[state.activePageIdx].questions[qi].options.choices;
choices.push('Option ' + (choices.length + 1));
state.dirty = true; renderProps();
}
function _setScreenoutUrl(qi, ci, url) {
const q = state.pages[state.activePageIdx].questions[qi];
if (!q.options.screenout_urls) q.options.screenout_urls = {};
q.options.screenout_urls[ci] = url;
state.dirty = true;
}
function _removeChoice(qi, ci) {
state.pages[state.activePageIdx].questions[qi].options.choices.splice(ci, 1);
state.dirty = true; renderProps();
}
function _setOption(qi, key, idx, val) {
state.pages[state.activePageIdx].questions[qi].options[key][idx] = val;
state.dirty = true;
}
function _addOptionItem(qi, key, defaultVal) {
const arr = state.pages[state.activePageIdx].questions[qi].options[key] || [];
arr.push(defaultVal + ' ' + (arr.length + 1));
state.pages[state.activePageIdx].questions[qi].options[key] = arr;
state.dirty = true; renderProps();
}
function _removeOptionItem(qi, key, idx) {
state.pages[state.activePageIdx].questions[qi].options[key].splice(idx, 1);
state.dirty = true; renderProps();
}
function _changeType(qi, newType) {
const q = state.pages[state.activePageIdx].questions[qi];
q.type = newType;
q.options = defaultOptions(newType);
q.settings = defaultSettings(newType);
state.dirty = true; renderAll();
}
function _bulkEditChoices(qi) {
const choices = state.pages[state.activePageIdx].questions[qi].options.choices || [];
const val = prompt('Enter one option per line:', choices.join('\n'));
if (val !== null) {
state.pages[state.activePageIdx].questions[qi].options.choices = val.split('\n').map(s => s.trim()).filter(Boolean);
state.dirty = true; renderProps();
}
}
function _addPage() {
state.pages.push(newPage(state.pages.length + 1));
state.activePageIdx = state.pages.length - 1;
state.activeQIdx = null; state.dirty = true;
renderAll();
}
function _selectPage(idx) {
state.activePageIdx = idx;
state.activeQIdx = null;
renderAll();
}
function _deletePage(idx) {
if (!confirm('Delete this page and all its questions?')) return;
state.pages.splice(idx, 1);
state.activePageIdx = Math.max(0, idx - 1);
state.activeQIdx = null; state.dirty = true;
renderAll();
}
function _setPageTitle(idx, val) { state.pages[idx].title = val; state.dirty = true; renderPageTabs(); }
function _setPageDesc(idx, val) { state.pages[idx].description = val; state.dirty = true; }
function _addLogicRule(qi) {
const q = state.pages[state.activePageIdx].questions[qi];
if (!q.logic) q.logic = [];
q.logic.push({ q_uid: '', operator: 'equals', value: '' });
state.dirty = true; openLogicEditor(qi);
}
function _setLogicQ(ri, val) {
const q = state.pages[state.activePageIdx].questions[state.activeQIdx];
if (q.logic[ri]) q.logic[ri].q_uid = val; state.dirty = true;
}
function _setLogicOp(ri, val) {
const q = state.pages[state.activePageIdx].questions[state.activeQIdx];
if (q.logic[ri]) q.logic[ri].operator = val; state.dirty = true;
}
function _setLogicVal(ri, val) {
const q = state.pages[state.activePageIdx].questions[state.activeQIdx];
if (q.logic[ri]) q.logic[ri].value = val; state.dirty = true;
}
function _removeLogicRule(ri) {
const q = state.pages[state.activePageIdx].questions[state.activeQIdx];
q.logic.splice(ri, 1); state.dirty = true;
openLogicEditor(state.activeQIdx);
}
function _saveLogic(qi) {
const q = state.pages[state.activePageIdx].questions[qi];
q.logic_match = document.getElementById('logicMatchMode')?.value || 'all';
q.logic_action = document.getElementById('logicAction')?.value || 'show';
q.screenout_url = document.getElementById('screenoutUrl')?.value || '';
state.dirty = true;
closeModal('logicEditorModal');
renderProps();
showToast('Logic saved', 'success');
}
function _togglePipe(qi, enabled) {
const q = state.pages[state.activePageIdx].questions[qi];
q.pipe_config = enabled ? { source: '' } : null;
state.dirty = true; renderProps();
}
function _setPipeSource(qi, uid) {
state.pages[state.activePageIdx].questions[qi].pipe_config.source = uid;
state.dirty = true;
}
function _addQuota() {
const name = document.getElementById('newQuotaName')?.value?.trim();
const limit = parseInt(document.getElementById('newQuotaLimit')?.value || '0');
const action = document.getElementById('newQuotaAction')?.value;
if (!name || !limit) { showToast('Enter quota name and limit', 'warning'); return; }
apiPost(window.SURVAM_API_URL, { action:'add_quota', survey_id: state.surveyId, name, limit_count: limit, action, conditions: [] })
.then(r => { if (r.success) openQuotaEditor(); else showToast(r.error, 'error'); });
}
function _previewQ(qi) {
const page = state.pages[state.activePageIdx];
const q = page.questions[qi];
if (!q) return;
const o = q.options || {};
const s = q.settings || {};
// Build preview HTML for this question
const mediaUrl = q.media_url || s.media_url || '';
const mediaType = q.media_type || s.media_type || 'none';
const mediaPos = q.media_position || s.media_position || 'above';
let mediaHtml = '';
if (mediaUrl && mediaType !== 'none') {
if (mediaType === 'image' || mediaType === 'image_upload') {
mediaHtml = ` `;
} else if (mediaType === 'video') {
let embed = escH(mediaUrl);
const ytMatch = mediaUrl.match(/youtube\.com\/watch\?v=([^&]+)|youtu\.be\/([^?]+)/);
const vimMatch = mediaUrl.match(/vimeo\.com\/(\d+)/);
if (ytMatch) embed = `https://www.youtube.com/embed/${ytMatch[1]||ytMatch[2]}?rel=0`;
else if (vimMatch) embed = `https://player.vimeo.com/video/${vimMatch[1]}`;
mediaHtml = `
`;
}
}
let answerHtml = '';
const type = q.type;
if (['single_choice','multi_choice','dropdown'].includes(type)) {
const choices = o.choices || [];
if (type === 'dropdown') {
answerHtml = `-- Select -- ${choices.map(c=>`${escH(c)} `).join('')} `;
} else {
const inputType = type === 'multi_choice' ? 'checkbox' : 'radio';
answerHtml = `${choices.map(c=>`
${escH(c)}
`).join('')}
`;
}
} else if (type === 'yes_no') {
answerHtml = ` Yes No
`;
} else if (type === 'text_short') {
answerHtml = ` `;
} else if (type === 'text_long') {
answerHtml = ` `;
} else if (type === 'number') {
answerHtml = ` `;
} else if (type === 'rating_scale' || type === 'nps') {
const min = o.min ?? (type === 'nps' ? 0 : 1);
const max = o.max ?? (type === 'nps' ? 10 : 10);
const btns = [];
for (let i = min; i <= max; i++) btns.push(`${i} `);
answerHtml = `${btns.join('')}
`;
if (o.min_label || o.max_label) answerHtml += `${escH(o.min_label||'')} ${escH(o.max_label||'')}
`;
} else if (type === 'star_rating') {
const stars = o.stars || 5;
answerHtml = `${Array.from({length:stars},(_,i)=>`★ `).join('')}
`;
} else if (type === 'slider') {
answerHtml = ``;
} else if (type === 'emoji_scale') {
const emojis = o.emojis || [{icon:'😡',label:'Bad'},{icon:'😐',label:'Neutral'},{icon:'😊',label:'Good'}];
answerHtml = `${emojis.map(e=>`${e.icon} ${escH(e.label||'')} `).join('')}
`;
} else if (type === 'date_time') {
const mode = o.mode || 'date';
answerHtml = ` `;
} else if (type === 'statement') {
answerHtml = `${escH(q.description||q.title)}
`;
} else if (type === 'section_break') {
answerHtml = ` `;
} else {
answerHtml = `[${questionTypeLabel(type)} — preview not available for this type]
`;
}
const titleHtml = `${escH(q.title||'Untitled question')}${q.required?'* ':''}
`;
const descHtml = q.description ? `${escH(q.description)}
` : '';
const aboveMedia = (mediaUrl && mediaPos === 'above') ? mediaHtml : '';
const belowMedia = (mediaUrl && mediaPos === 'below') ? mediaHtml : '';
const body = `${aboveMedia}${titleHtml}${descHtml}${belowMedia}${answerHtml}`;
// Create/show the modal
let overlay = document.getElementById('qPreviewOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'qPreviewOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,15,30,0.75);z-index:99999;display:flex;align-items:center;justify-content:center;padding:1.5rem;backdrop-filter:blur(4px);';
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.style.display='none'; });
document.body.appendChild(overlay);
}
overlay.innerHTML = `
Question Preview
${questionTypeLabel(q.type)}
×
${body}
← Back
Preview only — no data saved
Continue →
`;
overlay.style.display = 'flex';
}
async function _uploadMediaFile(qi, input) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'media');
// Show uploading state
const btn = input.closest('[style*="dashed"]')?.querySelector('button');
const origText = btn ? btn.innerHTML : '';
if (btn) btn.innerHTML = ' Uploading…';
try {
const res = await fetch(window.SURVAM_API_URL.replace('surveys.php', 'upload.php'), {
method: 'POST', body: formData
});
const data = await res.json();
if (data.success) {
state.pages[state.activePageIdx].questions[qi].media_url = data.url;
state.dirty = true;
renderProps();
showToast('Image uploaded!', 'success');
} else {
showToast(data.error || 'Upload failed', 'error');
if (btn) btn.innerHTML = origText;
}
} catch(e) {
showToast('Upload failed. Check connection.', 'error');
if (btn) btn.innerHTML = origText;
}
}
function _deleteQuota(id) {
if (!confirm('Delete this quota?')) return;
apiPost(window.SURVAM_API_URL, { action:'delete_quota', id }).then(r => { if (r.success) openQuotaEditor(); });
}
function _openLogicEditor(qi) { openLogicEditor(qi); }
// ── Drag & drop questions ─────────────────────────────────
function initQuestionDrag() {
const list = document.getElementById('questionList');
if (!list) return;
let dragging = null;
list.querySelectorAll('.question-block').forEach(block => {
block.draggable = true;
block.addEventListener('dragstart', () => { dragging = block; block.classList.add('dragging'); });
block.addEventListener('dragend', () => {
dragging = null; block.classList.remove('dragging');
// Re-sync state order
const newOrder = [...list.querySelectorAll('.question-block')].map(b => parseInt(b.dataset.qi));
const page = state.pages[state.activePageIdx];
const reordered = newOrder.map(i => page.questions[i]);
page.questions = reordered;
state.dirty = true;
renderCanvas(); renderProps();
});
block.addEventListener('dragover', (e) => {
e.preventDefault();
const after = getDragAfterEl(list, e.clientY);
if (after) list.insertBefore(dragging, after);
else list.appendChild(dragging);
});
});
}
function getDragAfterEl(list, y) {
const els = [...list.querySelectorAll('.question-block:not(.dragging)')];
return els.reduce((closest, el) => {
const box = el.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > (closest.offset || -Infinity)) return { offset, element: el };
return closest;
}, {}).element;
}
// ── Events ────────────────────────────────────────────────
function bindEvents() {
document.getElementById('saveBtn')?.addEventListener('click', () => save(true));
document.getElementById('previewBtn')?.addEventListener('click', () => {
save(false).then(() => window.open(`/s/${state.surveyId}?preview=1`, '_blank'));
});
document.getElementById('openQuotaBtn')?.addEventListener('click', openQuotaEditor);
// Left panel: add question by clicking type
document.querySelectorAll('.q-type-btn').forEach(btn => {
btn.addEventListener('click', () => _addQ(btn.dataset.type));
});
}
// ── Helpers ───────────────────────────────────────────────
function getPipeableQuestions(qi) {
const page = state.pages[state.activePageIdx];
return page.questions.slice(0, qi).filter(q => !['statement','section_break'].includes(q.type));
}
function escH(str) {
return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
return {
init, save,
_addQ, _selectQ, _deleteQ, _duplicateQ, _moveQ,
_setField, _setSetting, _setOpt,
_setChoice, _addChoice, _removeChoice,
_setScreenoutUrl,
_setOption, _addOptionItem, _removeOptionItem,
_changeType, _bulkEditChoices,
_addPage, _selectPage, _deletePage, _setPageTitle, _setPageDesc,
_addLogicRule, _setLogicQ, _setLogicOp, _setLogicVal, _removeLogicRule, _saveLogic,
_openLogicEditor,
_togglePipe, _setPipeSource,
_renderProps: renderProps,
_addQuota, _deleteQuota,
_previewQ,
_uploadMediaFile,
};
})();
// ── Question type registry ────────────────────────────────────
const ALL_QUESTION_TYPES = [
{ value:'single_choice', label:'Single Choice' },
{ value:'multi_choice', label:'Multiple Choice' },
{ value:'dropdown', label:'Dropdown' },
{ value:'text_short', label:'Short Text' },
{ value:'text_long', label:'Long Text / Essay' },
{ value:'rating_scale', label:'Rating Scale' },
{ value:'star_rating', label:'Star Rating' },
{ value:'likert_scale', label:'Likert Scale' },
{ value:'matrix_single', label:'Matrix – Single' },
{ value:'matrix_multi', label:'Matrix – Multiple' },
{ value:'ranking', label:'Ranking' },
{ value:'slider', label:'Slider' },
{ value:'nps', label:'NPS Score' },
{ value:'yes_no', label:'Yes / No' },
{ value:'date_time', label:'Date / Time' },
{ value:'number', label:'Number' },
{ value:'emoji_scale', label:'Emoji Scale' },
{ value:'image_choice', label:'Image Choice' },
{ value:'file_upload', label:'File Upload' },
{ value:'demographic', label:'Demographic' },
{ value:'video_response', label:'Video Response' },
{ value:'signature', label:'Signature' },
{ value:'heatmap', label:'Heat Map' },
{ value:'card_sort', label:'Card Sort' },
{ value:'constant_sum', label:'Constant Sum' },
{ value:'max_diff', label:'MaxDiff / Best-Worst' },
{ value:'statement', label:'Statement / Display Text' },
{ value:'section_break', label:'Section Break' },
];
function questionTypeLabel(type) { return ALL_QUESTION_TYPES.find(t => t.value === type)?.label || type; }
-------------------- END OF FILE --------------------
### FILE 23: auth/forgot.php
- Type: PHP
- Size: 3.36 KB
- Path: auth
- Name: forgot.php
------------------------------------------------------------
Forgot Password — SURVAM
= sanitize($success) ?>
= sanitize($error) ?>
= csrfField() ?>
Email Address
Send Reset Link
-------------------- END OF FILE --------------------
### FILE 24: auth/login.php
- Type: PHP
- Size: 3.56 KB
- Path: auth
- Name: login.php
------------------------------------------------------------
Sign In — SURVAM
© = date('Y') ?> Relevant Reflex Consulting · SURVAM
-------------------- END OF FILE --------------------
### FILE 25: auth/logout.php
- Type: PHP
- Size: 256 B
- Path: auth
- Name: logout.php
------------------------------------------------------------
Registration is currently closed. Please contact support .');
}
$error = '';
$success = false;
$form = ['name'=>'','email'=>'','company_name'=>'','phone'=>''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verifyCsrf($_POST['csrf_token'] ?? '')) {
$error = 'Invalid request.';
} else {
$form = [
'name' => trim($_POST['name'] ?? ''),
'email' => strtolower(trim($_POST['email'] ?? '')),
'company_name' => trim($_POST['company_name'] ?? ''),
'phone' => trim($_POST['phone'] ?? ''),
];
$password = $_POST['password'] ?? '';
$password2 = $_POST['password2'] ?? '';
if (!$form['name'] || !$form['email'] || !$password) {
$error = 'Name, email, and password are required.';
} elseif (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
$error = 'Please enter a valid email address.';
} elseif (strlen($password) < 8) {
$error = 'Password must be at least 8 characters.';
} elseif ($password !== $password2) {
$error = 'Passwords do not match.';
} elseif (DB::row("SELECT id FROM users WHERE email = ?", [$form['email']])) {
$error = 'An account with this email already exists.';
} else {
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => HASH_COST]);
DB::insert(
"INSERT INTO users (name, email, password_hash, company_name, phone, plan_id, responses_reset_date)
VALUES (?,?,?,?,?,?,?)",
[$form['name'], $form['email'], $hash, $form['company_name'], $form['phone'], PLAN_FREE_ID, date('Y-m-01')]
);
$user = DB::row("SELECT * FROM users WHERE email = ?", [$form['email']]);
loginUser($user);
flash('success', 'Welcome to SURVAM, ' . $form['name'] . '! Your free account is ready.');
redirect(APP_URL . '/dashboard.php');
}
}
}
?>
Create Account — SURVAM
= sanitize($error) ?>
= csrfField() ?>
Create Free Account
-------------------- END OF FILE --------------------
### FILE 27: auth/reset.php
- Type: PHP
- Size: 3.06 KB
- Path: auth
- Name: reset.php
------------------------------------------------------------
NOW()", [$token]);
if (!$record) { $error = 'This reset link is invalid or has expired. Please request a new one.'; }
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $record) {
if (!verifyCsrf($_POST['csrf_token'] ?? '')) { $error = 'Invalid request.'; }
else {
$pass = $_POST['password'] ?? '';
$pass2 = $_POST['password2'] ?? '';
if (strlen($pass) < 8) {
$error = 'Password must be at least 8 characters.';
} elseif ($pass !== $pass2) {
$error = 'Passwords do not match.';
} else {
$hash = password_hash($pass, PASSWORD_BCRYPT, ['cost' => HASH_COST]);
DB::query("UPDATE users SET password_hash=? WHERE email=?", [$hash, $record['email']]);
DB::query("DELETE FROM password_resets WHERE token=?", [$token]);
$done = true;
}
}
}
?>
Reset Password — SURVAM
-------------------- END OF FILE --------------------
### FILE 28: includes/admin_footer.php
- Type: PHP
- Size: 557 B
- Path: includes
- Name: admin_footer.php
------------------------------------------------------------