Commit 8b31a26d authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: profile edit + org application with proof upload

Players can now fill all profile fields (bio, country, city, FIDE info)
from their profile tab and apply for organizations with document proof.
Also fixes avatar.php to use SERVICE_KEY for storage uploads.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ed1fc3ce
...@@ -73,7 +73,7 @@ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); ...@@ -73,7 +73,7 @@ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY, 'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'apikey: ' . SUPABASE_ANON_KEY, 'apikey: ' . SUPABASE_SERVICE_KEY,
'Content-Type: ' . $mime, 'Content-Type: ' . $mime,
'x-upsert: true' 'x-upsert: true'
]); ]);
......
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
set_exception_handler(function($e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => $e->getMessage(), 'line' => $e->getLine()]);
exit;
});
set_error_handler(function($errno, $errstr, $errfile, $errline) {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/supabase.php';
$token = requireAuth();
$userId = getUserId($token);
if (!$userId) jsonError('Invalid user', 401);
// Validate required fields
$orgId = $_POST['org_id'] ?? null;
$documentType = $_POST['document_type'] ?? null;
$notes = $_POST['notes'] ?? '';
if (!$orgId) jsonError('org_id is required');
if (!$documentType) jsonError('document_type is required');
// Validate document_type
$validTypes = ['membership_card', 'id_card', 'receipt', 'other'];
if (!in_array($documentType, $validTypes)) {
jsonError('Invalid document_type. Allowed: ' . implode(', ', $validTypes));
}
// Check if already a member
$db = supabaseService();
$existing = $db->get('org_memberships', [
'user_id' => 'eq.' . $userId,
'organization_id' => 'eq.' . $orgId,
'status' => 'eq.active',
'limit' => 1
]);
if (is_array($existing) && !isset($existing['error']) && !empty($existing)) {
jsonError('Already a member of this organization');
}
// Check if pending application exists
$pending = $db->get('org_membership_applications', [
'player_id' => 'eq.' . $userId,
'org_id' => 'eq.' . $orgId,
'status' => 'eq.pending',
'limit' => 1
]);
if (is_array($pending) && !isset($pending['error']) && !empty($pending)) {
jsonError('You already have a pending application for this organization');
}
// Handle file upload
$documentUrl = null;
if (isset($_FILES['proof_document'])) {
if ($_FILES['proof_document']['error'] !== UPLOAD_ERR_OK) {
$errors = [1 => 'File exceeds server limit', 2 => 'File exceeds form limit', 3 => 'Partial upload', 4 => 'No file sent', 6 => 'No tmp dir', 7 => 'Write failed'];
jsonError($errors[$_FILES['proof_document']['error']] ?? 'Upload error code ' . $_FILES['proof_document']['error']);
}
$file = $_FILES['proof_document'];
if ($file['size'] > 5 * 1024 * 1024) {
jsonError('File too large (max 5MB)');
}
$mime = mime_content_type($file['tmp_name']);
$allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($mime, $allowed)) {
jsonError('Invalid file type: ' . $mime . '. Allowed: JPEG, PNG, WebP');
}
$ext = match($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => 'jpg'
};
$filename = $userId . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$storagePath = 'org-proofs/' . $userId . '/' . $filename;
$bucket = 'assets';
$storageUrl = SUPABASE_STORAGE . '/object/' . $bucket . '/' . $storagePath;
$fileContents = file_get_contents($file['tmp_name']);
if ($fileContents === false) {
jsonError('Failed to read uploaded file');
}
$ch = curl_init($storageUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'apikey: ' . SUPABASE_SERVICE_KEY,
'Content-Type: ' . $mime,
'x-upsert: true'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContents);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
jsonError('Storage connection failed: ' . $curlError, 500);
}
if ($httpCode >= 400) {
$decoded = json_decode($response, true);
jsonError('Storage error (' . $httpCode . '): ' . ($decoded['message'] ?? $decoded['error'] ?? $response), 500);
}
$documentUrl = SUPABASE_STORAGE . '/object/public/' . $bucket . '/' . $storagePath . '?apikey=' . SUPABASE_ANON_KEY;
}
// Insert application
$appData = [
'org_id' => $orgId,
'player_id' => $userId,
'status' => 'pending',
'document_type' => $documentType,
'notes' => $notes
];
if ($documentUrl) {
$appData['document_url'] = $documentUrl;
}
$result = $db->insert('org_membership_applications', $appData);
if (isset($result['error'])) {
jsonError('Failed to submit application: ' . $result['error']);
}
jsonResponse([
'success' => true,
'application_id' => $result[0]['id'] ?? null,
'document_url' => $documentUrl
]);
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
header('Content-Type: application/json'); header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, PATCH, OPTIONS'); header('Access-Control-Allow-Methods: GET, POST, PATCH, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization'); header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; } if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
...@@ -35,11 +35,39 @@ if ($method === 'GET') { ...@@ -35,11 +35,39 @@ if ($method === 'GET') {
if ($method === 'PATCH') { if ($method === 'PATCH') {
$input = getInput(); $input = getInput();
$allowed = ['display_name', 'display_name_ar', 'avatar_url', 'frame_id', 'border_color', 'country_code']; $allowed = [
'display_name', 'display_name_ar', 'avatar_url', 'frame_id', 'border_color',
'bio', 'bio_ar', 'country_code', 'city', 'preferred_language',
'fide_id', 'fide_rating_standard', 'fide_rating_rapid', 'fide_rating_blitz', 'fide_title'
];
$data = array_intersect_key($input, array_flip($allowed)); $data = array_intersect_key($input, array_flip($allowed));
if (empty($data)) jsonError('No valid fields to update'); if (empty($data)) jsonError('No valid fields to update');
// Validate fide_title if provided
if (isset($data['fide_title']) && $data['fide_title'] !== null && $data['fide_title'] !== '') {
$validTitles = ['GM', 'IM', 'FM', 'CM', 'WGM', 'WIM', 'WFM', 'WCM'];
if (!in_array($data['fide_title'], $validTitles)) {
jsonError('Invalid FIDE title');
}
}
// Validate numeric fields
$numericFields = ['fide_rating_standard', 'fide_rating_rapid', 'fide_rating_blitz'];
foreach ($numericFields as $field) {
if (isset($data[$field]) && $data[$field] !== null && $data[$field] !== '') {
$data[$field] = (int)$data[$field];
if ($data[$field] < 0 || $data[$field] > 4000) {
jsonError("Invalid $field value");
}
}
}
// Validate preferred_language
if (isset($data['preferred_language']) && !in_array($data['preferred_language'], ['ar', 'en'])) {
jsonError('Invalid preferred_language');
}
$db = supabase($token); $db = supabase($token);
$result = $db->update('profiles', $data, ['id' => 'eq.' . $userId]); $result = $db->update('profiles', $data, ['id' => 'eq.' . $userId]);
...@@ -50,4 +78,91 @@ if ($method === 'PATCH') { ...@@ -50,4 +78,91 @@ if ($method === 'PATCH') {
jsonResponse($result[0] ?? ['success' => true]); jsonResponse($result[0] ?? ['success' => true]);
} }
if ($method === 'POST') {
$input = getInput();
$action = $input['action'] ?? '';
if ($action === 'get-orgs') {
$db = supabaseService();
$orgs = $db->get('organizations', ['select' => 'id,name,slug,logo_url,country_code', 'order' => 'name.asc']);
if (isset($orgs['error'])) jsonError($orgs['error']);
jsonResponse($orgs);
}
if ($action === 'my-memberships') {
$db = supabase($token);
$memberships = $db->get('org_memberships', [
'user_id' => 'eq.' . $userId,
'select' => 'id,organization_id,role,status,joined_at,organizations(id,name,slug,logo_url)'
]);
if (isset($memberships['error'])) jsonError($memberships['error']);
jsonResponse(is_array($memberships) ? $memberships : []);
}
if ($action === 'my-applications') {
$db = supabase($token);
$apps = $db->get('org_membership_applications', [
'player_id' => 'eq.' . $userId,
'select' => 'id,org_id,status,document_type,notes,rejection_reason,created_at,organizations(id,name,slug,logo_url)',
'order' => 'created_at.desc',
'limit' => 50
]);
if (isset($apps['error'])) jsonError($apps['error']);
jsonResponse(is_array($apps) ? $apps : []);
}
if ($action === 'apply-org') {
$orgId = $input['org_id'] ?? null;
$documentType = $input['document_type'] ?? null;
$notes = $input['notes'] ?? '';
$documentUrl = $input['document_url'] ?? null;
if (!$orgId || !$documentType) {
jsonError('org_id and document_type are required');
}
// Check if already a member
$db = supabase($token);
$existing = $db->get('org_memberships', [
'user_id' => 'eq.' . $userId,
'organization_id' => 'eq.' . $orgId,
'status' => 'eq.active',
'limit' => 1
]);
if (is_array($existing) && !isset($existing['error']) && !empty($existing)) {
jsonError('Already a member of this organization');
}
// Check if pending application exists
$pending = $db->get('org_membership_applications', [
'player_id' => 'eq.' . $userId,
'org_id' => 'eq.' . $orgId,
'status' => 'eq.pending',
'limit' => 1
]);
if (is_array($pending) && !isset($pending['error']) && !empty($pending)) {
jsonError('You already have a pending application for this organization');
}
$appData = [
'org_id' => $orgId,
'player_id' => $userId,
'status' => 'pending',
'document_type' => $documentType,
'notes' => $notes
];
if ($documentUrl) {
$appData['document_url'] = $documentUrl;
}
$dbService = supabaseService();
$result = $dbService->insert('org_membership_applications', $appData);
if (isset($result['error'])) jsonError($result['error']);
jsonResponse($result[0] ?? ['success' => true]);
}
jsonError('Invalid action');
}
jsonError('Method not allowed', 405); jsonError('Method not allowed', 405);
import * as scene from '../../core/scene.js'; import * as scene from '../../core/scene.js';
import { mountView } from './scenes/view.js'; import { mountView } from './scenes/view.js';
import { mountSettings } from './scenes/settings.js'; import { mountSettings } from './scenes/settings.js';
import { mountEdit } from './scenes/edit.js';
import { mountOrgApply } from './scenes/org-apply.js';
scene.register('profile-view', mountView); scene.register('profile-view', mountView);
scene.register('profile-settings', mountSettings); scene.register('profile-settings', mountSettings);
scene.register('profile-edit', mountEdit);
scene.register('profile-org-apply', mountOrgApply);
import * as store from '../../../core/store.js';
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as net from '../../../core/net.js';
import { emoji } from '../../../core/theme.js';
const COUNTRIES = [
{ code: 'EG', name: 'مصر' },
{ code: 'SA', name: 'السعودية' },
{ code: 'AE', name: 'الإمارات' },
{ code: 'KW', name: 'الكويت' },
{ code: 'QA', name: 'قطر' },
{ code: 'BH', name: 'البحرين' },
{ code: 'OM', name: 'عُمان' },
{ code: 'JO', name: 'الأردن' },
{ code: 'IQ', name: 'العراق' },
{ code: 'SY', name: 'سوريا' },
{ code: 'LB', name: 'لبنان' },
{ code: 'PS', name: 'فلسطين' },
{ code: 'LY', name: 'ليبيا' },
{ code: 'TN', name: 'تونس' },
{ code: 'DZ', name: 'الجزائر' },
{ code: 'MA', name: 'المغرب' },
{ code: 'SD', name: 'السودان' },
{ code: 'YE', name: 'اليمن' },
{ code: 'MR', name: 'موريتانيا' },
{ code: 'OTHER', name: 'أخرى' }
];
const FIDE_TITLES = [
{ value: '', label: 'بدون' },
{ value: 'GM', label: 'GM — أستاذ كبير' },
{ value: 'IM', label: 'IM — أستاذ دولي' },
{ value: 'FM', label: 'FM — أستاذ فيدرالي' },
{ value: 'CM', label: 'CM — مرشح أستاذ' },
{ value: 'WGM', label: 'WGM — أستاذة كبيرة' },
{ value: 'WIM', label: 'WIM — أستاذة دولية' },
{ value: 'WFM', label: 'WFM — أستاذة فيدرالية' },
{ value: 'WCM', label: 'WCM — مرشحة أستاذة' }
];
export async function mountEdit(el) {
const player = store.get('player') || {};
el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);padding-bottom:max(var(--s-4), env(safe-area-inset-bottom));">
<!-- Header -->
<div style="display:flex;align-items:center;gap:var(--s-3);">
<button id="btn-back" class="btn btn-secondary" style="min-width:40px;min-height:40px;padding:0;display:flex;align-items:center;justify-content:center;">
${emoji('arrow_back', '←', 18)}
</button>
<div style="font-size:18px;font-weight:700;">تعديل الملف الشخصي</div>
</div>
<!-- Section: Basic Info -->
<div class="card">
<div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">${emoji('person', '👤', 18)} المعلومات الأساسية</div>
<div style="display:flex;flex-direction:column;gap:var(--s-3);">
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">الاسم المعروض (إنجليزي)</label>
<input id="field-display_name" type="text" maxlength="30" dir="auto" value="${escAttr(player.display_name || '')}"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;">
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">الاسم المعروض (عربي)</label>
<input id="field-display_name_ar" type="text" maxlength="30" dir="rtl" value="${escAttr(player.display_name_ar || '')}"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;">
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">النبذة (إنجليزي)</label>
<textarea id="field-bio" maxlength="200" dir="auto" rows="3"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;resize:vertical;">${escHtml(player.bio || '')}</textarea>
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">النبذة (عربي)</label>
<textarea id="field-bio_ar" maxlength="200" dir="rtl" rows="3"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;resize:vertical;">${escHtml(player.bio_ar || '')}</textarea>
</div>
</div>
</div>
<!-- Section: Location -->
<div class="card">
<div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">${emoji('globe', '🌍', 18)} الموقع</div>
<div style="display:flex;flex-direction:column;gap:var(--s-3);">
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">الدولة</label>
<select id="field-country_code"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;">
<option value="">اختر الدولة</option>
${COUNTRIES.map(c => `<option value="${c.code}" ${player.country_code === c.code ? 'selected' : ''}>${c.name}</option>`).join('')}
</select>
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">المدينة</label>
<input id="field-city" type="text" dir="auto" value="${escAttr(player.city || '')}"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;">
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">اللغة المفضلة</label>
<div id="lang-toggle" style="display:flex;gap:var(--s-2);">
<button class="btn lang-opt ${(player.preferred_language || 'ar') === 'ar' ? 'btn-primary' : 'btn-secondary'}" data-lang="ar" style="flex:1;min-height:40px;">العربية</button>
<button class="btn lang-opt ${player.preferred_language === 'en' ? 'btn-primary' : 'btn-secondary'}" data-lang="en" style="flex:1;min-height:40px;">English</button>
</div>
</div>
</div>
</div>
<!-- Section: FIDE Info -->
<div class="card">
<div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">${emoji('trophy', '🏆', 18)} معلومات FIDE</div>
<div style="display:flex;flex-direction:column;gap:var(--s-3);">
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">FIDE ID</label>
<input id="field-fide_id" type="text" inputmode="numeric" dir="ltr" value="${escAttr(player.fide_id || '')}"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;">
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">تصنيف FIDE الكلاسيكي</label>
<input id="field-fide_rating_standard" type="number" inputmode="numeric" dir="ltr" min="0" max="4000" value="${player.fide_rating_standard || ''}"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;">
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">تصنيف FIDE السريع</label>
<input id="field-fide_rating_rapid" type="number" inputmode="numeric" dir="ltr" min="0" max="4000" value="${player.fide_rating_rapid || ''}"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;">
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">تصنيف FIDE الخاطف</label>
<input id="field-fide_rating_blitz" type="number" inputmode="numeric" dir="ltr" min="0" max="4000" value="${player.fide_rating_blitz || ''}"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;">
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">لقب FIDE</label>
<select id="field-fide_title"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;">
${FIDE_TITLES.map(t => `<option value="${t.value}" ${player.fide_title === t.value ? 'selected' : ''}>${t.label}</option>`).join('')}
</select>
</div>
</div>
</div>
<!-- Save Button -->
<button id="btn-save" class="btn btn-primary" style="width:100%;min-height:48px;font-size:16px;font-weight:700;">
${emoji('save', '💾', 18)} حفظ التغييرات
</button>
</div>
`;
// Language toggle
let selectedLang = player.preferred_language || 'ar';
el.querySelectorAll('.lang-opt').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
selectedLang = btn.dataset.lang;
el.querySelectorAll('.lang-opt').forEach(b => {
b.classList.remove('btn-primary');
b.classList.add('btn-secondary');
});
btn.classList.remove('btn-secondary');
btn.classList.add('btn-primary');
});
});
// Back button
el.querySelector('#btn-back').addEventListener('click', () => {
audio.play('click');
scene.pop();
});
// Save
el.querySelector('#btn-save').addEventListener('click', async () => {
audio.play('click');
const btn = el.querySelector('#btn-save');
btn.disabled = true;
btn.style.opacity = '0.6';
btn.textContent = 'جاري الحفظ...';
const data = {
display_name: el.querySelector('#field-display_name').value.trim(),
display_name_ar: el.querySelector('#field-display_name_ar').value.trim(),
bio: el.querySelector('#field-bio').value.trim(),
bio_ar: el.querySelector('#field-bio_ar').value.trim(),
country_code: el.querySelector('#field-country_code').value,
city: el.querySelector('#field-city').value.trim(),
preferred_language: selectedLang,
fide_id: el.querySelector('#field-fide_id').value.trim(),
fide_rating_standard: el.querySelector('#field-fide_rating_standard').value ? parseInt(el.querySelector('#field-fide_rating_standard').value) : null,
fide_rating_rapid: el.querySelector('#field-fide_rating_rapid').value ? parseInt(el.querySelector('#field-fide_rating_rapid').value) : null,
fide_rating_blitz: el.querySelector('#field-fide_rating_blitz').value ? parseInt(el.querySelector('#field-fide_rating_blitz').value) : null,
fide_title: el.querySelector('#field-fide_title').value || null
};
// Remove empty strings to avoid overwriting with blanks
Object.keys(data).forEach(k => {
if (data[k] === '') data[k] = null;
});
try {
const result = await net.patch('profile.php', data);
// Update local store
const updated = { ...store.get('player'), ...data };
store.set('player', updated);
bus.emit('store:player');
bus.emit('toast', { text: 'تم حفظ التغييرات بنجاح', type: 'success' });
scene.pop();
} catch (err) {
bus.emit('toast', { text: err.message || 'فشل حفظ التغييرات', type: 'error' });
}
btn.disabled = false;
btn.style.opacity = '1';
btn.innerHTML = `${emoji('save', '💾', 18)} حفظ التغييرات`;
});
}
function escAttr(str) {
return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
import * as store from '../../../core/store.js';
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as net from '../../../core/net.js';
import { emoji } from '../../../core/theme.js';
const DOC_TYPES = [
{ value: 'membership_card', label: 'بطاقة عضوية' },
{ value: 'id_card', label: 'بطاقة هوية' },
{ value: 'receipt', label: 'إيصال دفع' },
{ value: 'other', label: 'أخرى' }
];
export async function mountOrgApply(el) {
el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);padding-bottom:max(var(--s-4), env(safe-area-inset-bottom));">
<div style="display:flex;align-items:center;gap:var(--s-3);">
<button id="btn-back" class="btn btn-secondary" style="min-width:40px;min-height:40px;padding:0;display:flex;align-items:center;justify-content:center;">
${emoji('arrow_back', '←', 18)}
</button>
<div style="font-size:18px;font-weight:700;">الانضمام لمنظمة</div>
</div>
<div id="org-content" style="display:flex;flex-direction:column;gap:var(--s-3);">
<div style="text-align:center;padding:var(--s-6);color:var(--text-secondary);">جاري التحميل...</div>
</div>
</div>
`;
el.querySelector('#btn-back').addEventListener('click', () => {
audio.play('click');
scene.pop();
});
await loadOrgs(el);
}
async function loadOrgs(el) {
const content = el.querySelector('#org-content');
try {
const [orgs, memberships, applications] = await Promise.all([
net.post('profile.php', { action: 'get-orgs' }),
net.post('profile.php', { action: 'my-memberships' }),
net.post('profile.php', { action: 'my-applications' })
]);
if (!orgs || !Array.isArray(orgs) || orgs.length === 0) {
content.innerHTML = `<div class="card" style="text-align:center;padding:var(--s-6);color:var(--text-secondary);">لا توجد منظمات متاحة حالياً</div>`;
return;
}
const memberOrgIds = new Set(
(Array.isArray(memberships) ? memberships : [])
.filter(m => m.status === 'active')
.map(m => m.organization_id)
);
const appMap = {};
(Array.isArray(applications) ? applications : []).forEach(app => {
if (!appMap[app.org_id] || new Date(app.created_at) > new Date(appMap[app.org_id].created_at)) {
appMap[app.org_id] = app;
}
});
content.innerHTML = orgs.map(org => {
const isMember = memberOrgIds.has(org.id);
const app = appMap[org.id];
let badge = '';
let actionBtn = '';
if (isMember) {
badge = `<span style="background:var(--success);color:#fff;font-size:11px;padding:2px 8px;border-radius:12px;font-weight:600;">${emoji('check', '✓', 12)} عضو</span>`;
} else if (app && app.status === 'pending') {
badge = `<span style="background:#f59e0b;color:#000;font-size:11px;padding:2px 8px;border-radius:12px;font-weight:600;">قيد المراجعة</span>`;
} else if (app && app.status === 'rejected') {
badge = `<span style="background:var(--error);color:#fff;font-size:11px;padding:2px 8px;border-radius:12px;font-weight:600;">مرفوض</span>`;
if (app.rejection_reason) {
badge += `<div style="font-size:11px;color:var(--error);margin-top:4px;">السبب: ${escHtml(app.rejection_reason)}</div>`;
}
actionBtn = `<button class="btn btn-primary btn-apply" data-org-id="${org.id}" style="min-height:36px;font-size:13px;margin-top:var(--s-2);">إعادة التقديم</button>`;
} else {
actionBtn = `<button class="btn btn-primary btn-apply" data-org-id="${org.id}" style="min-height:36px;font-size:13px;margin-top:var(--s-2);">تقديم طلب</button>`;
}
const logoHtml = org.logo_url
? `<img src="${org.logo_url}" style="width:40px;height:40px;border-radius:8px;object-fit:contain;" alt="">`
: `<div style="width:40px;height:40px;border-radius:8px;background:var(--bg-elevated);display:flex;align-items:center;justify-content:center;">${emoji('building', '🏢', 20)}</div>`;
return `
<div class="card" style="padding:var(--s-3);">
<div style="display:flex;align-items:center;gap:var(--s-3);">
${logoHtml}
<div style="flex:1;min-width:0;">
<div style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(org.name)}</div>
${org.country_code ? `<div style="font-size:12px;color:var(--text-secondary);">${org.country_code}</div>` : ''}
</div>
<div style="text-align:center;">${badge}</div>
</div>
${actionBtn}
</div>
`;
}).join('');
// Apply button handlers
content.querySelectorAll('.btn-apply').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
const orgId = btn.dataset.orgId;
const org = orgs.find(o => o.id === orgId);
showApplyForm(el, org, content);
});
});
} catch (err) {
content.innerHTML = `<div class="card" style="text-align:center;padding:var(--s-4);color:var(--error);">فشل تحميل المنظمات: ${escHtml(err.message)}</div>`;
}
}
function showApplyForm(el, org, contentParent) {
const content = el.querySelector('#org-content');
content.innerHTML = `
<div class="card" style="padding:var(--s-4);">
<div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">تقديم طلب انضمام — ${escHtml(org.name)}</div>
<div style="display:flex;flex-direction:column;gap:var(--s-3);">
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">نوع المستند</label>
<select id="apply-doc-type"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;">
${DOC_TYPES.map(d => `<option value="${d.value}">${d.label}</option>`).join('')}
</select>
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">ملاحظات (اختياري)</label>
<textarea id="apply-notes" maxlength="500" dir="auto" rows="3"
style="background:#1e1e3a;border:1px solid rgba(255,255,255,0.1);color:#f8fafc;border-radius:8px;padding:10px 12px;font-size:14px;font-family:inherit;width:100%;box-sizing:border-box;resize:vertical;"
placeholder="رسالة لإدارة المنظمة..."></textarea>
</div>
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">صورة إثبات</label>
<div id="upload-area" style="background:#1e1e3a;border:2px dashed rgba(255,255,255,0.15);border-radius:8px;padding:var(--s-4);text-align:center;cursor:pointer;">
<div id="upload-placeholder">
<div>${emoji('camera', '📷', 28)}</div>
<div style="font-size:13px;color:var(--text-secondary);margin-top:var(--s-2);">اضغط لاختيار صورة</div>
<div style="font-size:11px;color:var(--text-secondary);">PNG, JPG, WebP — حد أقصى 5MB</div>
</div>
<div id="upload-preview" style="display:none;">
<img id="preview-img" style="max-width:100%;max-height:200px;border-radius:6px;object-fit:contain;">
</div>
</div>
<input type="file" id="apply-file" accept="image/jpeg,image/png,image/webp" style="display:none;">
</div>
<div style="display:flex;gap:var(--s-3);margin-top:var(--s-2);">
<button id="btn-cancel-apply" class="btn btn-secondary" style="flex:1;min-height:44px;">إلغاء</button>
<button id="btn-submit-apply" class="btn btn-primary" style="flex:1;min-height:44px;font-weight:700;">إرسال الطلب</button>
</div>
</div>
</div>
`;
let selectedFile = null;
// Upload area click
const uploadArea = content.querySelector('#upload-area');
const fileInput = content.querySelector('#apply-file');
uploadArea.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
bus.emit('toast', { text: 'حجم الملف أكبر من 5MB', type: 'error' });
return;
}
selectedFile = file;
const reader = new FileReader();
reader.onload = (ev) => {
content.querySelector('#upload-placeholder').style.display = 'none';
const preview = content.querySelector('#upload-preview');
preview.style.display = 'block';
content.querySelector('#preview-img').src = ev.target.result;
};
reader.readAsDataURL(file);
});
// Cancel
content.querySelector('#btn-cancel-apply').addEventListener('click', () => {
audio.play('click');
loadOrgs(el);
});
// Submit
content.querySelector('#btn-submit-apply').addEventListener('click', async () => {
audio.play('click');
const docType = content.querySelector('#apply-doc-type').value;
const notes = content.querySelector('#apply-notes').value.trim();
const btn = content.querySelector('#btn-submit-apply');
btn.disabled = true;
btn.style.opacity = '0.6';
btn.textContent = 'جاري الإرسال...';
try {
const formData = new FormData();
formData.append('org_id', org.id);
formData.append('document_type', docType);
formData.append('notes', notes);
if (selectedFile) {
const compressed = await compressProofImage(selectedFile);
formData.append('proof_document', compressed, 'proof.' + getExt(selectedFile));
}
const token = store.get('auth.token');
const res = await fetch('/api/org-apply.php', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(data.error || 'فشل إرسال الطلب');
}
bus.emit('toast', { text: 'تم إرسال الطلب بنجاح', type: 'success' });
await loadOrgs(el);
} catch (err) {
bus.emit('toast', { text: err.message || 'فشل إرسال الطلب', type: 'error' });
btn.disabled = false;
btn.style.opacity = '1';
btn.textContent = 'إرسال الطلب';
}
});
}
function compressProofImage(file) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const maxDim = 1200;
let w = img.width;
let h = img.height;
if (w > maxDim || h > maxDim) {
if (w > h) { h = Math.round(h * maxDim / w); w = maxDim; }
else { w = Math.round(w * maxDim / h); h = maxDim; }
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob(
(blob) => blob ? resolve(blob) : reject(new Error('Compression failed')),
'image/jpeg',
0.8
);
URL.revokeObjectURL(img.src);
};
img.onerror = () => { URL.revokeObjectURL(img.src); reject(new Error('Invalid image')); };
img.src = URL.createObjectURL(file);
});
}
function getExt(file) {
const type = file.type;
if (type === 'image/png') return 'png';
if (type === 'image/webp') return 'webp';
return 'jpg';
}
function escHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
...@@ -58,6 +58,18 @@ export async function mountView(el) { ...@@ -58,6 +58,18 @@ export async function mountView(el) {
</div> </div>
</div> </div>
<!-- Organization Membership -->
<div class="card" id="org-section">
<div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">${emoji('building', '🏢', 18)} المنظمة</div>
<div id="org-membership-content" style="font-size:13px;color:var(--text-secondary);">جاري التحميل...</div>
</div>
<!-- Profile Actions -->
<div style="display:flex;flex-direction:column;gap:var(--s-3);">
<button class="btn btn-primary w-full" id="btn-edit-profile" style="min-height:44px;">${emoji('edit', '✏️', 16)} تعديل الملف الشخصي</button>
<button class="btn btn-secondary w-full" id="btn-org-apply" style="min-height:44px;">${emoji('building', '🏢', 16)} الانضمام لمنظمة</button>
</div>
<!-- Actions --> <!-- Actions -->
<div style="display:flex;gap:var(--s-3);"> <div style="display:flex;gap:var(--s-3);">
<button class="btn btn-secondary w-full" id="btn-settings">${t('profile.settings')}</button> <button class="btn btn-secondary w-full" id="btn-settings">${t('profile.settings')}</button>
...@@ -110,6 +122,16 @@ export async function mountView(el) { ...@@ -110,6 +122,16 @@ export async function mountView(el) {
uploading.style.display = 'none'; uploading.style.display = 'none';
}); });
el.querySelector('#btn-edit-profile').addEventListener('click', () => {
audio.play('click');
scene.push('profile-edit');
});
el.querySelector('#btn-org-apply').addEventListener('click', () => {
audio.play('click');
scene.push('profile-org-apply');
});
el.querySelector('#btn-settings').addEventListener('click', () => { el.querySelector('#btn-settings').addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.push('profile-settings'); scene.push('profile-settings');
...@@ -119,6 +141,42 @@ export async function mountView(el) { ...@@ -119,6 +141,42 @@ export async function mountView(el) {
audio.play('click'); audio.play('click');
bus.emit('auth:logout'); bus.emit('auth:logout');
}); });
// Load org membership
loadOrgMembership(el);
}
async function loadOrgMembership(el) {
const content = el.querySelector('#org-membership-content');
if (!content) return;
try {
const memberships = await net.post('profile.php', { action: 'my-memberships' });
if (Array.isArray(memberships) && memberships.length > 0) {
const active = memberships.filter(m => m.status === 'active');
if (active.length > 0) {
content.innerHTML = active.map(m => {
const org = m.organizations;
const logoHtml = org && org.logo_url
? `<img src="${org.logo_url}" style="width:28px;height:28px;border-radius:6px;object-fit:contain;" alt="">`
: `<div style="width:28px;height:28px;border-radius:6px;background:var(--bg-elevated);display:flex;align-items:center;justify-content:center;">${emoji('building', '🏢', 14)}</div>`;
return `
<div style="display:flex;align-items:center;gap:var(--s-2);padding:var(--s-2) 0;">
${logoHtml}
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;color:var(--text-primary);">${org ? org.name : 'منظمة'}</div>
<div style="font-size:11px;color:var(--text-secondary);">${m.role || 'عضو'}</div>
</div>
<span style="background:var(--success);color:#fff;font-size:10px;padding:2px 6px;border-radius:10px;">عضو</span>
</div>
`;
}).join('');
return;
}
}
content.innerHTML = `<div style="font-size:13px;color:var(--text-secondary);">لست عضواً في أي منظمة بعد</div>`;
} catch (e) {
content.innerHTML = `<div style="font-size:13px;color:var(--text-secondary);">لست عضواً في أي منظمة بعد</div>`;
}
} }
function compressAvatar(file) { function compressAvatar(file) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment