# 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'; ?>
Total Surveys
allowed on plan
Active Surveys
Total Responses
Responses This Month
allowed

Recent Surveys

View All
📋

No surveys yet

Create your first survey to get started with collecting responses.

Create First Survey
SurveyStatusResponsesCompletionActions

📊

Plan & Usage

Upgrade
Current Plan
Wallet
Surveys /
Monthly Responses /
= 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.

Start Free — No Credit Card See All Features →

Trusted by panel companies and research agencies across India

28+
Question Types
Branching Rules
5
Export Formats
100%
Mobile Responsive

Everything You Need to Run Research

Built for professional research agencies, not hobbyists.

📋

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.

Simple, Transparent Pricing

Start free. Pay only when you need more.

Most Popular
0 ? '/ month' : '' ?>
  • No custom branding
  • Custom branding

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 ------------------------------------------------------------

Current Plan

Upgrade
Plan
Monthly Responses
/
Surveys
/

Transaction History

💳

No transactions yet

Top up your wallet to purchase or upgrade a plan.

DateDescriptionTypeAmountStatus

Wallet Balance

Available balance

Quick Top-up

'Starter Pack', 'amount'=>999, 'tag'=>''], ['label'=>'Growth Pack', 'amount'=>2999, 'tag'=>'Popular'], ['label'=>'Scale Pack', 'amount'=>7999, 'tag'=>'Best Value'], ]; foreach ($packs as $pack): ?>
-------------------- 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'; ?>

Personal Information

Email cannot be changed. Contact support if needed.

Change Password

Account Information

Member Since
Current Plan
Wallet Balance
User ID
#
-------------------- END OF FILE -------------------- ### FILE 7: account/upgrade.php - Type: PHP - Size: 6.09 KB - Path: account - Name: upgrade.php ------------------------------------------------------------
Most Popular
0 ? '/ month' : '' ?>
  • Custom branding & logo
  • No custom branding
  • API access
= $plan['price_monthly']): ?>

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'; ?>
Total Clients
active
Total Surveys
live
All-time Responses
this month
Total Revenue
this month

Response Activity (14 Days)

Recent Surveys

View All
SurveyOwnerStatusResponsesCreated

Recent Signups

View All
NameEmailStatusJoined

Plan Distribution

users
-------------------- END OF FILE -------------------- ### FILE 9: admin/plans.php - Type: PHP - Size: 10.16 KB - Path: admin - Name: plans.php ------------------------------------------------------------

Plans ()

PlanPrice/moSurveysResponses UsersActiveActions

Free' : '₹' . number_format($p['price_monthly']) ?>
-------------------- 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'; ?>
This Month Last Month This Year
New Signups
Surveys Created
Responses Collected
Revenue

Daily Responses

No responses in this period.

Daily Revenue

No revenue in this period.

Daily Signups

No signups in this period.

Device Breakdown (period)

No data yet.

· %

Top Surveys by Responses

$ts): ?>
Survey Responses
.

Question Type Usage

Top Clients by Responses

$c): ?>
#ClientPlanSurveysTotal Responses

-------------------- 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'; ?>

Application Settings

super_admin only

General

Razorpay Payment Gateway

Set this in your Razorpay Dashboard → Webhooks to verify payment callbacks.

Email (SMTP)

Cancel
-------------------- END OF FILE -------------------- ### FILE 12: admin/surveys.php - Type: PHP - Size: 4.23 KB - Path: admin - Name: surveys.php ------------------------------------------------------------

All Surveys ()

$s): ?>
#SurveyOwnerStatusResponsesCreatedActions

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'; ?>
Total Revenue
This Month
All Transactions

Transaction Log

DateUserDescriptionTypeAmountStatusRazorpay ID

1): ?>
-------------------- END OF FILE -------------------- ### FILE 14: admin/users.php - Type: PHP - Size: 9.98 KB - Path: admin - Name: users.php ------------------------------------------------------------

Users ()

$u): ?>
#Name / EmailCompanyRolePlan SurveysCreditsStatusJoinedActions

×
1): ?>
-------------------- 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) => ` `).join('') + ``; } function renderCanvas() { const canvas = document.getElementById('builderCanvas'); if (!canvas) return; const page = state.pages[state.activePageIdx]; if (!page) return; canvas.innerHTML = `
${state.pages.length > 1 ? `` : ''}
${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 Properties
${!['statement','section_break'].includes(q.type) ? `
Required
` : `
Display only — no response needed.
`} ${['single_choice','multi_choice','dropdown','ranking','card_sort'].includes(q.type) ? `
Randomize options
` : ''} ${['single_choice','multi_choice'].includes(q.type) ? `
Add "Other" option
Add "None" option
` : ''} ${q.type === 'multi_choice' ? ` ` : ''}
${optionsHTML}
Branching / Skip Logic
📎 Stimulus Media
${(q.media_type && q.media_type !== 'none') ? ` ${q.media_type === 'image_upload' ? `
${q.media_url ? `` : '
🖼
'} ${q.media_url ? `
✓ Image uploaded
` : '
JPG, PNG, GIF, WebP · Max 5MB
'}
` : ` ${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 ? `` : ''}
`; } 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) => `
🚫 Screenout:
`).join('')}
`; } if (q.type === 'ranking') { const items = o.items || []; return `
Items to Rank
${items.map((item,ii) => `
`).join('')}
`; } if (q.type === 'likert_scale') { const stmts = o.statements || []; return `
Statements
${stmts.map((s,si) => `
`).join('')}
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('')}
Columns
${(o.cols||[]).map((c,ci) => `
`).join('')}
`; } if (q.type === 'rating_scale' || q.type === 'nps') { return `
Scale Settings
`; } if (q.type === 'slider') { return `
Slider Settings
`; } if (q.type === 'constant_sum') { return `
Items
${(o.items||[]).map((item,ii) => `
`).join('')}
`; } 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('')}
Respondent is sent here when screened out.
`; } function renderLogicRule(rule, ri, prevQs) { return `
`; } // ── 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})
`).join('')}

Add New 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 = ``; } else { const inputType = type === 'multi_choice' ? 'checkbox' : 'radio'; answerHtml = `
${choices.map(c=>` `).join('')}
`; } } else if (type === 'yes_no') { answerHtml = `
`; } 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(``); 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 = `
${o.default??50}
`; } else if (type === 'emoji_scale') { const emojis = o.emojis || [{icon:'😡',label:'Bad'},{icon:'😐',label:'Neutral'},{icon:'😊',label:'Good'}]; answerHtml = `
${emojis.map(e=>``).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}
Preview only — no data saved
`; 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

Forgot Password

Enter your email and we'll send you a reset link.

-------------------- END OF FILE -------------------- ### FILE 24: auth/login.php - Type: PHP - Size: 3.56 KB - Path: auth - Name: login.php ------------------------------------------------------------ Sign In — SURVAM

Welcome back

Sign in to your account to continue

© 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

Start for free

Create your account — no credit card required

By creating an account you agree to our Terms of Service and Privacy Policy.
-------------------- 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

Reset Password

Password reset successfully! Sign in →
Request new link →
-------------------- END OF FILE -------------------- ### FILE 28: includes/admin_footer.php - Type: PHP - Size: 557 B - Path: includes - Name: admin_footer.php ------------------------------------------------------------ -------------------- END OF FILE -------------------- ### FILE 29: includes/admin_header.php - Type: PHP - Size: 7.18 KB - Path: includes - Name: admin_header.php ------------------------------------------------------------ <?= sanitize(PAGE_TITLE) ?> — SURVAM Admin

-------------------- END OF FILE -------------------- ### FILE 30: includes/auth.php - Type: PHP - Size: 4.83 KB - Path: includes - Name: auth.php ------------------------------------------------------------ SESSION_LIFETIME, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ]); session_start(); } // ── Auth checks ────────────────────────────────────────────── function isLoggedIn(): bool { return isset($_SESSION['user_id']) && !empty($_SESSION['user_id']); } function requireLogin(string $redirect = '/auth/login.php'): void { if (!isLoggedIn()) { header('Location: ' . APP_URL . $redirect); exit; } } function requireAdmin(): void { requireLogin('/auth/login.php'); if (!in_array($_SESSION['user_role'] ?? '', ['super_admin', 'manager'])) { header('Location: ' . APP_URL . '/dashboard.php'); exit; } } function requireSuperAdmin(): void { requireLogin('/auth/login.php'); if (($_SESSION['user_role'] ?? '') !== 'super_admin') { header('Location: ' . APP_URL . '/admin/index.php'); exit; } } function currentUser(bool $refresh = false): ?array { if (!isLoggedIn()) return null; static $user = null; if ($user === null || $refresh) { $user = DB::row( "SELECT u.*, p.name AS plan_name, p.surveys_limit, p.responses_monthly, p.questions_limit, p.file_uploads, p.custom_branding, p.api_access, p.export_formats FROM users u JOIN plans p ON u.plan_id = p.id WHERE u.id = ? AND u.is_active = 1", [$_SESSION['user_id']] ); } return $user; } function loginUser(array $user): void { session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; $_SESSION['user_role'] = $user['role']; $_SESSION['user_name'] = $user['name']; DB::query("UPDATE users SET last_login = NOW() WHERE id = ?", [$user['id']]); } function logoutUser(): void { $_SESSION = []; if (ini_get('session.use_cookies')) { $p = session_get_cookie_params(); setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']); } session_destroy(); } // ── CSRF ───────────────────────────────────────────────────── function csrfToken(): string { if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf_token']; } function verifyCsrf(string $token): bool { return hash_equals($_SESSION['csrf_token'] ?? '', $token); } function csrfField(): string { return ''; } // ── Plan / quota helpers ────────────────────────────────────── function canCreateSurvey(): bool { $user = currentUser(); if (!$user) return false; if (in_array($user['role'], ['super_admin', 'manager'])) return true; if ($user['surveys_limit'] === -1) return true; return $user['surveys_created'] < $user['surveys_limit']; } function canCollectResponse(int $survey_id): bool { $survey = DB::row("SELECT user_id FROM surveys WHERE id = ?", [$survey_id]); if (!$survey) return false; $user = DB::row( "SELECT u.role, u.responses_used_this_month, u.responses_reset_date, p.responses_monthly FROM users u JOIN plans p ON u.plan_id = p.id WHERE u.id = ?", [$survey['user_id']] ); if (!$user) return false; if (in_array($user['role'], ['super_admin', 'manager'])) return true; if ($user['responses_monthly'] === -1) return true; // Reset monthly count if needed if ($user['responses_reset_date'] && $user['responses_reset_date'] < date('Y-m-01')) { DB::query("UPDATE users SET responses_used_this_month=0, responses_reset_date=? WHERE id=?", [date('Y-m-01'), $survey['user_id']]); return true; } return $user['responses_used_this_month'] < $user['responses_monthly']; } function incrementResponseCount(int $survey_id): void { $survey = DB::row("SELECT user_id FROM surveys WHERE id = ?", [$survey_id]); if ($survey) { DB::query("UPDATE users SET responses_used_this_month = responses_used_this_month + 1 WHERE id = ?", [$survey['user_id']]); DB::query("UPDATE surveys SET response_count = response_count + 1 WHERE id = ?", [$survey_id]); } } -------------------- END OF FILE -------------------- ### FILE 31: includes/config.php - Type: PHP - Size: 1.79 KB - Path: includes - Name: config.php ------------------------------------------------------------ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci", ]; try { self::$instance = new PDO($dsn, DB_USER, DB_PASS, $options); } catch (PDOException $e) { // Never expose DB details in production error_log("DB Connection failed: " . $e->getMessage()); die(json_encode(['error' => 'Database connection failed. Please try again later.'])); } } return self::$instance; } // Quick helper: run a query with params, return PDOStatement public static function query(string $sql, array $params = []): PDOStatement { $stmt = self::get()->prepare($sql); $stmt->execute($params); return $stmt; } // Fetch single row public static function row(string $sql, array $params = []): ?array { $row = self::query($sql, $params)->fetch(); return $row ?: null; } // Fetch all rows public static function all(string $sql, array $params = []): array { return self::query($sql, $params)->fetchAll(); } // Insert and return last insert id public static function insert(string $sql, array $params = []): int { self::query($sql, $params); return (int) self::get()->lastInsertId(); } // Get app setting from DB public static function getSetting(string $key, string $default = ''): string { $row = self::row("SELECT `value` FROM settings WHERE `key` = ?", [$key]); return $row ? (string)($row['value'] ?? $default) : $default; } } -------------------- END OF FILE -------------------- ### FILE 33: includes/footer.php - Type: PHP - Size: 1.54 KB - Path: includes - Name: footer.php ------------------------------------------------------------
-------------------- END OF FILE -------------------- ### FILE 34: includes/functions.php - Type: PHP - Size: 4.9 KB - Path: includes - Name: functions.php ------------------------------------------------------------ $type, 'msg' => $message]; } function getFlash(): array { $msgs = $_SESSION['flash'] ?? []; unset($_SESSION['flash']); return $msgs; } function formatINR(float $amount): string { return '₹' . number_format($amount, 2); } function timeSince(string $datetime): string { $diff = time() - strtotime($datetime); if ($diff < 60) return 'just now'; if ($diff < 3600) return floor($diff/60) . 'm ago'; if ($diff < 86400) return floor($diff/3600) . 'h ago'; if ($diff < 604800) return floor($diff/86400) . 'd ago'; return date('d M Y', strtotime($datetime)); } function deviceType(string $ua = ''): string { $ua = $ua ?: ($_SERVER['HTTP_USER_AGENT'] ?? ''); if (preg_match('/Mobile|Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i', $ua)) return 'mobile'; if (preg_match('/Tablet|iPad/i', $ua)) return 'tablet'; return 'desktop'; } function isAjax(): bool { return !empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; } function getClientIP(): string { foreach (['HTTP_CF_CONNECTING_IP','HTTP_X_FORWARDED_FOR','REMOTE_ADDR'] as $key) { if (!empty($_SERVER[$key])) { return trim(explode(',', $_SERVER[$key])[0]); } } return '0.0.0.0'; } // Survey-specific helpers function surveyStatusBadge(string $status): string { $map = [ 'draft' => ['class' => 'badge-gray', 'label' => 'Draft'], 'active' => ['class' => 'badge-green', 'label' => 'Active'], 'paused' => ['class' => 'badge-yellow', 'label' => 'Paused'], 'closed' => ['class' => 'badge-red', 'label' => 'Closed'], 'archived' => ['class' => 'badge-dark', 'label' => 'Archived'], ]; $b = $map[$status] ?? ['class' => 'badge-gray', 'label' => ucfirst($status)]; return '' . $b['label'] . ''; } function planBadge(string $plan): string { $map = [ 'free' => 'badge-gray', 'starter' => 'badge-blue', 'growth' => 'badge-teal', 'scale' => 'badge-purple', ]; $class = $map[strtolower($plan)] ?? 'badge-gray'; return '' . ucfirst($plan) . ''; } function completionRate(int $started, int $completed): string { if ($started === 0) return '0%'; return round(($completed / $started) * 100) . '%'; } // Question type display names function questionTypeLabel(string $type): string { $labels = [ 'single_choice' => 'Single Choice', 'multi_choice' => 'Multiple Choice', 'dropdown' => 'Dropdown', 'text_short' => 'Short Text', 'text_long' => 'Long Text / Essay', 'rating_scale' => 'Rating Scale', 'star_rating' => 'Star Rating', 'likert_scale' => 'Likert Scale', 'matrix_single' => 'Matrix – Single', 'matrix_multi' => 'Matrix – Multiple', 'ranking' => 'Ranking', 'slider' => 'Slider', 'date_time' => 'Date / Time', 'number' => 'Number', 'yes_no' => 'Yes / No', 'image_choice' => 'Image Choice', 'file_upload' => 'File Upload', 'nps' => 'NPS Score', 'demographic' => 'Demographic', 'video_response' => 'Video Response', 'signature' => 'Signature', 'heatmap' => 'Heat Map Click', 'card_sort' => 'Card Sort', 'emoji_scale' => 'Emoji / Sentiment Scale', 'constant_sum' => 'Constant Sum', 'max_diff' => 'MaxDiff / Best-Worst', 'statement' => 'Statement / Display Text', 'section_break' => 'Section Break', ]; return $labels[$type] ?? ucwords(str_replace('_', ' ', $type)); } // Export format helpers function parseExportFormats(string $formats): array { return array_map('trim', explode(',', $formats)); } function exportFormatLabel(string $fmt): string { $map = [ 'csv' => 'CSV', 'xlsx' => 'Excel (XLSX)', 'pdf' => 'PDF Report', 'spss' => 'SPSS (.sav)', 'json' => 'JSON', ]; return $map[$fmt] ?? strtoupper($fmt); } -------------------- END OF FILE -------------------- ### FILE 35: includes/header.php - Type: PHP - Size: 3.28 KB - Path: includes - Name: header.php ------------------------------------------------------------ <?= sanitize(PAGE_TITLE) ?> — SURVAM
-------------------- END OF FILE -------------------- ### FILE 36: includes/index.php - Type: PHP - Size: 9.01 KB - Path: includes - 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
Branching Rules
5
Export Formats
100%
Mobile Responsive

Everything You Need to Run Research

Built for professional research agencies, not hobbyists.

📋

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.

Simple, Transparent Pricing

Start free. Pay only when you need more.

Most Popular
0 ? '/ month' : '' ?>
  • No custom branding
  • Custom branding

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 37: s/index.php - Type: PHP - Size: 26.79 KB - Path: s - Name: index.php ------------------------------------------------------------ Survey not found.'); } // Check status if (!$preview) { if ($survey['status'] === 'draft') { http_response_code(403); die('

This survey is not yet published.

'); } if ($survey['status'] === 'closed' || $survey['status'] === 'archived') { die(renderClosed($survey)); } if ($survey['closes_at'] && strtotime($survey['closes_at']) < time()) { die(renderClosed($survey)); } } // Load structure $pages = DB::all("SELECT * FROM survey_pages WHERE survey_id = ? ORDER BY page_number", [$survey['id']]); foreach ($pages as &$p) { $p['questions'] = DB::all("SELECT * FROM survey_questions WHERE page_id = ? AND survey_id = ? ORDER BY order_num", [$p['id'], $survey['id']]); foreach ($p['questions'] as &$q) { $q['options'] = $q['options'] ? json_decode($q['options'], true) : []; $q['settings'] = $q['settings'] ? json_decode($q['settings'], true) : []; $q['logic'] = $q['logic'] ? json_decode($q['logic'], true) : []; } unset($q); // Break reference — prevents last $q from corrupting later pages } unset($p); // Break reference — prevents last $p from corrupting later pages $settings = $survey['settings'] ? json_decode($survey['settings'], true) : []; $theme = $survey['theme'] ? json_decode($survey['theme'], true) : []; // Handle POST submission if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$preview) { handleSubmission($survey, $pages, $settings); } $primaryColor = $theme['primary_color'] ?? '#e94560'; $bgColor = $theme['bg_color'] ?? '#f4f6fb'; $logoUrl = $theme['logo_url'] ?? ''; $removeBrand = !empty($theme['remove_branding']); $showProgress = !isset($settings['show_progress_bar']) || $settings['show_progress_bar']; $allowBack = !isset($settings['allow_back']) || $settings['allow_back']; $totalQ = array_sum(array_map(fn($p) => count($p['questions']), $pages)); function handleSubmission($survey, $pages, $settings) { $respToken = bin2hex(random_bytes(32)); $respId = DB::insert( "INSERT INTO survey_responses (survey_id, respondent_token, status, ip_address, user_agent, device_type) VALUES (?,?,?,?,?,?)", [$survey['id'], $respToken, 'complete', getClientIP(), $_SERVER['HTTP_USER_AGENT'] ?? '', deviceType()] ); foreach ($pages as $page) { foreach ($page['questions'] as $q) { $key = 'q_' . $q['id']; $val = $_POST[$key] ?? null; if ($val === null) continue; $answerText = null; $answerVal = null; $answerJson = null; if (is_array($val)) { $answerJson = json_encode($val); } elseif (is_numeric($val)) { $answerVal = (float)$val; } else { $answerText = substr(strip_tags((string)$val), 0, 10000); } DB::insert( "INSERT INTO survey_answers (response_id, question_id, survey_id, answer_text, answer_value, answer_json) VALUES (?,?,?,?,?,?)", [$respId, $q['id'], $survey['id'], $answerText, $answerVal, $answerJson] ); } } DB::query("UPDATE survey_responses SET status='complete', completed_at=NOW(), time_taken_seconds=? WHERE id=?", [max(1, (int)($_POST['_time_elapsed'] ?? 0)), $respId]); DB::query("UPDATE surveys SET response_count=response_count+1, complete_count=complete_count+1 WHERE id=?", [$survey['id']]); $user = DB::row("SELECT user_id FROM surveys WHERE id=?", [$survey['id']]); if ($user) DB::query("UPDATE users SET responses_used_this_month=responses_used_this_month+1 WHERE id=?", [$user['user_id']]); $redirectUrl = $settings['redirect_url'] ?? ''; if ($redirectUrl) { header("Location: $redirectUrl"); exit; } echo renderThankYou($survey, $settings, $theme); exit; } function renderClosed($survey) { return 'Survey Closed
🔒

This survey is closed

Thank you for your interest. This survey is no longer accepting responses.

'; } function renderThankYou($survey, $settings, $theme) { $msg = htmlspecialchars($settings['thank_you_message'] ?? 'Thank you for completing this survey!'); $primary = $theme['primary_color'] ?? '#e94560'; return 'Thank You

All done!

' . $msg . '

'; } ?> <?= htmlspecialchars($survey['title']) ?>
👁 PREVIEW MODE — Responses are not recorded
Logo

Page 1 of
$page): ?>
1): ?>

$q): renderQuestion($q, $qi); endforeach; ?>
0): ?>
Survey image
*' : ''; $o = $q['options'] ?? []; $s = $q['settings'] ?? []; ?>
>
$choice): $soUrl = htmlspecialchars($o['screenout_urls'][$ci] ?? ''); ?>
$choice): $soUrl = htmlspecialchars($o['screenout_urls'][$ci] ?? ''); ?>
>
placeholder="Enter a number…" > >
$stmt): ?>
$row): ?> $col): ?>
$item): ?>
Distribute exactly points. Remaining:
>
Max file size: MB

-------------------- END OF FILE -------------------- ### FILE 38: surveys/builder.php - Type: PHP - Size: 19.76 KB - Path: surveys - Name: builder.php ------------------------------------------------------------ Builder — <?= sanitize($survey['title']) ?> — SURVAM
Question Types
👈

Click a question to edit its properties

-------------------- END OF FILE -------------------- ### FILE 39: surveys/create.php - Type: PHP - Size: 3.43 KB - Path: surveys - Name: create.php ------------------------------------------------------------
This is the internal name. You can customise the display title in the builder.
Cancel
-------------------- END OF FILE -------------------- ### FILE 40: surveys/delete.php - Type: PHP - Size: 1.52 KB - Path: surveys - Name: delete.php ------------------------------------------------------------ $resp) { $row = [ $ri + 1, $resp['created_at'], $resp['status'], $resp['device_type'] ?? '', $resp['time_taken_seconds'] ?? '', ]; foreach ($questions as $q) { if (in_array($q['type'], ['statement','section_break'])) continue; $row[] = $answers[$resp['id']][$q['id']] ?? ''; } $rows[] = $row; } $safeName = preg_replace('/[^a-z0-9_-]/i', '_', $survey['title']); $filename = 'survam_' . $safeName . '_' . date('Ymd_His'); switch ($fmt) { // ── CSV ─────────────────────────────────────────────────── case 'csv': header('Content-Type: text/csv; charset=UTF-8'); header("Content-Disposition: attachment; filename=\"$filename.csv\""); $out = fopen('php://output', 'w'); // BOM for Excel UTF-8 compatibility fputs($out, "\xEF\xBB\xBF"); fputcsv($out, $headers); foreach ($rows as $row) fputcsv($out, $row); fclose($out); exit; // ── JSON ────────────────────────────────────────────────── case 'json': header('Content-Type: application/json; charset=UTF-8'); header("Content-Disposition: attachment; filename=\"$filename.json\""); $out = []; foreach ($responses as $ri => $resp) { $record = [ 'response_num' => $ri + 1, 'submitted_at' => $resp['created_at'], 'status' => $resp['status'], 'device' => $resp['device_type'] ?? '', 'time_seconds' => $resp['time_taken_seconds'], 'answers' => [], ]; foreach ($questions as $q) { if (in_array($q['type'], ['statement','section_break'])) continue; $record['answers'][$q['id']] = [ 'question' => $q['title'], 'type' => $q['type'], 'answer' => $answers[$resp['id']][$q['id']] ?? null, ]; } $out[] = $record; } echo json_encode(['survey' => $survey['title'], 'exported_at' => date('c'), 'responses' => $out], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); exit; // ── XLSX ────────────────────────────────────────────────── case 'xlsx': if (!file_exists(__DIR__ . '/../vendor/autoload.php')) { // Fallback to CSV if PhpSpreadsheet not available header('Content-Type: text/csv; charset=UTF-8'); header("Content-Disposition: attachment; filename=\"$filename.csv\""); $out = fopen('php://output', 'w'); fputs($out, "\xEF\xBB\xBF"); fputcsv($out, $headers); foreach ($rows as $row) fputcsv($out, $row); fclose($out); exit; } require __DIR__ . '/../vendor/autoload.php'; $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('Responses'); // Header row foreach ($headers as $ci => $h) { $col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($ci + 1); $sheet->setCellValue($col . '1', $h); $sheet->getStyle($col . '1')->getFont()->setBold(true); $sheet->getStyle($col . '1')->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setARGB('FF1A1A2E'); $sheet->getStyle($col . '1')->getFont()->getColor()->setARGB('FFFFFFFF'); } // Data rows foreach ($rows as $ri => $row) { foreach ($row as $ci => $val) { $col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($ci + 1); $sheet->setCellValue($col . ($ri + 2), $val); } } foreach (range(1, count($headers)) as $ci) { $sheet->getColumnDimensionByColumn($ci)->setAutoSize(true); } $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); header("Content-Disposition: attachment; filename=\"$filename.xlsx\""); $writer->save('php://output'); exit; // ── PDF ─────────────────────────────────────────────────── case 'pdf': // Render to HTML then print - works without library $html = generatePDFHTML($survey, $headers, $rows, $filename); header('Content-Type: text/html; charset=UTF-8'); echo $html; exit; // ── SPSS ───────────────────────────────────────────────── case 'spss': // Generate SAV-compatible syntax file (.sps) header('Content-Type: text/plain; charset=UTF-8'); header("Content-Disposition: attachment; filename=\"$filename.sps\""); echo generateSPSSsyntax($survey, $questions, $responses, $answers); exit; default: http_response_code(400); die('Unknown format.'); } function generatePDFHTML(array $survey, array $headers, array $rows, string $filename): string { $title = htmlspecialchars($survey['title']); $safeH = implode('', array_map('htmlspecialchars', $headers)); $rowsHTML = ''; foreach ($rows as $row) { $cells = implode('', array_map('htmlspecialchars', $row)); $rowsHTML .= "$cells"; } return "$title

$title — Survey Responses

Exported " . date('d M Y H:i') . " · " . count($rows) . " responses · SURVAM by Relevant Reflex Consulting

$rowsHTML
$safeH
"; } function generateSPSSsyntax(array $survey, array $questions, array $responses, array $answers): string { $out = "* SURVAM SPSS Export\n"; $out .= "* Survey: " . $survey['title'] . "\n"; $out .= "* Exported: " . date('Y-m-d H:i:s') . "\n\n"; $out .= "DATA LIST FREE\n /resp_num status device time_sec"; foreach ($questions as $i => $q) { if (in_array($q['type'], ['statement','section_break'])) continue; $out .= " q{$i}"; } $out .= " .\n\nBEGIN DATA.\n"; foreach ($responses as $ri => $resp) { $out .= ($ri + 1) . " " . $resp['status'] . " " . ($resp['device_type'] ?? '') . " " . ($resp['time_taken_seconds'] ?? 0); foreach ($questions as $i => $q) { if (in_array($q['type'], ['statement','section_break'])) continue; $val = $answers[$resp['id']][$q['id']] ?? ''; $val = str_replace(['"', "\n"], ['', ' '], $val); $out .= " \"$val\""; } $out .= "\n"; } $out .= "END DATA.\n\nVARIABLE LABELS\n resp_num 'Response Number'\n status 'Response Status'\n device 'Device Type'\n time_sec 'Time Taken (seconds)'"; foreach ($questions as $i => $q) { if (in_array($q['type'], ['statement','section_break'])) continue; $lbl = addslashes(substr($q['title'], 0, 120)); $out .= "\n q{$i} '$lbl'"; } $out .= " .\n\nEXECUTE.\n"; return $out; } -------------------- END OF FILE -------------------- ### FILE 42: surveys/index.php - Type: PHP - Size: 6.3 KB - Path: surveys - Name: index.php ------------------------------------------------------------
📋

No surveys found

Create First Survey

responses complete
1): ?>
-------------------- END OF FILE -------------------- ### FILE 43: surveys/results.php - Type: PHP - Size: 17.86 KB - Path: surveys - Name: results.php ------------------------------------------------------------ = ?'; $dateParams[] = $dateFrom . ' 00:00:00'; } if ($dateTo) { $dateWhere .= ' AND r.created_at <= ?'; $dateParams[] = $dateTo . ' 23:59:59'; } $responses = DB::all( "SELECT r.*, COUNT(a.id) as answer_count FROM survey_responses r LEFT JOIN survey_answers a ON r.id = a.response_id WHERE r.survey_id=? $dateWhere GROUP BY r.id ORDER BY r.created_at DESC LIMIT $per_page OFFSET $offset", $dateParams ); $totalFiltered = DB::row("SELECT COUNT(*) c FROM survey_responses r WHERE r.survey_id=? $dateWhere", $dateParams)['c']; $totalPages = ceil($totalFiltered / $per_page); // Question-level aggregates (for chart data) $questionStats = []; foreach ($pages as $page) { foreach ($page['questions'] as $q) { $qid = $q['id']; $answered = DB::row("SELECT COUNT(*) c FROM survey_answers WHERE question_id=? AND response_id IN (SELECT id FROM survey_responses WHERE survey_id=?)", [$qid, $surveyId])['c']; $stats = ['question' => $q, 'answered' => $answered]; if (in_array($q['type'], ['single_choice','dropdown','yes_no'])) { $vals = DB::all("SELECT answer_text, COUNT(*) cnt FROM survey_answers WHERE question_id=? AND answer_text IS NOT NULL GROUP BY answer_text ORDER BY cnt DESC", [$qid]); $stats['distribution'] = $vals; } elseif (in_array($q['type'], ['rating_scale','nps','star_rating','slider'])) { $row = DB::row("SELECT AVG(answer_value) avg_val, MIN(answer_value) min_val, MAX(answer_value) max_val FROM survey_answers WHERE question_id=? AND answer_value IS NOT NULL", [$qid]); $stats['avg'] = round($row['avg_val'] ?? 0, 2); $stats['min'] = $row['min_val'] ?? 0; $stats['max'] = $row['max_val'] ?? 0; $dist = DB::all("SELECT answer_value val, COUNT(*) cnt FROM survey_answers WHERE question_id=? AND answer_value IS NOT NULL GROUP BY answer_value ORDER BY val", [$qid]); $stats['distribution'] = $dist; } elseif ($q['type'] === 'multi_choice') { $vals = DB::all("SELECT answer_json FROM survey_answers WHERE question_id=? AND answer_json IS NOT NULL", [$qid]); $counts = []; foreach ($vals as $v) { $arr = json_decode($v['answer_json'], true) ?? []; foreach ($arr as $item) { $counts[$item] = ($counts[$item] ?? 0) + 1; } } arsort($counts); $stats['distribution'] = array_map(fn($k,$v) => ['answer_text'=>$k,'cnt'=>$v], array_keys($counts), array_values($counts)); } $questionStats[$qid] = $stats; } } // Daily response trend (last 30 days) $trend = DB::all( "SELECT DATE(created_at) as dt, COUNT(*) cnt FROM survey_responses WHERE survey_id=? AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY DATE(created_at) ORDER BY dt", [$surveyId] ); // Device breakdown $deviceStats = DB::all("SELECT device_type, COUNT(*) cnt FROM survey_responses WHERE survey_id=? GROUP BY device_type", [$surveyId]); define('PAGE_TITLE', 'Results: ' . $survey['title']); require_once __DIR__ . '/../includes/header.php'; ?>
📊
Total Responses
Completed
Partial
%
0 ? round(($completeResp / $totalResp) * 100) : 0 ?>%
Completion Rate
0", [$surveyId])['avg']; echo $avgTime ? round($avgTime / 60, 1) . ' min' : '—'; ?>
Avg. Completion Time
📱
$d['device_type'] === 'mobile'), 'cnt')); echo $totalResp > 0 ? round(($mobile / $totalResp) * 100) . '%' : '—'; ?>
Mobile Respondents
📋

No questions yet

Add questions to your survey to see summary statistics here.

$stat): $q = $stat['question']; $skipTypes = ['statement', 'section_break']; if (in_array($q['type'], $skipTypes)) continue; ?>
answered
0 ? round(($d['cnt'] / $total) * 100) : 0; ?>
(%)
Average
Min
Max

No text responses yet.

Showing most recent responses.

answers collected.

Clear
📭

No responses yet

Share your survey link to start collecting responses.

$r): ?>
# Submitted Status Device Time Taken Answers Actions
View
1): ?>
📈

No data yet to show trend.

Daily Responses (Last 30 Days)

Device Breakdown

-------------------- END OF FILE -------------------- ### FILE 44: uploads/media/media_1_1776222718_df5f8684.png - Type: PNG - Size: 169.91 KB - Path: uploads/media - Name: media_1_1776222718_df5f8684.png ------------------------------------------------------------ [IMAGE FILE: PNG - Content not displayed] -------------------- END OF FILE -------------------- ### FILE 45: uploads/media/media_1_1776222776_e54cdee1.png - Type: PNG - Size: 169.91 KB - Path: uploads/media - Name: media_1_1776222776_e54cdee1.png ------------------------------------------------------------ [IMAGE FILE: PNG - Content not displayed] -------------------- END OF FILE -------------------- ### FILE 46: vendor/README.txt - Type: TXT - Size: 227 B - Path: vendor - Name: README.txt ------------------------------------------------------------ Place Composer dependencies here for XLSX export. To enable Excel export: composer require phpoffice/phpspreadsheet Without this, Excel export falls back to CSV. For SPSS, the built-in exporter generates .sps syntax files. -------------------- END OF FILE -------------------- ================================================================================ ## SUMMARY ================================================================================ Repository contains 46 files total. All file contents have been extracted and are shown above. This repository snapshot was generated on: 2026-04-16 20:41:42 ================================================================================ ## END OF REPOSITORY ================================================================================