import * as React from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { addPropertyControls, ControlType, RenderTarget } from "framer"
/**
* TigerFunded — Page FAQ complète
* Conversion Framer du HTML autonome `tigerfunded-faq.html`.
* Hero sombre (étoiles, auroras, recherche live à placeholder typewriter,
* note légale), 4 cartes catégories à cheval sur la couture sombre→clair,
* sidebar sticky (scrollspy + carte support), Q&A en 4 sections avec
* réponses riches, filtre temps réel (questions + réponses) avec compteur
* et état vide, bandeau de pied.
* Les questions sont dans UNE SEULE liste « Questions » ; chaque élément a
* un menu « Catégorie » (General / Platform / Rules / Rewards). L'ordre
* dans la liste = l'ordre d'affichage dans la section.
* Les réponses utilisent une micro-syntaxe éditable dans les menus :
* **gras** · "- " liste · "### " sous-titre · "~ " note
* "[ex Label]" … "[/ex]" encadré exemple · "| a | b |" tableau (1re
* ligne = en-têtes). Une ligne = un paragraphe.
* Numérotation et compteurs (cartes, sidebar, sections, recherche)
* recalculés automatiquement depuis la liste.
* CSS scopé sous .tg-faq ; canvas Framer et reduced-motion statiques.
*
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight auto
* @framerIntrinsicWidth 1440
* @framerDisableUnlink
*/
// ---------------------------------------------------------------- types ---
type CatIcon = "help" | "monitor" | "shield" | "award"
interface HeroTexts {
eyebrow: string
titlePlain: string
titleAccent: string
subtitle: string
note: string
}
interface SearchTexts {
placeholder: string
entriesSuffix: string
noresPrefix: string
noresSuffix: string
typewriter: boolean
hints: string
}
interface SectionsTexts {
sidebarHead: string
questionsLabel: string
generalTitle: string
generalIcon: CatIcon
platformTitle: string
platformIcon: CatIcon
rulesTitle: string
rulesIcon: CatIcon
rewardsTitle: string
rewardsIcon: CatIcon
}
interface HelpTexts {
title: string
text: string
ctaLabel: string
ctaLink: string
hours: string
}
interface QA {
q: string
a: string
}
type CatKey = "general" | "platform" | "rules" | "rewards"
interface QAC extends QA {
cat: CatKey
}
interface Props {
style?: React.CSSProperties
hero?: Partial<HeroTexts>
search?: Partial<SearchTexts>
sections?: Partial<SectionsTexts>
help?: Partial<HelpTexts>
faqs?: QAC[]
footText?: string
}
// -------------------------------------------------------------- défauts ---
const D = {
hero: {
eyebrow: "Help Center",
titlePlain: "Questions,",
titleAccent: "answered",
subtitle:
"Every rule, target and payout condition — documented in full. Search below, or jump straight to a category.",
note: "All trading on TigerFunded is conducted in a simulated environment using demo accounts with simulated funds. TigerFunded is not a financial institution and does not provide any financial products or services.",
} as HeroTexts,
search: {
placeholder: "Search the FAQ",
entriesSuffix: "entries",
noresPrefix: "No entry matches",
noresSuffix:
"— try another keyword, or clear the search to see all {total} questions.",
typewriter: true,
hints: "How fast will I get paid?\nWhat leverage do we offer?\nIs the evaluation fee refundable?\nAre EAs and trade copiers allowed?\nHow does the scaling plan work?",
} as SearchTexts,
sections: {
sidebarHead: "On this page",
questionsLabel: "questions",
generalTitle: "General",
generalIcon: "help" as CatIcon,
platformTitle: "Platform",
platformIcon: "monitor" as CatIcon,
rulesTitle: "Rules",
rulesIcon: "shield" as CatIcon,
rewardsTitle: "Rewards",
rewardsIcon: "award" as CatIcon,
} as SectionsTexts,
help: {
title: "Need more help?",
text: "Our support team answers within a few hours, every business day.",
ctaLabel: "Contact support",
ctaLink: "/contact-us/",
hours: "Office hours — **Mon 9AM → Fri 5PM** · Dubai (GST)",
} as HelpTexts,
faqGeneral: [
{
q: "How do I become funded by TigerFunded?",
a: "To become funded by TigerFunded, you need to successfully complete our evaluation process. This involves demonstrating your trading skills and adhering to our risk management rules. Once you pass the evaluation, you will receive funding to trade with simulated capital provided by TigerFunded.",
},
{
q: "How many accounts can I trade?",
a: "You can trade up to **20 demo accounts** with TigerFunded. You can also merge all accounts when funded, if you want.",
},
{
q: "Can I have a free retry?",
a: "After your first simulated payout, if things don't go as planned, no worries! We offer a **free reset** that lets you restart the demo challenge phase at no cost. It's a fresh opportunity to showcase your skills and get back to earning simulated payouts — without any added expenses.",
},
{
q: "Keep 20% of your challenge profits once funded?",
a: "TigerFunded offers an exclusive **20% virtual profit bonus** to all traders who successfully complete our Evaluation and become funded demo traders. Starting from your second simulated payout, you will receive 20% of the virtual profits earned during the challenge, along with your refundable fee.",
},
{
q: "Do we have a scaling plan?",
a: "Yes, we have a scaling plan. With our scaling plan, you can grow your demo account to a maximum of **$2,000,000** in simulated funds. In order to scale your capital and grow, you must achieve a **12% profit within a 3-month period**. If you accomplish this, we will increase your simulated account size by **15% of your initial balance**.\nFor traders looking to accelerate their growth even further, TigerFunded offers a premium upgrade designed to unlock significantly higher capital expansion:\n[ex Scaling Boost]\nThis upgrade keeps the exact same scaling conditions — you still only need to achieve 12% profit within 3 months. However, instead of the standard increase, your account will grow by **40% of your initial balance** after each successful scaling milestone.\nIn addition, this option **removes the capital ceiling**, allowing you to scale your account without any maximum limit. This means faster progression, larger capital jumps, and the ability to reach significantly higher account sizes in fewer scaling cycles. It's the ideal option for traders who want to maximize every profitable period and compound their growth at a much higher pace.\nIf your goal is to scale quickly and make the most out of your performance, this upgrade gives you a clear advantage while keeping the same simple and achievable conditions.\n[/ex]",
},
{
q: "Is there a breach for inactivity?",
a: "No, there is no breach for inactivity. You can take breaks as needed without worrying about your demo account being flagged for inactivity.",
},
{
q: "Are there any countries we cannot trade from?",
a: "Yes — North Korea, Cuba, Syria, and Yemen.",
},
{
q: "When does the evaluation start?",
a: "The Evaluation starts as soon as you complete your registration and payment process. You will receive your demo account details and can begin trading immediately to start your evaluation period.",
},
{
q: "Offer conditions",
a: "**The biggest TigerFunded promotion ever.** At TigerFunded, we don't believe in average. Our community is built around traders who think bigger, act faster, and never settle for just one shot at success.\nOur promotions aren't just discounts — they're game changers. We've redefined what \"Buy One Get One\" means in prop trading.\n### Unleash multiple funded accounts instantly\nWhen TigerFunded launches a promotion, you're not just getting one account. You unlock instant access to multiple funded opportunities without waiting for payouts or milestones.\nOur signature **Buy 1 Get 3 instantly** deal has become a legend in the industry. While others make you wait weeks or months for extra accounts, we deliver them from day one.\nThree accounts. One purchase. Zero delays. No gimmicks. No hidden timers. Just pure trading power.\n### Exclusive, limited, unmatched\nEach TigerFunded promotion is limited in time and exclusive to active traders. Once a campaign ends, it's gone for good — no extensions, no reactivations. Every offer is designed to reward serious traders who take action fast. If you see a TigerFunded BOGO live, don't wait, it probably won't come back.\nWe update our promotions regularly to keep them fresh and rewarding. Whether it's a massive discount or an instant multi-account deal, one thing is certain: no one in the industry matches TigerFunded's scale, transparency, and generosity. Remember — once a TigerFunded offer ends, it's gone forever. Stay connected to our official channels to catch the next big opportunity.\n### Promotion rules\nUnless stated otherwise, these rules apply only to accounts obtained through promotional or bonus offers.\n- Standard TigerFunded trading rules apply.\n- Trailing drawdown applies by default.\n- Consistency rule — no single trading day may represent more than 15% of total profit.\n[ex Example]\nYou buy a 2-Step $100K account with a BOGO offer. Purchased 100K account: only standard rules apply. BOGO free account(s): additional promotion rules apply — trailing drawdown and 15% consistency.\n[/ex]\nBy following these rules, you're set up to learn, perform, and succeed safely while enjoying the full benefits of your bonus accounts.\n### Why these rules exist\nOur rules are built to empower traders to reach their full potential. They're inspired by the same principles applied by the world's most successful professional traders in top trading floors and institutions worldwide. This structure creates the discipline and consistency that drive real success, helping you trade smarter, grow faster, and achieve lasting results.\nEvery condition we set exists to ensure a fair, transparent, and truly rewarding environment where your skills and dedication are the only things that matter. This is where serious traders evolve into professionals — and where your journey to mastery truly begins.",
},
{
q: "Do I have to pay for my loss?",
a: "No, you do not have to pay for your losses. TigerFunded absorbs the losses incurred in your funded demo account.",
},
{
q: "Can I leave profits in my account?",
a: "Yes, you can leave simulated profits in your demo account. Leaving simulated profits can help you grow your account balance and demonstrate consistent performance, which may qualify you for our scaling plan and increased capital allocations.",
},
{
q: "Is the evaluation fee refundable?",
a: "Yes, the Evaluation Fee is refundable after your second simulated payout. This is our way of acknowledging your successful trading and commitment.",
},
{
q: "What is the maximum capital I am allowed?",
a: "The maximum simulated capital you are allowed is **4 million USD**.",
},
{
q: "What happens if I violate a rule or the trading parameters?",
a: "If you violate a rule or the trading parameters, it may result in a breach of your demo account terms. Depending on the severity of the breach, your demo account could be suspended or terminated. It is crucial to understand and adhere to our trading rules to maintain your funded demo status.",
},
{
q: "How to become an affiliate?",
a: "To become an Affiliate, you can sign up through our affiliate program page on the website. As an affiliate, you can earn commissions by referring new traders to TigerFunded. We provide marketing materials and support to help you succeed in promoting our simulated trading services.",
},
] as QA[],
faqPlatform: [
{
q: "What platform is available?",
a: "At TigerFunded, we utilize **Match-Trader**, a platform designed to meet the needs of modern traders. Match-Trader, based on TradingView, represents the future of trading platforms with its advanced charting tools, user-friendly interface, and seamless integration with various financial markets. All trading on this platform is conducted in a simulated environment using demo accounts.",
},
{
q: "What are your spreads and commissions?",
a: "At TigerFunded, we do not charge any commissions on trades. Our goal is to provide a cost-effective simulated trading environment for our traders. Our spreads are competitive and vary depending on the financial instrument being traded, ensuring that you can maximize your simulated profits without worrying about additional fees.",
},
{
q: "What leverage do we offer ?",
a: "The account leverage is **1:100 max** for all evaluations and funded accounts .\n| Asset class | Max leverage |\n| Forex | 1:100 |\n| Indices | 1:30 |\n| Commodities | 1:30 |\n| Crypto | 1:2 |\n~ Leverage for Instant Pro is 1:50.",
},
{
q: "When is the daily max limit reset time?",
a: "The max daily limit resets at **8 PM EST**.",
},
] as QA[],
faqRules: [
{
q: "What is the target?",
a: "The profit target is the trading objective. If the profit target is reached after all positions are closed, you are offered a funded account.\n| Challenge | Target |\n| 1-Step Challenge | Reach 10% profit target without violating any rules |\n| 2-Step — Phase 1 | Reach 8% profit target without violating any rules |\n| 2-Step — Phase 2 | Reach 5% profit target without violating any rules |",
},
{
q: "What is the max daily loss?",
a: "Max daily loss is based on a percentage of the initial account balance. It is calculated as follows: at **8 PM EST**, the system takes the highest value between the account balance and account equity, then subtracts a fixed **5% of the initial balance** to determine the new daily loss limit.\n[ex Examples]\n- If a $100K account has an equity of $102K at 5 PM EST, the next day's loss limit is $97K ($102K − $5K).\n- If the equity is $98K but the balance is still $100K, the loss limit remains at $95K.\n- If there are no open trades at 5 PM, the drawdown is calculated based on the closed balance.\n[/ex]",
},
{
q: "What is maximum total loss?",
a: "The maximum total loss is the overall amount a trader can lose on their account. For 2-Step challenges, the max total loss allowed is **12%**.",
},
{
q: "Can I hold overnight & trade over the weekend?",
a: "Yes, there are no restrictions on holding trades overnight or over the weekend.",
},
{
q: "Are EAs & trade copiers allowed?",
a: "You are allowed to use EAs (Expert Advisors) and trade copiers, but EAs are not permitted during the funded stage.",
},
{
q: "Is news trading allowed?",
a: "Yes, news trading is allowed.",
},
{
q: "Position management rules",
a: "### Hedging guideline\n**Description.** This rule applies exclusively to funded accounts. To maintain disciplined and consistent trading, opening opposing positions on the same asset within a single account or across multiple accounts is not permitted. This principle follows the standards used by professional traders worldwide and helps maintain clear directional strategies while keeping control over trade exposure.\n**Definition.** Opening both a buy and a sell position on the same asset at the same time, regardless of lot size.\n[ex Example]\nA trader opens a buy and sell position on the same asset simultaneously. This approach falls outside the approved trading framework and does not comply with the firm's rules. The purpose of this guideline is to help traders refine their strategy, focus on clear setups, and strengthen their long-term consistency.\n[/ex]\nThis rule forms part of the professional foundation that encourages precise, transparent, and sustainable trading practices.\n### Layering guideline\n**Description.** This rule applies exclusively to funded accounts. To promote balanced exposure and controlled trading, traders must limit the number of open positions on the same asset. This prevents over-concentration and supports better focus on trade quality and execution, aligning with the standards of top-level trading environments.\n**Definition.** Having three or more open positions at the same time on a single asset.\n[ex Example]\nA trader opens three positions simultaneously on the same asset. This setup falls outside the approved framework and is not compliant with the firm's rules. It serves as guidance to help traders manage exposure responsibly and strengthen long-term trading discipline.\n[/ex]\nThis rule contributes to maintaining a fair, transparent, and professional trading ecosystem where skill and strategy lead to consistent results.",
},
{
q: "Margin trading",
a: "Opening trades that use the full available margin or leverage in a single position or set of correlated trades is not allowed.\n[ex Example]\nIf you use 100% margin on one XAUUSD trade with 0 buffer left → violation. Proper margin use should leave at least **20–30% free margin** after trade placement.\n[/ex]",
},
{
q: "Trade duration guidelines",
a: "This rule applies exclusively to funded accounts. Every trade must remain open for at least **2 minutes**. It follows the same disciplined approach used by professional traders in major trading institutions worldwide and ensures that each position has enough time to capture genuine market movement.\nKeeping trades open for the required duration demonstrates patience, precision, and control — qualities that define successful and consistent traders. Trades closed before the 2-minute mark fall outside the approved trading framework and therefore do not comply with the firm's rules. This policy helps maintain a fair, transparent, and professional trading environment for everyone.\n[ex Example]\nA trader opens a BUY position on XAUUSD at 10:00:00. If the trade remains open until 10:02:00 or later, it fully complies with the duration rule and allows the strategy to perform as intended. Closing the trade earlier would fall outside the rule, which helps traders understand and refine their approach for better long-term results.\n[/ex]\nThis rule forms part of the foundation that separates disciplined traders from impulsive ones, creating a trusted ecosystem where strategy, skill, and patience lead to real, lasting success.",
},
{
q: "Is max drawdown trailing or static?",
a: "The drawdown is **static** for 1-step, 2-step and 3-step challenges, and is **trailing** for instant challenges.",
},
{
q: "Is copy trading allowed?",
a: "Yes, traders can manage multiple accounts and use copy trading, under these conditions:\n- Copy trading between TigerFunded and external accounts is allowed.\n- Copy trading between different platforms.",
},
{
q: "Is there a minimum trading days requirement?",
a: "Yes — and it's set low on purpose.\n### During evaluation — 1, 2 and 3 Step\n**3 trading days per phase.** Any day you close a trade counts, whether it's a win or a loss. There's **no time limit**, so you can spread those days however you like.\n### Once funded after an evaluation\n**5 qualifying trading days before each payout.** A day qualifies when it closes with at least **0.5% profit**.\n### On Instant Funding\n**5 qualifying trading days before each payout**, with at least **1% profit on each qualifying day**.\nIn both cases, the days **do not need to be consecutive**, and one strong day counts as one qualifying day just like any other.\n### What if a day doesn't qualify?\nIt **does not breach your account**. A day that doesn't qualify simply doesn't count toward the total — you keep trading as normal and pick it up on the next one.",
},
{
q: "Is there a time limit for trading?",
a: "No, the trading period is unlimited. You can trade without any time constraints.",
},
{
q: "Special 600K challenge terms",
a: "This challenge operates under a **trailing drawdown** model, meaning the drawdown limit adjusts based on the highest account equity achieved during the challenge. Traders must ensure their current equity does not fall below the dynamically adjusted drawdown level.",
},
{
q: "Consistency rules",
a: "The consistency rule applies only to our Instant Funding programs. Please review the details below carefully before you begin trading.\n### Instant Funding Pro model\nThis model uses a strict consistency threshold of **15%**. That means no single trading day should account for 15% or more of the total profit in a given payout period. If a day's profit equals or surpasses 15% of the total profits, you cannot request a payout until that day's contribution is below the 15% threshold.\n**What if I break the 15% limit in one day?** Your account remains active and safe. However, to become eligible for a payout, you must keep trading and increase your total profits until your most profitable day represents less than 15% of the cumulative profit.\n[ex Example]\nIf you make $2,250 on your top trading day, you'll need to reach $15,000 in total profits during that payout period to be allowed to withdraw.\n[/ex]",
},
{
q: "Instant Elite model",
a: "**Trade your way, get paid your way.** No consistency rule. No minimum trading days. Made for traders whose edge shows up in a few big sessions, not a smooth curve.\nNo profit target. No time limit. Funded from your first trade.\n| Available sizes |\n| $10,000 · $25,000 · $50,000 · $100,000 · $200,000 |\n### Can one day be my entire profit?\nYes. **No consistency rule, ever.** One great session and it's all withdrawable — no cap, no waiting on other days.\n### Minimum trading days?\n**None.** Withdrawals unlock the moment your equity hits **105% of your starting balance** — in one day or fifty, your call.\n### Your daily allowance grows with you\n**2.5% of your equity at the start of the day**, floating P&L included — so the better you do, the more room you get.\n[ex Example · $100K]\nStart the day at $110,000 equity → **$2,750 allowance**, not $2,500. Set daily at **5:00 PM New York**.\n[/ex]\n### Maximum loss\n**5% trailing** on your highest-ever equity. It only moves up, never down.\n[ex Example · $100K]\nFloor starts at $95,000 → equity hits $110,000 → floor rises to **$104,500** and stays there.\n[/ex]\n### Withdraw on your terms\nAbove 105% equity, everything is yours on demand. A **5% buffer** stays on the account while active.\n| Account size | Unlocks at | Buffer |\n| $10,000 | $10,500 | $500 |\n| $25,000 | $26,250 | $1,250 |\n| $50,000 | $52,500 | $2,500 |\n| $100,000 | $105,000 | $5,000 |\n| $200,000 | $210,000 | $10,000 |\nFirst payout capped at **3% of balance** — the rest simply carries forward, nothing lost. **Uncapped from your second payout.**\n[ex Example · $100K]\nEquity at $112,000 → **$3,000 paid now**, $9,000 stays on the account for next time.\n[/ex]\n### At a glance\n| Rule | Instant Elite |\n| Consistency rule | None |\n| Minimum trading days | None |\n| Profit target / time limit | None |\n| Daily loss | 2.5% of start-of-day equity |\n| Maximum loss | 5% trailing |\n| Withdrawal buffer | 5% |\n| First withdrawal cap | 3%, then uncapped |\n| Risk per trade idea | 1.5% |\n| Profit split | 80% (100% add-on) |",
},
{
q: "Is there a limit on margin usage?",
a: "Your used margin must stay below **80% of your account equity at all times**.\nThis leaves you with a working buffer, helping ensure that normal drawdown on open positions does not push the account into a margin call. In practice, staying within your account's position limits keeps you well below this threshold.\n### What happens if the limit is exceeded?\n**First occurrence** — Your account remains active, but your profit split is reduced to **40%**.\n**Second occurrence** — The account is **closed**.",
},
] as QA[],
faqRewards: [
{
q: "How do payouts work?",
a: "A trader is eligible for a payout **14 days** from their first trade on the funded account, and then again becomes eligible **7 days** after the first trade after withdrawing.\nTo be eligible for a payout on funded accounts, your account balance must be above the initial account balance and no violations recorded. All of the trader's positions must be flat (no open trades and no open orders) to receive a payout.\nThe payouts are processed within **1–2 business days**. All funded traders can request payouts through their dashboard. Withdrawals are made by bank transfer (crypto can be arranged if requested).\n~ Minimum funded account withdrawal: $100 · Minimum affiliate withdrawal: $100.",
},
{
q: "What is the profit split?",
a: "The profit split our traders receive is **95%**.",
},
{
q: "Do you have a payout guarantee?",
a: "Yes! We have a Payout Guarantee — **get paid in 48 hours or we pay 95% reward split**.\nOur business hours begin at 9 AM Monday and close at 5 PM Friday (Dubai time). Payouts are only processed on business days, so the 48-hour Payout Guarantee timer is paused on weekends.\nPlease keep in mind that the actual arrival of the profit share at the trader's end will depend on the processing times of your bank or payment provider.",
},
] as QA[],
footText:
"© 2026 **TigerFunded** — simulated environment · demo accounts only",
}
/* Liste unique par défaut : chaque question porte sa catégorie. */
const D_FAQS: QAC[] = [
...D.faqGeneral.map((x) => ({ ...x, cat: "general" as CatKey })),
...D.faqPlatform.map((x) => ({ ...x, cat: "platform" as CatKey })),
...D.faqRules.map((x) => ({ ...x, cat: "rules" as CatKey })),
...D.faqRewards.map((x) => ({ ...x, cat: "rewards" as CatKey })),
]
// ------------------------------------------------------------------ CSS ---
// CSS d'origine scopé sous .tg-faq ; keyframes prefixées ; variante
// statique .tg-static (équivalent html.static) ; gradient d'icône en uid.
const css = `
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
.tg-faq{
--dk0:#050B1E;
--navy:#0A1B4D;
--blue:#2076F3;
--blue2:#2E7DFF;
--wht:#F4F8FF;
--ink:#16275B;
--mut:#5E6E92;
--soft:#EBF2FF;
--line:rgba(14,30,70,.08);
--green:#2BD587;
--ease:cubic-bezier(.16,1,.3,1);
}
.tg-faq,.tg-faq *{margin:0;padding:0;box-sizing:border-box}
.tg-faq{
font-family:'Plus Jakarta Sans',system-ui,sans-serif;
background:#F8FBFF;color:var(--ink);
-webkit-font-smoothing:antialiased;font-size:16px;line-height:1.6;
}
.tg-faq ::selection{background:var(--blue);color:#fff}
.tg-faq section{overflow-x:clip}
.tg-faq .wrap{width:min(1200px,calc(100% - 48px));margin-inline:auto;position:relative;z-index:2}
/* ---------- entrées ---------- */
.tg-faq .tf-anim{opacity:0;transform:translateY(26px);transition:opacity .9s var(--ease),transform .9s var(--ease)}
.tg-faq .tf-anim.in{opacity:1;transform:none}
.tg-faq .tf-d1{transition-delay:.1s}.tg-faq .tf-d2{transition-delay:.2s}.tg-faq .tf-d3{transition-delay:.3s}.tg-faq .tf-d4{transition-delay:.4s}
.tg-faq.tg-static .tf-anim{opacity:1;transform:none;transition:none}
/* ════════════════ HERO (sombre) ════════════════ */
.tg-faq .hero{
position:relative;color:var(--wht);
background:
radial-gradient(820px 480px at 50% -12%,rgba(32,118,243,.30),transparent 62%),
radial-gradient(620px 420px at 88% 70%,rgba(46,125,255,.10),transparent 60%),
linear-gradient(158deg,#0A1850 0%,#071236 52%,#050B1E 100%);
padding:104px 0 150px;
text-align:center;
}
.tg-faq .hero::before{
content:"";position:absolute;inset:0;
background-image:
linear-gradient(rgba(120,170,255,.05) 1px,transparent 1px),
linear-gradient(90deg,rgba(120,170,255,.05) 1px,transparent 1px);
background-size:64px 64px;
-webkit-mask-image:radial-gradient(78% 75% at 50% 30%,#000 30%,transparent 100%);
mask-image:radial-gradient(78% 75% at 50% 30%,#000 30%,transparent 100%);
}
.tg-faq .hero-stars{position:absolute;inset:0;pointer-events:none;
background-image:
radial-gradient(1.4px 1.4px at 12% 24%,rgba(190,215,255,.7),transparent 60%),
radial-gradient(1.2px 1.2px at 28% 66%,rgba(190,215,255,.5),transparent 60%),
radial-gradient(1.6px 1.6px at 46% 12%,rgba(190,215,255,.8),transparent 60%),
radial-gradient(1.2px 1.2px at 64% 46%,rgba(190,215,255,.45),transparent 60%),
radial-gradient(1.5px 1.5px at 80% 20%,rgba(190,215,255,.7),transparent 60%),
radial-gradient(1.2px 1.2px at 92% 60%,rgba(190,215,255,.5),transparent 60%);
animation:tgfaq-twinkle 6s ease-in-out infinite alternate}
@keyframes tgfaq-twinkle{from{opacity:.55}to{opacity:1}}
.tg-faq .aurora{position:absolute;width:50vw;height:50vw;min-width:460px;min-height:460px;border-radius:50%;
pointer-events:none;mix-blend-mode:screen}
.tg-faq .aurora.a1{top:-22vw;left:-14vw;background:radial-gradient(closest-side,rgba(46,125,255,.16),transparent 70%);
animation:tgfaq-auroraA 26s ease-in-out infinite alternate}
.tg-faq .aurora.a2{bottom:-24vw;right:-10vw;background:radial-gradient(closest-side,rgba(110,177,255,.11),transparent 70%);
animation:tgfaq-auroraB 32s ease-in-out infinite alternate}
@keyframes tgfaq-auroraA{from{transform:translate(0,0)}to{transform:translate(6vw,4vw) scale(1.15)}}
@keyframes tgfaq-auroraB{from{transform:translate(0,0)}to{transform:translate(-5vw,-4vw) scale(.92)}}
.tg-faq .eyebrow{
display:inline-flex;align-items:center;gap:14px;
font-size:13px;font-weight:700;letter-spacing:.26em;text-transform:uppercase;color:#7FB0F7;
}
.tg-faq .eyebrow::before,.tg-faq .eyebrow::after{content:"";width:34px;height:1px;background:currentColor;opacity:.45}
.tg-faq .hero h1{
margin-top:20px;
font-size:clamp(40px,5.6vw,76px);font-weight:800;line-height:1.04;
letter-spacing:-.025em;text-transform:uppercase;
}
.tg-faq .hero h1 em{
font-style:normal;
background:linear-gradient(100deg,#5EA1FF 10%,#A9CDFF 90%);
-webkit-background-clip:text;background-clip:text;color:transparent;
}
.tg-faq .hero-sub{margin:20px auto 0;max-width:540px;font-size:17.5px;line-height:1.65;color:rgba(196,214,246,.78);font-weight:500}
/* recherche */
.tg-faq .search{
margin:40px auto 0;max-width:640px;
display:flex;align-items:center;gap:14px;
padding:8px 10px 8px 22px;border-radius:999px;
background:rgba(13,32,74,.62);border:1px solid rgba(125,175,255,.22);
backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
box-shadow:0 24px 60px rgba(2,8,30,.45);
transition:border-color .3s var(--ease),box-shadow .3s var(--ease);
text-align:left;
}
.tg-faq .search:focus-within{border-color:rgba(110,177,255,.55);box-shadow:0 24px 60px rgba(2,8,30,.5),0 0 0 4px rgba(46,125,255,.16)}
.tg-faq .search svg{width:19px;height:19px;flex:0 0 auto;stroke-width:2;fill:none;stroke-linecap:round}
.tg-faq .search input{
flex:1;min-width:0;background:none;border:0;outline:0;color:var(--wht);
font-family:inherit;font-size:15.5px;font-weight:500;padding:11px 0;
}
.tg-faq .search input::placeholder{color:rgba(150,185,240,.55)}
.tg-faq .search-count{
flex:0 0 auto;padding:8px 16px;border-radius:999px;
background:rgba(32,118,243,.16);border:1px solid rgba(110,177,255,.3);
font-size:12.5px;font-weight:700;color:#9CC8FF;white-space:nowrap;
font-variant-numeric:tabular-nums;
}
.tg-faq .hero-note{
display:inline-flex;align-items:flex-start;gap:10px;margin-top:26px;
max-width:640px;text-align:left;
font-size:12.5px;line-height:1.6;color:rgba(160,185,235,.62);font-weight:500;
}
.tg-faq .hero-note svg{width:15px;height:15px;flex:0 0 auto;margin-top:2px;stroke:#7FB0F7;stroke-width:1.8;fill:none;stroke-linecap:round;stroke-linejoin:round}
/* cartes catégories — à cheval dark→light */
.tg-faq .cats{
display:grid;grid-template-columns:repeat(4,1fr);gap:18px;
margin-top:54px;margin-bottom:-104px;position:relative;z-index:3;
}
.tg-faq .cat{
display:flex;align-items:center;gap:15px;
padding:21px 22px;border-radius:20px;text-decoration:none;
background:#FFFFFF;color:var(--navy);
box-shadow:0 24px 60px rgba(7,18,54,.35),inset 0 1px 0 rgba(255,255,255,.9);
border:1px solid rgba(14,30,70,.06);
transition:transform .45s var(--ease),box-shadow .45s var(--ease);
}
.tg-faq .cat:hover{transform:translateY(-5px);box-shadow:0 32px 70px rgba(7,18,54,.42),inset 0 1px 0 rgba(255,255,255,.9)}
.tg-faq .cat-ico{
width:46px;height:46px;flex:0 0 auto;border-radius:13px;display:grid;place-items:center;
background:linear-gradient(160deg,#3E8BFF 0%,#1B55D9 100%);
box-shadow:0 10px 22px rgba(20,80,220,.35),inset 0 1px 0 rgba(255,255,255,.35);
}
.tg-faq .cat-ico svg{width:23px;height:23px;stroke:#fff;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round}
.tg-faq .cat-body{flex:1;min-width:0}
.tg-faq .cat-body b{display:block;font-size:16.5px;font-weight:800;letter-spacing:-.01em}
.tg-faq .cat-body small{display:block;margin-top:2px;font-size:12px;font-weight:600;color:#7C8AAD}
.tg-faq .cat-arr{width:30px;height:30px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;
background:var(--soft);transition:background .3s var(--ease)}
.tg-faq .cat-arr svg{width:15px;height:15px;stroke:var(--blue);stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round;
transition:transform .3s var(--ease)}
.tg-faq .cat:hover .cat-arr{background:var(--blue)}
.tg-faq .cat:hover .cat-arr svg{stroke:#fff;transform:rotate(45deg)}
/* ════════════════ CORPS (clair) ════════════════ */
.tg-faq .body{
position:relative;
background:linear-gradient(180deg,#EDF3FC 0%,#F8FBFF 100%);
padding:170px 0 110px;
}
.tg-faq .body::before{
content:"";position:absolute;inset:0;
background-image:
linear-gradient(rgba(32,118,243,.055) 1px,transparent 1px),
linear-gradient(90deg,rgba(32,118,243,.055) 1px,transparent 1px);
background-size:56px 56px;
-webkit-mask-image:radial-gradient(90% 80% at 50% 12%,#000 40%,transparent 100%);
mask-image:radial-gradient(90% 80% at 50% 12%,#000 40%,transparent 100%);
}
.tg-faq .glow-bg{position:absolute;left:50%;top:-130px;transform:translateX(-50%);
width:900px;height:480px;pointer-events:none;
background:radial-gradient(50% 50% at 50% 50%,rgba(50,131,255,.12),transparent 70%)}
.tg-faq .layout{display:grid;grid-template-columns:272px 1fr;gap:30px;align-items:start}
.tg-faq .layout>*{min-width:0}
/* sidebar */
.tg-faq .side{position:sticky;top:26px;display:flex;flex-direction:column;gap:16px}
.tg-faq .sd-card{
background:#fff;border-radius:20px;padding:10px;
box-shadow:0 18px 50px rgba(10,27,77,.10);
border:1px solid rgba(14,30,70,.05);
}
.tg-faq .sd-head{padding:12px 14px 8px;font-size:11px;font-weight:800;letter-spacing:.2em;
text-transform:uppercase;color:#9AA8C9}
.tg-faq .sd-it{
position:relative;display:grid;grid-template-columns:30px 1fr auto;align-items:center;gap:10px;
padding:12px 14px;border-radius:13px;text-decoration:none;
font-size:15px;font-weight:700;color:var(--ink);
transition:background .25s var(--ease),color .25s var(--ease);
}
.tg-faq .sd-it:hover{background:#F2F7FF}
.tg-faq .sd-n{font-size:12px;font-weight:800;color:#9FB0D6;font-variant-numeric:tabular-nums;transition:color .25s}
.tg-faq .sd-c{font-size:12px;font-weight:700;color:#9FB0D6;font-variant-numeric:tabular-nums}
.tg-faq .sd-it::before{
content:"";position:absolute;left:0;top:22%;bottom:22%;width:3px;border-radius:99px;
background:var(--blue);opacity:0;transform:scaleY(.4);
transition:opacity .25s var(--ease),transform .25s var(--ease);
}
.tg-faq .sd-it.act{background:var(--soft);color:var(--blue)}
.tg-faq .sd-it.act .sd-n{color:var(--blue)}
.tg-faq .sd-it.act::before{opacity:1;transform:scaleY(1)}
.tg-faq .sd-help{
background:linear-gradient(165deg,#0E2566 0%,#081232 100%);
border-radius:20px;padding:22px;color:var(--wht);
box-shadow:0 18px 50px rgba(10,27,77,.18);
position:relative;overflow:hidden;
}
.tg-faq .sd-help::before{content:"";position:absolute;inset:0;
background:radial-gradient(160px 120px at 85% 0%,rgba(46,125,255,.35),transparent 70%)}
.tg-faq .sd-help b{position:relative;display:block;font-size:16.5px;font-weight:800;letter-spacing:-.01em}
.tg-faq .sd-help p{position:relative;margin-top:7px;font-size:13px;line-height:1.6;color:rgba(196,214,246,.75);font-weight:500}
.tg-faq .sd-btn{
position:relative;display:inline-flex;align-items:center;gap:9px;margin-top:16px;
padding:11px 18px;border-radius:999px;text-decoration:none;
background:linear-gradient(160deg,#3E8BFF,#1B55D9);color:#fff;
font-size:13.5px;font-weight:700;
box-shadow:0 10px 24px rgba(20,80,220,.4),inset 0 1px 0 rgba(255,255,255,.3);
transition:transform .3s var(--ease),box-shadow .3s var(--ease);
}
.tg-faq .sd-btn:hover{transform:translateY(-2px);box-shadow:0 14px 30px rgba(20,80,220,.5),inset 0 1px 0 rgba(255,255,255,.3)}
.tg-faq .sd-btn svg{width:14px;height:14px;stroke:#fff;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}
.tg-faq .sd-hours{position:relative;margin-top:14px;font-size:11.5px;font-weight:600;letter-spacing:.04em;color:rgba(160,185,235,.6)}
.tg-faq .sd-hours b{display:inline;font-size:11.5px;color:#9CC8FF;font-weight:700}
/* sections + rows */
.tg-faq .sec{margin-bottom:46px}
.tg-faq .sec:last-of-type{margin-bottom:0}
.tg-faq .sec-h{display:flex;align-items:center;gap:16px;margin-bottom:18px}
.tg-faq .sec-badge{
width:52px;height:52px;border-radius:15px;display:grid;place-items:center;flex:0 0 auto;
background:linear-gradient(160deg,#3E8BFF 0%,#1B55D9 100%);
box-shadow:0 12px 26px rgba(20,80,220,.32),inset 0 1px 0 rgba(255,255,255,.35);
}
.tg-faq .sec-badge svg{width:25px;height:25px;stroke:#fff;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round}
.tg-faq .h2-s{font-size:clamp(24px,2.6vw,32px);font-weight:800;letter-spacing:-.02em;color:var(--navy);text-transform:uppercase}
.tg-faq .sec-c{display:block;margin-top:2px;font-size:12.5px;font-weight:700;letter-spacing:.06em;color:#8B99BD;
font-variant-numeric:tabular-nums}
.tg-faq .qcard{
background:#fff;border-radius:22px;
box-shadow:0 22px 60px rgba(10,27,77,.10);
border:1px solid rgba(14,30,70,.05);
overflow:hidden;
}
.tg-faq .row{border-top:1px solid var(--line)}
.tg-faq .row:first-child{border-top:0}
.tg-faq .row.hide{display:none}
.tg-faq .row-q{
display:grid;grid-template-columns:40px 1fr 34px;align-items:center;gap:12px;
width:100%;text-align:left;background:none;border:0;cursor:pointer;
padding:21px 24px;color:var(--ink);font-family:inherit;
transition:background .25s var(--ease);
}
.tg-faq .row-q:hover{background:#F4F8FF}
.tg-faq .row-n{font-size:13px;font-weight:800;color:#A9B7D9;font-variant-numeric:tabular-nums;
transition:color .25s var(--ease)}
.tg-faq .row-t{font-size:16.5px;font-weight:700;letter-spacing:-.01em;line-height:1.4}
.tg-faq .row-x{
position:relative;width:32px;height:32px;border-radius:50%;justify-self:end;
background:var(--soft);
transition:background .3s var(--ease),transform .45s var(--ease);
}
.tg-faq .row-x i{position:absolute;background:var(--blue);border-radius:2px;transition:background .3s var(--ease)}
.tg-faq .row-x i:first-child{left:9px;right:9px;top:15px;height:2px}
.tg-faq .row-x i:last-child{top:9px;bottom:9px;left:15px;width:2px}
.tg-faq .row.open .row-x{background:var(--blue);transform:rotate(135deg)}
.tg-faq .row.open .row-x i{background:#fff}
.tg-faq .row.open .row-n{color:var(--blue)}
.tg-faq .row-a{max-height:0;overflow:hidden;transition:max-height .5s var(--ease)}
.tg-faq .row-a-in{
padding:2px 28px 30px 76px;
color:var(--mut);font-size:15.5px;font-weight:500;
}
.tg-faq .row-a-in>p{margin-top:13px;max-width:720px}
.tg-faq .row-a-in b{color:var(--navy);font-weight:700}
.tg-faq .row-a-in ul{margin:13px 0 0;list-style:none}
.tg-faq .row-a-in li{padding-left:20px;position:relative;margin-top:8px;max-width:700px}
.tg-faq .row-a-in li::before{content:"";position:absolute;left:2px;top:.62em;width:7px;height:7px;border-radius:50%;
background:linear-gradient(160deg,#3E8BFF,#1B55D9)}
.tg-faq .sub-h{
font-size:12px !important;font-weight:800;letter-spacing:.18em;text-transform:uppercase;
color:var(--blue) !important;margin-top:24px !important;
}
.tg-faq .note{font-size:13px !important;color:#8B99BD !important;font-weight:600}
.tg-faq .ex{
margin-top:16px;border-radius:16px;padding:18px 22px;max-width:720px;
background:var(--soft);border:1px solid #DCE7FA;
}
.tg-faq .ex-tag{
display:inline-block;padding:4px 11px;border-radius:999px;
background:#fff;border:1px solid #D5E2F8;
font-size:10.5px;font-weight:800;letter-spacing:.16em;text-transform:uppercase;color:var(--blue);
}
.tg-faq .ex p{margin-top:10px !important}
.tg-faq .ex ul{margin-top:6px !important}
.tg-faq .tbl{
width:100%;max-width:540px;margin-top:16px;border-collapse:separate;border-spacing:0;
font-size:14.5px;border:1px solid #E0E9F8;border-radius:14px;overflow:hidden;
}
.tg-faq .tbl th{
background:#F0F5FE;text-align:left;padding:11px 16px;
font-size:11px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;color:#7C8AAD;
}
.tg-faq .tbl td{padding:12px 16px;border-top:1px solid #E8EFFA;color:var(--navy);font-weight:600;
font-variant-numeric:tabular-nums}
.tg-faq .tbl td:last-child{color:var(--blue);font-weight:800}
/* aucun résultat */
.tg-faq .noresult{
background:#fff;border-radius:22px;padding:46px 30px;text-align:center;
box-shadow:0 22px 60px rgba(10,27,77,.10);border:1px solid rgba(14,30,70,.05);
color:var(--mut);font-weight:600;
}
.tg-faq .noresult b{color:var(--navy)}
/* bandeau pied */
.tg-faq .foot{
margin-top:64px;border-radius:999px;padding:16px 30px;
background:linear-gradient(160deg,#0E2566,#081232);color:rgba(196,214,246,.7);
display:flex;justify-content:center;gap:10px;flex-wrap:wrap;
font-size:12.5px;font-weight:600;letter-spacing:.03em;text-align:center;
box-shadow:0 18px 50px rgba(10,27,77,.18);
}
.tg-faq .foot b{color:#9CC8FF;font-weight:700}
/* ---------- responsive ---------- */
@media (max-width:1020px){
.tg-faq .cats{grid-template-columns:1fr 1fr}
.tg-faq .cats{margin-bottom:-84px}
.tg-faq .body{padding-top:150px}
.tg-faq .layout{grid-template-columns:1fr}
.tg-faq .side{position:static;flex-direction:row;flex-wrap:wrap}
.tg-faq .sd-card{flex:1 1 100%;min-width:0;display:flex;gap:6px;overflow-x:auto;padding:8px}
.tg-faq .sd-head{display:none}
.tg-faq .sd-it{flex:0 0 auto;grid-template-columns:auto auto;padding:10px 16px}
.tg-faq .sd-n{display:none}
.tg-faq .sd-it::before{display:none}
.tg-faq .sd-help{display:none}
}
@media (max-width:760px){
.tg-faq .hero{padding:84px 0 120px}
.tg-faq .cats{grid-template-columns:1fr;gap:12px;margin-bottom:-260px}
.tg-faq .body{padding-top:300px}
.tg-faq .search{margin-top:30px}
.tg-faq .row-q{grid-template-columns:30px 1fr 30px;padding:18px 16px}
.tg-faq .row-n{font-size:11.5px}
.tg-faq .row-t{font-size:15px}
.tg-faq .row-x{width:28px;height:28px}
.tg-faq .row-x i:first-child{left:8px;right:8px;top:13px}
.tg-faq .row-x i:last-child{top:8px;bottom:8px;left:13px}
.tg-faq .row-a-in{padding:0 16px 26px 16px}
.tg-faq .foot{border-radius:22px}
}
@media (prefers-reduced-motion:reduce){
.tg-faq *,.tg-faq *::before,.tg-faq *::after{animation-duration:.01ms !important;transition-duration:.01ms !important}
.tg-faq .tf-anim{opacity:1;transform:none}
}
.tg-faq.tg-static .hero-stars,.tg-faq.tg-static .aurora{animation:none}
`
// ------------------------------------------------- parseur micro-markup ---
type Block =
| { t: "p"; text: string }
| { t: "subh"; text: string }
| { t: "note"; text: string }
| { t: "ul"; items: string[] }
| { t: "tbl"; head: string[]; rows: string[][] }
| { t: "ex"; tag: string; blocks: Block[] }
const splitRow = (l: string) =>
l
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((c) => c.trim())
/** Parse la micro-syntaxe des réponses en blocs structurés. */
function parseBlocks(src: string): Block[] {
const lines = String(src).replace(/\r/g, "").split("\n")
let i = 0
const parseInto = (stop: (l: string) => boolean): Block[] => {
const blocks: Block[] = []
while (i < lines.length) {
const l = lines[i].trim()
if (stop(l)) break
i++
if (!l) continue
if (l.startsWith("### ")) {
blocks.push({ t: "subh", text: l.slice(4) })
continue
}
if (l.startsWith("~ ")) {
blocks.push({ t: "note", text: l.slice(2) })
continue
}
if (l.startsWith("- ")) {
const items = [l.slice(2)]
while (i < lines.length && lines[i].trim().startsWith("- ")) {
items.push(lines[i].trim().slice(2))
i++
}
blocks.push({ t: "ul", items })
continue
}
if (l.startsWith("|")) {
const rows: string[][] = [splitRow(l)]
while (i < lines.length && lines[i].trim().startsWith("|")) {
rows.push(splitRow(lines[i].trim()))
i++
}
blocks.push({ t: "tbl", head: rows[0], rows: rows.slice(1) })
continue
}
const mEx = l.match(/^\[ex\s+(.+?)\]$/)
if (mEx) {
const inner = parseInto((x) => x === "[/ex]")
if (i < lines.length) i++ /* consomme [/ex] */
blocks.push({ t: "ex", tag: mEx[1], blocks: inner })
continue
}
blocks.push({ t: "p", text: l })
}
return blocks
}
return parseInto(() => false)
}
/** Rend les segments **gras** d'un texte. */
function renderBold(t: string): React.ReactNode {
const parts = String(t).split(/\*\*(.+?)\*\*/g)
if (parts.length === 1) return t
return parts.map((p, i) => (i % 2 ? <b key={i}>{p}</b> : p))
}
function renderBlocks(blocks: Block[]): React.ReactNode {
return blocks.map((b, i) => {
switch (b.t) {
case "subh":
return (
<p className="sub-h" key={i}>
{renderBold(b.text)}
</p>
)
case "note":
return (
<p className="note" key={i}>
{renderBold(b.text)}
</p>
)
case "ul":
return (
<ul key={i}>
{b.items.map((it, j) => (
<li key={j}>{renderBold(it)}</li>
))}
</ul>
)
case "tbl":
return (
<table className="tbl" key={i}>
<thead>
<tr>
{b.head.map((h, j) => (
<th key={j}>{renderBold(h)}</th>
))}
</tr>
</thead>
<tbody>
{b.rows.map((r, j) => (
<tr key={j}>
{r.map((c, k) => (
<td key={k}>{renderBold(c)}</td>
))}
</tr>
))}
</tbody>
</table>
)
case "ex":
return (
<div className="ex" key={i}>
<span className="ex-tag">{b.tag}</span>
{renderBlocks(b.blocks)}
</div>
)
default:
return <p key={i}>{renderBold(b.text)}</p>
}
})
}
/** Version texte brut (pour la recherche). */
function plainText(s: string): string {
return String(s)
.replace(/\[\/?ex[^\]]*\]/g, " ")
.replace(/^###\s+/gm, "")
.replace(/^~\s+/gm, "")
.replace(/^-\s+/gm, "")
.replace(/\*\*/g, "")
.replace(/\|/g, " ")
.toLowerCase()
}
const pad2 = (n: number) => String(n).padStart(2, "0")
// ----------------------------------------------------------------- svgs ---
function CatIconSvg({ icon }: { icon: CatIcon }) {
if (icon === "monitor")
return (
<svg viewBox="0 0 24 24">
<rect x="3" y="4.5" width="18" height="12.5" rx="2.5" />
<path d="M8 21h8M12 17v4M7 13l2.6-3 2.2 2.2L15 8.6l2 2.4" />
</svg>
)
if (icon === "shield")
return (
<svg viewBox="0 0 24 24">
<path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z" />
<path d="M9 12l2.2 2.2L15.5 9.8" />
</svg>
)
if (icon === "award")
return (
<svg viewBox="0 0 24 24">
<path d="M12 15a5 5 0 1 0 0-10 5 5 0 0 0 0 10z" />
<path d="M8.5 14.5L7 21l5-2.6L17 21l-1.5-6.5" />
</svg>
)
return (
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M9.2 9.4a2.9 2.9 0 1 1 4 2.7c-.9.4-1.2 1-1.2 1.9" />
<path d="M12 17h.01" />
</svg>
)
}
const ArrowSvg = () => (
<svg viewBox="0 0 24 24">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
)
// ------------------------------------------------------------------ row ---
function Row({
num,
item,
open,
hidden,
onToggle,
}: {
num: number
item: QA
open: boolean
hidden: boolean
onToggle: () => void
}) {
const aRef = useRef<HTMLDivElement>(null)
const blocks = useMemo(() => parseBlocks(item.a), [item.a])
return (
<div
className={"row" + (open ? " open" : "") + (hidden ? " hide" : "")}
>
<button className="row-q" aria-expanded={open} onClick={onToggle}>
<span className="row-n">{pad2(num)}</span>
<span className="row-t">{item.q}</span>
<span className="row-x" aria-hidden="true">
<i></i>
<i></i>
</span>
</button>
<div
className="row-a"
ref={aRef}
style={{
maxHeight: open ? (aRef.current?.scrollHeight ?? 2000) : 0,
}}
>
<div className="row-a-in">{renderBlocks(blocks)}</div>
</div>
</div>
)
}
// ------------------------------------------------------------ composant ---
export default function TigerFaq(props: Props) {
const heroM = { ...D.hero, ...props.hero }
const searchM = { ...D.search, ...props.search }
const sectionsM = { ...D.sections, ...props.sections }
const helpM = { ...D.help, ...props.help }
const footM = props.footText ?? D.footText
/* liste unique → répartie par catégorie */
const allFaqs: QAC[] = useMemo(() => {
if (!props.faqs || props.faqs.length === 0) {
return D_FAQS
}
const existingQuestions = new Set(
props.faqs.map((item) => item.q.trim().toLowerCase())
)
const newDefaultQuestions = D_FAQS.filter(
(item) => !existingQuestions.has(item.q.trim().toLowerCase())
)
return [...props.faqs, ...newDefaultQuestions]
}, [props.faqs])
const faqGeneralM = useMemo(
() => allFaqs.filter((x) => (x.cat || "general") === "general"),
[allFaqs]
)
const faqPlatformM = useMemo(
() => allFaqs.filter((x) => x.cat === "platform"),
[allFaqs]
)
const faqRulesM = useMemo(
() => allFaqs.filter((x) => x.cat === "rules"),
[allFaqs]
)
const faqRewardsM = useMemo(
() => allFaqs.filter((x) => x.cat === "rewards"),
[allFaqs]
)
const rawId = React.useId()
const uid = useMemo(() => rawId.replace(/[^a-zA-Z0-9_-]/g, ""), [rawId])
const isCanvas = RenderTarget.current() === RenderTarget.canvas
const reduced = useMemo(
() =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
[]
)
const isStatic = isCanvas || reduced
const cats = useMemo(
() => [
{
key: "general",
title: sectionsM.generalTitle,
icon: sectionsM.generalIcon,
items: faqGeneralM,
},
{
key: "platform",
title: sectionsM.platformTitle,
icon: sectionsM.platformIcon,
items: faqPlatformM,
},
{
key: "rules",
title: sectionsM.rulesTitle,
icon: sectionsM.rulesIcon,
items: faqRulesM,
},
{
key: "rewards",
title: sectionsM.rewardsTitle,
icon: sectionsM.rewardsIcon,
items: faqRewardsM,
},
],
[
sectionsM.generalTitle,
sectionsM.generalIcon,
sectionsM.platformTitle,
sectionsM.platformIcon,
sectionsM.rulesTitle,
sectionsM.rulesIcon,
sectionsM.rewardsTitle,
sectionsM.rewardsIcon,
faqGeneralM,
faqPlatformM,
faqRulesM,
faqRewardsM,
]
)
const TOTAL = cats.reduce((s, c) => s + c.items.length, 0)
/* index de recherche : [catIdx][itemIdx] -> texte cherchable */
const searchIndex = useMemo(
() =>
cats.map((c) =>
c.items.map((it) => it.q.toLowerCase() + " " + plainText(it.a))
),
[cats]
)
// ------------------------------------------------------------- état --
const [query, setQuery] = useState("")
const [openSet, setOpenSet] = useState<string[]>([])
const [activeSec, setActiveSec] = useState(cats[0]?.key ?? "general")
const v = query.trim().toLowerCase()
const hits = cats.map((c, ci) =>
c.items.map((_, ii) => !v || searchIndex[ci][ii].includes(v))
)
const visibleCounts = hits.map((h) => h.filter(Boolean).length)
const nVisible = visibleCounts.reduce((s, n) => s + n, 0)
const toggleRow = (key: string) =>
setOpenSet((o) =>
o.includes(key) ? o.filter((x) => x !== key) : [...o, key]
)
// ------------------------------------------------------------- refs --
const pageRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const secRefs = useRef<(HTMLElement | null)[]>([])
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
const scrollToSec = (ci: number) => (e: React.MouseEvent) => {
e.preventDefault()
secRefs.current[ci]?.scrollIntoView({
behavior: "smooth",
block: "start",
})
}
// --------------------------------------------------------- entrées --
useEffect(() => {
const root = pageRef.current
if (!root) return
const els = Array.from(root.querySelectorAll(".tf-anim"))
if (isStatic || typeof IntersectionObserver === "undefined") {
els.forEach((el) => el.classList.add("in"))
return
}
const io = new IntersectionObserver(
(es) =>
es.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add("in")
io.unobserve(e.target)
}
}),
{ threshold: 0, rootMargin: "0px 0px -36px 0px" }
)
els.forEach((el) => {
if (!el.classList.contains("in")) io.observe(el)
})
return () => io.disconnect()
}, [isStatic])
/* stagger des rows à l'arrivée de chaque qcard (une seule fois) */
useEffect(() => {
if (isStatic || typeof IntersectionObserver === "undefined") return
const cleanups: (() => void)[] = []
cardRefs.current.forEach((card) => {
if (!card || card.dataset.tgStaggered) return
card.dataset.tgStaggered = "1"
const rows = Array.from(card.querySelectorAll<HTMLElement>(".row"))
rows.forEach((r, i) => {
r.style.opacity = "0"
r.style.transform = "translateY(14px)"
r.style.transition =
"opacity .6s var(--ease) " +
i * 45 +
"ms, transform .6s var(--ease) " +
i * 45 +
"ms"
})
const io = new IntersectionObserver(
(es, obs) => {
if (!es[0].isIntersecting) return
obs.disconnect()
rows.forEach((r) => {
r.style.opacity = "1"
r.style.transform = "none"
})
const t = window.setTimeout(
() => {
rows.forEach((r) => {
r.style.transition = ""
r.style.transform = ""
r.style.opacity = ""
})
},
1600 + rows.length * 45
)
cleanups.push(() => window.clearTimeout(t))
},
{ threshold: 0, rootMargin: "0px 0px -60px 0px" }
)
io.observe(card)
cleanups.push(() => io.disconnect())
})
return () => cleanups.forEach((f) => f())
}, [isStatic])
// -------------------------------------------------------- scrollspy --
const spyRef = useRef<() => void>(() => {})
spyRef.current = () => {
if (typeof window === "undefined") return
const mark = window.innerHeight * 0.34
let cur: string | null = null
cats.forEach((c, ci) => {
const el = secRefs.current[ci]
if (!el || visibleCounts[ci] === 0) return
if (cur === null) cur = c.key
if (el.getBoundingClientRect().top <= mark) cur = c.key
})
if (cur && cur !== activeSec) setActiveSec(cur)
}
useEffect(() => {
if (isStatic || typeof window === "undefined") return
const onScroll = () => spyRef.current()
window.addEventListener("scroll", onScroll, { passive: true })
spyRef.current()
return () => window.removeEventListener("scroll", onScroll)
}, [isStatic])
/* re-spy quand le filtre change (des sections disparaissent) */
useEffect(() => {
spyRef.current()
}, [query])
// ------------------------------------------- placeholder typewriter --
useEffect(() => {
if (isStatic || !searchM.typewriter || typeof window === "undefined")
return
const hints = searchM.hints
.split("\n")
.map((h) => h.trim())
.filter(Boolean)
const input = inputRef.current
if (!input || !hints.length) return
let h = 0,
c = 0,
dir = 1,
wait = 0
const t = window.setInterval(() => {
if (document.activeElement === input || input.value) return
if (wait > 0) {
wait--
return
}
c += dir
input.placeholder = hints[h].slice(0, c) + (c % 2 ? "|" : "")
if (dir > 0 && c >= hints[h].length) {
dir = -1
wait = 22
input.placeholder = hints[h]
}
if (dir < 0 && c <= 0) {
dir = 1
h = (h + 1) % hints.length
wait = 5
}
}, 65)
return () => {
window.clearInterval(t)
if (inputRef.current)
inputRef.current.placeholder = searchM.placeholder
}
}, [isStatic, searchM.typewriter, searchM.hints, searchM.placeholder])
// ------------------------------------------------------------ rendu --
let runningNum = 0
const catOffsets = cats.map((c) => {
const o = runningNum
runningNum += c.items.length
return o
})
return (
<div
ref={pageRef}
className={"tg-faq" + (isStatic ? " tg-static" : "")}
style={{ width: "100%", ...props.style }}
>
<style dangerouslySetInnerHTML={{ __html: css }} />
<svg
width="0"
height="0"
style={{ position: "absolute" }}
aria-hidden="true"
>
<defs>
<linearGradient
id={`tfIco-${uid}`}
x1="0"
y1="0"
x2="1"
y2="1"
>
<stop offset="0" stopColor="#8FC6FF" />
<stop offset="1" stopColor="#2E7DFF" />
</linearGradient>
</defs>
</svg>
{/* ════════════════ HERO ════════════════ */}
<section className="hero">
<div className="hero-stars"></div>
<div className="aurora a1" aria-hidden="true"></div>
<div className="aurora a2" aria-hidden="true"></div>
<div className="wrap">
<span className="eyebrow tf-anim">{heroM.eyebrow}</span>
<h1 className="tf-anim tf-d1">
{heroM.titlePlain} <em>{heroM.titleAccent}</em>
</h1>
<p className="hero-sub tf-anim tf-d2">{heroM.subtitle}</p>
<div className="search tf-anim tf-d3">
<svg viewBox="0 0 24 24" stroke={`url(#tfIco-${uid})`}>
<circle cx="11" cy="11" r="7" />
<path d="M20 20l-3.5-3.5" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchM.placeholder}
autoComplete="off"
spellCheck={false}
aria-label={searchM.placeholder}
/>
<span className="search-count">
{v
? `${nVisible} / ${TOTAL}`
: `${TOTAL} ${searchM.entriesSuffix}`}
</span>
</div>
<div className="hero-note tf-anim tf-d4">
<svg viewBox="0 0 24 24">
<path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z" />
<path d="M12 8.5v4M12 15.8h.01" />
</svg>
<span>{heroM.note}</span>
</div>
<nav className="cats" aria-label="FAQ categories">
{cats.map((c, ci) => (
<a
key={c.key}
className={`cat tf-anim tf-d${ci + 1}`}
href={"#" + c.key}
onClick={scrollToSec(ci)}
>
<span className="cat-ico">
<CatIconSvg icon={c.icon} />
</span>
<span className="cat-body">
<b>{c.title}</b>
<small>{`${pad2(c.items.length)} ${sectionsM.questionsLabel}`}</small>
</span>
<span className="cat-arr">
<ArrowSvg />
</span>
</a>
))}
</nav>
</div>
</section>
{/* ════════════════ CORPS ════════════════ */}
<section className="body">
<div className="glow-bg" aria-hidden="true"></div>
<div className="wrap">
<div className="layout">
<aside className="side tf-anim">
<div className="sd-card">
<div className="sd-head">
{sectionsM.sidebarHead}
</div>
{cats.map((c, ci) => (
<a
key={c.key}
className={
"sd-it" +
(activeSec === c.key ? " act" : "")
}
href={"#" + c.key}
onClick={scrollToSec(ci)}
>
<span className="sd-n">
{pad2(ci + 1)}
</span>
<span>{c.title}</span>
<span className="sd-c">
{pad2(c.items.length)}
</span>
</a>
))}
</div>
<div className="sd-help">
<b>{helpM.title}</b>
<p>{helpM.text}</p>
<a
className="sd-btn"
href={helpM.ctaLink || "#"}
>
{helpM.ctaLabel}
<ArrowSvg />
</a>
<div className="sd-hours">
{renderBold(helpM.hours)}
</div>
</div>
</aside>
<main className="content">
{cats.map((c, ci) => (
<section
key={c.key}
className="sec"
ref={(el) => {
secRefs.current[ci] = el
}}
style={{
display: visibleCounts[ci]
? undefined
: "none",
}}
>
<header className="sec-h tf-anim">
<span className="sec-badge">
<CatIconSvg icon={c.icon} />
</span>
<div>
<h2 className="h2-s">{c.title}</h2>
<span className="sec-c">{`${pad2(ci + 1)} · ${pad2(c.items.length)} ${sectionsM.questionsLabel}`}</span>
</div>
</header>
<div
className="qcard tf-anim tf-d1"
ref={(el) => {
cardRefs.current[ci] = el
}}
>
{c.items.map((item, ii) => (
<Row
key={ii}
num={catOffsets[ci] + ii + 1}
item={item}
open={openSet.includes(
`${ci}-${ii}`
)}
hidden={!hits[ci][ii]}
onToggle={() =>
toggleRow(`${ci}-${ii}`)
}
/>
))}
</div>
</section>
))}
{nVisible === 0 && v ? (
<div className="noresult">
{searchM.noresPrefix}{" "}
<b>"{query.trim()}"</b>{" "}
{searchM.noresSuffix.replace(
"{total}",
String(TOTAL)
)}
</div>
) : null}
<div className="foot tf-anim">
<span>{renderBold(footM)}</span>
</div>
</main>
</div>
</div>
</section>
</div>
)
}
// ------------------------------------------------- property controls ---
// UNE SEULE liste « Questions » avec un menu « Catégorie » par élément :
// plusieurs listes d'objets dans un même composant faisaient ouvrir le
// mauvais élément dans le panneau Framer.
addPropertyControls(TigerFaq, {
hero: {
type: ControlType.Object,
title: "Hero",
controls: {
eyebrow: {
type: ControlType.String,
title: "Surtitre",
defaultValue: D.hero.eyebrow,
},
titlePlain: {
type: ControlType.String,
title: "Titre",
defaultValue: D.hero.titlePlain,
},
titleAccent: {
type: ControlType.String,
title: "Titre · dégradé",
defaultValue: D.hero.titleAccent,
},
subtitle: {
type: ControlType.String,
title: "Sous-titre",
defaultValue: D.hero.subtitle,
displayTextArea: true,
},
note: {
type: ControlType.String,
title: "Note légale",
defaultValue: D.hero.note,
displayTextArea: true,
},
},
},
search: {
type: ControlType.Object,
title: "Recherche",
controls: {
placeholder: {
type: ControlType.String,
title: "Placeholder",
defaultValue: D.search.placeholder,
},
entriesSuffix: {
type: ControlType.String,
title: "Suffixe compteur",
defaultValue: D.search.entriesSuffix,
},
noresPrefix: {
type: ControlType.String,
title: "Vide · avant",
defaultValue: D.search.noresPrefix,
},
noresSuffix: {
type: ControlType.String,
title: "Vide · après",
defaultValue: D.search.noresSuffix,
description: "{total} = nombre de questions",
},
typewriter: {
type: ControlType.Boolean,
title: "Typewriter",
defaultValue: true,
},
hints: {
type: ControlType.String,
title: "Suggestions",
defaultValue: D.search.hints,
displayTextArea: true,
description: "Une par ligne",
},
},
},
sections: {
type: ControlType.Object,
title: "Catégories",
controls: {
sidebarHead: {
type: ControlType.String,
title: "Titre sidebar",
defaultValue: D.sections.sidebarHead,
},
questionsLabel: {
type: ControlType.String,
title: "Mot « questions »",
defaultValue: D.sections.questionsLabel,
},
generalTitle: {
type: ControlType.String,
title: "Cat 1 · titre",
defaultValue: D.sections.generalTitle,
},
generalIcon: {
type: ControlType.Enum,
title: "Cat 1 · icône",
options: ["help", "monitor", "shield", "award"],
optionTitles: ["Question", "Écran", "Bouclier", "Médaille"],
defaultValue: D.sections.generalIcon,
},
platformTitle: {
type: ControlType.String,
title: "Cat 2 · titre",
defaultValue: D.sections.platformTitle,
},
platformIcon: {
type: ControlType.Enum,
title: "Cat 2 · icône",
options: ["help", "monitor", "shield", "award"],
optionTitles: ["Question", "Écran", "Bouclier", "Médaille"],
defaultValue: D.sections.platformIcon,
},
rulesTitle: {
type: ControlType.String,
title: "Cat 3 · titre",
defaultValue: D.sections.rulesTitle,
},
rulesIcon: {
type: ControlType.Enum,
title: "Cat 3 · icône",
options: ["help", "monitor", "shield", "award"],
optionTitles: ["Question", "Écran", "Bouclier", "Médaille"],
defaultValue: D.sections.rulesIcon,
},
rewardsTitle: {
type: ControlType.String,
title: "Cat 4 · titre",
defaultValue: D.sections.rewardsTitle,
},
rewardsIcon: {
type: ControlType.Enum,
title: "Cat 4 · icône",
options: ["help", "monitor", "shield", "award"],
optionTitles: ["Question", "Écran", "Bouclier", "Médaille"],
defaultValue: D.sections.rewardsIcon,
},
},
},
help: {
type: ControlType.Object,
title: "Carte support",
controls: {
title: {
type: ControlType.String,
title: "Titre",
defaultValue: D.help.title,
},
text: {
type: ControlType.String,
title: "Texte",
defaultValue: D.help.text,
displayTextArea: true,
},
ctaLabel: {
type: ControlType.String,
title: "CTA",
defaultValue: D.help.ctaLabel,
},
ctaLink: { type: ControlType.Link, title: "Lien CTA" },
hours: {
type: ControlType.String,
title: "Horaires (** = accent)",
defaultValue: D.help.hours,
},
},
},
faqs: {
type: ControlType.Array,
title: "Questions",
maxCount: 150,
control: {
type: ControlType.Object,
controls: {
q: {
type: ControlType.String,
title: "Question",
defaultValue: "Question ?",
},
a: {
type: ControlType.String,
title: "Réponse",
defaultValue: "Réponse.",
displayTextArea: true,
description:
"**gras** · « - » liste · « ### » sous-titre · « ~ » note · [ex Label]…[/ex] encadré · | a | b | tableau",
},
cat: {
type: ControlType.Enum,
title: "Catégorie",
options: ["general", "platform", "rules", "rewards"],
optionTitles: ["General", "Platform", "Rules", "Rewards"],
defaultValue: "general",
},
},
} as any,
defaultValue: D_FAQS,
},
footText: {
type: ControlType.String,
title: "Bandeau pied (** = accent)",
defaultValue: D.footText,
displayTextArea: true,
},
})